fix: use native close-only window decorations for notes

This commit is contained in:
2026-10-19 17:40:41 +02:00
parent adac0723a1
commit 2e4a791f29
5 changed files with 77 additions and 22 deletions
+2
View File
@@ -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.
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.
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
)
window.title = "Conjure"
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.animationBehavior = .none
window.appearance = NSAppearance(named: .darkAqua)
window.backgroundColor = .clear
@@ -238,6 +240,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
alignmentGuides?.update(for: window)
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
dismiss()
return false
}
func windowDidResignKey(_ notification: Notification) {
(window as? LauncherPanel)?.resetNumberHints()
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?.setAccessibilityHidden(false)
if command == .notes, let previousFrame {
self.window?.styleMask = [.borderless]
self.window?.setFrame(previousFrame, display: true)
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
}
@@ -945,6 +953,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
)
searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes {
window?.styleMask = [.titled, .closable, .fullSizeContentView]
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
window?.standardWindowButton(.zoomButton)?.isHidden = true
launcherViews.first?.isHidden = true
launcherViews.first?.setAccessibilityHidden(true)
if var frame = window?.frame {
+2 -22
View File
@@ -45,21 +45,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
let content = NotesView()
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
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.action = #selector(browseNotes)
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
@@ -76,16 +61,15 @@ final class NotesController: NSViewController, NSTextViewDelegate {
noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title")
let header = NSView()
for control in [lights, noteTitle, controls] {
for control in [noteTitle, controls] {
control.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(control)
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
NSLayoutConstraint.activate([
lights.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 10),
controls.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8),
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: lights.trailingAnchor, constant: 16),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: controls.leadingAnchor, constant: -16),
])
actions.closed = { [weak self] in self?.focusEditor() }
@@ -202,10 +186,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
return button
}
@objc private func closeWindow() {
guard save() else { return }
view.window?.performClose(nil)
}
@objc private func toggleFormatting(_ sender: NSButton) {
formatting.isHidden.toggle()
+6
View File
@@ -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.
- track down the cause and fix it.
@@ -228,6 +228,62 @@ func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigatio
#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
func markdownListReturnContinuesExitsAndUndoesAtArbitraryDepths() throws {
_ = NSApplication.shared