From 2e4a791f299fc0160d7593260fbed8d841019f25 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 19 Oct 2026 17:40:41 +0200 Subject: [PATCH] fix: use native close-only window decorations for notes --- README.md | 2 + .../Conjure/LauncherWindowController.swift | 11 ++++ Sources/Conjure/NotesController.swift | 24 +------- TODO.md | 6 ++ Tests/ConjureKitTests/NotesEditingTests.swift | 56 +++++++++++++++++++ 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1fb5dd5..f10ef59 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 9697b87..52ca33a 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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 { diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index e914cf2..c6c7250 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -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() diff --git a/TODO.md b/TODO.md index 7a4f8e1..cf9c806 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 6be578a..6b097f8 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -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