Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 090c9045a4 | |||
| 63d0387339 | |||
| 0d9f7e0f00 | |||
| 662200e684 | |||
| afa27b941f | |||
| fea0041302 | |||
| a41b526b58 | |||
| 2e4a791f29 |
@@ -3,9 +3,11 @@
|
|||||||
APP := .build/Conjure.app
|
APP := .build/Conjure.app
|
||||||
INSTALL_DIR ?= /Applications
|
INSTALL_DIR ?= /Applications
|
||||||
CODE_SIGN_IDENTITY ?= -
|
CODE_SIGN_IDENTITY ?= -
|
||||||
|
# Keep AppKit on the current SDK appearance, not deployment-target compatibility styling.
|
||||||
|
PLATFORM_FLAGS := -Xlinker -platform_version -Xlinker macos -Xlinker 14.0 -Xlinker $(shell xcrun --show-sdk-version)
|
||||||
|
|
||||||
build:
|
build:
|
||||||
swift build -c release
|
swift build -c release $(PLATFORM_FLAGS)
|
||||||
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
|
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
|
||||||
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
|
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
|
||||||
cp Resources/Info.plist $(APP)/Contents/Info.plist
|
cp Resources/Info.plist $(APP)/Contents/Info.plist
|
||||||
@@ -21,4 +23,4 @@ install: build
|
|||||||
swift Scripts/install.swift "$(APP)" "$(INSTALL_DIR)"
|
swift Scripts/install.swift "$(APP)" "$(INSTALL_DIR)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
swift test
|
swift test $(PLATFORM_FLAGS)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ A native macOS launcher for applications, calculations, unit and currency conver
|
|||||||
|
|
||||||
Requires macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later).
|
Requires macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later).
|
||||||
|
|
||||||
|
The Makefile links with the active Xcode SDK version while retaining the macOS 14 deployment target. This keeps AppKit’s native window controls on the current system appearance instead of older-SDK compatibility styling. Use `make build` / `make run` for this build configuration; plain `swift build` does not include the explicit linker platform flags.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make run # Build and launch .build/Conjure.app.
|
make run # Build and launch .build/Conjure.app.
|
||||||
make build # Build without launching.
|
make build # Build without launching.
|
||||||
@@ -40,7 +42,7 @@ Favorited launcher entries show a small star before their **Application** or **C
|
|||||||
|
|
||||||
Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it.
|
Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it.
|
||||||
|
|
||||||
Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background to reposition it; its position is remembered.
|
Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position.
|
||||||
|
|
||||||
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
|
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
|
||||||
|
|
||||||
@@ -135,15 +137,17 @@ 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. 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.
|
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with 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. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close, **Actions** (⌘), **Browse Notes** (note icon), and **New Note** (+). Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker below the title bar, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
|
||||||
|
|
||||||
Actions (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.
|
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses monospaced text, including Markdown headings, bold, and italic, while retaining Conjure’s warm palette. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
|
||||||
|
|
||||||
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.
|
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. 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, load, or deletion errors appear below the editor.
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
||||||
|
|
||||||
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.
|
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 change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. 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 `[ ]`.
|
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 `[ ]`.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
var selectNote: ((UUID) -> Void)?
|
var selectNote: ((UUID) -> Void)?
|
||||||
var browseNotes: (() -> Void)?
|
var browseNotes: (() -> Void)?
|
||||||
var newNote: (() -> Void)?
|
var newNote: (() -> Void)?
|
||||||
|
var deleteNote: (() -> Void)?
|
||||||
private enum ChoiceID: Equatable {
|
private enum ChoiceID: Equatable {
|
||||||
case language(String)
|
case language(String)
|
||||||
case note(UUID)
|
case note(UUID)
|
||||||
@@ -84,7 +85,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
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: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
||||||
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N")
|
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
|
||||||
|
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed)
|
||||||
]
|
]
|
||||||
view = ActionsPanelView()
|
view = ActionsPanelView()
|
||||||
view.wantsLayer = true
|
view.wantsLayer = true
|
||||||
@@ -257,7 +259,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showNotesActions(relativeTo anchor: NSView) {
|
func showNotesActions(relativeTo anchor: NSView) {
|
||||||
show(title: "Notes", relativeTo: anchor, available: [12, 13],
|
show(title: "Notes", relativeTo: anchor, available: [12, 13, 14],
|
||||||
searchAccessibilityLabel: "Search notes actions", belowAnchor: true)
|
searchAccessibilityLabel: "Search notes actions", belowAnchor: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +466,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleCommandKey(_ key: String) -> Bool {
|
private func handleCommandKey(_ key: String) -> Bool {
|
||||||
guard isShown, availableEntries == [12, 13] else { return false }
|
guard isShown, availableEntries == [12, 13, 14] else { return false }
|
||||||
switch key {
|
switch key {
|
||||||
case "p": close(); browseNotes?()
|
case "p": close(); browseNotes?()
|
||||||
case "n": close(); newNote?()
|
case "n": close(); newNote?()
|
||||||
@@ -537,6 +539,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
case 11: selectProvider?(true)
|
case 11: selectProvider?(true)
|
||||||
case 12: browseNotes?()
|
case 12: browseNotes?()
|
||||||
case 13: newNote?()
|
case 13: newNote?()
|
||||||
|
case 14: deleteNote?()
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var previousApplication: NSRunningApplication?
|
private var previousApplication: NSRunningApplication?
|
||||||
private var alignmentGuides: LauncherAlignmentGuides?
|
private var alignmentGuides: LauncherAlignmentGuides?
|
||||||
private static let positionKey = "launcherPosition"
|
private static let positionKey = "launcherPosition"
|
||||||
|
private static let notesSizeKey = "notesWindowSize"
|
||||||
private let uninstaller: ApplicationUninstaller
|
private let uninstaller: ApplicationUninstaller
|
||||||
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
|
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
|
||||||
private let actions = ApplicationActionsController()
|
private let actions = ApplicationActionsController()
|
||||||
@@ -69,13 +70,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
self.pasteboard = pasteboard
|
self.pasteboard = pasteboard
|
||||||
self.notesDirectory = notesDirectory
|
self.notesDirectory = notesDirectory
|
||||||
self.toggleSystemTheme = toggleSystemTheme
|
self.toggleSystemTheme = toggleSystemTheme
|
||||||
let window = LauncherPanel(
|
let window = LauncherWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||||
styleMask: [.borderless],
|
styleMask: [.borderless],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.title = "Conjure"
|
window.title = "Conjure"
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
window.animationBehavior = .none
|
window.animationBehavior = .none
|
||||||
window.appearance = NSAppearance(named: .darkAqua)
|
window.appearance = NSAppearance(named: .darkAqua)
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .clear
|
||||||
@@ -146,7 +149,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
guard closeCommand() else { return }
|
guard closeCommand() else { return }
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
(window as? LauncherPanel)?.beginNumberHintSession()
|
(window as? LauncherWindow)?.beginNumberHintSession()
|
||||||
let frontmost = NSWorkspace.shared.frontmostApplication
|
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||||||
previousApplication = frontmost
|
previousApplication = frontmost
|
||||||
@@ -156,6 +159,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
updateResults()
|
updateResults()
|
||||||
if let command {
|
if let command {
|
||||||
executeCommand(command)
|
executeCommand(command)
|
||||||
|
// Validate the final size; compact Notes may fit where the launcher did not.
|
||||||
|
if command == .notes { restorePosition() }
|
||||||
}
|
}
|
||||||
window?.deminiaturize(nil)
|
window?.deminiaturize(nil)
|
||||||
showWindow(nil)
|
showWindow(nil)
|
||||||
@@ -163,7 +168,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
if let notes { notes.focusEditor() }
|
if let notes { notes.focusEditor() }
|
||||||
else { window?.makeFirstResponder(searchField) }
|
else { window?.makeFirstResponder(searchField) }
|
||||||
(window as? LauncherPanel)?.sampleNumberHints()
|
(window as? LauncherWindow)?.sampleNumberHints()
|
||||||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||||
refreshApplications()
|
refreshApplications()
|
||||||
}
|
}
|
||||||
@@ -200,22 +205,22 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
private func savePosition() {
|
private func savePosition() {
|
||||||
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
||||||
UserDefaults.standard.set([
|
defaults.set([
|
||||||
"display": identifier, "x": window.frame.minX, "y": window.frame.minY
|
"display": identifier, "centerX": window.frame.midX, "centerY": window.frame.midY
|
||||||
], forKey: Self.positionKey)
|
], forKey: Self.positionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restorePosition() {
|
private func restorePosition() {
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
let saved = UserDefaults.standard.dictionary(forKey: Self.positionKey)
|
let saved = defaults.dictionary(forKey: Self.positionKey)
|
||||||
let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String }
|
let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String }
|
||||||
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
||||||
var origin: CGPoint?
|
var center: CGPoint?
|
||||||
if savedScreen != nil, let x = saved?["x"] as? Double, let y = saved?["y"] as? Double {
|
if savedScreen != nil, let x = saved?["centerX"] as? Double, let y = saved?["centerY"] as? Double {
|
||||||
origin = CGPoint(x: x, y: y)
|
center = CGPoint(x: x, y: y)
|
||||||
}
|
}
|
||||||
let frame = LauncherAlignment.restoredFrame(
|
let frame = LauncherAlignment.restoredFrame(
|
||||||
savedOrigin: origin, windowSize: window.frame.size, visibleFrame: screen.visibleFrame
|
savedCenter: center, windowSize: window.frame.size, visibleFrame: screen.visibleFrame
|
||||||
)
|
)
|
||||||
if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) }
|
if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) }
|
||||||
}
|
}
|
||||||
@@ -238,8 +243,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
alignmentGuides?.update(for: window)
|
alignmentGuides?.update(for: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windowDidResize(_ notification: Notification) {
|
||||||
|
guard notes != nil, let window, window.styleMask.contains(.resizable) else { return }
|
||||||
|
defaults.set(NSStringFromSize(window.frame.size), forKey: Self.notesSizeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
dismiss()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherWindow)?.resetNumberHints()
|
||||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||||
dismiss(restoringFocus: false)
|
dismiss(restoringFocus: false)
|
||||||
}
|
}
|
||||||
@@ -433,7 +448,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
currency.update(query)
|
currency.update(query)
|
||||||
isCurrencyCalculation = currency.isCurrency
|
isCurrencyCalculation = currency.isCurrency
|
||||||
calculation = currency.result
|
calculation = currency.result
|
||||||
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
|
if calculation != nil { (window as? LauncherWindow)?.resetNumberHints() }
|
||||||
if calculation != nil {
|
if calculation != nil {
|
||||||
results = []
|
results = []
|
||||||
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
@@ -463,7 +478,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands"
|
statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands"
|
||||||
}
|
}
|
||||||
updateOpenButton()
|
updateOpenButton()
|
||||||
(window as? LauncherPanel)?.sampleNumberHints()
|
(window as? LauncherWindow)?.sampleNumberHints()
|
||||||
}
|
}
|
||||||
|
|
||||||
func controlTextDidChange(_ notification: Notification) {
|
func controlTextDidChange(_ notification: Notification) {
|
||||||
@@ -606,7 +621,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
|
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
|
||||||
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
|
let visible = (window as? LauncherWindow)?.numberHintsVisible == true
|
||||||
&& calculation == nil && row < 9 && row < resultCount && !isCommandOpen
|
&& calculation == nil && row < 9 && row < resultCount && !isCommandOpen
|
||||||
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
||||||
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
|
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
|
||||||
@@ -631,7 +646,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
@objc private func showActions() {
|
@objc private func showActions() {
|
||||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||||||
guard !isCommandOpen else { return }
|
guard !isCommandOpen else { return }
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherWindow)?.resetNumberHints()
|
||||||
let application = results[tableView.selectedRow]
|
let application = results[tableView.selectedRow]
|
||||||
actions.open = { [weak self] in self?.openApplication(application) }
|
actions.open = { [weak self] in self?.openApplication(application) }
|
||||||
actions.isFavorite = history.isFavorite(application)
|
actions.isFavorite = history.isFavorite(application)
|
||||||
@@ -680,7 +695,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
||||||
])
|
])
|
||||||
for view in launcherViews { view.setAccessibilityHidden(true) }
|
for view in launcherViews { view.setAccessibilityHidden(true) }
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherWindow)?.resetNumberHints()
|
||||||
openButton.keyEquivalent = ""
|
openButton.keyEquivalent = ""
|
||||||
updateOpenButton()
|
updateOpenButton()
|
||||||
var frame = window.frame
|
var frame = window.frame
|
||||||
@@ -751,7 +766,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
executeCommand(command, dismissOnSuccess: true)
|
executeCommand(command, dismissOnSuccess: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherWindow)?.resetNumberHints()
|
||||||
isLaunching = true
|
isLaunching = true
|
||||||
updateOpenButton()
|
updateOpenButton()
|
||||||
Task {
|
Task {
|
||||||
@@ -906,8 +921,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
self.searchField.isHidden = false
|
self.searchField.isHidden = false
|
||||||
self.launcherViews.first?.isHidden = false
|
self.launcherViews.first?.isHidden = false
|
||||||
self.launcherViews.first?.setAccessibilityHidden(false)
|
self.launcherViews.first?.setAccessibilityHidden(false)
|
||||||
if command == .notes, let previousFrame {
|
if command == .notes, let previousFrame, let window = self.window {
|
||||||
self.window?.setFrame(previousFrame, display: true)
|
let frame = LauncherAlignment.restoredFrame(
|
||||||
|
savedCenter: CGPoint(x: window.frame.midX, y: window.frame.midY),
|
||||||
|
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
||||||
|
window.styleMask = [.borderless]
|
||||||
|
window.minSize = .zero
|
||||||
|
window.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||||||
}
|
}
|
||||||
self.activeCommand = nil
|
self.activeCommand = nil
|
||||||
@@ -930,7 +951,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
emojiPicker?.closed = close
|
emojiPicker?.closed = close
|
||||||
notes?.closed = close
|
notes?.closed = close
|
||||||
actions.close(animated: false)
|
actions.close(animated: false)
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherWindow)?.resetNumberHints()
|
||||||
openButton.keyEquivalent = ""
|
openButton.keyEquivalent = ""
|
||||||
searchSymbol.isHidden = true
|
searchSymbol.isHidden = true
|
||||||
commandBack.setAccessibilityLabel("Back to applications")
|
commandBack.setAccessibilityLabel("Back to applications")
|
||||||
@@ -945,13 +966,23 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
)
|
)
|
||||||
searchField.setAccessibilityLabel(accessibilityLabel)
|
searchField.setAccessibilityLabel(accessibilityLabel)
|
||||||
if command == .notes {
|
if command == .notes {
|
||||||
|
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
|
||||||
|
?? NSSize(width: 570, height: 404)
|
||||||
|
window?.appearance = nil
|
||||||
|
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
||||||
|
window?.minSize = NSSize(width: 570, height: 260)
|
||||||
|
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||||
|
window?.standardWindowButton(.zoomButton)?.isHidden = true
|
||||||
launcherViews.first?.isHidden = true
|
launcherViews.first?.isHidden = true
|
||||||
launcherViews.first?.setAccessibilityHidden(true)
|
launcherViews.first?.setAccessibilityHidden(true)
|
||||||
if var frame = window?.frame {
|
if let previousFrame, let window {
|
||||||
frame.origin.x += (frame.width - 570) / 2
|
let visibleFrame = window.screen?.visibleFrame ?? previousFrame
|
||||||
frame.origin.y = frame.maxY - 404
|
let size = NSSize(width: min(max(savedSize.width, 570), visibleFrame.width),
|
||||||
frame.size = NSSize(width: 570, height: 404)
|
height: min(max(savedSize.height, 260), visibleFrame.height))
|
||||||
window?.setFrame(frame, display: true)
|
let frame = LauncherAlignment.restoredFrame(
|
||||||
|
savedCenter: CGPoint(x: previousFrame.midX, y: previousFrame.midY),
|
||||||
|
windowSize: size, visibleFrame: visibleFrame)
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
}
|
}
|
||||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
|
||||||
}
|
}
|
||||||
@@ -1203,7 +1234,7 @@ private final class ApplicationTableView: NSTableView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LauncherPanel: NSPanel {
|
final class LauncherWindow: NSWindow {
|
||||||
override var canBecomeKey: Bool { true }
|
override var canBecomeKey: Bool { true }
|
||||||
override var canBecomeMain: Bool { true }
|
override var canBecomeMain: Bool { true }
|
||||||
var navigateSelection: ((Int) -> Void)?
|
var navigateSelection: ((Int) -> Void)?
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Highlighter
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum NotesAppearance {
|
enum NotesAppearance {
|
||||||
static let font = NSFont.systemFont(ofSize: 17)
|
static let font = NSFont.monospacedSystemFont(ofSize: 17, weight: .regular)
|
||||||
static let paragraph: NSParagraphStyle = {
|
static let paragraph: NSParagraphStyle = {
|
||||||
let style = NSMutableParagraphStyle()
|
let style = NSMutableParagraphStyle()
|
||||||
style.lineSpacing = 5
|
style.lineSpacing = 5
|
||||||
@@ -45,21 +45,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
let content = NotesView()
|
let content = NotesView()
|
||||||
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
|
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
|
||||||
view = content
|
view = content
|
||||||
let close = iconButton("circle.fill", label: "Close notes", action: #selector(closeWindow))
|
|
||||||
close.contentTintColor = SunsetPalette.accent
|
|
||||||
close.isBordered = false
|
|
||||||
close.image = close.image?.withSymbolConfiguration(.init(paletteColors: [SunsetPalette.accent]))
|
|
||||||
let lights = NSStackView(views: [close])
|
|
||||||
lights.spacing = 8
|
|
||||||
for _ in 0..<2 {
|
|
||||||
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.target = self
|
||||||
browseButton.action = #selector(browseNotes)
|
browseButton.action = #selector(browseNotes)
|
||||||
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
|
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
|
||||||
@@ -76,22 +61,22 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
noteTitle.lineBreakMode = .byTruncatingTail
|
noteTitle.lineBreakMode = .byTruncatingTail
|
||||||
noteTitle.setAccessibilityLabel("Note title")
|
noteTitle.setAccessibilityLabel("Note title")
|
||||||
let header = NSView()
|
let header = NSView()
|
||||||
for control in [lights, noteTitle, controls] {
|
for control in [noteTitle, controls] {
|
||||||
control.translatesAutoresizingMaskIntoConstraints = false
|
control.translatesAutoresizingMaskIntoConstraints = false
|
||||||
header.addSubview(control)
|
header.addSubview(control)
|
||||||
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
lights.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 10),
|
|
||||||
controls.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8),
|
controls.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8),
|
||||||
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||||||
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: lights.trailingAnchor, constant: 16),
|
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
|
||||||
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: controls.leadingAnchor, constant: -16),
|
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: controls.leadingAnchor, constant: -16),
|
||||||
])
|
])
|
||||||
actions.closed = { [weak self] in self?.focusEditor() }
|
actions.closed = { [weak self] in self?.focusEditor() }
|
||||||
actions.selectNote = { [weak self] in self?.switchNote($0) }
|
actions.selectNote = { [weak self] in self?.switchNote($0) }
|
||||||
actions.newNote = { [weak self] in self?.newNote() }
|
actions.newNote = { [weak self] in self?.newNote() }
|
||||||
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
||||||
|
actions.deleteNote = { [weak self] in self?.deleteNote() }
|
||||||
let tools = [
|
let tools = [
|
||||||
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
||||||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||||||
@@ -202,10 +187,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func closeWindow() {
|
|
||||||
guard save() else { return }
|
|
||||||
view.window?.performClose(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func toggleFormatting(_ sender: NSButton) {
|
@objc private func toggleFormatting(_ sender: NSButton) {
|
||||||
formatting.isHidden.toggle()
|
formatting.isHidden.toggle()
|
||||||
@@ -365,9 +346,77 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
guard indenting || removed > 0 else { return true }
|
guard indenting || removed > 0 else { return true }
|
||||||
let replacement = indenting ? " " : ""
|
let replacement = indenting ? " " : ""
|
||||||
let delta = replacement.utf16.count - removed
|
let delta = replacement.utf16.count - removed
|
||||||
let selectedStart = max(start, selection.location + delta)
|
var selectedStart = max(start, selection.location + delta)
|
||||||
let selectedEnd = max(start, NSMaxRange(selection) + delta)
|
var selectedEnd = max(start, NSMaxRange(selection) + delta)
|
||||||
replaceListMarkdown(replacement, range: NSRange(location: start, length: removed),
|
func isListLine(_ range: NSRange) -> Bool {
|
||||||
|
Self.listMatch(in: source.substring(with: range) as NSString) != nil
|
||||||
|
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
|
||||||
|
}
|
||||||
|
var block = source.lineRange(for: NSRange(location: start, length: 0))
|
||||||
|
while block.location > 0 {
|
||||||
|
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
|
||||||
|
guard isListLine(previous) else { break }
|
||||||
|
block = NSUnionRange(previous, block)
|
||||||
|
}
|
||||||
|
while NSMaxRange(block) < source.length {
|
||||||
|
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
|
||||||
|
guard isListLine(next) else { break }
|
||||||
|
block = NSUnionRange(block, next)
|
||||||
|
}
|
||||||
|
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
|
||||||
|
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
|
||||||
|
let changed = body.string as NSString
|
||||||
|
let oldDepth = Self.indentationWidth(indent)
|
||||||
|
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
|
||||||
|
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
|
||||||
|
var edits: [(range: NSRange, number: String)] = []
|
||||||
|
var oldLevel = true, newLevel = true
|
||||||
|
var offset = 0
|
||||||
|
while offset < changed.length {
|
||||||
|
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
|
||||||
|
let text = changed.substring(with: row) as NSString
|
||||||
|
guard let match = Self.listMatch(in: text) else { break }
|
||||||
|
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
|
||||||
|
if offset + block.location > start {
|
||||||
|
if depth < oldDepth { oldLevel = false }
|
||||||
|
if depth < newDepth { newLevel = false }
|
||||||
|
}
|
||||||
|
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
|
||||||
|
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
|
||||||
|
var number: UInt64?
|
||||||
|
var delimiter = ""
|
||||||
|
if match.range(at: 3).location != NSNotFound {
|
||||||
|
number = UInt64(text.substring(with: match.range(at: 3)))
|
||||||
|
delimiter = text.substring(with: match.range(at: 4))
|
||||||
|
if offset + block.location >= start,
|
||||||
|
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
|
||||||
|
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
|
||||||
|
number = previous + 1
|
||||||
|
} else if !ancestors.isEmpty || offset + block.location > start {
|
||||||
|
number = 1
|
||||||
|
}
|
||||||
|
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
|
||||||
|
let digits = match.range(at: 3)
|
||||||
|
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ancestors.append((depth, number, delimiter))
|
||||||
|
offset = NSMaxRange(row)
|
||||||
|
}
|
||||||
|
for edit in edits.reversed() {
|
||||||
|
body.replaceCharacters(in: edit.range, with: edit.number)
|
||||||
|
let location = block.location + edit.range.location
|
||||||
|
let difference = edit.number.utf16.count - edit.range.length
|
||||||
|
func adjusted(_ position: Int) -> Int {
|
||||||
|
if position <= location { return position }
|
||||||
|
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
|
||||||
|
return position + difference
|
||||||
|
}
|
||||||
|
selectedStart = adjusted(selectedStart)
|
||||||
|
selectedEnd = adjusted(selectedEnd)
|
||||||
|
}
|
||||||
|
replaceMarkdown(body, range: block,
|
||||||
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -461,6 +510,37 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
catch { showError(error) }
|
catch { showError(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func deleteNote() {
|
||||||
|
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||||||
|
actions.close(animated: false)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
|
||||||
|
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
|
||||||
|
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}"
|
||||||
|
let delete = alert.addButton(withTitle: "Delete Note")
|
||||||
|
delete.contentTintColor = .systemRed
|
||||||
|
delete.keyEquivalent = "\r"
|
||||||
|
alert.window.initialFirstResponder = delete
|
||||||
|
alert.window.defaultButtonCell = delete.cell as? NSButtonCell
|
||||||
|
alert.beginSheetModal(for: window) { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.focusEditor() }
|
||||||
|
guard response == .alertSecondButtonReturn, self.selectedID == id,
|
||||||
|
self.save(), let store = self.store else { return }
|
||||||
|
do {
|
||||||
|
try store.delete(id: id)
|
||||||
|
self.selectedID = nil
|
||||||
|
self.defaults.removeObject(forKey: "selectedNote")
|
||||||
|
self.editor.setMarkdownContents(NSAttributedString(string: ""))
|
||||||
|
self.editor.undoManager?.removeAllActions()
|
||||||
|
self.editor.isEditable = false
|
||||||
|
self.noteTitle.stringValue = "Untitled Note"
|
||||||
|
self.select(try store.notes.first?.id ?? store.create())
|
||||||
|
} catch { self.showError(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func switchNote(_ id: UUID) {
|
private func switchNote(_ id: UUID) {
|
||||||
guard save() else { return }
|
guard save() else { return }
|
||||||
select(id)
|
select(id)
|
||||||
@@ -484,7 +564,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
|
|
||||||
private func showError(_ error: Error) {
|
private func showError(_ error: Error) {
|
||||||
status.isHidden = false
|
status.isHidden = false
|
||||||
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
|
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
|
||||||
status.toolTip = status.stringValue
|
status.toolTip = status.stringValue
|
||||||
status.textColor = .systemRed
|
status.textColor = .systemRed
|
||||||
}
|
}
|
||||||
@@ -519,8 +599,8 @@ private final class NotesView: NSView {
|
|||||||
enum MarkdownHighlighting {
|
enum MarkdownHighlighting {
|
||||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||||||
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
||||||
(try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 23, weight: .semibold)]),
|
(try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.monospacedSystemFont(ofSize: 23, weight: .semibold)]),
|
||||||
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 17)]),
|
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, 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]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
(try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ public struct LauncherAlignment: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func restoredFrame(savedOrigin: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect {
|
public static func restoredFrame(savedCenter: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect {
|
||||||
if let savedOrigin, savedOrigin.x.isFinite, savedOrigin.y.isFinite {
|
if let savedCenter, savedCenter.x.isFinite, savedCenter.y.isFinite {
|
||||||
let saved = CGRect(origin: savedOrigin, size: windowSize)
|
let saved = CGRect(x: savedCenter.x - windowSize.width / 2,
|
||||||
|
y: savedCenter.y - windowSize.height / 2,
|
||||||
|
width: windowSize.width, height: windowSize.height)
|
||||||
if visibleFrame.contains(saved) { return saved }
|
if visibleFrame.contains(saved) { return saved }
|
||||||
}
|
}
|
||||||
var frame = LauncherAlignment(
|
var frame = LauncherAlignment(
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ public final class NotesStore {
|
|||||||
notes[index] = note
|
notes[index] = note
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func delete(id: UUID) throws {
|
||||||
|
guard let index = notes.firstIndex(where: { $0.id == id }) else {
|
||||||
|
throw CocoaError(.fileNoSuchFile)
|
||||||
|
}
|
||||||
|
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
|
||||||
|
notes.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
private func save(_ note: Note) throws {
|
private func save(_ note: Note) throws {
|
||||||
let wrapper = try note.contents.fileWrapper(
|
let wrapper = try note.contents.fileWrapper(
|
||||||
from: NSRange(location: 0, length: note.contents.length),
|
from: NSRange(location: 0, length: note.contents.length),
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
|
- [x] conjure notes:
|
||||||
|
- [x] window should be resizable (and set size should be remembered)
|
||||||
|
- [x] make font for note/input text monospaced
|
||||||
|
- [x] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows
|
||||||
|
- when moving a conjure window, the saved position should always be the center of the window, and windows should always be spawned at some position taking to mean their center.
|
||||||
|
- this should keep window placement invariant of corner window sizes
|
||||||
|
- [x] conjure notes:
|
||||||
|
- [x] add action to delete a note (currently selected note)
|
||||||
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
||||||
- track down the cause and fix it.
|
- track down the cause and fix it.
|
||||||
@@ -19,7 +19,6 @@ import Testing
|
|||||||
#expect(keyword != name)
|
#expect(keyword != name)
|
||||||
#expect(comment != keyword)
|
#expect(comment != keyword)
|
||||||
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
||||||
#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"
|
||||||
MarkdownHighlighting.apply(to: editor)
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import Testing
|
|||||||
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
||||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
||||||
#expect(history.suggestions(in: [command, competitor]).first?.id == competitor.id)
|
#expect(history.suggestions(in: [command, competitor]).first?.id == competitor.id)
|
||||||
let panel = try #require(launcher.window as? LauncherPanel)
|
let panel = try #require(launcher.window as? LauncherWindow)
|
||||||
panel.launchNumber?(0)
|
panel.launchNumber?(0)
|
||||||
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
||||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
||||||
|
|||||||
@@ -48,17 +48,21 @@ func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func launcherPositionRestoresVisibleOriginsAndFallsBackForMissingOrOffscreenPositions() {
|
func launcherPositionRestoresCentersAcrossSizesAndFallsBackForMissingOrOffscreenPositions() {
|
||||||
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
|
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
|
||||||
let size = CGSize(width: 750, height: 474)
|
let size = CGSize(width: 750, height: 474)
|
||||||
let origin = CGPoint(x: -1500, y: 450)
|
let center = CGPoint(x: -1125, y: 687)
|
||||||
#expect(LauncherAlignment.restoredFrame(savedOrigin: origin, windowSize: size, visibleFrame: display)
|
for windowSize in [size, CGSize(width: 570, height: 404), CGSize(width: 840, height: 620)] {
|
||||||
== CGRect(origin: origin, size: size))
|
let frame = LauncherAlignment.restoredFrame(savedCenter: center, windowSize: windowSize, visibleFrame: display)
|
||||||
for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 450), CGPoint(x: -1500, y: 39), CGPoint(x: CGFloat.nan, y: 450)] {
|
#expect(CGPoint(x: frame.midX, y: frame.midY) == center)
|
||||||
#expect(LauncherAlignment.restoredFrame(savedOrigin: invalid, windowSize: size, visibleFrame: display)
|
#expect(frame.size == windowSize)
|
||||||
|
}
|
||||||
|
for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 687), CGPoint(x: -1125, y: 276),
|
||||||
|
CGPoint(x: CGFloat.nan, y: 687), CGPoint(x: -1125, y: CGFloat.infinity)] {
|
||||||
|
#expect(LauncherAlignment.restoredFrame(savedCenter: invalid, windowSize: size, visibleFrame: display)
|
||||||
== CGRect(x: -1335, y: 398, width: 750, height: 474))
|
== CGRect(x: -1335, y: 398, width: 750, height: 474))
|
||||||
}
|
}
|
||||||
let small = CGRect(x: 0, y: 40, width: 800, height: 500)
|
let small = CGRect(x: 0, y: 40, width: 800, height: 500)
|
||||||
#expect(LauncherAlignment.restoredFrame(savedOrigin: nil, windowSize: size, visibleFrame: small)
|
#expect(LauncherAlignment.restoredFrame(savedCenter: nil, windowSize: size, visibleFrame: small)
|
||||||
== CGRect(x: 25, y: 40, width: 750, height: 474))
|
== CGRect(x: 25, y: 40, width: 750, height: 474))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
@testable import Conjure
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func launcherPositionKeepsTheDraggedCenterAcrossCommandsSizesAndRelaunch() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "LauncherPosition.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
func makeLauncher() -> LauncherWindowController {
|
||||||
|
LauncherWindowController(
|
||||||
|
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||||
|
defaults: defaults, notesDirectory: directory)
|
||||||
|
}
|
||||||
|
func center(_ frame: CGRect) -> CGPoint { CGPoint(x: frame.midX, y: frame.midY) }
|
||||||
|
let launcher = makeLauncher()
|
||||||
|
let window = try #require(launcher.window as? LauncherWindow)
|
||||||
|
defer { launcher.dismiss(restoringFocus: false) }
|
||||||
|
let display = try #require(window.screen).visibleFrame
|
||||||
|
let initialCenter = CGPoint(x: display.midX.rounded(.down), y: display.midY.rounded(.down))
|
||||||
|
window.setFrameOrigin(CGPoint(x: initialCenter.x - window.frame.width / 2,
|
||||||
|
y: initialCenter.y - window.frame.height / 2))
|
||||||
|
// Open from the launcher rather than presenting again (which restores the saved position).
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let content = try #require(window.contentView)
|
||||||
|
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
search.stringValue = "notes"
|
||||||
|
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
#expect(launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))))
|
||||||
|
try #require(center(window.frame) == initialCenter)
|
||||||
|
|
||||||
|
let draggedCenter = CGPoint(x: initialCenter.x + 30, y: initialCenter.y - 20)
|
||||||
|
window.setFrameOrigin(CGPoint(x: draggedCenter.x - window.frame.width / 2,
|
||||||
|
y: draggedCenter.y - window.frame.height / 2))
|
||||||
|
window.dragEnded?()
|
||||||
|
// Back must use the moved Notes center, not the launcher frame captured on entry.
|
||||||
|
let back = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
|
||||||
|
$0.accessibilityLabel() == "Back to applications"
|
||||||
|
})
|
||||||
|
back.performClick(nil)
|
||||||
|
#expect(center(window.frame) == draggedCenter)
|
||||||
|
for command: Application.Command in [.emoji, .translate, .notes] {
|
||||||
|
launcher.present(command: command)
|
||||||
|
#expect(center(window.frame) == draggedCenter)
|
||||||
|
}
|
||||||
|
var resized = window.frame
|
||||||
|
resized.size = CGSize(width: 620, height: 330)
|
||||||
|
resized.origin = CGPoint(x: draggedCenter.x - resized.width / 2, y: draggedCenter.y - resized.height / 2)
|
||||||
|
window.setFrame(resized, display: true)
|
||||||
|
window.dragEnded?()
|
||||||
|
launcher.dismiss(restoringFocus: false)
|
||||||
|
|
||||||
|
let reopened = makeLauncher()
|
||||||
|
defer { reopened.dismiss(restoringFocus: false) }
|
||||||
|
for command: Application.Command in [.notes, .emoji, .translate] {
|
||||||
|
reopened.present(command: command)
|
||||||
|
#expect(center(try #require(reopened.window).frame) == draggedCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compact Notes window can fit where the hidden launcher-sized frame cannot.
|
||||||
|
reopened.present(command: .notes)
|
||||||
|
let reopenedWindow = try #require(reopened.window as? LauncherWindow)
|
||||||
|
let edgeCenter = CGPoint(x: display.minX + 320, y: initialCenter.y)
|
||||||
|
reopenedWindow.setFrameOrigin(CGPoint(x: edgeCenter.x - reopenedWindow.frame.width / 2,
|
||||||
|
y: edgeCenter.y - reopenedWindow.frame.height / 2))
|
||||||
|
reopenedWindow.dragEnded?()
|
||||||
|
reopened.dismiss(restoringFocus: false)
|
||||||
|
reopened.present(command: .notes)
|
||||||
|
#expect(center(reopenedWindow.frame) == edgeCenter)
|
||||||
|
}
|
||||||
@@ -228,6 +228,118 @@ func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigatio
|
|||||||
#expect(launcherSearch.isHidden == false)
|
#expect(launcherSearch.isHidden == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [false, true]) @MainActor
|
||||||
|
func notesNativeClosePreservesUnsavedEditsAndRestoresBorderlessLauncher(_ blockSaving: Bool) throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesNativeClose.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
let directory = root.appendingPathComponent("Notes")
|
||||||
|
let backup = root.appendingPathComponent("Backup")
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
let launcher = LauncherWindowController(
|
||||||
|
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||||
|
defaults: defaults, notesDirectory: directory)
|
||||||
|
let window = try #require(launcher.window)
|
||||||
|
defer { launcher.dismiss(restoringFocus: false) }
|
||||||
|
let originalSize = window.frame.size
|
||||||
|
launcher.present(command: .notes)
|
||||||
|
let content = try #require(window.contentView)
|
||||||
|
content.layoutSubtreeIfNeeded()
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
let close = try #require(window.standardWindowButton(.closeButton))
|
||||||
|
#expect(window.styleMask.contains(.titled))
|
||||||
|
#expect(!close.isHidden && close.isEnabled)
|
||||||
|
#expect(window.standardWindowButton(.miniaturizeButton)?.isHidden != false)
|
||||||
|
#expect(window.standardWindowButton(.zoomButton)?.isHidden != false)
|
||||||
|
if blockSaving {
|
||||||
|
try FileManager.default.moveItem(at: directory, to: backup)
|
||||||
|
try Data("Blocked storage".utf8).write(to: directory)
|
||||||
|
}
|
||||||
|
editor.insertText("Keep this note", replacementRange: editor.selectedRange())
|
||||||
|
close.performClick(nil)
|
||||||
|
if blockSaving {
|
||||||
|
#expect(window.isVisible)
|
||||||
|
#expect(editor.markdownString == "Keep this note")
|
||||||
|
#expect(window.styleMask.contains(.titled))
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
try FileManager.default.moveItem(at: backup, to: directory)
|
||||||
|
close.performClick(nil)
|
||||||
|
}
|
||||||
|
#expect(!window.isVisible)
|
||||||
|
#expect(window.styleMask == .borderless)
|
||||||
|
#expect(window.frame.size == originalSize)
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Keep this note")
|
||||||
|
launcher.present(command: .emoji)
|
||||||
|
#expect(window.styleMask == .borderless)
|
||||||
|
#expect(!descendants(content).contains { $0 is NotesTextView })
|
||||||
|
launcher.present(command: .notes)
|
||||||
|
#expect(window.standardWindowButton(.closeButton)?.isHidden == false)
|
||||||
|
#expect(window.standardWindowButton(.miniaturizeButton)?.isHidden != false)
|
||||||
|
#expect(window.standardWindowButton(.zoomButton)?.isHidden != false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notesRememberResizedDimensionsWithoutResizingOtherCommandsAndKeepTextMonospaced() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesWindowSize.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
func makeLauncher() -> LauncherWindowController {
|
||||||
|
LauncherWindowController(
|
||||||
|
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||||
|
defaults: defaults, notesDirectory: directory)
|
||||||
|
}
|
||||||
|
let launcher = makeLauncher()
|
||||||
|
let window = try #require(launcher.window)
|
||||||
|
defer { launcher.dismiss(restoringFocus: false) }
|
||||||
|
let launcherSize = window.frame.size
|
||||||
|
launcher.present(command: .notes)
|
||||||
|
#expect(window.styleMask.contains(.resizable))
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let content = try #require(window.contentView)
|
||||||
|
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
editor.insertText("Plain\n# Heading\n**bold** and *italic* and `code`", replacementRange: editor.selectedRange())
|
||||||
|
let storage = try #require(editor.textStorage)
|
||||||
|
storage.enumerateAttribute(.font, in: NSRange(location: 0, length: storage.length)) { value, _, _ in
|
||||||
|
#expect((value as? NSFont)?.isFixedPitch == true)
|
||||||
|
}
|
||||||
|
#expect((editor.typingAttributes[.font] as? NSFont)?.isFixedPitch == true)
|
||||||
|
let expanded = NSSize(width: 840, height: 620)
|
||||||
|
var frame = window.frame
|
||||||
|
frame.size = expanded
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
|
window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
#expect(editor.enclosingScrollView?.frame.width == expanded.width)
|
||||||
|
#expect(editor.visibleRect.height > 400)
|
||||||
|
launcher.present(command: .emoji)
|
||||||
|
#expect(window.frame.size == launcherSize)
|
||||||
|
#expect(!window.styleMask.contains(.resizable))
|
||||||
|
launcher.present(command: .notes)
|
||||||
|
#expect(window.frame.size == expanded)
|
||||||
|
let compact = NSSize(width: 620, height: 330)
|
||||||
|
frame = window.frame
|
||||||
|
frame.size = compact
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
|
launcher.dismiss(restoringFocus: false)
|
||||||
|
|
||||||
|
// A fresh controller reads persisted dimensions, not an in-memory frame.
|
||||||
|
let reopened = makeLauncher()
|
||||||
|
defer { reopened.dismiss(restoringFocus: false) }
|
||||||
|
reopened.present(command: .notes)
|
||||||
|
#expect(reopened.window?.frame.size == compact)
|
||||||
|
reopened.present(command: .translate)
|
||||||
|
#expect(reopened.window?.frame.size == launcherSize)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
@@ -403,6 +515,38 @@ func listTabAndShiftTabPreserveMarkersSourceSelectionsUndoAndPersistence() throw
|
|||||||
editor.breakUndoCoalescing()
|
editor.breakUndoCoalescing()
|
||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
}
|
}
|
||||||
|
for (before, after, shift) in [
|
||||||
|
("1. hello,\n2. world!", "1. hello,\n 1. world!", false),
|
||||||
|
("1. parent\n2. world!\n3. tail", "1. parent\n 1. world!\n2. tail", false),
|
||||||
|
("1. parent\n 1. child\n2. world!\n3. tail", "1. parent\n 1. child\n 2. world!\n2. tail", false),
|
||||||
|
("1. parent\n 1. world!\n2. tail", "1. parent\n2. world!\n3. tail", true),
|
||||||
|
("7) parent\n 1) child\n 2) world!\n8) tail", "7) parent\n 1) child\n8) world!\n9) tail", true),
|
||||||
|
("1. parent\n - bullet\n2. world!", "1. parent\n - bullet\n 1. world!", false),
|
||||||
|
("1. parent\n\t1. child\n 2. world!", "1. parent\n\t1. child\n 2. world!", false),
|
||||||
|
("1. parent\n2. world!\n\n9. separate", "1. parent\n 1. world!\n\n9. separate", false),
|
||||||
|
("9) parent\n 1) world!\n10) tail", "9) parent\n10) world!\n11) tail", true),
|
||||||
|
("1. parent\n10. world!\n11. tail", "1. parent\n 1. world!\n2. tail", false),
|
||||||
|
("1. parent\n2. world!\n3. tail\n 7. separate", "1. parent\n 1. world!\n2. tail\n 7. separate", false),
|
||||||
|
("1. parent\n 1. world!\n 2. child\n2. tail", "1. parent\n2. world!\n 1. child\n3. tail", true),
|
||||||
|
("1. parent\r\n2) [X]\tworld!\r\n3. tail", "1. parent\r\n 1) [X]\tworld!\r\n2. tail", false),
|
||||||
|
] {
|
||||||
|
reset(before)
|
||||||
|
let selected = (editor.string as NSString).range(of: "world!")
|
||||||
|
editor.setSelectedRange(selected)
|
||||||
|
editor.keyDown(with: shift ? backtab : tab)
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
#expect((editor.string as NSString).substring(with: editor.selectedRange()) == "world!")
|
||||||
|
let id = try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)))
|
||||||
|
let saved = try NotesStore(directory: directory).notes.first { $0.id == id }?.contents.string
|
||||||
|
#expect(saved == after.replacingOccurrences(of: "\r\n", with: "\n"))
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == before)
|
||||||
|
#expect(editor.selectedRange() == selected)
|
||||||
|
#expect(editor.undoManager?.canUndo == false)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
}
|
||||||
for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] {
|
for marker in ["-", "*", "+", "009)", "12.", "- [X]", "2) []"] {
|
||||||
let source = " " + marker + " \t🦊 café\n - child"
|
let source = " " + marker + " \t🦊 café\n - child"
|
||||||
reset(source)
|
reset(source)
|
||||||
@@ -1351,3 +1495,84 @@ func notesFormattingPreservesAttachmentsMarkdownSelectionAndUndo() throws {
|
|||||||
#expect(saved.contents.string == before)
|
#expect(saved.contents.string == before)
|
||||||
#expect(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) is NSTextAttachment)
|
#expect(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) is NSTextAttachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notesDeletionRequiresConfirmationPreservesFailuresAndReplacesTheLastNote() async throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesDeletion.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
let backup = directory.appendingPathExtension("backup")
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
try? FileManager.default.removeItem(at: backup)
|
||||||
|
}
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let first = try store.create()
|
||||||
|
let second = try store.create()
|
||||||
|
try store.update(id: first, contents: NSAttributedString(string: "Delete this note"))
|
||||||
|
try store.update(id: second, contents: NSAttributedString(string: "Keep this note"))
|
||||||
|
defaults.set(first.uuidString, forKey: "selectedNote")
|
||||||
|
let controller = NotesController(defaults: defaults, directory: directory)
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||||
|
styleMask: [.titled], backing: .buffered, defer: false)
|
||||||
|
window.contentViewController = controller
|
||||||
|
defer { _ = controller.close(); window.orderOut(nil) }
|
||||||
|
window.orderFront(nil)
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
func delete(confirm: Bool) async throws {
|
||||||
|
controller.showActions()
|
||||||
|
let search = try #require(descendants(controller.view).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
search.stringValue = "delete"
|
||||||
|
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
#expect(controller.handle(#selector(NSResponder.insertNewline(_:))))
|
||||||
|
let sheet = try #require(window.attachedSheet)
|
||||||
|
let content = try #require(sheet.contentView)
|
||||||
|
let buttons = descendants(content).compactMap { $0 as? NSButton }
|
||||||
|
let delete = try #require(buttons.first { $0.title == "Delete Note" })
|
||||||
|
#expect(sheet.firstResponder === delete)
|
||||||
|
let button = try #require(buttons.first { $0.title == (confirm ? "Delete Note" : "Cancel") })
|
||||||
|
button.performClick(nil)
|
||||||
|
// Scheduling deadline only; deletion does not depend on wall-clock time.
|
||||||
|
for _ in 0..<1_000 {
|
||||||
|
if window.attachedSheet == nil, window.firstResponder === editor { return }
|
||||||
|
try await Task.sleep(for: .milliseconds(1))
|
||||||
|
}
|
||||||
|
try #require(window.attachedSheet == nil && window.firstResponder === editor)
|
||||||
|
}
|
||||||
|
try await delete(confirm: false)
|
||||||
|
#expect(defaults.string(forKey: "selectedNote") == first.uuidString)
|
||||||
|
#expect(editor.markdownString == "Delete this note")
|
||||||
|
#expect(Set(try NotesStore(directory: directory).notes.map(\.id)) == Set([first, second]))
|
||||||
|
|
||||||
|
// A failed save must not let confirmation discard an unsaved buffer.
|
||||||
|
try FileManager.default.moveItem(at: directory, to: backup)
|
||||||
|
try Data("Blocked storage".utf8).write(to: directory)
|
||||||
|
editor.insertText("Unsaved changes", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
|
try await delete(confirm: true)
|
||||||
|
#expect(defaults.string(forKey: "selectedNote") == first.uuidString)
|
||||||
|
#expect(editor.markdownString == "Unsaved changes")
|
||||||
|
#expect(!controller.save())
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
try FileManager.default.moveItem(at: backup, to: directory)
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first { $0.id == first }?.contents.string == "Delete this note")
|
||||||
|
|
||||||
|
try await delete(confirm: true)
|
||||||
|
#expect(try NotesStore(directory: directory).notes.map(\.id) == [second])
|
||||||
|
#expect(defaults.string(forKey: "selectedNote") == second.uuidString)
|
||||||
|
#expect(editor.markdownString == "Keep this note")
|
||||||
|
#expect(editor.undoManager?.canUndo != true)
|
||||||
|
try await delete(confirm: true)
|
||||||
|
let replacement = try #require(NotesStore(directory: directory).notes.first)
|
||||||
|
#expect(replacement.id != first && replacement.id != second)
|
||||||
|
#expect(replacement.contents.string.isEmpty)
|
||||||
|
#expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString)
|
||||||
|
#expect(editor.markdownString.isEmpty)
|
||||||
|
#expect(editor.isEditable)
|
||||||
|
#expect(editor.undoManager?.canUndo != true)
|
||||||
|
let reopened = NotesController(defaults: defaults, directory: directory)
|
||||||
|
#expect(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first?.markdownString == "")
|
||||||
|
#expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString)
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,3 +101,33 @@ func noteTitlesUseFirstNonemptyLineAndCapAtSixtyCharacters() throws {
|
|||||||
try store.update(id: id, contents: NSAttributedString(string: " \t\n\n"))
|
try store.update(id: id, contents: NSAttributedString(string: " \t\n\n"))
|
||||||
#expect(store.notes.first?.title == "Untitled Note")
|
#expect(store.notes.first?.title == "Untitled Note")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
let backup = directory.appendingPathExtension("backup")
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
try? FileManager.default.removeItem(at: backup)
|
||||||
|
}
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let deleted = try store.create()
|
||||||
|
let kept = try store.create()
|
||||||
|
try store.update(id: kept, contents: NSAttributedString(string: "Keep this note"))
|
||||||
|
try store.delete(id: deleted)
|
||||||
|
#expect(store.notes.map(\.id) == [kept])
|
||||||
|
#expect(try NotesStore(directory: directory).notes.map(\.id) == [kept])
|
||||||
|
#expect(throws: (any Error).self) { try store.delete(id: deleted) }
|
||||||
|
|
||||||
|
try FileManager.default.moveItem(at: directory, to: backup)
|
||||||
|
try Data("Blocked storage".utf8).write(to: directory)
|
||||||
|
#expect(throws: (any Error).self) { try store.delete(id: kept) }
|
||||||
|
#expect(store.notes.map(\.id) == [kept])
|
||||||
|
#expect(store.notes.first?.contents.string == "Keep this note")
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
try FileManager.default.moveItem(at: backup, to: directory)
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Keep this note")
|
||||||
|
try store.delete(id: kept)
|
||||||
|
#expect(store.notes.isEmpty)
|
||||||
|
#expect(try NotesStore(directory: directory).notes.isEmpty)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user