feat: persist notes window size and use monospaced text
This commit is contained in:
@@ -137,9 +137,9 @@ 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.
|
||||||
|
|
||||||
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor retains 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -240,6 +241,11 @@ 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 {
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
dismiss()
|
dismiss()
|
||||||
return false
|
return false
|
||||||
@@ -915,6 +921,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
self.launcherViews.first?.setAccessibilityHidden(false)
|
self.launcherViews.first?.setAccessibilityHidden(false)
|
||||||
if command == .notes, let previousFrame {
|
if command == .notes, let previousFrame {
|
||||||
self.window?.styleMask = [.borderless]
|
self.window?.styleMask = [.borderless]
|
||||||
|
self.window?.minSize = .zero
|
||||||
self.window?.appearance = NSAppearance(named: .darkAqua)
|
self.window?.appearance = NSAppearance(named: .darkAqua)
|
||||||
self.window?.setFrame(previousFrame, display: true)
|
self.window?.setFrame(previousFrame, display: true)
|
||||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||||||
@@ -954,16 +961,22 @@ 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?.appearance = nil
|
||||||
window?.styleMask = [.titled, .closable, .fullSizeContentView]
|
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
||||||
|
window?.minSize = NSSize(width: 570, height: 260)
|
||||||
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||||
window?.standardWindowButton(.zoomButton)?.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 var frame = window?.frame {
|
||||||
frame.origin.x += (frame.width - 570) / 2
|
let available = window?.screen?.visibleFrame.size ?? savedSize
|
||||||
frame.origin.y = frame.maxY - 404
|
let size = NSSize(width: min(max(savedSize.width, 570), available.width),
|
||||||
frame.size = NSSize(width: 570, height: 404)
|
height: min(max(savedSize.height, 260), available.height))
|
||||||
|
frame.origin.x += (frame.width - size.width) / 2
|
||||||
|
frame.origin.y = frame.maxY - size.height
|
||||||
|
frame.size = size
|
||||||
window?.setFrame(frame, display: true)
|
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) }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -499,8 +499,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]),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
- [ ] conjure notes:
|
- [x] conjure notes:
|
||||||
- [ ] window should be resizable (and set size should be remembered)
|
- [x] window should be resizable (and set size should be remembered)
|
||||||
- [ ] make font for note/input text monospaced
|
- [x] make font for note/input text monospaced
|
||||||
- [ ] 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
|
- [ ] 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.
|
- 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
|
- this should keep window placement invariant of corner window sizes
|
||||||
|
|||||||
@@ -256,7 +256,6 @@ func notesNativeClosePreservesUnsavedEditsAndRestoresBorderlessLauncher(_ blockS
|
|||||||
#expect(!close.isHidden && close.isEnabled)
|
#expect(!close.isHidden && close.isEnabled)
|
||||||
#expect(window.standardWindowButton(.miniaturizeButton)?.isHidden != false)
|
#expect(window.standardWindowButton(.miniaturizeButton)?.isHidden != false)
|
||||||
#expect(window.standardWindowButton(.zoomButton)?.isHidden != false)
|
#expect(window.standardWindowButton(.zoomButton)?.isHidden != false)
|
||||||
#expect(window.frame.size == NSSize(width: 570, height: 404))
|
|
||||||
if blockSaving {
|
if blockSaving {
|
||||||
try FileManager.default.moveItem(at: directory, to: backup)
|
try FileManager.default.moveItem(at: directory, to: backup)
|
||||||
try Data("Blocked storage".utf8).write(to: directory)
|
try Data("Blocked storage".utf8).write(to: directory)
|
||||||
@@ -284,6 +283,63 @@ func notesNativeClosePreservesUnsavedEditsAndRestoresBorderlessLauncher(_ blockS
|
|||||||
#expect(window.standardWindowButton(.zoomButton)?.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
|
||||||
|
|||||||
Reference in New Issue
Block a user