Compare commits

..

8 Commits

14 changed files with 549 additions and 77 deletions
+4 -2
View File
@@ -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)
+9 -5
View File
@@ -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
} }
} }
+57 -26
View File
@@ -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)?
+109 -29
View File
@@ -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]),
+5 -3
View File
@@ -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(
+8
View File
@@ -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),
+8
View File
@@ -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)
}