Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adac0723a1 | |||
| 999d44226d | |||
| 1c3ecb232b | |||
| 21619c5420 | |||
| efabd3ae18 | |||
| bbee699cc8 | |||
| ac28b6dd61 | |||
| e14b195856 | |||
| 85425ee71b | |||
| f275695d2f | |||
| 7c26a88c6c | |||
| 655f4b4d98 | |||
| 8e1546d502 | |||
| 9ce2997c19 | |||
| 0b5d27752c |
@@ -54,18 +54,19 @@ The launcher, emoji picker, notes, and translation tool open immediately without
|
|||||||
| Open a result or copy a calculated answer | Return or double-click |
|
| Open a result or copy a calculated answer | Return or double-click |
|
||||||
| Open actions for the selected entry | Command-K |
|
| Open actions for the selected entry | Command-K |
|
||||||
| Open one of the first nine results | Hold Command until numbers appear, then press 1–9 |
|
| Open one of the first nine results | Hold Command until numbers appear, then press 1–9 |
|
||||||
| Close the open action/provider picker, otherwise dismiss the launcher | Escape |
|
| Close the open actions, provider, language, or note picker, otherwise dismiss the launcher | Escape |
|
||||||
| Dismiss the launcher, including the current Command and pickers | Command-W |
|
| Dismiss the launcher, including the current Command and pickers | Command-W |
|
||||||
| Open the translation provider picker | Command-P (in Translate) |
|
| Browse notes / open the translation provider picker | Command-P (in Notes / Translate) |
|
||||||
|
| Create a note | Command-N (in Notes, including while a popup is open) |
|
||||||
| Go back without dismissing | Back chevron |
|
| Go back without dismissing | Back chevron |
|
||||||
| Open Settings | Command-comma |
|
| Open Settings | Command-comma |
|
||||||
| Quit | Command-Q |
|
| Quit | Command-Q |
|
||||||
|
|
||||||
Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them.
|
Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them.
|
||||||
|
|
||||||
Action and provider 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, and Translate share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action with Return first, then Actions with separate Command and K keycaps. 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.
|
||||||
|
|
||||||
@@ -134,13 +135,29 @@ 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. **New Note** (Command-N) creates a note; the menu switches between notes, and reopening Notes restores the last selection. Each note’s first nonempty line becomes its menu title.
|
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes keeps a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. 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.
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or use **Insert Image…** to embed a local image. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
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.
|
||||||
|
|
||||||
|
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands affect only the current line, not nested children. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
||||||
|
|
||||||
|
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
||||||
|
|
||||||
|
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Tab completes a still-literal marker; on an already rendered list item it indents the line. Loading or pasting valid list Markdown still renders it directly.
|
||||||
|
|
||||||
|
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
||||||
|
|
||||||
|
Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling.
|
||||||
|
|
||||||
|
Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax.
|
||||||
|
|
||||||
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; Escape or Command-W dismisses Conjure.
|
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 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
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AppKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ActionPill: NSView {
|
final class ActionPill: NSView {
|
||||||
init(primaryButton: NSButton, actionsButton: NSButton) {
|
init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
wantsLayer = true
|
wantsLayer = true
|
||||||
layer?.cornerRadius = 18
|
layer?.cornerRadius = 18
|
||||||
@@ -14,12 +14,12 @@ final class ActionPill: NSView {
|
|||||||
button.font = .systemFont(ofSize: 12, weight: .medium)
|
button.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
button.contentTintColor = SunsetPalette.text
|
button.contentTintColor = SunsetPalette.text
|
||||||
}
|
}
|
||||||
primaryButton.keyEquivalent = "\r"
|
primaryButton.keyEquivalent = primaryCommand ?? "\r"
|
||||||
primaryButton.keyEquivalentModifierMask = []
|
primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command
|
||||||
actionsButton.title = "Actions"
|
actionsButton.title = "Actions"
|
||||||
actionsButton.keyEquivalent = "k"
|
actionsButton.keyEquivalent = "k"
|
||||||
actionsButton.keyEquivalentModifierMask = .command
|
actionsButton.keyEquivalentModifierMask = .command
|
||||||
let returnKey = ShortcutIndicator("↵")
|
let returnKey = primaryCommand.map { ShortcutIndicator("⌘", $0.uppercased()) } ?? ShortcutIndicator("↵")
|
||||||
let actionKeys = ShortcutIndicator("⌘", "K")
|
let actionKeys = ShortcutIndicator("⌘", "K")
|
||||||
for control in [primaryButton, returnKey, actionsButton, actionKeys] {
|
for control in [primaryButton, returnKey, actionsButton, actionKeys] {
|
||||||
control.translatesAutoresizingMaskIntoConstraints = false
|
control.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|||||||
@@ -33,17 +33,26 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
var selectProvider: ((Bool) -> Void)?
|
var selectProvider: ((Bool) -> Void)?
|
||||||
private var currentProviderEntry: Int?
|
private var currentProviderEntry: Int?
|
||||||
var selectLanguage: ((String) -> Void)?
|
var selectLanguage: ((String) -> Void)?
|
||||||
private struct LanguageChoice {
|
var selectNote: ((UUID) -> Void)?
|
||||||
let code: String
|
var browseNotes: (() -> Void)?
|
||||||
let name: String
|
var newNote: (() -> Void)?
|
||||||
|
private enum ChoiceID: Equatable {
|
||||||
|
case language(String)
|
||||||
|
case note(UUID)
|
||||||
|
}
|
||||||
|
private struct SelectionChoice {
|
||||||
|
let id: ChoiceID
|
||||||
|
let title: String
|
||||||
let searchText: FuzzySearchText
|
let searchText: FuzzySearchText
|
||||||
}
|
}
|
||||||
private var languages: [LanguageChoice]?
|
private var choices: [SelectionChoice]?
|
||||||
private var matchingLanguages: [LanguageChoice] = []
|
private var matchingChoices: [SelectionChoice] = []
|
||||||
private var currentLanguage: String?
|
private var currentChoice: ChoiceID?
|
||||||
private var languageTable: NSTableView?
|
private var selectionTable: NSTableView?
|
||||||
private var languageScroll: NSScrollView?
|
private var selectionScroll: NSScrollView?
|
||||||
private var languageViewportHeight: NSLayoutConstraint?
|
private var selectionViewportHeight: NSLayoutConstraint?
|
||||||
|
private var selectionAccessibilityLabel = ""
|
||||||
|
private var currentChoiceAccessibilityLabel = ""
|
||||||
var closed: (() -> Void)?
|
var closed: (() -> Void)?
|
||||||
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
|
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
|
||||||
|
|
||||||
@@ -58,9 +67,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
empty = NSTextField(labelWithString: "No matching actions")
|
empty = NSTextField(labelWithString: "No matching actions")
|
||||||
applicationLabel = NSTextField(labelWithString: "")
|
applicationLabel = NSTextField(labelWithString: "")
|
||||||
rowTopConstraints = []
|
rowTopConstraints = []
|
||||||
languageTable = nil
|
selectionTable = nil
|
||||||
languageScroll = nil
|
selectionScroll = nil
|
||||||
languageViewportHeight = nil
|
selectionViewportHeight = nil
|
||||||
entries = [
|
entries = [
|
||||||
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
|
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
|
||||||
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
|
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
|
||||||
@@ -73,7 +82,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
|
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
|
||||||
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
|
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
|
||||||
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
|
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
|
||||||
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text)
|
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
|
||||||
|
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
||||||
|
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N")
|
||||||
]
|
]
|
||||||
view = ActionsPanelView()
|
view = ActionsPanelView()
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
@@ -108,6 +119,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
search.sendsSearchStringImmediately = true
|
search.sendsSearchStringImmediately = true
|
||||||
search.setAccessibilityLabel("Search application actions")
|
search.setAccessibilityLabel("Search application actions")
|
||||||
search.closeActions = { [weak self] in self?.close() }
|
search.closeActions = { [weak self] in self?.close() }
|
||||||
|
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
|
||||||
empty.textColor = SunsetPalette.secondaryText
|
empty.textColor = SunsetPalette.secondaryText
|
||||||
empty.font = .systemFont(ofSize: 14)
|
empty.font = .systemFont(ofSize: 14)
|
||||||
empty.isHidden = true
|
empty.isHidden = true
|
||||||
@@ -156,11 +168,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
search.heightAnchor.constraint(equalToConstant: 24)
|
search.heightAnchor.constraint(equalToConstant: 24)
|
||||||
])
|
])
|
||||||
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||||
if languages != nil {
|
if choices != nil {
|
||||||
height.isActive = false
|
height.isActive = false
|
||||||
let table = NSTableView()
|
let table = NSTableView()
|
||||||
table.style = .plain
|
table.style = .plain
|
||||||
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language")))
|
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Selection")))
|
||||||
table.headerView = nil
|
table.headerView = nil
|
||||||
table.backgroundColor = .clear
|
table.backgroundColor = .clear
|
||||||
table.selectionHighlightStyle = .none
|
table.selectionHighlightStyle = .none
|
||||||
@@ -172,8 +184,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
table.dataSource = self
|
table.dataSource = self
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
table.target = self
|
table.target = self
|
||||||
table.action = #selector(chooseLanguageRow)
|
table.action = #selector(chooseSelectionRow)
|
||||||
table.setAccessibilityLabel("Translation languages")
|
table.setAccessibilityLabel(selectionAccessibilityLabel)
|
||||||
let scroll = NSScrollView()
|
let scroll = NSScrollView()
|
||||||
scroll.documentView = table
|
scroll.documentView = table
|
||||||
scroll.hasVerticalScroller = true
|
scroll.hasVerticalScroller = true
|
||||||
@@ -189,9 +201,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
|
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
])
|
])
|
||||||
languageTable = table
|
selectionTable = table
|
||||||
languageScroll = scroll
|
selectionScroll = scroll
|
||||||
languageViewportHeight = viewportHeight
|
selectionViewportHeight = viewportHeight
|
||||||
}
|
}
|
||||||
updateSelection()
|
updateSelection()
|
||||||
}
|
}
|
||||||
@@ -223,22 +235,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
|
|
||||||
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
|
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
|
||||||
source: Bool, relativeTo anchor: NSView) {
|
source: Bool, relativeTo anchor: NSView) {
|
||||||
currentLanguage = selected
|
currentChoice = .language(selected)
|
||||||
|
selectionAccessibilityLabel = "Translation languages"
|
||||||
|
currentChoiceAccessibilityLabel = "Current language"
|
||||||
show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
|
show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
|
||||||
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages",
|
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages",
|
||||||
languageChoices: choices.map { LanguageChoice(code: $0.code, name: $0.name, searchText: FuzzySearchText($0.name)) })
|
selectionChoices: choices.map {
|
||||||
|
SelectionChoice(id: .language($0.code), title: $0.name, searchText: FuzzySearchText($0.name))
|
||||||
|
}, selectionName: "languages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func showNotes(_ notes: [(id: UUID, title: String)], selected: UUID?, relativeTo anchor: NSView) {
|
||||||
|
currentChoice = selected.map { .note($0) }
|
||||||
|
selectionAccessibilityLabel = "Notes"
|
||||||
|
currentChoiceAccessibilityLabel = "Current note"
|
||||||
|
show(title: "Browse Notes", relativeTo: anchor, available: [],
|
||||||
|
searchAccessibilityLabel: "Search notes", belowAnchor: true,
|
||||||
|
selectionChoices: notes.map {
|
||||||
|
SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title))
|
||||||
|
}, selectionName: "notes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func showNotesActions(relativeTo anchor: NSView) {
|
||||||
|
show(title: "Notes", relativeTo: anchor, available: [12, 13],
|
||||||
|
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,
|
||||||
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
|
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
|
||||||
languageChoices: [LanguageChoice]? = nil) {
|
selectionChoices: [SelectionChoice]? = nil, selectionName: String = "") {
|
||||||
if viewIfLoaded?.superview != nil {
|
if viewIfLoaded?.superview != nil {
|
||||||
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
|
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
|
||||||
close(animated: false)
|
close(animated: false)
|
||||||
}
|
}
|
||||||
guard let window = anchor.window, let host = window.contentView else { return }
|
guard let window = anchor.window, let host = window.contentView else { return }
|
||||||
let pill = anchor.superview ?? anchor
|
let pill = anchor.superview ?? anchor
|
||||||
languages = languageChoices
|
choices = selectionChoices
|
||||||
// Reuse behavior, not native drawing state from a differently sized popup.
|
// Reuse behavior, not native drawing state from a differently sized popup.
|
||||||
if isViewLoaded { preparePresentation() } else { _ = view }
|
if isViewLoaded { preparePresentation() } else { _ = view }
|
||||||
applicationLabel.stringValue = title
|
applicationLabel.stringValue = title
|
||||||
@@ -248,7 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
entries[0].button.setAccessibilityLabel(openTitle)
|
entries[0].button.setAccessibilityLabel(openTitle)
|
||||||
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
|
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
|
||||||
search.setAccessibilityLabel(searchAccessibilityLabel)
|
search.setAccessibilityLabel(searchAccessibilityLabel)
|
||||||
empty.stringValue = languages == nil ? "No matching actions" : "No matching languages"
|
empty.stringValue = choices == nil ? "No matching actions" : "No matching \(selectionName)"
|
||||||
let favorite = entries[1]
|
let favorite = entries[1]
|
||||||
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
|
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
|
||||||
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
|
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
|
||||||
@@ -260,7 +292,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
entries[index].icon.isHidden = currentProviderEntry != index
|
entries[index].icon.isHidden = currentProviderEntry != index
|
||||||
}
|
}
|
||||||
search.placeholderAttributedString = NSAttributedString(
|
search.placeholderAttributedString = NSAttributedString(
|
||||||
string: languages != nil ? "Search for languages…" : belowAnchor ? "Search for providers…" : "Search for actions…",
|
string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…",
|
||||||
attributes: [.foregroundColor: SunsetPalette.secondaryText,
|
attributes: [.foregroundColor: SunsetPalette.secondaryText,
|
||||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
|
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
|
||||||
search.stringValue = ""
|
search.stringValue = ""
|
||||||
@@ -287,7 +319,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
coveredPill?.setAccessibilityHidden(true)
|
coveredPill?.setAccessibilityHidden(true)
|
||||||
host.layoutSubtreeIfNeeded()
|
host.layoutSubtreeIfNeeded()
|
||||||
background.needsDisplay = true
|
background.needsDisplay = true
|
||||||
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) }
|
if let selectedEntry { selectionTable?.scrollRowToVisible(selectedEntry) }
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
|
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
|
||||||
view.animator().alphaValue = 1
|
view.animator().alphaValue = 1
|
||||||
@@ -345,16 +377,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
|
|
||||||
func controlTextDidChange(_ notification: Notification) {
|
func controlTextDidChange(_ notification: Notification) {
|
||||||
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let languages, let table = languageTable {
|
if let choices, let table = selectionTable {
|
||||||
matchingLanguages = FuzzySearchText.results(for: query, in: languages, text: \.searchText) {
|
matchingChoices = FuzzySearchText.results(for: query, in: choices, text: \.searchText) {
|
||||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
$0.title.localizedStandardCompare($1.title) == .orderedAscending
|
||||||
}
|
}
|
||||||
let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil)
|
let selection = (query.isEmpty ? matchingChoices.firstIndex(where: { $0.id == currentChoice }) : nil)
|
||||||
?? (matchingLanguages.isEmpty ? nil : 0)
|
?? (matchingChoices.isEmpty ? nil : 0)
|
||||||
for entry in entries { entry.view.isHidden = true }
|
for entry in entries { entry.view.isHidden = true }
|
||||||
languageViewportHeight?.constant = CGFloat(max(min(matchingLanguages.count, 6), 1)) * table.rowHeight
|
selectionViewportHeight?.constant = CGFloat(max(min(matchingChoices.count, 6), 1)) * table.rowHeight
|
||||||
empty.isHidden = !matchingLanguages.isEmpty
|
empty.isHidden = !matchingChoices.isEmpty
|
||||||
languageScroll?.isHidden = matchingLanguages.isEmpty
|
selectionScroll?.isHidden = matchingChoices.isEmpty
|
||||||
view.superview?.layoutSubtreeIfNeeded()
|
view.superview?.layoutSubtreeIfNeeded()
|
||||||
table.reloadData()
|
table.reloadData()
|
||||||
selectedEntry = selection
|
selectedEntry = selection
|
||||||
@@ -380,9 +412,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func moveSelection(by offset: Int) {
|
func moveSelection(by offset: Int) {
|
||||||
if let table = languageTable {
|
if let table = selectionTable {
|
||||||
guard !matchingLanguages.isEmpty else { return }
|
guard !matchingChoices.isEmpty else { return }
|
||||||
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingLanguages.count - 1)
|
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingChoices.count - 1)
|
||||||
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
|
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
|
||||||
table.scrollRowToVisible(next)
|
table.scrollRowToVisible(next)
|
||||||
return
|
return
|
||||||
@@ -394,10 +426,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateSelection() {
|
private func updateSelection() {
|
||||||
if let table = languageTable {
|
if let table = selectionTable {
|
||||||
let visible = table.rows(in: table.visibleRect)
|
let visible = table.rows(in: table.visibleRect)
|
||||||
for row in visible.location..<NSMaxRange(visible) {
|
for row in visible.location..<NSMaxRange(visible) {
|
||||||
if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? LanguageCell {
|
if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? SelectionCell {
|
||||||
cell.entry.view.isSelected = selectedEntry == row
|
cell.entry.view.isSelected = selectedEntry == row
|
||||||
cell.entry.returnKey.isHidden = selectedEntry != row
|
cell.entry.returnKey.isHidden = selectedEntry != row
|
||||||
}
|
}
|
||||||
@@ -407,7 +439,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
for index in entries.indices {
|
for index in entries.indices {
|
||||||
let selected = selectedEntry == index
|
let selected = selectedEntry == index
|
||||||
entries[index].view.isSelected = selected
|
entries[index].view.isSelected = selected
|
||||||
entries[index].returnKey.isHidden = !selected
|
entries[index].returnKey.isHidden = !selected && !entries[index].hasCommandKey
|
||||||
let current = currentProviderEntry == index ? ", Current provider" : ""
|
let current = currentProviderEntry == index ? ", Current provider" : ""
|
||||||
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
|
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
|
||||||
}
|
}
|
||||||
@@ -431,32 +463,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func numberOfRows(in tableView: NSTableView) -> Int { matchingLanguages.count }
|
private func handleCommandKey(_ key: String) -> Bool {
|
||||||
|
guard isShown, availableEntries == [12, 13] else { return false }
|
||||||
|
switch key {
|
||||||
|
case "p": close(); browseNotes?()
|
||||||
|
case "n": close(); newNote?()
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { matchingChoices.count }
|
||||||
|
|
||||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
let identifier = NSUserInterfaceItemIdentifier("LanguageCell")
|
let identifier = NSUserInterfaceItemIdentifier("SelectionCell")
|
||||||
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? LanguageCell ?? LanguageCell()
|
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? SelectionCell ?? SelectionCell()
|
||||||
cell.identifier = identifier
|
cell.identifier = identifier
|
||||||
let choice = matchingLanguages[row]
|
let choice = matchingChoices[row]
|
||||||
cell.entry.button.title = choice.name
|
cell.entry.button.title = choice.title
|
||||||
cell.entry.button.setAccessibilityLabel(choice.name)
|
cell.entry.button.setAccessibilityLabel(choice.title)
|
||||||
cell.entry.button.setAccessibilityValue(choice.code == currentLanguage ? "Current language" : "")
|
cell.entry.button.setAccessibilityValue(choice.id == currentChoice ? currentChoiceAccessibilityLabel : "")
|
||||||
cell.entry.button.target = self
|
cell.entry.button.target = self
|
||||||
cell.entry.button.action = #selector(chooseButton(_:))
|
cell.entry.button.action = #selector(chooseButton(_:))
|
||||||
cell.entry.button.tag = row
|
cell.entry.button.tag = row
|
||||||
cell.entry.icon.isHidden = choice.code != currentLanguage
|
cell.entry.icon.isHidden = choice.id != currentChoice
|
||||||
cell.entry.view.isSelected = selectedEntry == row
|
cell.entry.view.isSelected = selectedEntry == row
|
||||||
cell.entry.returnKey.isHidden = selectedEntry != row
|
cell.entry.returnKey.isHidden = selectedEntry != row
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||||
guard let table = languageTable else { return }
|
guard let table = selectionTable else { return }
|
||||||
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
|
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
|
||||||
updateSelection()
|
updateSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func chooseLanguageRow() {
|
@objc private func chooseSelectionRow() {
|
||||||
if let selectedEntry { chooseAction(at: selectedEntry) }
|
if let selectedEntry { chooseAction(at: selectedEntry) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,11 +510,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
|
|
||||||
private func chooseAction(at index: Int) {
|
private func chooseAction(at index: Int) {
|
||||||
guard isShown else { return }
|
guard isShown else { return }
|
||||||
if languages != nil {
|
if choices != nil {
|
||||||
guard matchingLanguages.indices.contains(index) else { return }
|
guard matchingChoices.indices.contains(index) else { return }
|
||||||
let code = matchingLanguages[index].code
|
let id = matchingChoices[index].id
|
||||||
close()
|
close()
|
||||||
selectLanguage?(code)
|
switch id {
|
||||||
|
case .language(let code): selectLanguage?(code)
|
||||||
|
case .note(let id): selectNote?(id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard visibleEntries.contains(index) else { return }
|
guard visibleEntries.contains(index) else { return }
|
||||||
@@ -490,6 +535,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
case 9: swapLanguages?()
|
case 9: swapLanguages?()
|
||||||
case 10: selectProvider?(false)
|
case 10: selectProvider?(false)
|
||||||
case 11: selectProvider?(true)
|
case 11: selectProvider?(true)
|
||||||
|
case 12: browseNotes?()
|
||||||
|
case 13: newNote?()
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,9 +547,12 @@ private struct ActionEntry {
|
|||||||
let view = LauncherRowView()
|
let view = LauncherRowView()
|
||||||
let button: NSButton
|
let button: NSButton
|
||||||
let icon: IconView
|
let icon: IconView
|
||||||
let returnKey = ShortcutIndicator("↵")
|
let returnKey: ShortcutIndicator
|
||||||
|
let hasCommandKey: Bool
|
||||||
|
|
||||||
init(title: String, symbol: String, color: NSColor) {
|
init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) {
|
||||||
|
hasCommandKey = commandKey != nil
|
||||||
|
returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵")
|
||||||
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||||||
icon = IconView(font: font)
|
icon = IconView(font: font)
|
||||||
button = NSButton(title: title, target: nil, action: nil)
|
button = NSButton(title: title, target: nil, action: nil)
|
||||||
@@ -535,7 +585,7 @@ private struct ActionEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class LanguageCell: NSView {
|
private final class SelectionCell: NSView {
|
||||||
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
|
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -550,7 +600,7 @@ private final class LanguageCell: NSView {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("Conjure creates language rows programmatically") }
|
required init?(coder: NSCoder) { fatalError("Conjure creates selection rows programmatically") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -565,13 +615,17 @@ private final class ActionsPanelView: NSView {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class ActionsSearchField: NSSearchField {
|
private final class ActionsSearchField: NSSearchField {
|
||||||
var closeActions: (() -> Void)?
|
var closeActions: (() -> Void)?
|
||||||
|
var commandKey: ((String) -> Bool)?
|
||||||
|
|
||||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
||||||
event.charactersIgnoringModifiers?.lowercased() == "k" {
|
let key = event.charactersIgnoringModifiers?.lowercased() {
|
||||||
|
if key == "k" {
|
||||||
closeActions?()
|
closeActions?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if commandKey?(key) == true { return true }
|
||||||
|
}
|
||||||
return super.performKeyEquivalent(with: event)
|
return super.performKeyEquivalent(with: event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
else { self.moveSelection(by: offset) }
|
else { self.moveSelection(by: offset) }
|
||||||
}
|
}
|
||||||
window.commandNavigation = { [weak self] key in
|
window.commandNavigation = { [weak self] key in
|
||||||
self?.translation?.handleControlKey(key) ?? self?.emojiPicker?.handleControlKey(key) ?? false
|
self?.notes?.handleControlKey(key) ?? self?.translation?.handleControlKey(key)
|
||||||
|
?? self?.emojiPicker?.handleControlKey(key) ?? false
|
||||||
}
|
}
|
||||||
|
window.commandShortcut = { [weak self] key in self?.notes?.handleCommandKey(key) ?? false }
|
||||||
window.usesTextNavigation = { [weak self] in self?.notes != nil }
|
window.usesTextNavigation = { [weak self] in self?.notes != nil }
|
||||||
window.dismiss = { [weak self] in self?.dismiss() }
|
window.dismiss = { [weak self] in self?.dismiss() }
|
||||||
window.escape = { [weak self] in self?.handleEscape() }
|
window.escape = { [weak self] in self?.handleEscape() }
|
||||||
@@ -472,7 +474,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
private func handleEscape() {
|
private func handleEscape() {
|
||||||
let selector = #selector(NSResponder.cancelOperation(_:))
|
let selector = #selector(NSResponder.cancelOperation(_:))
|
||||||
if actions.handle(selector) || translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return }
|
if actions.handle(selector) || notes?.handle(selector) == true
|
||||||
|
|| translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return }
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +484,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
handleEscape()
|
handleEscape()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if let notes { return notes.handle(commandSelector) }
|
||||||
if let emojiPicker { return emojiPicker.handle(commandSelector) }
|
if let emojiPicker { return emojiPicker.handle(commandSelector) }
|
||||||
if let translation { return translation.handle(commandSelector) }
|
if let translation { return translation.handle(commandSelector) }
|
||||||
switch commandSelector {
|
switch commandSelector {
|
||||||
@@ -803,8 +807,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func closeCommand() -> Bool {
|
private func closeCommand() -> Bool {
|
||||||
guard saveNotes() else { return false }
|
guard notes?.close() != false else { return false }
|
||||||
notes?.closed?()
|
|
||||||
translation?.close()
|
translation?.close()
|
||||||
emojiPicker?.close()
|
emojiPicker?.close()
|
||||||
return true
|
return true
|
||||||
@@ -890,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 }
|
||||||
@@ -899,7 +903,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
self.translation = nil
|
self.translation = nil
|
||||||
self.emojiPicker = nil
|
self.emojiPicker = nil
|
||||||
self.notes = nil
|
self.notes = nil
|
||||||
self.searchField.isEnabled = true
|
self.searchField.isHidden = false
|
||||||
|
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
|
||||||
@@ -935,8 +945,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
)
|
)
|
||||||
searchField.setAccessibilityLabel(accessibilityLabel)
|
searchField.setAccessibilityLabel(accessibilityLabel)
|
||||||
if command == .notes {
|
if command == .notes {
|
||||||
searchField.stringValue = "Conjure Notes"
|
launcherViews.first?.isHidden = true
|
||||||
searchField.isEnabled = 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
|
||||||
@@ -951,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: 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() }
|
||||||
@@ -1195,6 +1214,7 @@ final class LauncherPanel: NSPanel {
|
|||||||
var numberHintsChanged: (() -> Void)?
|
var numberHintsChanged: (() -> Void)?
|
||||||
var launchNumber: ((Int) -> Void)?
|
var launchNumber: ((Int) -> Void)?
|
||||||
var commandNavigation: ((String) -> Bool)?
|
var commandNavigation: ((String) -> Bool)?
|
||||||
|
var commandShortcut: ((String) -> Bool)?
|
||||||
var usesTextNavigation: (() -> Bool)?
|
var usesTextNavigation: (() -> Bool)?
|
||||||
private var numberShortcuts = CommandNumberShortcuts()
|
private var numberShortcuts = CommandNumberShortcuts()
|
||||||
private var commandHoldTask: Task<Void, Never>?
|
private var commandHoldTask: Task<Void, Never>?
|
||||||
@@ -1299,9 +1319,10 @@ final class LauncherPanel: NSPanel {
|
|||||||
}
|
}
|
||||||
resetNumberHints(modifierFlags: event.modifierFlags)
|
resetNumberHints(modifierFlags: event.modifierFlags)
|
||||||
}
|
}
|
||||||
if event.type == .keyDown, attachedSheet == nil, usesTextNavigation?() != true,
|
if event.type == .keyDown, attachedSheet == nil,
|
||||||
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
|
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
|
||||||
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
|
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
|
||||||
|
if usesTextNavigation?() != true {
|
||||||
switch event.charactersIgnoringModifiers?.lowercased() {
|
switch event.charactersIgnoringModifiers?.lowercased() {
|
||||||
case "j":
|
case "j":
|
||||||
navigateSelection?(1)
|
navigateSelection?(1)
|
||||||
@@ -1313,12 +1334,22 @@ final class LauncherPanel: NSPanel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
super.sendEvent(event)
|
super.sendEvent(event)
|
||||||
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
|
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
|
||||||
dragEnded?()
|
dragEnded?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
if attachedSheet == nil,
|
||||||
|
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
||||||
|
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
override func cancelOperation(_ sender: Any?) {
|
override func cancelOperation(_ sender: Any?) {
|
||||||
if let escape { escape() } else { dismiss?() }
|
if let escape { escape() } else { dismiss?() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ConjureKit
|
import ConjureKit
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import Highlighter
|
import Highlighter
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum NotesAppearance {
|
||||||
|
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 notePicker = NSPopUpButton()
|
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil)
|
||||||
|
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||||||
|
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?
|
||||||
@@ -28,18 +42,75 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") }
|
required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") }
|
||||||
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
view = NSView()
|
let content = NotesView()
|
||||||
notePicker.target = self
|
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
|
||||||
notePicker.action = #selector(switchNote)
|
view = content
|
||||||
notePicker.setAccessibilityLabel("Choose a note")
|
let close = iconButton("circle.fill", label: "Close notes", action: #selector(closeWindow))
|
||||||
let create = NSButton(title: "New Note", target: self, action: #selector(newNote))
|
close.contentTintColor = SunsetPalette.accent
|
||||||
create.keyEquivalent = "n"
|
close.isBordered = false
|
||||||
create.keyEquivalentModifierMask = .command
|
close.image = close.image?.withSymbolConfiguration(.init(paletteColors: [SunsetPalette.accent]))
|
||||||
let image = NSButton(title: "Insert Image…", target: self, action: #selector(insertImage))
|
let lights = NSStackView(views: [close])
|
||||||
let header = NSStackView(views: [notePicker, create, image])
|
lights.spacing = 8
|
||||||
header.spacing = 8
|
for _ in 0..<2 {
|
||||||
notePicker.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
let dot = NSView()
|
||||||
|
dot.wantsLayer = true
|
||||||
|
dot.layer?.backgroundColor = SunsetPalette.secondaryText.withAlphaComponent(0.18).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)
|
||||||
|
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
|
||||||
|
browseButton.keyEquivalent = "p"
|
||||||
|
actionsButton.target = self
|
||||||
|
actionsButton.action = #selector(showActions)
|
||||||
|
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 = SunsetPalette.secondaryText
|
||||||
|
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 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 = 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
|
||||||
@@ -47,7 +118,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
editor.drawsBackground = false
|
editor.drawsBackground = false
|
||||||
editor.textColor = SunsetPalette.text
|
editor.textColor = SunsetPalette.text
|
||||||
editor.insertionPointColor = SunsetPalette.accent
|
editor.insertionPointColor = SunsetPalette.accent
|
||||||
editor.font = .systemFont(ofSize: 14)
|
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.selectionFill]
|
||||||
|
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
|
||||||
@@ -55,41 +128,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 = SunsetPalette.secondaryText
|
||||||
status.setAccessibilityLabel("Notes save status")
|
status.setAccessibilityLabel("Notes save status")
|
||||||
status.lineBreakMode = .byTruncatingTail
|
status.lineBreakMode = .byTruncatingTail
|
||||||
for control in [header, scroll, status] {
|
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
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([
|
||||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
||||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
header.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
notePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10),
|
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
scroll.leadingAnchor.constraint(equalTo: header.leadingAnchor),
|
header.heightAnchor.constraint(equalToConstant: 38),
|
||||||
scroll.trailingAnchor.constraint(equalTo: header.trailingAnchor),
|
body.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
|
||||||
scroll.bottomAnchor.constraint(equalTo: status.topAnchor, constant: -8),
|
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
status.leadingAnchor.constraint(equalTo: header.leadingAnchor),
|
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
status.trailingAnchor.constraint(equalTo: header.trailingAnchor),
|
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -4),
|
||||||
status.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12),
|
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 [notePicker, create, image] { 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
|
||||||
}
|
}
|
||||||
@@ -103,23 +184,235 @@ 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 = SunsetPalette.secondaryText
|
||||||
|
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) {
|
||||||
isDirty = true
|
isDirty = true
|
||||||
MarkdownHighlighting.apply(to: editor)
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
editor.refreshTasks()
|
||||||
_ = save()
|
_ = save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let listItem = try! NSRegularExpression(
|
||||||
|
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
|
||||||
|
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
|
||||||
|
let marker = line.substring(with: item.range(at: 2))
|
||||||
|
// Spaced thematic breaks are not list items or list ancestors.
|
||||||
|
if marker == "-" || marker == "*" {
|
||||||
|
let characters = (line as String).lazy.filter { !$0.isWhitespace }
|
||||||
|
let bullet = marker.first!
|
||||||
|
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func indentationWidth(_ indentation: String) -> Int {
|
||||||
|
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func outdentLength(_ indentation: String) -> Int {
|
||||||
|
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
|
||||||
|
var marker = line.substring(with: item.range(at: 2))
|
||||||
|
if item.range(at: 3).location != NSNotFound {
|
||||||
|
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
|
||||||
|
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
|
||||||
|
}
|
||||||
|
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
|
||||||
|
if item.range(at: 6).location != NSNotFound {
|
||||||
|
prefix += "[ ]" + line.substring(with: item.range(at: 7))
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSelectionWithUndo(_ selection: NSRange) {
|
||||||
|
let previous = editor.selectedRange()
|
||||||
|
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
|
||||||
|
editor.setSelectedRange(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
editor.replaceMarkdown(replacement, range: range)
|
||||||
|
setSelectionWithUndo(editor.renderedRange(for: selection))
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
|
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
|
||||||
|
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
|
||||||
|
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
|
||||||
|
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
|
||||||
|
let source = editor.markdownString as NSString
|
||||||
|
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
||||||
|
var start = 0, end = 0
|
||||||
|
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
||||||
|
for: NSRange(location: selection.location, length: 0))
|
||||||
|
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
||||||
|
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
|
||||||
|
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
|
||||||
|
guard !codeBlocks.contains(where: {
|
||||||
|
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
|
||||||
|
}) else { return false }
|
||||||
|
let indent = line.substring(with: item.range(at: 1))
|
||||||
|
if indenting {
|
||||||
|
let marker = editor.renderedRange(for:
|
||||||
|
NSRange(location: start + item.range(at: 2).location, length: 0)).location
|
||||||
|
// A literal marker still needs native Tab to complete its separator.
|
||||||
|
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
|
||||||
|
}
|
||||||
|
if indenting || outdenting {
|
||||||
|
let removed = outdenting ? Self.outdentLength(indent) : 0
|
||||||
|
guard indenting || removed > 0 else { return true }
|
||||||
|
let replacement = indenting ? " " : ""
|
||||||
|
let delta = replacement.utf16.count - removed
|
||||||
|
let selectedStart = max(start, selection.location + delta)
|
||||||
|
let selectedEnd = max(start, NSMaxRange(selection) + delta)
|
||||||
|
replaceListMarkdown(replacement, range: NSRange(location: start, length: removed),
|
||||||
|
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let markerEnd = item.range(at: 6).location == NSNotFound
|
||||||
|
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
|
||||||
|
guard selection.location >= start + markerEnd else { return false }
|
||||||
|
let body = line.substring(from: item.range.length)
|
||||||
|
if body.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
|
||||||
|
var previous = start
|
||||||
|
let depth = Self.indentationWidth(indent)
|
||||||
|
while depth > 0, previous > 0 {
|
||||||
|
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
|
||||||
|
previous = range.location
|
||||||
|
let ancestor = source.substring(with: range) as NSString
|
||||||
|
if let parent = Self.listMatch(in: ancestor),
|
||||||
|
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
|
||||||
|
let parentIndent = ancestor.substring(with: parent.range(at: 1))
|
||||||
|
if Self.indentationWidth(parentIndent) < depth {
|
||||||
|
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
|
||||||
|
if Self.indentationWidth(String(leading)) < depth { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
|
||||||
|
selection: NSRange(location: start + replacement.utf16.count, length: 0))
|
||||||
|
} else {
|
||||||
|
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
||||||
|
replaceListMarkdown("\n" + prefix, range: selection,
|
||||||
|
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func save() -> Bool {
|
func save() -> Bool {
|
||||||
guard isDirty, let store, let selectedID else { return true }
|
guard isDirty, let store, let selectedID else { return true }
|
||||||
do {
|
do {
|
||||||
try store.update(id: selectedID, contents: editor.attributedString())
|
try store.update(id: selectedID, contents: editor.markdownContents)
|
||||||
isDirty = false
|
isDirty = false
|
||||||
updatePicker()
|
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
||||||
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
|
status.isHidden = true
|
||||||
status.textColor = SunsetPalette.secondaryText
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
showError(error)
|
showError(error)
|
||||||
@@ -127,21 +420,50 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func goBack() {
|
func handle(_ selector: Selector) -> Bool {
|
||||||
guard save() else { return }
|
actions.isShown && actions.handle(selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleControlKey(_ key: String) -> Bool {
|
||||||
|
guard actions.isShown, key == "j" || key == "k" else { return false }
|
||||||
|
actions.moveSelection(by: key == "j" ? 1 : -1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCommandKey(_ key: String) -> Bool {
|
||||||
|
guard store != nil else { return false }
|
||||||
|
switch key {
|
||||||
|
case "p": browseNotes()
|
||||||
|
case "k": showActions()
|
||||||
|
case "n": newNote()
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func close() -> Bool {
|
||||||
|
guard save() else { return false }
|
||||||
|
actions.close(animated: false)
|
||||||
closed?()
|
closed?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func goBack() {
|
||||||
|
if actions.isShown { actions.close() }
|
||||||
|
else { _ = close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func newNote() {
|
@objc private func newNote() {
|
||||||
|
actions.close(animated: false)
|
||||||
guard save(), let store else { return }
|
guard save(), let store else { return }
|
||||||
do { select(try store.create()); focusEditor() }
|
do { select(try store.create()); focusEditor() }
|
||||||
catch { showError(error) }
|
catch { showError(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func switchNote() {
|
private func switchNote(_ id: UUID) {
|
||||||
guard let value = notePicker.selectedItem?.representedObject as? UUID else { return }
|
guard save() else { return }
|
||||||
guard save() else { updatePicker(); return }
|
select(id)
|
||||||
select(value)
|
|
||||||
focusEditor()
|
focusEditor()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,77 +471,70 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
|
||||||
selectedID = id
|
selectedID = id
|
||||||
defaults.set(id.uuidString, forKey: "selectedNote")
|
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||||
editor.textStorage?.setAttributedString(note.contents)
|
editor.setMarkdownContents(note.contents)
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.isEditable = true
|
editor.isEditable = true
|
||||||
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)
|
||||||
updatePicker()
|
editor.refreshTasks()
|
||||||
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
|
noteTitle.stringValue = note.title
|
||||||
status.textColor = SunsetPalette.secondaryText
|
status.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePicker() {
|
|
||||||
notePicker.removeAllItems()
|
|
||||||
for note in store?.notes ?? [] {
|
|
||||||
notePicker.menu?.addItem(withTitle: note.title, action: nil, keyEquivalent: "")
|
|
||||||
notePicker.lastItem?.representedObject = note.id
|
|
||||||
if note.id == selectedID { notePicker.select(notePicker.lastItem) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func insertImage() {
|
@objc func browseNotes() {
|
||||||
guard let window = view.window, editor.isEditable else { return }
|
guard let store else { return }
|
||||||
let panel = NSOpenPanel()
|
actions.showNotes(store.notes.map { (id: $0.id, title: $0.title) },
|
||||||
panel.allowedContentTypes = [.image]
|
selected: selectedID, relativeTo: browseButton)
|
||||||
panel.canChooseDirectories = false
|
|
||||||
panel.beginSheetModal(for: window) { [weak self] response in
|
|
||||||
guard response == .OK, let self, let url = panel.url else { return }
|
|
||||||
do { try self.editor.insertImage(at: url); self.focusEditor() }
|
|
||||||
catch { self.showError(error) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func showActions() {
|
||||||
|
guard store != nil else { return }
|
||||||
|
actions.showNotesActions(relativeTo: actionsButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NotesTextView: NSTextView {
|
private final class NotesView: NSView {
|
||||||
func insertImage(at url: URL) throws {
|
var commandShortcut: ((String) -> Bool)?
|
||||||
guard let image = NSImage(contentsOf: url) else {
|
|
||||||
throw CocoaError(.fileReadCorruptFile)
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
||||||
|
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
let attachment = NSTextAttachment()
|
return super.performKeyEquivalent(with: event)
|
||||||
attachment.attachmentCell = NSTextAttachmentCell(imageCell: image)
|
|
||||||
attachment.fileWrapper = try FileWrapper(url: url, options: .immediate)
|
|
||||||
let contents = NSAttributedString(attachment: attachment)
|
|
||||||
insertText(contents, replacementRange: selectedRange())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
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: SunsetPalette.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")
|
||||||
|
|
||||||
private static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
||||||
let source = text as NSString
|
let source = text as NSString
|
||||||
var blocks: [(NSRange, NSRange, String?)] = []
|
var blocks: [(NSRange, NSRange, String?)] = []
|
||||||
var cursor = 0
|
var cursor = 0
|
||||||
@@ -253,7 +568,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: SunsetPalette.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) {
|
||||||
@@ -262,7 +579,7 @@ 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: SunsetPalette.secondaryText], range: block.range)
|
||||||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
||||||
let code = (text as NSString).substring(with: block.body)
|
let code = (text as NSString).substring(with: block.body)
|
||||||
@@ -275,6 +592,7 @@ enum MarkdownHighlighting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text]
|
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||||||
|
.paragraphStyle: NotesAppearance.paragraph]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
|
||||||
|
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
|
||||||
|
private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph")
|
||||||
|
private let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace")
|
||||||
|
|
||||||
|
private final class ListAttachment: NSTextAttachment {
|
||||||
|
let source: NSAttributedString
|
||||||
|
let isTask: Bool
|
||||||
|
var marker: String { source.string }
|
||||||
|
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
init(source: NSAttributedString, isTask: Bool) {
|
||||||
|
self.source = source
|
||||||
|
self.isTask = isTask
|
||||||
|
super.init(data: nil, ofType: nil)
|
||||||
|
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
||||||
|
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
||||||
|
attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
|
||||||
|
cell.alignment = .right
|
||||||
|
attachmentCell = cell
|
||||||
|
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ListAttachmentCell: NSTextAttachmentCell {
|
||||||
|
nonisolated(unsafe) var columnWidth: CGFloat = 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
|
||||||
|
let size = label.size()
|
||||||
|
label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width,
|
||||||
|
y: cellFrame.midY - size.height / 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||||
|
private static let listPattern = try! NSRegularExpression(
|
||||||
|
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
||||||
|
)
|
||||||
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
||||||
|
private var checkboxes: [NSButton] = []
|
||||||
|
private var horizontalSelection: (anchor: Int, head: Int)?
|
||||||
|
|
||||||
|
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
||||||
|
var markdownString: String { markdownContents.string }
|
||||||
|
|
||||||
|
func setMarkdownContents(_ contents: NSAttributedString) {
|
||||||
|
horizontalSelection = nil
|
||||||
|
textStorage?.setAttributedString(Self.render(contents))
|
||||||
|
refreshTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
||||||
|
let result = NSMutableAttributedString(attributedString: contents)
|
||||||
|
restoreListLayout(in: result)
|
||||||
|
var replacements: [(NSRange, ListAttachment)] = []
|
||||||
|
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
||||||
|
if let task = value as? ListAttachment { replacements.append((range, task)) }
|
||||||
|
}
|
||||||
|
for (range, task) in replacements.reversed() {
|
||||||
|
let marker = NSMutableAttributedString(attributedString: task.source)
|
||||||
|
let markerRange = NSRange(location: 0, length: marker.length)
|
||||||
|
marker.removeAttribute(literalTaskMarker, range: markerRange)
|
||||||
|
if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) }
|
||||||
|
result.replaceCharacters(in: range, with: marker)
|
||||||
|
}
|
||||||
|
if !keepingMetadata {
|
||||||
|
let range = NSRange(location: 0, length: result.length)
|
||||||
|
result.removeAttribute(literalTaskMarker, range: range)
|
||||||
|
result.removeAttribute(sourceTaskAttachment, range: range)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func restoreListLayout(in contents: NSMutableAttributedString) {
|
||||||
|
let range = NSRange(location: 0, length: contents.length)
|
||||||
|
contents.enumerateAttribute(sourceListParagraph, in: range) { value, range, _ in
|
||||||
|
guard let value else { return }
|
||||||
|
if let style = value as? NSParagraphStyle {
|
||||||
|
contents.addAttribute(.paragraphStyle, value: style, range: range)
|
||||||
|
} else {
|
||||||
|
contents.removeAttribute(.paragraphStyle, range: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contents.removeAttribute(sourceListParagraph, range: range)
|
||||||
|
contents.removeAttribute(listWhitespace, range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: parse the whole note on edits; incremental parsing if large lists make typing slow.
|
||||||
|
private static func render(_ contents: NSAttributedString) -> NSAttributedString {
|
||||||
|
let source = contents.string as NSString
|
||||||
|
let range = NSRange(location: 0, length: source.length)
|
||||||
|
let code = MarkdownHighlighting.codeBlocks(in: contents.string)
|
||||||
|
let result = NSMutableAttributedString(attributedString: contents)
|
||||||
|
result.removeAttribute(sourceTaskAttachment, range: range)
|
||||||
|
// Explicit values stop AppKit inheriting display-only attributes from replaced list text.
|
||||||
|
result.addAttribute(listWhitespace, value: false, range: range)
|
||||||
|
contents.enumerateAttribute(.paragraphStyle, in: range) { style, run, _ in
|
||||||
|
result.addAttribute(sourceListParagraph, value: style ?? NSNull(), range: run)
|
||||||
|
}
|
||||||
|
result.addAttribute(literalTaskMarker, value: false, range: range)
|
||||||
|
contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in
|
||||||
|
if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) }
|
||||||
|
}
|
||||||
|
for match in listPattern.matches(in: contents.string, range: range).reversed() {
|
||||||
|
let isTask = match.range(at: 4).location != NSNotFound
|
||||||
|
var markerRange = match.range(at: 2)
|
||||||
|
if isTask { markerRange.length = NSMaxRange(match.range(at: 4)) - markerRange.location }
|
||||||
|
let line = source.lineRange(for: match.range)
|
||||||
|
if !isTask {
|
||||||
|
let body = source.substring(with: NSRange(location: NSMaxRange(match.range), length: NSMaxRange(line) - NSMaxRange(match.range)))
|
||||||
|
.trimmingCharacters(in: .newlines)
|
||||||
|
// Leave a task's bracket prefix editable until its closing bracket has a separator.
|
||||||
|
if ["[", "[ ", "[x", "[X"].contains(body)
|
||||||
|
|| ["[]", "[ ]", "[x]", "[X]"].contains(where: { body.hasPrefix($0) }) { continue }
|
||||||
|
let marker = source.substring(with: markerRange)
|
||||||
|
if marker == "-" || marker == "*" {
|
||||||
|
let characters = source.substring(with: line).filter { !$0.isWhitespace }
|
||||||
|
if characters.count >= 3, characters.allSatisfy({ String($0) == marker }) { continue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||||||
|
var literal = false
|
||||||
|
contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in
|
||||||
|
if value as? Bool == true { literal = true; stop.pointee = true }
|
||||||
|
}
|
||||||
|
guard !literal else { continue }
|
||||||
|
let marker = source.substring(with: markerRange)
|
||||||
|
let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? ListAttachment
|
||||||
|
let attachment: ListAttachment
|
||||||
|
if let existing, existing.marker == marker {
|
||||||
|
attachment = existing
|
||||||
|
} else {
|
||||||
|
let markerSource = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: markerRange))
|
||||||
|
markerSource.removeAttribute(sourceTaskAttachment, range: NSRange(location: 0, length: markerSource.length))
|
||||||
|
attachment = ListAttachment(source: markerSource, isTask: isTask)
|
||||||
|
}
|
||||||
|
let token = NSMutableAttributedString(attachment: attachment)
|
||||||
|
var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil)
|
||||||
|
attributes.removeValue(forKey: .attachment)
|
||||||
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
||||||
|
attributes[literalTaskMarker] = false
|
||||||
|
attributes[listWhitespace] = false
|
||||||
|
attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull()
|
||||||
|
token.addAttributes(attributes, range: NSRange(location: 0, length: 1))
|
||||||
|
result.replaceCharacters(in: markerRange, with: token)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownRange(for rendered: NSRange) -> NSRange {
|
||||||
|
func offset(_ position: Int) -> Int {
|
||||||
|
var result = min(position, string.utf16.count)
|
||||||
|
for task in tasks where NSMaxRange(task.range) <= position {
|
||||||
|
result += task.attachment.marker.utf16.count - 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
let start = offset(rendered.location)
|
||||||
|
return NSRange(location: start, length: offset(NSMaxRange(rendered)) - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedRange(for markdown: NSRange) -> NSRange {
|
||||||
|
func offset(_ position: Int, end: Bool) -> Int {
|
||||||
|
var expansion = 0
|
||||||
|
for task in tasks {
|
||||||
|
let start = task.range.location + expansion
|
||||||
|
let length = task.attachment.marker.utf16.count
|
||||||
|
if position < start { break }
|
||||||
|
if position < start + length {
|
||||||
|
return task.range.location + ((position > start && end) ? 1 : 0)
|
||||||
|
}
|
||||||
|
expansion += length - 1
|
||||||
|
}
|
||||||
|
return min(max(0, position - expansion), string.utf16.count)
|
||||||
|
}
|
||||||
|
let start = offset(markdown.location, end: markdown.length == 0)
|
||||||
|
return NSRange(location: start, length: offset(NSMaxRange(markdown), end: true) - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textTypingAttributes: [NSAttributedString.Key: Any] {
|
||||||
|
var attributes = typingAttributes
|
||||||
|
attributes.removeValue(forKey: .attachment)
|
||||||
|
attributes.removeValue(forKey: literalTaskMarker)
|
||||||
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
||||||
|
if let original = attributes.removeValue(forKey: sourceListParagraph) {
|
||||||
|
attributes[.paragraphStyle] = original as? NSParagraphStyle
|
||||||
|
}
|
||||||
|
attributes.removeValue(forKey: listWhitespace)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceMarkdown(_ text: String, range: NSRange) {
|
||||||
|
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)
|
||||||
|
return markdownRange(for: NSRange(location: body, length: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if event.keyCode == 48,
|
||||||
|
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
|
||||||
|
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
||||||
|
let replacement: NSAttributedString
|
||||||
|
if let attributed = insertString as? NSAttributedString {
|
||||||
|
replacement = Self.serialize(attributed, keepingMetadata: true)
|
||||||
|
} else if let text = insertString as? String {
|
||||||
|
replacement = NSAttributedString(string: text, attributes: textTypingAttributes)
|
||||||
|
} else { return }
|
||||||
|
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
|
||||||
|
replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false,
|
||||||
|
typedInput: insertString is String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
|
||||||
|
guard isEditable else { return }
|
||||||
|
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
||||||
|
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
||||||
|
var existingWhitespace = false
|
||||||
|
if typedInput, range.length == 0, replacement.length > 0 {
|
||||||
|
var end = 0
|
||||||
|
(source.string as NSString).getLineStart(nil, end: nil, contentsEnd: &end,
|
||||||
|
for: NSRange(location: range.location, length: 0))
|
||||||
|
let tail = (source.string as NSString).substring(with: NSRange(location: range.location, length: max(0, end - range.location)))
|
||||||
|
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
|
||||||
|
}
|
||||||
|
source.replaceCharacters(in: range, with: replacement)
|
||||||
|
if typedInput, range.length == 0, replacement.length > 0 {
|
||||||
|
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
|
||||||
|
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
|
||||||
|
var marker = match.range(at: 2)
|
||||||
|
if match.range(at: 4).location != NSNotFound {
|
||||||
|
marker.length = NSMaxRange(match.range(at: 4)) - marker.location
|
||||||
|
}
|
||||||
|
if range.location == NSMaxRange(marker),
|
||||||
|
replacement.string.allSatisfy({ $0 == " " || $0 == "\t" }) {
|
||||||
|
source.removeAttribute(literalTaskMarker, range: marker)
|
||||||
|
} else if existingWhitespace, range.location >= marker.location,
|
||||||
|
range.location + replacement.length == NSMaxRange(marker) {
|
||||||
|
source.addAttribute(literalTaskMarker, value: true, range: marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rendered = Self.render(source)
|
||||||
|
let before = attributedString()
|
||||||
|
let old = before.string as NSString, new = rendered.string as NSString
|
||||||
|
func equal(_ left: Int, _ right: Int) -> Bool {
|
||||||
|
guard old.character(at: left) == new.character(at: right) else { return false }
|
||||||
|
let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment
|
||||||
|
let rhs = rendered.attribute(.attachment, at: right, effectiveRange: nil) as? NSTextAttachment
|
||||||
|
if let lhs = lhs as? ListAttachment, let rhs = rhs as? ListAttachment {
|
||||||
|
return lhs.marker == rhs.marker
|
||||||
|
}
|
||||||
|
guard lhs === rhs else { return false }
|
||||||
|
return (before.attribute(literalTaskMarker, at: left, effectiveRange: nil) as? Bool == true)
|
||||||
|
== (rendered.attribute(literalTaskMarker, at: right, effectiveRange: nil) as? Bool == true)
|
||||||
|
}
|
||||||
|
var prefix = 0
|
||||||
|
while prefix < min(old.length, new.length), equal(prefix, prefix) { prefix += 1 }
|
||||||
|
if prefix < old.length { prefix = min(prefix, old.rangeOfComposedCharacterSequence(at: prefix).location) }
|
||||||
|
if prefix < new.length { prefix = min(prefix, new.rangeOfComposedCharacterSequence(at: prefix).location) }
|
||||||
|
var suffix = 0
|
||||||
|
while suffix < min(old.length, new.length) - prefix,
|
||||||
|
equal(old.length - suffix - 1, new.length - suffix - 1) { suffix += 1 }
|
||||||
|
for text in [old, new] where suffix > 0 && suffix < text.length {
|
||||||
|
let boundary = text.length - suffix
|
||||||
|
let character = text.rangeOfComposedCharacterSequence(at: boundary)
|
||||||
|
if character.location < boundary { suffix = min(suffix, text.length - NSMaxRange(character)) }
|
||||||
|
}
|
||||||
|
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
||||||
|
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
||||||
|
if removed.length > 0 || inserted.length > 0 {
|
||||||
|
if semantic { breakUndoCoalescing() }
|
||||||
|
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
||||||
|
if semantic { breakUndoCoalescing() }
|
||||||
|
}
|
||||||
|
refreshTasks()
|
||||||
|
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
||||||
|
setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func deleteBackward(_ sender: Any?) {
|
||||||
|
let selection = selectedRange()
|
||||||
|
let boundary = listWhitespaceBoundary(from: selection.location, forward: false)
|
||||||
|
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
||||||
|
expand(task, backwards: true)
|
||||||
|
} else if selection.length > 0 {
|
||||||
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
|
} else if selection.location > 0 {
|
||||||
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location - 1)
|
||||||
|
replaceMarkdown("", range: markdownRange(for: range))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func deleteForward(_ sender: Any?) {
|
||||||
|
let selection = selectedRange()
|
||||||
|
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
|
||||||
|
expand(task, backwards: false)
|
||||||
|
} else if selection.length > 0 {
|
||||||
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
|
} else if selection.location < string.utf16.count {
|
||||||
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
|
||||||
|
replaceMarkdown("", range: markdownRange(for: range))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expand(_ task: (range: NSRange, attachment: ListAttachment), backwards: Bool) {
|
||||||
|
let marker = task.attachment.source
|
||||||
|
let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1)
|
||||||
|
let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining))
|
||||||
|
text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length))
|
||||||
|
let range = markdownRange(for: task.range)
|
||||||
|
replaceSource(text, range: range, semantic: true)
|
||||||
|
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only separator glyphs are null; indentation and body whitespace remain native stops.
|
||||||
|
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
|
||||||
|
guard let storage = textStorage else { return position }
|
||||||
|
let index = forward ? position : position - 1
|
||||||
|
guard index >= 0, index < storage.length else { return position }
|
||||||
|
var range = NSRange()
|
||||||
|
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
return forward ? NSMaxRange(range) : range.location
|
||||||
|
}
|
||||||
|
|
||||||
|
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
|
||||||
|
let boundary = listWhitespaceBoundary(from: position, forward: forward)
|
||||||
|
guard let task = tasks.first(where: {
|
||||||
|
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
||||||
|
}) else { return nil }
|
||||||
|
return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setSelectedRange(_ charRange: NSRange) {
|
||||||
|
horizontalSelection = nil
|
||||||
|
super.setSelectedRange(charRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting: Bool) {
|
||||||
|
horizontalSelection = nil
|
||||||
|
super.setSelectedRange(charRange, affinity: affinity, stillSelecting: stillSelecting)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) {
|
||||||
|
horizontalSelection = nil
|
||||||
|
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveNatively(forward: Bool, selecting: Bool, sender: Any?) {
|
||||||
|
if selecting {
|
||||||
|
if forward { super.moveRightAndModifySelection(sender) }
|
||||||
|
else { super.moveLeftAndModifySelection(sender) }
|
||||||
|
} else {
|
||||||
|
if forward { super.moveRight(sender) }
|
||||||
|
else { super.moveLeft(sender) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveHorizontally(forward: Bool, selecting: Bool, sender: Any?) {
|
||||||
|
let selection = selectedRange()
|
||||||
|
let tracked = selecting ? horizontalSelection : nil
|
||||||
|
if !selecting { horizontalSelection = nil }
|
||||||
|
let anchor: Int
|
||||||
|
let head: Int
|
||||||
|
var nativeHead: Int?
|
||||||
|
if let tracked {
|
||||||
|
anchor = tracked.anchor
|
||||||
|
head = tracked.head
|
||||||
|
} else if selecting, selection.length > 0 {
|
||||||
|
// Let AppKit identify the active end of a mouse or otherwise native selection.
|
||||||
|
moveNatively(forward: forward, selecting: true, sender: sender)
|
||||||
|
let moved = selectedRange()
|
||||||
|
guard moved != selection else { return }
|
||||||
|
anchor = moved.location == selection.location ? selection.location : NSMaxRange(selection)
|
||||||
|
head = anchor == selection.location ? NSMaxRange(selection) : selection.location
|
||||||
|
nativeHead = moved.location == anchor ? NSMaxRange(moved) : moved.location
|
||||||
|
} else {
|
||||||
|
anchor = selection.location
|
||||||
|
head = selection.location
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selecting || selection.length == 0),
|
||||||
|
let target = visualListBoundary(from: head, forward: forward) {
|
||||||
|
if selecting { setHorizontalSelection(anchor: anchor, head: target) }
|
||||||
|
else { setSelectedRange(NSRange(location: target, length: 0)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native movement supplies composed-character, bidi, and line-boundary behavior.
|
||||||
|
// Starting inside a null run already means "after the marker", so advance into the body.
|
||||||
|
let origin = listWhitespaceBoundary(from: head, forward: true)
|
||||||
|
if let nativeHead, origin == head {
|
||||||
|
let target = listWhitespaceBoundary(from: nativeHead, forward: true)
|
||||||
|
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tracked != nil || nativeHead != nil || (selection.length == 0 && origin != head) {
|
||||||
|
setSelectedRange(NSRange(location: origin, length: 0))
|
||||||
|
}
|
||||||
|
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
||||||
|
let moved = selectedRange()
|
||||||
|
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
|
||||||
|
let target = listWhitespaceBoundary(from: newHead, forward: true)
|
||||||
|
if selecting {
|
||||||
|
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
||||||
|
setHorizontalSelection(anchor: anchor, head: target)
|
||||||
|
}
|
||||||
|
} else if target != newHead { setSelectedRange(NSRange(location: target, length: 0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setHorizontalSelection(anchor: Int, head: Int) {
|
||||||
|
setSelectedRange(NSRange(location: min(anchor, head), length: abs(head - anchor)),
|
||||||
|
affinity: selectionAffinity, stillSelecting: false)
|
||||||
|
horizontalSelection = (anchor, head)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func moveRight(_ sender: Any?) {
|
||||||
|
moveHorizontally(forward: true, selecting: false, sender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func moveLeft(_ sender: Any?) {
|
||||||
|
moveHorizontally(forward: false, selecting: false, sender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func moveRightAndModifySelection(_ sender: Any?) {
|
||||||
|
moveHorizontally(forward: true, selecting: true, sender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func moveLeftAndModifySelection(_ sender: Any?) {
|
||||||
|
moveHorizontally(forward: false, selecting: true, sender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markdownSelectionWriter() -> NSTextView {
|
||||||
|
let writer = NSTextView()
|
||||||
|
writer.isRichText = isRichText
|
||||||
|
writer.importsGraphics = importsGraphics
|
||||||
|
writer.textStorage?.setAttributedString(Self.serialize(attributedString().attributedSubstring(from: selectedRange())))
|
||||||
|
writer.setSelectedRange(NSRange(location: 0, length: writer.string.utf16.count))
|
||||||
|
return writer
|
||||||
|
}
|
||||||
|
|
||||||
|
override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
||||||
|
markdownSelectionWriter().writeSelection(to: pboard, type: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool {
|
||||||
|
markdownSelectionWriter().writeSelection(to: pboard, types: types)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
||||||
|
guard isEditable else { return false }
|
||||||
|
// Let AppKit decode its supported rich text, file and image formats without touching this undo stack.
|
||||||
|
let reader = NSTextView()
|
||||||
|
reader.isRichText = isRichText
|
||||||
|
reader.importsGraphics = importsGraphics
|
||||||
|
guard reader.readSelection(from: pboard, type: type) else { return false }
|
||||||
|
replaceSource(Self.serialize(reader.attributedString(), keepingMetadata: true),
|
||||||
|
range: markdownInsertionRange(for: selectedRange()), semantic: true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func cut(_ sender: Any?) {
|
||||||
|
guard isEditable, selectedRange().length > 0 else { return }
|
||||||
|
copy(sender)
|
||||||
|
replaceMarkdown("", range: markdownRange(for: selectedRange()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didChangeText() {
|
||||||
|
horizontalSelection = nil
|
||||||
|
refreshTasks()
|
||||||
|
super.didChangeText()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTasks() {
|
||||||
|
tasks = []
|
||||||
|
let contents = attributedString()
|
||||||
|
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
||||||
|
if let attachment = value as? ListAttachment { tasks.append((range, attachment)) }
|
||||||
|
}
|
||||||
|
let taskIndices = tasks.indices.filter { tasks[$0].attachment.isTask }
|
||||||
|
while checkboxes.count > taskIndices.count {
|
||||||
|
let checkbox = checkboxes.removeLast()
|
||||||
|
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
|
||||||
|
checkbox.removeFromSuperview()
|
||||||
|
}
|
||||||
|
while checkboxes.count < taskIndices.count {
|
||||||
|
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
||||||
|
checkbox.controlSize = .small
|
||||||
|
checkbox.contentTintColor = SunsetPalette.accent
|
||||||
|
checkbox.sizeToFit()
|
||||||
|
checkboxes.append(checkbox)
|
||||||
|
addSubview(checkbox)
|
||||||
|
}
|
||||||
|
let source = string as NSString
|
||||||
|
for (index, taskIndex) in taskIndices.enumerated() {
|
||||||
|
let task = tasks[taskIndex]
|
||||||
|
let checkbox = checkboxes[index]
|
||||||
|
checkbox.tag = taskIndex
|
||||||
|
checkbox.state = task.attachment.checked ? .on : .off
|
||||||
|
checkbox.isEnabled = isEditable
|
||||||
|
let end = NSMaxRange(source.lineRange(for: task.range))
|
||||||
|
let title = source.substring(with: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range)))
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
||||||
|
checkbox.toolTip = title
|
||||||
|
}
|
||||||
|
applyListLayout()
|
||||||
|
positionCheckboxes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyListLayout() {
|
||||||
|
guard let storage = textStorage, let layoutManager else { return }
|
||||||
|
let range = NSRange(location: 0, length: storage.length)
|
||||||
|
layoutManager.delegate = self
|
||||||
|
storage.beginEditing()
|
||||||
|
Self.restoreListLayout(in: storage)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
let space = (" " as NSString).size(withAttributes: [.font: markerFont]).width
|
||||||
|
let source = storage.string as NSString
|
||||||
|
for task in tasks {
|
||||||
|
let paragraph = source.paragraphRange(for: task.range)
|
||||||
|
let indentation = source.substring(with: NSRange(location: paragraph.location, length: task.range.location - paragraph.location))
|
||||||
|
let depth = indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
||||||
|
let style = (storage.attribute(.paragraphStyle, at: task.range.location, effectiveRange: nil) as? NSParagraphStyle
|
||||||
|
?? NSParagraphStyle.default).mutableCopy() as! NSMutableParagraphStyle
|
||||||
|
storage.enumerateAttribute(.paragraphStyle, in: paragraph) { original, run, _ in
|
||||||
|
storage.addAttribute(sourceListParagraph, value: original ?? NSNull(), range: run)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
storage.addAttribute(.paragraphStyle, value: style, range: paragraph)
|
||||||
|
let separator = NSMaxRange(task.range)
|
||||||
|
if separator < NSMaxRange(paragraph), source.character(at: separator) == 32 || source.character(at: separator) == 9 {
|
||||||
|
storage.addAttribute(listWhitespace, value: true,
|
||||||
|
range: NSRange(location: separator, length: 1))
|
||||||
|
}
|
||||||
|
(task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column
|
||||||
|
task.attachment.bounds.size.width = column
|
||||||
|
}
|
||||||
|
storage.endEditing()
|
||||||
|
// Only the required separator is hidden; additional whitespace is visible item-body text.
|
||||||
|
layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>,
|
||||||
|
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
||||||
|
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
||||||
|
forGlyphRange glyphRange: NSRange) -> Int {
|
||||||
|
guard let storage = layoutManager.textStorage,
|
||||||
|
(0..<glyphRange.length).contains(where: {
|
||||||
|
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
|
||||||
|
}) else { return 0 }
|
||||||
|
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
||||||
|
for index in properties.indices {
|
||||||
|
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
|
||||||
|
properties[index] = .null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layoutManager.setGlyphs(glyphs, properties: &properties, characterIndexes: charIndexes,
|
||||||
|
font: aFont, forGlyphRange: glyphRange)
|
||||||
|
return glyphRange.length
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
positionCheckboxes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func positionCheckboxes() {
|
||||||
|
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
|
||||||
|
layoutManager.ensureLayout(for: textContainer)
|
||||||
|
for checkbox in checkboxes {
|
||||||
|
let task = tasks[checkbox.tag]
|
||||||
|
let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
||||||
|
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
||||||
|
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
||||||
|
checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleTask(_ sender: NSButton) {
|
||||||
|
guard isEditable, tasks.indices.contains(sender.tag), tasks[sender.tag].attachment.isTask else { return }
|
||||||
|
let task = tasks[sender.tag]
|
||||||
|
let selection = selectedRange()
|
||||||
|
let marker = task.attachment.marker as NSString
|
||||||
|
let range = markdownRange(for: task.range)
|
||||||
|
let opening = marker.range(of: "[", options: .backwards).location
|
||||||
|
replaceMarkdown(task.attachment.checked ? " " : "x",
|
||||||
|
range: NSRange(location: range.location + opening + 1,
|
||||||
|
length: marker.length - opening - 2))
|
||||||
|
setSelectedRange(selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,2 @@
|
|||||||
- [x] bugfix: the emoji picker currently doesn't scroll with the selection (arrows or ctrl+hjkl), only with mouse scroll.
|
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
||||||
- the window should scroll with the current selection
|
- track down the cause and fix it.
|
||||||
- [x] make escape close the window that was opened, be it the application launcher, the emoji picker, or something else.
|
|
||||||
- clicking the "back" chevron should still take you back to the application launcher view though (as it does today).
|
|
||||||
- [x] remove window open/fade in/transition animation when opening conjure from keybindings
|
|
||||||
- this is already the case for the application launcher window.
|
|
||||||
- evidently not the case for the emoji picker (though it should be ubiquitous)
|
|
||||||
- [x] add a notes app inspired by raycast notes
|
|
||||||
- support multiple different notes
|
|
||||||
- support switching between notes
|
|
||||||
- highlight markdown syntax
|
|
||||||
- allow images
|
|
||||||
- [x] put the border already in use on some elements around the emoji containers in the emoji picker.
|
|
||||||
- [x] improve on the conjure translation command
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ConjureKit
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
|
|
||||||
@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-actions", "translate-providers", "notes-editor"])
|
@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-actions", "translate-providers", "notes-editor", "notes-browse", "notes-actions"])
|
||||||
@MainActor func escapeClosesPickerBeforeDismissingLauncher(_ destination: String) throws {
|
@MainActor func escapeClosesPickerBeforeDismissingLauncher(_ destination: String) throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "ConjureEscape.\(UUID().uuidString)"
|
let suite = "ConjureEscape.\(UUID().uuidString)"
|
||||||
@@ -58,8 +58,18 @@ import Testing
|
|||||||
$0.accessibilityLabel() == "Japanese translation"
|
$0.accessibilityLabel() == "Japanese translation"
|
||||||
})
|
})
|
||||||
window.makeFirstResponder(output)
|
window.makeFirstResponder(output)
|
||||||
|
} else if destination == "notes-browse" || destination == "notes-actions" {
|
||||||
|
let shortcut = try #require(NSEvent.keyEvent(
|
||||||
|
with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 1,
|
||||||
|
windowNumber: window.windowNumber, context: nil,
|
||||||
|
characters: destination == "notes-browse" ? "p" : "k",
|
||||||
|
charactersIgnoringModifiers: destination == "notes-browse" ? "p" : "k",
|
||||||
|
isARepeat: false, keyCode: destination == "notes-browse" ? 35 : 40))
|
||||||
|
#expect(window.performKeyEquivalent(with: shortcut))
|
||||||
} else if destination == "notes-editor" {
|
} else if destination == "notes-editor" {
|
||||||
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first {
|
||||||
|
$0.accessibilityLabel() == "Note editor"
|
||||||
|
})
|
||||||
window.makeFirstResponder(editor)
|
window.makeFirstResponder(editor)
|
||||||
}
|
}
|
||||||
#expect(window.isVisible)
|
#expect(window.isVisible)
|
||||||
@@ -68,11 +78,21 @@ import Testing
|
|||||||
windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
|
windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
|
||||||
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
|
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
|
||||||
window.sendEvent(escape)
|
window.sendEvent(escape)
|
||||||
if destination.hasSuffix("-actions") || destination == "translate-providers" {
|
if destination.hasSuffix("-actions") || destination == "translate-providers" || destination == "notes-browse" {
|
||||||
#expect(window.isVisible)
|
#expect(window.isVisible)
|
||||||
|
if destination.hasPrefix("notes") {
|
||||||
|
let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first {
|
||||||
|
$0.accessibilityLabel() == "Note editor"
|
||||||
|
})
|
||||||
|
#expect(window.firstResponder === editor)
|
||||||
|
} else {
|
||||||
#expect(window.firstResponder === search.currentEditor())
|
#expect(window.firstResponder === search.currentEditor())
|
||||||
|
}
|
||||||
window.sendEvent(escape)
|
window.sendEvent(escape)
|
||||||
}
|
}
|
||||||
#expect(!window.isVisible)
|
#expect(!window.isVisible)
|
||||||
#expect(!descendants(content).contains { $0 is NSCollectionView })
|
#expect(!descendants(content).contains { $0 is NSCollectionView })
|
||||||
|
#expect(!descendants(content).compactMap { $0 as? NSSearchField }.contains {
|
||||||
|
$0.accessibilityLabel() == "Search notes" || $0.accessibilityLabel() == "Search notes actions"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,4 +118,91 @@ struct LanguagePickerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notesShareScrollableFuzzySelectionAndKeepDuplicateTitlesDistinct() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||||
|
styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
let host = try #require(window.contentView)
|
||||||
|
let pill = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36))
|
||||||
|
let anchor = NSButton(frame: pill.bounds)
|
||||||
|
pill.addSubview(anchor)
|
||||||
|
host.addSubview(pill)
|
||||||
|
let popup = ApplicationActionsController()
|
||||||
|
defer { popup.close(animated: false); window.orderOut(nil) }
|
||||||
|
var notes = (0..<20).map { (id: UUID(), title: String(format: "Note %02d", $0)) }
|
||||||
|
notes[18].title = "Shared Plan"
|
||||||
|
notes[19].title = "Shared Plan"
|
||||||
|
var selected: UUID?
|
||||||
|
popup.selectNote = { selected = $0 }
|
||||||
|
popup.showNotes(notes, selected: notes[19].id, relativeTo: anchor)
|
||||||
|
host.layoutSubtreeIfNeeded()
|
||||||
|
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
|
||||||
|
#expect(table.accessibilityLabel() == "Notes")
|
||||||
|
#expect(table.numberOfRows == 20)
|
||||||
|
#expect(table.selectedRow == 19)
|
||||||
|
#expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect)))
|
||||||
|
#expect(table.visibleRect.height < table.bounds.height)
|
||||||
|
let currentCell = try #require(table.view(atColumn: 0, row: 19, makeIfNecessary: true))
|
||||||
|
let current = try #require(descendants(currentCell).compactMap { $0 as? NSButton }.first)
|
||||||
|
#expect(current.accessibilityValue() as? String == "Current note")
|
||||||
|
popup.moveSelection(by: -1)
|
||||||
|
#expect(popup.handle(#selector(NSResponder.insertNewline(_:))))
|
||||||
|
#expect(selected == notes[18].id)
|
||||||
|
popup.close(animated: false)
|
||||||
|
|
||||||
|
popup.showNotes(notes, selected: notes[19].id, relativeTo: anchor)
|
||||||
|
let search = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
#expect(search.accessibilityLabel() == "Search notes")
|
||||||
|
search.stringValue = "shpl"
|
||||||
|
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
let filteredTable = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
|
||||||
|
#expect(filteredTable.numberOfRows == 2)
|
||||||
|
popup.moveSelection(by: 1)
|
||||||
|
#expect(popup.handle(#selector(NSResponder.insertNewline(_:))))
|
||||||
|
#expect(selected == notes[19].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notesActionsSearchAndCommandShortcutsUseTheSharedPopup() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||||
|
styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
let host = try #require(window.contentView)
|
||||||
|
let pill = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36))
|
||||||
|
let anchor = NSButton(frame: pill.bounds)
|
||||||
|
pill.addSubview(anchor)
|
||||||
|
host.addSubview(pill)
|
||||||
|
let popup = ApplicationActionsController()
|
||||||
|
defer { popup.close(animated: false); window.orderOut(nil) }
|
||||||
|
var browsed = 0
|
||||||
|
var created = 0
|
||||||
|
popup.browseNotes = { browsed += 1 }
|
||||||
|
popup.newNote = { created += 1 }
|
||||||
|
|
||||||
|
popup.showNotesActions(relativeTo: anchor)
|
||||||
|
let search = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
#expect(search.accessibilityLabel() == "Search notes actions")
|
||||||
|
search.stringValue = "new"
|
||||||
|
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
#expect(popup.handle(#selector(NSResponder.insertNewline(_:))))
|
||||||
|
#expect(created == 1)
|
||||||
|
popup.close(animated: false)
|
||||||
|
|
||||||
|
for key in ["p", "n"] {
|
||||||
|
popup.showNotesActions(relativeTo: anchor)
|
||||||
|
let field = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command,
|
||||||
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: key,
|
||||||
|
charactersIgnoringModifiers: key, isARepeat: false, keyCode: key == "p" ? 35 : 45))
|
||||||
|
#expect(field.performKeyEquivalent(with: event))
|
||||||
|
#expect(!popup.isShown)
|
||||||
|
popup.close(animated: false)
|
||||||
|
}
|
||||||
|
#expect(browsed == 1)
|
||||||
|
#expect(created == 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user