fix: use native close-only window decorations for notes
This commit is contained in:
@@ -137,6 +137,8 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
|
Notes uses native macOS window decorations with only the Close button visible; minimize and zoom are hidden. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
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
|
||||||
@@ -238,6 +240,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
alignmentGuides?.update(for: window)
|
alignmentGuides?.update(for: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
dismiss()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherPanel)?.resetNumberHints()
|
||||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||||
@@ -907,6 +914,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
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 {
|
||||||
|
self.window?.styleMask = [.borderless]
|
||||||
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) }
|
||||||
}
|
}
|
||||||
@@ -945,6 +953,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
)
|
)
|
||||||
searchField.setAccessibilityLabel(accessibilityLabel)
|
searchField.setAccessibilityLabel(accessibilityLabel)
|
||||||
if command == .notes {
|
if command == .notes {
|
||||||
|
window?.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||||
|
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 var frame = window?.frame {
|
||||||
|
|||||||
@@ -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,16 +61,15 @@ 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() }
|
||||||
@@ -202,10 +186,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()
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
- [ ] conjure notes:
|
||||||
|
- [ ] window should be resizable (and set size should be remembered)
|
||||||
|
- [ ] 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
|
||||||
|
- 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
|
||||||
- [ ] 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.
|
||||||
@@ -228,6 +228,62 @@ 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)
|
||||||
|
#expect(window.frame.size == NSSize(width: 570, height: 404))
|
||||||
|
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
|
@Test @MainActor
|
||||||
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
|
|||||||
Reference in New Issue
Block a user