From 671b05faf737ed56bc66b19c20da1941d3861f2c Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:50:24 +0530 Subject: [PATCH 1/7] Scratchpad: add an always-there menu bar notepad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New helper tool (DMonteScratchpad, com.havokentity.mactools.scratchpad): a plain-text notepad in the menu bar with multiple named notes, a live word/character/line readout, copy-all, and an undoable clear. Losing typed text is the only unacceptable failure for a notepad, so the persistence is layered rather than trusted to one mechanism. Each note is its own atomically written JSON file in Application Support, so a bad write can cost at most the note it belonged to instead of taking the whole set with it; a file that no longer parses is salvaged into a "Recovered Note" under its original id, and bytes that are not text at all are skipped and deliberately left on disk rather than deleted. Saves follow the corrected ClipboardStore shape: debounced off the main actor, with the write generation claimed inside a serial queue so an older large snapshot cannot land after a newer one, plus forced flushes when the panel closes and at applicationWillTerminate — the debounced tasks are detached and die with the process, so without those the last 400ms of typing would never reach disk. Deleting a note retires its generation permanently so an in-flight write cannot recreate the file behind the user. Clear stashes the removed body per note for the session, so the undo survives switching to another note and back; it is withdrawn once the user types again, since restoring at that point would clobber the new text. Deleting a note takes two presses instead of an alert, matching the suite's no-alerts rule. The editor is NSTextView via NSViewRepresentable rather than TextEditor: TextEditor round-trips the whole document through its binding on every keystroke, which is visible lag on a long note, and NSTextView brings native undo and correct Unicode handling with it. Smart quote/dash/ replacement substitution is off — a scratchpad is where people park URLs and snippets that must stay verbatim. Counting is over Characters, not UTF-16 units, so a ZWJ family emoji reads as one character; the count runs on a short debounce off the main actor so a very large note does not make typing sticky. Pure storage, naming/dedup and counting live in ScratchpadKit, covered by ScratchpadKitTests plus ScratchpadControllerTests, both against per-test temp directories and an isolated defaults suite. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + Package.swift | 11 + Packaging/ScratchpadInfo.plist | 32 ++ README.md | 1 + Scripts/package_app.sh | 1 + Sources/DMonteCore/AppPreferences.swift | 3 +- Sources/DMonteCore/ScratchpadController.swift | 357 +++++++++++++ Sources/DMonteCore/ScratchpadKit.swift | 232 +++++++++ Sources/DMonteCore/ScratchpadSizing.swift | 17 + Sources/DMonteCore/ScratchpadView.swift | 492 ++++++++++++++++++ Sources/DMonteCore/ToolboxCatalog.swift | 3 +- .../ScratchpadAppDelegate.swift | 70 +++ Sources/DMonteScratchpadApp/main.swift | 24 + .../ScratchpadControllerTests.swift | 270 ++++++++++ .../DMonteCoreTests/ScratchpadKitTests.swift | 273 ++++++++++ 15 files changed, 1788 insertions(+), 2 deletions(-) create mode 100644 Packaging/ScratchpadInfo.plist create mode 100644 Sources/DMonteCore/ScratchpadController.swift create mode 100644 Sources/DMonteCore/ScratchpadKit.swift create mode 100644 Sources/DMonteCore/ScratchpadSizing.swift create mode 100644 Sources/DMonteCore/ScratchpadView.swift create mode 100644 Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift create mode 100644 Sources/DMonteScratchpadApp/main.swift create mode 100644 Tests/DMonteCoreTests/ScratchpadControllerTests.swift create mode 100644 Tests/DMonteCoreTests/ScratchpadKitTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5cf2f..f96cf14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ adheres to [Semantic Versioning](https://semver.org) and the and runs the full test suite on a pinned macOS runner, using the same toolchain the release workflow ships with. Previously only version tags ran CI, so a branch could go unverified until release day. +- **Scratchpad** — a new menu bar tool: an always-there plain-text notepad with + multiple named notes, autosave (debounced while typing, flushed when the panel + closes and at quit, one atomically written file per note), a live + word/character/line count, copy-all, and an undoable clear. ## [0.13.0] — 2026-07-12 diff --git a/Package.swift b/Package.swift index e40120a..729f343 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,10 @@ let package = Package( .executable( name: "DMonteWindowManager", targets: ["DMonteWindowManager"] + ), + .executable( + name: "DMonteScratchpad", + targets: ["DMonteScratchpad"] ) ], dependencies: [ @@ -253,6 +257,13 @@ let package = Package( ], path: "Sources/DMonteWindowManagerApp" ), + .executableTarget( + name: "DMonteScratchpad", + dependencies: [ + "DMonteCore" + ], + path: "Sources/DMonteScratchpadApp" + ), .testTarget( name: "DMonteCoreTests", dependencies: ["DMonteCore"], diff --git a/Packaging/ScratchpadInfo.plist b/Packaging/ScratchpadInfo.plist new file mode 100644 index 0000000..6d3e8be --- /dev/null +++ b/Packaging/ScratchpadInfo.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + DMonteScratchpad + CFBundleIdentifier + com.havokentity.mactools.scratchpad + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + DMonte Scratchpad + CFBundleName + DMonte Scratchpad + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.13.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSMultipleInstancesProhibited + + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Yahushad Monte + + diff --git a/README.md b/README.md index 925c765..6c7f23d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org); | **Keep Awake** | Prevent sleep, optionally for a set duration | | **Maintenance** | Handy Finder/system toggles and cache/index refreshes | | **Dev Tools** | JSON, Base64, URL, hashing, UUID, timestamp, and case utilities | +| **Scratchpad** | Always‑there plain‑text notepad with named notes, autosave, and a live word count | ### Permissions diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9b0f96b..6ad5f1f 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -36,6 +36,7 @@ HELPERS=( "DMonteGrabText|DMonte Grab Text.app|GrabTextInfo.plist" "DMonteFocusTimer|DMonte Focus Timer.app|FocusTimerInfo.plist" "DMonteWindowManager|DMonte Window Manager.app|WindowManagerInfo.plist" + "DMonteScratchpad|DMonte Scratchpad.app|ScratchpadInfo.plist" ) stamp_version() { diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index b746647..4525ca8 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -114,7 +114,8 @@ public enum AppDefaults { DefaultsKey.focusTimerFocusMinutes: 25, DefaultsKey.focusTimerShortBreakMinutes: 5, DefaultsKey.focusTimerLongBreakMinutes: 15, - DefaultsKey.focusTimerLongBreakInterval: 4 + DefaultsKey.focusTimerLongBreakInterval: 4, + DefaultsKey.scratchpadFontSize: 13 ]) } } diff --git a/Sources/DMonteCore/ScratchpadController.swift b/Sources/DMonteCore/ScratchpadController.swift new file mode 100644 index 0000000..7d2743b --- /dev/null +++ b/Sources/DMonteCore/ScratchpadController.swift @@ -0,0 +1,357 @@ +import AppKit + +public extension DefaultsKey { + /// UUID string of the note that was open when the panel last closed, so reopening lands the + /// user back where they were. A stale id (note deleted elsewhere) falls back to the first note. + static let scratchpadSelectedNoteID = "tool.scratchpad.selectedNoteID" + + /// Editor point size. The integrator registers a default of `13`. + static let scratchpadFontSize = "tool.scratchpad.fontSize" +} + +/// Owns the Scratchpad's notes and their persistence. +/// +/// Losing typed text is this tool's only unacceptable failure, so saves are layered: every edit +/// schedules a debounced background write, and `flushPendingSaves()` forces everything to disk +/// when the panel closes and again at termination. Writes go through `ScratchpadNoteWriter`, +/// which serializes them and drops any snapshot older than one already on disk — the debounced +/// tasks are independent and unordered, so without that a slow large-note write could land after +/// a newer one and resurrect deleted text. +@MainActor +public final class ScratchpadController: ObservableObject { + /// All notes, oldest-first. Never empty: the UI always has something to edit. + @Published public private(set) var notes: [ScratchpadNote] = [] + + @Published public private(set) var selectedNoteID: UUID? + + /// Size of the note being edited. Recomputed on a short debounce off the main actor, because + /// counting graphemes in a very large note on every keystroke is exactly the kind of O(n) + /// main-thread work that makes typing feel sticky. + @Published public private(set) var counts: ScratchpadCounts = .empty + + /// Inline status line — this tool never raises an alert. + @Published public private(set) var statusMessage: String? + + /// True while the selected note's last clear can still be taken back. + @Published public private(set) var canUndoClear = false + + /// Set by the first press of Delete Note; the second press within the same selection commits. + /// Deleting notes is destructive and there are no alerts in this suite, so the confirmation + /// lives in the button itself. + @Published public private(set) var pendingDeleteNoteID: UUID? + + private let directory: URL + + /// Persisted-settings store. Defaults to the app-wide shared suite in production; tests + /// inject an isolated suite so they never touch the user's real preferences. + private let defaults: UserDefaults + + private let writer = ScratchpadNoteWriter() + + private var saveTasks: [UUID: Task] = [:] + private var saveGeneration: UInt64 = 0 + private var countTask: Task? + + /// Text removed by Clear, kept per note for the lifetime of the process. Keyed by note so + /// switching away and back does not silently forfeit the undo. + private var clearedBodies: [UUID: String] = [:] + + /// - Parameters: + /// - directory: storage location. Defaults to Application Support; tests pass a temporary + /// directory so they never touch the user's real notes. + /// - defaults: preferences store, defaulting to the shared suite. + public init(directory: URL? = nil, defaults: UserDefaults = AppDefaults.shared) { + self.defaults = defaults + + if let directory { + self.directory = directory + } else { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + self.directory = base.appendingPathComponent("DMonteScratchpad", isDirectory: true) + } + + ScratchpadKit.prepareDirectory(self.directory) + load() + } + + // MARK: - Public API + + /// Body of the note currently being edited; empty string when there somehow is no selection. + public var activeText: String { + guard let index = activeIndex else { return "" } + return notes[index].body + } + + public var activeNote: ScratchpadNote? { + guard let index = activeIndex else { return nil } + return notes[index] + } + + /// Editor point size, clamped to a range that stays legible in the panel. + public var fontSize: CGFloat { + let stored = defaults.integer(forKey: DefaultsKey.scratchpadFontSize) + return CGFloat(min(20, max(11, stored == 0 ? 13 : stored))) + } + + public func setFontSize(_ size: CGFloat) { + defaults.set(Int(size), forKey: DefaultsKey.scratchpadFontSize) + objectWillChange.send() + } + + /// Records an edit from the text view. + public func updateText(_ newText: String) { + guard let index = activeIndex, notes[index].body != newText else { return } + + // Typing after a clear means the user has moved on; offering to restore the old body at + // that point would silently overwrite what they just wrote. + if canUndoClear { + clearedBodies[notes[index].id] = nil + canUndoClear = false + } + + apply(body: newText, at: index) + statusMessage = nil + } + + public func selectNote(_ id: UUID) { + guard id != selectedNoteID, notes.contains(where: { $0.id == id }) else { return } + + // Switching is a natural commit point, and the note being left may still be sitting on an + // unfired debounce. + flushPendingSaves() + + selectedNoteID = id + defaults.set(id.uuidString, forKey: DefaultsKey.scratchpadSelectedNoteID) + pendingDeleteNoteID = nil + statusMessage = nil + canUndoClear = clearedBodies[id] != nil + refreshCounts() + } + + /// Adds an empty note and selects it. + public func addNote() { + flushPendingSaves() + + let note = ScratchpadNote(title: ScratchpadKit.uniqueTitle( + ScratchpadKit.untitledTitle, + existing: notes.map(\.title) + )) + notes.append(note) + selectedNoteID = note.id + defaults.set(note.id.uuidString, forKey: DefaultsKey.scratchpadSelectedNoteID) + pendingDeleteNoteID = nil + canUndoClear = false + statusMessage = nil + refreshCounts() + + // Written immediately rather than debounced: an empty new note has nothing to lose to a + // crash, but a note file that never appears would make the switcher lie after a relaunch. + writeNow(note) + } + + /// Renames the selected note, de-duplicating against the others. + public func renameSelectedNote(to raw: String) { + guard let index = activeIndex else { return } + + let others = notes.enumerated().filter { $0.offset != index }.map(\.element.title) + let title = ScratchpadKit.uniqueTitle(raw, existing: others) + guard notes[index].title != title else { return } + + notes[index].title = title + notes[index].modified = Date() + scheduleSave(notes[index]) + } + + /// Two-step delete of the selected note. The first call arms the confirmation and returns + /// without touching anything; the second commits. + public func deleteSelectedNote() { + guard let index = activeIndex else { return } + let note = notes[index] + + guard pendingDeleteNoteID == note.id else { + pendingDeleteNoteID = note.id + statusMessage = "Press again to delete “\(note.title)”." + return + } + + pendingDeleteNoteID = nil + saveTasks[note.id]?.cancel() + saveTasks[note.id] = nil + clearedBodies[note.id] = nil + notes.remove(at: index) + writer.delete(id: note.id, in: directory) + + // The editor needs a note to point at, so the last one deleted is replaced by a fresh + // blank rather than leaving the panel with nothing to show. + if notes.isEmpty { + let replacement = ScratchpadNote(title: ScratchpadKit.untitledTitle) + notes.append(replacement) + writeNow(replacement) + } + + let next = notes[min(index, notes.count - 1)] + selectedNoteID = next.id + defaults.set(next.id.uuidString, forKey: DefaultsKey.scratchpadSelectedNoteID) + canUndoClear = clearedBodies[next.id] != nil + statusMessage = "Deleted “\(note.title)”." + refreshCounts() + } + + /// Copies the whole note to the clipboard. + public func copyAll() { + let text = activeText + guard !text.isEmpty else { + statusMessage = "Nothing to copy yet." + return + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + statusMessage = "Copied to the clipboard." + } + + /// Empties the selected note, keeping the removed text so `undoClear()` can put it back. + public func clearActiveNote() { + guard let index = activeIndex, !notes[index].body.isEmpty else { return } + + clearedBodies[notes[index].id] = notes[index].body + apply(body: "", at: index) + canUndoClear = true + pendingDeleteNoteID = nil + statusMessage = "Cleared — you can still undo this." + } + + public func undoClear() { + guard let index = activeIndex, let restored = clearedBodies[notes[index].id] else { return } + + apply(body: restored, at: index) + clearedBodies[notes[index].id] = nil + canUndoClear = false + statusMessage = "Restored." + } + + /// Forces every pending debounced write to disk right now. + /// + /// The debounced tasks are detached and die with the process, so without this the last 400ms + /// of typing never reaches disk — quitting straight after a burst of edits would lose exactly + /// the words the user just wrote. Every note is written, not only the dirty ones: the set is + /// small, and a redundant write costs far less than a missed one. + public func flushPendingSaves() { + saveTasks.values.forEach { $0.cancel() } + saveTasks.removeAll() + + saveGeneration &+= 1 + let generation = saveGeneration + for note in notes { + writer.write(note, to: directory, generation: generation) + } + } + + // MARK: - Private + + private var activeIndex: Int? { + guard let selectedNoteID else { return nil } + return notes.firstIndex { $0.id == selectedNoteID } + } + + private func load() { + notes = ScratchpadKit.loadNotes(from: directory) + + if notes.isEmpty { + // First run (or a wiped directory): start with one note so the editor is usable + // immediately instead of presenting an empty switcher and no place to type. + let first = ScratchpadNote(title: "Notes") + notes.append(first) + writeNow(first) + } + + let storedID = defaults.string(forKey: DefaultsKey.scratchpadSelectedNoteID) + .flatMap(UUID.init(uuidString:)) + selectedNoteID = notes.first(where: { $0.id == storedID })?.id ?? notes[0].id + canUndoClear = false + refreshCounts() + } + + /// Applies a new body to the note at `index` and starts the debounced save plus the count + /// refresh. The single funnel for body changes so no path can update text without saving it. + private func apply(body: String, at index: Int) { + notes[index].body = body + notes[index].modified = Date() + scheduleSave(notes[index]) + refreshCounts() + } + + private func scheduleSave(_ note: ScratchpadNote) { + saveTasks[note.id]?.cancel() + saveGeneration &+= 1 + let generation = saveGeneration + let directory = self.directory + let writer = self.writer + + // Task.detached takes a @Sendable closure, so it does NOT inherit this method's + // @MainActor isolation — the encode and write run on a background executor and a very + // large note can never stall the keystroke that triggered it. + saveTasks[note.id] = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: 400_000_000) + if Task.isCancelled { return } + writer.write(note, to: directory, generation: generation) + } + } + + /// Undebounced write for structural changes (a note appearing or being replaced), where the + /// file's existence — not its contents — is what the UI has already committed to. + private func writeNow(_ note: ScratchpadNote) { + saveGeneration &+= 1 + writer.write(note, to: directory, generation: saveGeneration) + } + + private func refreshCounts() { + countTask?.cancel() + let text = activeText + + countTask = Task { [weak self] in + // Short enough to read as live, long enough that a fast typist counts once per pause + // rather than once per keystroke. + try? await Task.sleep(nanoseconds: 120_000_000) + if Task.isCancelled { return } + + let computed = await Task.detached(priority: .userInitiated) { + ScratchpadKit.counts(for: text) + }.value + + if Task.isCancelled { return } + self?.counts = computed + } + } +} + +/// Serializes note writes and drops any snapshot older than one already written for the same +/// note. The controller's debounced save tasks are independent and unordered: a large note can +/// still be encoding when a newer, smaller snapshot of it is written, and the older atomic +/// rename would then land last and resurrect text the user deleted. The generation is claimed +/// inside the queue, so `flushPendingSaves()` cannot race an in-flight write either. Encoding +/// stays outside the queue; only the file operations are serialized. +final class ScratchpadNoteWriter: @unchecked Sendable { + private let queue = DispatchQueue(label: "com.havokentity.mactools.scratchpad.note-write", qos: .utility) + private var lastWrittenGeneration: [UUID: UInt64] = [:] + + func write(_ note: ScratchpadNote, to directory: URL, generation: UInt64) { + guard let data = ScratchpadKit.encode(note) else { return } + queue.sync { + guard generation > (lastWrittenGeneration[note.id] ?? 0) else { return } + lastWrittenGeneration[note.id] = generation + ScratchpadKit.writeEncodedNote(data, id: note.id, to: directory) + } + } + + /// Deletes a note's file and permanently retires its generation, so a debounced write that + /// was already in flight when the user deleted the note cannot recreate the file behind them. + func delete(id: UUID, in directory: URL) { + queue.sync { + lastWrittenGeneration[id] = .max + ScratchpadKit.deleteNote(id: id, in: directory) + } + } +} diff --git a/Sources/DMonteCore/ScratchpadKit.swift b/Sources/DMonteCore/ScratchpadKit.swift new file mode 100644 index 0000000..59cb397 --- /dev/null +++ b/Sources/DMonteCore/ScratchpadKit.swift @@ -0,0 +1,232 @@ +import Foundation + +/// One note in the Scratchpad: a title, its plain-text body, and the two timestamps the UI +/// orders and labels by. Persisted as a single self-contained file so no note depends on any +/// other note's file being readable. +public struct ScratchpadNote: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + + /// Display name in the switcher. Sanitized and de-duplicated through `ScratchpadKit` before + /// it is ever stored, so the UI can render it verbatim. + public var title: String + + /// The note's full plain text. Deliberately not truncated anywhere — this is the only copy. + public var body: String + + /// Creation instant. Notes are ordered by this rather than by `modified` so the switcher + /// never reshuffles under the user's finger while they are typing into one of the chips. + public let created: Date + + public var modified: Date + + public init( + id: UUID = UUID(), + title: String, + body: String = "", + created: Date = Date(), + modified: Date = Date() + ) { + self.id = id + self.title = title + self.body = body + self.created = created + self.modified = modified + } +} + +/// Live size readout for the note being edited. +public struct ScratchpadCounts: Sendable, Equatable { + public let words: Int + + /// Grapheme clusters, NOT UTF-16 units: a flag or a skin-toned family emoji is one character + /// to the person who typed it, and reporting 11 for "👨‍👩‍👧‍👦" would be nonsense. + public let characters: Int + + public let lines: Int + + public static let empty = ScratchpadCounts(words: 0, characters: 0, lines: 0) + + public init(words: Int, characters: Int, lines: Int) { + self.words = words + self.characters = characters + self.lines = lines + } +} + +/// Pure, UI-free storage and text-measurement layer behind DMonte Scratchpad. Every function +/// here is synchronous and side-effect free apart from the explicitly file-touching ones, so the +/// controller can hop the expensive parts off the main actor with `Task.detached` and the test +/// target can exercise all of it against a temporary directory. +/// +/// Storage shape: one JSON file per note, named after the note's UUID. A single file per note is +/// the whole point — losing notes is this tool's only unacceptable failure, and a single shared +/// index file would mean one bad write takes every note with it. +public enum ScratchpadKit { + /// Longest title the switcher can show without the chips collapsing into ellipses. Titles are + /// clipped here rather than in the view so what is stored is what is displayed. + public static let maximumTitleLength = 40 + + /// Name used when a note has no usable title of its own. + public static let untitledTitle = "Untitled" + + // MARK: - Counting + + /// Words, characters and lines for `text`. + /// + /// Words are runs separated by Unicode whitespace, which is the definition a Latin-script + /// writer expects. It under-counts scripts that do not space their words (Chinese, Japanese, + /// Thai); a word count is an approximation everywhere, and the character count beside it is + /// exact, so this stays simple rather than pulling in a locale-aware tokenizer. + public static func counts(for text: String) -> ScratchpadCounts { + let words = text.split(whereSeparator: { $0.isWhitespace }).count + // An empty document is zero lines; anything else has one more line than it has newlines. + // `isNewline` on Character treats CRLF as the single grapheme cluster it is, so text + // pasted from Windows does not double-count. + let lines = text.isEmpty + ? 0 + : text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).count + + return ScratchpadCounts(words: words, characters: text.count, lines: lines) + } + + // MARK: - Naming + + /// Collapses a raw, user-typed title into something a one-line chip can render: whitespace + /// runs (including pasted newlines) become single spaces, and the result is clipped to + /// `maximumTitleLength` *characters* so clipping never splits an emoji in half. + public static func sanitizedTitle(_ raw: String) -> String { + let collapsed = raw + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + guard !collapsed.isEmpty else { return untitledTitle } + return String(collapsed.prefix(maximumTitleLength)) + } + + /// Sanitizes `raw`, then appends " 2", " 3", … until it no longer collides with `existing`. + /// Comparison is case-insensitive because two chips reading "Notes" and "notes" are the same + /// note as far as anyone scanning the switcher is concerned. + public static func uniqueTitle(_ raw: String, existing: [String]) -> String { + let base = sanitizedTitle(raw) + let taken = Set(existing.map { $0.lowercased() }) + guard taken.contains(base.lowercased()) else { return base } + + var suffix = 2 + while true { + let candidate = "\(base) \(suffix)" + if !taken.contains(candidate.lowercased()) { return candidate } + suffix += 1 + } + } + + // MARK: - Storage + + /// File name a note is stored under. Derived from the UUID rather than the title so renaming + /// a note never has to move a file — a rename that failed halfway would otherwise be able to + /// leave two files claiming the same note. + public static func fileName(for id: UUID) -> String { + "\(id.uuidString).json" + } + + /// Creates `directory` if needed, restricted to the current user. Notes routinely hold + /// half-written messages and pasted credentials, so 0700 is the right default. Returns false + /// only when the directory could not be created at all. + @discardableResult + public static func prepareDirectory(_ directory: URL) -> Bool { + let ownerOnly: [FileAttributeKey: Any] = [.posixPermissions: 0o700] + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: ownerOnly + ) + // Re-asserted separately: createDirectory only applies its attributes to directories it + // actually creates, so a directory from an earlier build would keep its old mode. + try? FileManager.default.setAttributes(ownerOnly, ofItemAtPath: directory.path) + return FileManager.default.fileExists(atPath: directory.path) + } + + /// Loads every note in `directory`, ordered oldest-first. + /// + /// A file that fails to decode is never discarded. If its bytes are still readable text the + /// note is salvaged with its raw contents as the body and a "Recovered" title, so a partial + /// write costs the user some JSON punctuation in their note rather than the note itself; if + /// even that fails the file is skipped and left untouched on disk for manual rescue. Either + /// way one damaged file cannot stop the remaining notes from loading. + public static func loadNotes(from directory: URL) -> [ScratchpadNote] { + let contents = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + )) ?? [] + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + var notes: [ScratchpadNote] = [] + for url in contents where url.pathExtension == "json" { + guard let data = try? Data(contentsOf: url) else { continue } + + if let note = try? decoder.decode(ScratchpadNote.self, from: data) { + notes.append(note) + continue + } + + if let salvaged = salvage(data, at: url) { + notes.append(salvaged) + } + } + + return notes.sorted { $0.created < $1.created } + } + + /// Best-effort rescue of a note file whose JSON no longer parses. The id is taken from the + /// file name so a subsequent save overwrites the damaged file rather than leaving a duplicate + /// behind; unreadable bytes yield `nil` and the caller leaves the file alone. + private static func salvage(_ data: Data, at url: URL) -> ScratchpadNote? { + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return nil } + + let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) ?? UUID() + // The file's modification date is the closest thing to a real timestamp still available. + let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? Date() + + return ScratchpadNote( + id: id, + title: "Recovered Note", + body: text, + created: modified, + modified: modified + ) + } + + /// Encodes a note for storage. Separated from the write so the (potentially large) encode can + /// happen outside the writer's serial queue. + public static func encode(_ note: ScratchpadNote) -> Data? { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try? encoder.encode(note) + } + + /// Writes already-encoded note bytes atomically, so an interrupted save leaves the previous + /// contents intact instead of a half-written file. Returns whether the bytes reached disk. + @discardableResult + public static func writeEncodedNote(_ data: Data, id: UUID, to directory: URL) -> Bool { + let url = directory.appendingPathComponent(fileName(for: id)) + guard (try? data.write(to: url, options: .atomic)) != nil else { return false } + // Notes hold whatever the user parked in them; keep the file owner-only (0600). + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return true + } + + /// Encode-and-write convenience used by tests and by the non-debounced paths. + @discardableResult + public static func save(_ note: ScratchpadNote, to directory: URL) -> Bool { + guard let data = encode(note) else { return false } + return writeEncodedNote(data, id: note.id, to: directory) + } + + /// Removes a note's file. Missing files are not an error — the caller's intent was for the + /// note to be gone. + public static func deleteNote(id: UUID, in directory: URL) { + try? FileManager.default.removeItem(at: directory.appendingPathComponent(fileName(for: id))) + } +} diff --git a/Sources/DMonteCore/ScratchpadSizing.swift b/Sources/DMonteCore/ScratchpadSizing.swift new file mode 100644 index 0000000..ad4d2b1 --- /dev/null +++ b/Sources/DMonteCore/ScratchpadSizing.swift @@ -0,0 +1,17 @@ +import AppKit + +public enum ScratchpadSizing { + public static func preferredSize() -> NSSize { + let scale = currentScale + // Wider and taller than the toggle-style tools: this one is a writing surface, and a + // narrow column would wrap ordinary prose every few words. + return NSSize(width: (380 * scale).rounded(), height: (540 * scale).rounded()) + } + + static var currentScale: CGFloat { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let screenScale = visibleFrame.height / 950 + let menuBarScale = NSStatusBar.system.thickness / 26 + return min(1.0, max(0.82, min(screenScale, menuBarScale))) + } +} diff --git a/Sources/DMonteCore/ScratchpadView.swift b/Sources/DMonteCore/ScratchpadView.swift new file mode 100644 index 0000000..d6ea09d --- /dev/null +++ b/Sources/DMonteCore/ScratchpadView.swift @@ -0,0 +1,492 @@ +import AppKit +import SwiftUI + +/// The floating Scratchpad popover: a switcher strip of named notes, a plain-text editor, a live +/// size readout, and copy/clear/delete actions. Content is scaled to match the menu-bar/display +/// scale so it fits the scaled panel (same approach as the other tools). +public struct ScratchpadPopoverView: View { + @ObservedObject var controller: ScratchpadController + var onQuit: () -> Void + + @State private var isShowingSettings = false + @State private var isRenaming = false + @State private var draftTitle = "" + @FocusState private var isTitleFieldFocused: Bool + + private let scale = ScratchpadSizing.currentScale + + public init(controller: ScratchpadController, onQuit: @escaping () -> Void) { + self.controller = controller + self.onQuit = onQuit + } + + private func s(_ value: CGFloat) -> CGFloat { value * scale } + + private var accent: Color { .yellow } + + public var body: some View { + ZStack { + VStack(spacing: 0) { + header + Divider().opacity(0.6) + noteSwitcher + editor + countsRow + Divider().opacity(0.6) + actionRow + footer + } + + if isShowingSettings { + PreferencesOverlay(cornerRadius: 18) { + ScratchpadSettingsView( + controller: controller, + onQuit: onQuit, + onClose: { isShowingSettings = false } + ) + } + } + } + .frame(width: ScratchpadSizing.preferredSize().width, height: ScratchpadSizing.preferredSize().height) + .frostedPanel(cornerRadius: 18) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: s(8)) { + Image(systemName: "note.text") + .font(.system(size: s(15), weight: .semibold)) + .foregroundStyle(accent) + + Text("Scratchpad") + .font(.system(size: s(15), weight: .bold)) + .foregroundStyle(.primary.opacity(0.9)) + + Spacer() + + Button { + isShowingSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: s(14), weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Settings") + } + .padding(.horizontal, s(16)) + .padding(.top, s(14)) + .padding(.bottom, s(10)) + } + + // MARK: - Note switcher + + private var noteSwitcher: some View { + VStack(spacing: s(8)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: s(6)) { + ForEach(controller.notes) { note in + noteChip(note) + } + + Button { + endRenaming() + controller.addNote() + } label: { + Image(systemName: "plus") + .font(.system(size: s(12), weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: s(28), height: s(26)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(Color.secondary.opacity(0.14)) + ) + } + .buttonStyle(.plain) + .help("New note") + } + .padding(.horizontal, s(16)) + } + + if isRenaming { + renameField + } + } + .padding(.top, s(10)) + .padding(.bottom, s(10)) + } + + private func noteChip(_ note: ScratchpadNote) -> some View { + let isSelected = note.id == controller.selectedNoteID + + return Button { + // A second tap on the note already open is the natural "rename this" gesture, and it + // keeps a rename affordance out of the cramped chip itself. + if isSelected { + beginRenaming() + } else { + endRenaming() + controller.selectNote(note.id) + } + } label: { + Text(note.title) + .font(.system(size: s(12), weight: .semibold)) + .foregroundStyle(isSelected ? Color.black.opacity(0.85) : .primary) + .lineLimit(1) + .padding(.horizontal, s(10)) + .frame(height: s(26)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(isSelected ? accent : Color.secondary.opacity(0.14)) + ) + .contentShape(RoundedRectangle(cornerRadius: s(7), style: .continuous)) + } + .buttonStyle(.plain) + .help(isSelected ? "Tap again to rename" : note.title) + } + + private var renameField: some View { + HStack(spacing: s(8)) { + TextField("Note name", text: $draftTitle) + .textFieldStyle(.plain) + .font(.system(size: s(12), weight: .semibold)) + .focused($isTitleFieldFocused) + .onSubmit { commitRename() } + .padding(.horizontal, s(8)) + .frame(height: s(26)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(Color.secondary.opacity(0.12)) + ) + + Button("Rename") { commitRename() } + .buttonStyle(.plain) + .font(.system(size: s(12), weight: .semibold)) + .foregroundStyle(accent) + } + .padding(.horizontal, s(16)) + } + + private func beginRenaming() { + draftTitle = controller.activeNote?.title ?? "" + isRenaming = true + isTitleFieldFocused = true + } + + private func commitRename() { + controller.renameSelectedNote(to: draftTitle) + endRenaming() + } + + private func endRenaming() { + isRenaming = false + isTitleFieldFocused = false + } + + // MARK: - Editor + + private var editor: some View { + ScratchpadTextView( + noteID: controller.selectedNoteID, + text: controller.activeText, + fontSize: controller.fontSize, + onTextChange: { controller.updateText($0) } + ) + .padding(s(6)) + .background( + RoundedRectangle(cornerRadius: s(10), style: .continuous) + .fill(Color.black.opacity(0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: s(10), style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.75) + ) + .padding(.horizontal, s(16)) + .frame(maxHeight: .infinity) + } + + // MARK: - Counts + + private var countsRow: some View { + HStack(spacing: s(6)) { + Text(countsSummary) + .font(.system(size: s(11), weight: .medium)) + .foregroundStyle(.secondary) + + Spacer(minLength: s(6)) + + if let statusMessage = controller.statusMessage { + Text(statusMessage) + .font(.system(size: s(11), weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.horizontal, s(16)) + .padding(.top, s(8)) + .padding(.bottom, s(8)) + } + + private var countsSummary: String { + let counts = controller.counts + let words = counts.words == 1 ? "1 word" : "\(counts.words) words" + let characters = counts.characters == 1 ? "1 character" : "\(counts.characters) characters" + let lines = counts.lines == 1 ? "1 line" : "\(counts.lines) lines" + return "\(words) · \(characters) · \(lines)" + } + + // MARK: - Actions + + private var actionRow: some View { + HStack(spacing: s(8)) { + actionButton(title: "Copy All", symbol: "doc.on.doc") { + controller.copyAll() + } + + if controller.canUndoClear { + actionButton(title: "Undo Clear", symbol: "arrow.uturn.backward", tint: accent) { + controller.undoClear() + } + } else { + actionButton(title: "Clear", symbol: "eraser") { + controller.clearActiveNote() + } + } + + actionButton( + title: isDeleteArmed ? "Confirm" : "Delete", + symbol: "trash", + tint: isDeleteArmed ? .red : nil + ) { + endRenaming() + controller.deleteSelectedNote() + } + } + .padding(.horizontal, s(16)) + .padding(.top, s(10)) + .padding(.bottom, s(2)) + } + + private var isDeleteArmed: Bool { + controller.pendingDeleteNoteID != nil && controller.pendingDeleteNoteID == controller.selectedNoteID + } + + private func actionButton( + title: String, + symbol: String, + tint: Color? = nil, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: s(5)) { + Image(systemName: symbol) + .font(.system(size: s(11), weight: .semibold)) + Text(title) + .font(.system(size: s(12), weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(tint ?? .primary) + .frame(maxWidth: .infinity) + .frame(height: s(30)) + .background( + RoundedRectangle(cornerRadius: s(8), style: .continuous) + .fill((tint ?? Color.secondary).opacity(0.14)) + ) + .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous)) + } + .buttonStyle(.plain) + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Spacer() + + Button { + onQuit() + } label: { + Label("Quit", systemImage: "power") + .font(.system(size: s(12), weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, s(16)) + .padding(.top, s(8)) + .padding(.bottom, s(14)) + } +} + +// MARK: - Text view + +/// Plain-text editor backed directly by `NSTextView`. +/// +/// SwiftUI's `TextEditor` pushes the entire document back through its binding and re-diffs it on +/// every keystroke, which turns a long note into visible input lag. `NSTextView` edits its own +/// storage in place and merely reports the result, and it brings native undo, find and Unicode +/// text handling with it. +private struct ScratchpadTextView: NSViewRepresentable { + /// Identity of the note on screen. A change means the whole document is replaced; the same id + /// with different text means the controller rewrote the body (a clear, or its undo). + var noteID: UUID? + var text: String + var fontSize: CGFloat + var onTextChange: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onTextChange: onTextChange) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + + textView.delegate = context.coordinator + textView.isRichText = false + textView.allowsUndo = true + textView.drawsBackground = false + textView.textColor = .textColor + textView.font = NSFont.systemFont(ofSize: fontSize) + textView.textContainerInset = NSSize(width: 6, height: 8) + + // A scratchpad is where people park URLs, snippets and IDs. Silently rewriting their + // quotes, hyphens and abbreviations would corrupt exactly the text they came here to keep + // verbatim, so every "smart" substitution is off. + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isContinuousSpellCheckingEnabled = false + + textView.string = text + context.coordinator.noteID = noteID + context.coordinator.lastReportedText = text + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + + context.coordinator.onTextChange = onTextChange + + if textView.font?.pointSize != fontSize { + textView.font = NSFont.systemFont(ofSize: fontSize) + } + + let switchedNote = context.coordinator.noteID != noteID + // Compared against what the text view itself last reported rather than against + // `textView.string`, which would materialize the whole storage on every SwiftUI update. + // The two strings share a buffer in the common case, so this is effectively free. + guard switchedNote || text != context.coordinator.lastReportedText else { return } + + textView.string = text + context.coordinator.noteID = noteID + context.coordinator.lastReportedText = text + + if switchedNote { + // A different document must not inherit the previous note's undo stack — ⌘Z would + // otherwise paste one note's deleted text into another. + textView.undoManager?.removeAllActions() + textView.setSelectedRange(NSRange(location: 0, length: 0)) + textView.scroll(.zero) + } + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + var onTextChange: (String) -> Void + var noteID: UUID? + + /// Last value this text view handed to the controller. Lets `updateNSView` tell "the + /// controller echoed my own edit back" apart from "something else changed the body". + var lastReportedText = "" + + init(onTextChange: @escaping (String) -> Void) { + self.onTextChange = onTextChange + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + let updated = textView.string + lastReportedText = updated + onTextChange(updated) + } + } +} + +// MARK: - Settings + +private struct ScratchpadSettingsView: View { + @ObservedObject var controller: ScratchpadController + var onQuit: () -> Void + var onClose: () -> Void + + private let sizes: [CGFloat] = [11, 13, 16, 20] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Scratchpad Settings") + .font(.system(size: 16, weight: .bold)) + Spacer() + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + Text("Text size") + .font(.system(size: 13, weight: .semibold)) + + HStack(spacing: 8) { + ForEach(sizes, id: \.self) { size in + Button { + controller.setFontSize(size) + } label: { + Text("\(Int(size))") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(controller.fontSize == size ? Color.white : .primary) + .frame(maxWidth: .infinity) + .frame(height: 28) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(controller.fontSize == size ? Color.accentColor : Color.secondary.opacity(0.14)) + ) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + } + } + + Text("Notes are saved automatically as you type, one file per note, and are flushed to disk whenever the panel closes or the app quits.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + Button(role: .destructive) { + onClose() + onQuit() + } label: { + Label("Quit Scratchpad", systemImage: "power") + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + } + .padding(20) + .frame(width: 320, height: 290) + } +} diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift index 8d1039b..fe0918e 100644 --- a/Sources/DMonteCore/ToolboxCatalog.swift +++ b/Sources/DMonteCore/ToolboxCatalog.swift @@ -64,7 +64,8 @@ public enum ToolboxCatalog { ToolboxTool(id: "colorPicker", title: "Color Picker", iconName: "eyedropper.halffull", tint: .mint, bundleID: prefix + "colorpicker", appName: "DMonte Color Picker.app", executableName: "DMonteColorPicker", arguments: ["--open"]), ToolboxTool(id: "grabText", title: "Grab Text", iconName: "text.viewfinder", tint: contrastGreen, bundleID: prefix + "grabtext", appName: "DMonte Grab Text.app", executableName: "DMonteGrabText", arguments: ["--open"]), ToolboxTool(id: "focusTimer", title: "Focus Timer", iconName: "timer", tint: .red, bundleID: prefix + "focustimer", appName: "DMonte Focus Timer.app", executableName: "DMonteFocusTimer", arguments: ["--open"]), - ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]) + ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]), + ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "note.text", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", arguments: ["--open"]) ] } diff --git a/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift new file mode 100644 index 0000000..a458cf4 --- /dev/null +++ b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift @@ -0,0 +1,70 @@ +import AppKit +import DMonteCore +import SwiftUI + +/// Distributed notification used to reveal this helper's popover when the Toolbox (or a second +/// launch with `--open`) asks for it. +enum ScratchpadNotifications { + static let showWindow = Notification.Name("com.havokentity.mactools.scratchpad.showWindow") +} + +@MainActor +final class ScratchpadAppDelegate: NSObject, NSApplicationDelegate { + private let controller = ScratchpadController() + + private var statusItem: HelperStatusItem? + private var panelHost: HelperPanelHost? + + func applicationDidFinishLaunching(_ notification: Notification) { + AppDefaults.registerDefaults() + + let host = HelperPanelHost( + configuration: HelperPanelHost.Configuration( + sizing: .preferred({ ScratchpadSizing.preferredSize() }) + ), + content: .viewController({ [controller, weak self] in + NSHostingController( + rootView: ScratchpadPopoverView(controller: controller, onQuit: { self?.quit() }) + ) + }), + anchorView: { [weak self] in self?.statusItem?.button } + ) + panelHost = host + + // Closing the panel is the moment a user assumes their notes are "put away", and it is + // also the last chance to write before an idle process is killed — so the debounced save + // is forced through here as well as at termination. + host.onDidClose = { [controller] in controller.flushPendingSaves() } + host.configure() + + statusItem = HelperStatusItem( + image: Self.statusIcon(), + toolTip: "Scratchpad", + primaryAction: { [weak self] in self?.panelHost?.toggle() }, + quitAction: { [weak self] in self?.quit() } + ) + + host.observeShowNotification(named: ScratchpadNotifications.showWindow) + } + + func applicationWillTerminate(_ notification: Notification) { + panelHost?.stopObservingShowNotifications() + panelHost?.removeOutsideClickMonitor() + // Before anything else tears down: the debounced writes are detached tasks that die with + // the process, so the last few hundred milliseconds of typing only survives this flush. + controller.flushPendingSaves() + panelHost?.dismissForTermination() + statusItem?.remove() + } + + private static func statusIcon() -> NSImage { + let image = NSImage(systemSymbolName: "note.text", accessibilityDescription: "Scratchpad") ?? NSImage() + image.isTemplate = true + return image + } + + private func quit() { + panelHost?.close() + NSApp.terminate(nil) + } +} diff --git a/Sources/DMonteScratchpadApp/main.swift b/Sources/DMonteScratchpadApp/main.swift new file mode 100644 index 0000000..8495bb0 --- /dev/null +++ b/Sources/DMonteScratchpadApp/main.swift @@ -0,0 +1,24 @@ +import AppKit +import DMonteCore + +let singleInstanceGuard = SingleInstanceGuard(identifier: "com.havokentity.mactools.scratchpad") + +guard singleInstanceGuard.isPrimary else { + if CommandLine.arguments.contains("--open") { + DistributedNotificationCenter.default().postNotificationName( + ScratchpadNotifications.showWindow, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + exit(EXIT_SUCCESS) +} + +let app = NSApplication.shared +let delegate = ScratchpadAppDelegate() + +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/Tests/DMonteCoreTests/ScratchpadControllerTests.swift b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift new file mode 100644 index 0000000..db933c8 --- /dev/null +++ b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift @@ -0,0 +1,270 @@ +import Foundation +import XCTest +@testable import DMonteCore + +/// Covers the behaviour that decides whether the Scratchpad loses someone's notes: that an edit +/// survives a flush and a relaunch, that a clear can be taken back, and that deleting one note +/// leaves the rest alone. Each test gets its own temporary directory and its own `UserDefaults` +/// suite, so neither the user's real notes nor their real preferences are ever touched. +@MainActor +final class ScratchpadControllerTests: XCTestCase { + private var directory: URL! + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUpWithError() throws { + try super.setUpWithError() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ScratchpadControllerTests-\(UUID().uuidString)", isDirectory: true) + suiteName = "com.havokentity.mactools.tests.scratchpad.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDownWithError() throws { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + if let suiteName { + defaults?.removePersistentDomain(forName: suiteName) + } + directory = nil + defaults = nil + suiteName = nil + try super.tearDownWithError() + } + + private func makeController() -> ScratchpadController { + ScratchpadController(directory: directory, defaults: defaults) + } + + // MARK: - Startup + + /// An empty panel with no note to type into would be a dead end on first launch. + func testFirstLaunchStartsWithOneSelectedNote() { + let controller = makeController() + + XCTAssertEqual(controller.notes.count, 1) + XCTAssertEqual(controller.selectedNoteID, controller.notes.first?.id) + } + + // MARK: - Autosave + + /// The load-bearing test for the whole tool: text typed and then flushed must be on disk, + /// and must come back when the controller is rebuilt. + func testEditsSurviveAFlushAndAReload() { + let controller = makeController() + controller.updateText("remember the milk") + controller.flushPendingSaves() + + let reloaded = makeController() + + XCTAssertEqual(reloaded.activeText, "remember the milk") + } + + /// Flushing writes every note, not just the one on screen — the note left behind by a switch + /// may still be holding an unfired debounce. + func testEditsToSeveralNotesAllSurviveOneFlush() { + let controller = makeController() + controller.updateText("first note") + controller.addNote() + controller.updateText("second note") + controller.flushPendingSaves() + + let bodies = makeController().notes.map(\.body) + + XCTAssertEqual(bodies.count, 2) + XCTAssertTrue(bodies.contains("first note")) + XCTAssertTrue(bodies.contains("second note")) + } + + func testUnicodeSurvivesTheControllerRoundTrip() { + let body = "👨‍👩‍👧‍👦 नमस्ते — “quoted”" + let controller = makeController() + controller.updateText(body) + controller.flushPendingSaves() + + XCTAssertEqual(makeController().activeText, body) + } + + /// Selecting another note is a commit point; a quit immediately afterwards must not lose the + /// note the user just navigated away from. + func testSwitchingNotesCommitsTheOneBeingLeft() throws { + let controller = makeController() + let firstID = try XCTUnwrap(controller.selectedNoteID) + controller.updateText("left behind") + + controller.addNote() + + XCTAssertNotEqual(controller.selectedNoteID, firstID) + // Read straight from disk, with no flush of our own: the switch itself must have + // committed the note being left. + XCTAssertTrue(ScratchpadKit.loadNotes(from: directory).contains { $0.body == "left behind" }) + } + + /// The selected note is remembered so reopening the panel lands where the user left off. + func testSelectionIsRestoredOnReload() { + let controller = makeController() + controller.addNote() + let expected = controller.selectedNoteID + controller.flushPendingSaves() + + XCTAssertEqual(makeController().selectedNoteID, expected) + } + + // MARK: - Clear and undo + + func testClearEmptiesTheNoteAndOffersAnUndo() { + let controller = makeController() + controller.updateText("valuable") + + controller.clearActiveNote() + + XCTAssertEqual(controller.activeText, "") + XCTAssertTrue(controller.canUndoClear) + } + + func testUndoClearRestoresTheExactText() { + let controller = makeController() + controller.updateText("valuable 👍") + controller.clearActiveNote() + + controller.undoClear() + + XCTAssertEqual(controller.activeText, "valuable 👍") + XCTAssertFalse(controller.canUndoClear) + } + + /// Once the user starts writing again, restoring the old body would clobber the new one — so + /// the offer is withdrawn rather than left as a trap. + func testTypingAfterAClearWithdrawsTheUndoOffer() { + let controller = makeController() + controller.updateText("old") + controller.clearActiveNote() + + controller.updateText("new") + + XCTAssertFalse(controller.canUndoClear) + controller.undoClear() + XCTAssertEqual(controller.activeText, "new") + } + + /// The undo is kept per note for the whole session, so glancing at another note and coming + /// back does not quietly forfeit it. + func testUndoSurvivesASwitchAwayAndBack() throws { + let controller = makeController() + let firstID = try XCTUnwrap(controller.selectedNoteID) + controller.updateText("valuable") + controller.clearActiveNote() + + controller.addNote() + XCTAssertFalse(controller.canUndoClear) + controller.selectNote(firstID) + + XCTAssertTrue(controller.canUndoClear) + controller.undoClear() + XCTAssertEqual(controller.activeText, "valuable") + } + + func testClearingAnAlreadyEmptyNoteOffersNothingToUndo() { + let controller = makeController() + + controller.clearActiveNote() + + XCTAssertFalse(controller.canUndoClear) + } + + // MARK: - Delete + + /// The first press only arms the confirmation; the note is still there. + func testFirstDeletePressOnlyArmsTheConfirmation() { + let controller = makeController() + controller.addNote() + controller.updateText("still wanted") + + controller.deleteSelectedNote() + + XCTAssertEqual(controller.notes.count, 2) + XCTAssertEqual(controller.pendingDeleteNoteID, controller.selectedNoteID) + XCTAssertEqual(controller.activeText, "still wanted") + } + + func testSecondDeletePressRemovesOnlyThatNote() { + let controller = makeController() + controller.updateText("keep me") + controller.addNote() + controller.updateText("delete me") + + controller.deleteSelectedNote() + controller.deleteSelectedNote() + controller.flushPendingSaves() + + XCTAssertEqual(controller.notes.count, 1) + XCTAssertEqual(controller.activeText, "keep me") + XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).map(\.body), ["keep me"]) + } + + /// A deleted note must not come back when the switcher's own selection changes rebuild it. + func testDeletingTheLastNoteLeavesAFreshEmptyOne() { + let controller = makeController() + controller.updateText("goodbye") + + controller.deleteSelectedNote() + controller.deleteSelectedNote() + controller.flushPendingSaves() + + XCTAssertEqual(controller.notes.count, 1) + XCTAssertEqual(controller.activeText, "") + XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).map(\.body), [""]) + } + + // MARK: - Renaming + + func testRenamingDeduplicatesAgainstTheOtherNotes() throws { + let controller = makeController() + controller.renameSelectedNote(to: "Ideas") + controller.addNote() + + controller.renameSelectedNote(to: "Ideas") + + XCTAssertEqual(controller.activeNote?.title, "Ideas 2") + } + + /// Renaming compares against the *other* notes only, so re-committing a note's own name is a + /// no-op rather than a slow march to "Ideas 2", "Ideas 3", … + func testRenamingANoteToItsOwnNameChangesNothing() { + let controller = makeController() + controller.renameSelectedNote(to: "Ideas") + + controller.renameSelectedNote(to: "Ideas") + + XCTAssertEqual(controller.activeNote?.title, "Ideas") + } + + func testRenameSurvivesAReload() { + let controller = makeController() + controller.renameSelectedNote(to: " Shopping list ") + controller.flushPendingSaves() + + XCTAssertEqual(makeController().activeNote?.title, "Shopping list") + } + + // MARK: - Corrupt storage + + /// The store-level guarantee seen from the controller: one damaged file costs at most that + /// one note's formatting, never the others. + func testACorruptNoteFileDoesNotStopTheOthersFromOpening() { + let controller = makeController() + controller.updateText("healthy") + controller.flushPendingSaves() + + let corruptID = UUID() + try? Data("{\"body\":\"truncated".utf8) + .write(to: directory.appendingPathComponent(ScratchpadKit.fileName(for: corruptID))) + + let reloaded = makeController() + + XCTAssertEqual(reloaded.notes.count, 2) + XCTAssertTrue(reloaded.notes.contains { $0.body == "healthy" }) + XCTAssertTrue(reloaded.notes.contains { $0.title == "Recovered Note" }) + } +} diff --git a/Tests/DMonteCoreTests/ScratchpadKitTests.swift b/Tests/DMonteCoreTests/ScratchpadKitTests.swift new file mode 100644 index 0000000..8d48e3b --- /dev/null +++ b/Tests/DMonteCoreTests/ScratchpadKitTests.swift @@ -0,0 +1,273 @@ +import Foundation +import XCTest +@testable import DMonteCore + +/// Covers `ScratchpadKit`: the per-note file storage, the naming/dedup rules the switcher relies +/// on, and the counting logic. Every disk test runs against its own temporary directory created +/// in `setUpWithError` and torn down afterwards — the real Application Support notes are the one +/// thing this tool must never risk. +final class ScratchpadKitTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ScratchpadKitTests-\(UUID().uuidString)", isDirectory: true) + XCTAssertTrue(ScratchpadKit.prepareDirectory(directory)) + } + + override func tearDownWithError() throws { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + directory = nil + try super.tearDownWithError() + } + + // MARK: - Counting + + func testCountsForPlainProse() { + let counts = ScratchpadKit.counts(for: "the quick brown fox") + + XCTAssertEqual(counts.words, 4) + XCTAssertEqual(counts.characters, 19) + XCTAssertEqual(counts.lines, 1) + } + + func testEmptyTextCountsAsNothingAtAll() { + let counts = ScratchpadKit.counts(for: "") + + XCTAssertEqual(counts.words, 0) + XCTAssertEqual(counts.characters, 0) + // Zero, not one: an untouched note claiming "1 line" reads as though it already holds + // something. + XCTAssertEqual(counts.lines, 0) + } + + /// The whole point of counting `Character`s. A ZWJ family emoji is 11 UTF-16 units and 1 + /// grapheme cluster; reporting 11 characters for one typed glyph would be nonsense. + func testEmojiCountsAsOneCharacterNotItsUTF16Length() { + let family = "👨‍👩‍👧‍👦" + XCTAssertEqual(family.utf16.count, 11, "Precondition: this emoji really is a multi-unit cluster") + + XCTAssertEqual(ScratchpadKit.counts(for: family).characters, 1) + } + + func testCombiningMarksAndFlagsCountAsSingleCharacters() { + // "é" written as e + combining acute, and a regional-indicator flag pair. + let text = "e\u{0301}🇮🇳" + + XCTAssertEqual(ScratchpadKit.counts(for: text).characters, 2) + } + + func testEmojiSeparatedByWhitespaceStillCountAsWords() { + let counts = ScratchpadKit.counts(for: "ship it 🚀 🎉") + + XCTAssertEqual(counts.words, 4) + } + + /// Text pasted from Windows arrives with CRLF endings. Swift treats "\r\n" as one grapheme + /// cluster, so it must not be counted as two line breaks. + func testCarriageReturnLineFeedCountsAsOneLineBreak() { + let counts = ScratchpadKit.counts(for: "first\r\nsecond") + + XCTAssertEqual(counts.lines, 2) + } + + func testBlankLinesAreCounted() { + let counts = ScratchpadKit.counts(for: "first\n\nthird") + + XCTAssertEqual(counts.lines, 3) + XCTAssertEqual(counts.words, 2) + } + + func testRunsOfWhitespaceDoNotInflateTheWordCount() { + let counts = ScratchpadKit.counts(for: " spaced out \n\t words ") + + XCTAssertEqual(counts.words, 3) + } + + // MARK: - Naming + + func testSanitizedTitleCollapsesWhitespaceAndPastedNewlines() { + XCTAssertEqual(ScratchpadKit.sanitizedTitle(" Meeting \n notes "), "Meeting notes") + } + + func testBlankTitleFallsBackToUntitled() { + XCTAssertEqual(ScratchpadKit.sanitizedTitle(" \n\t "), ScratchpadKit.untitledTitle) + } + + /// Clipping is by `Character`, so a long title ending in an emoji can never be cut in half + /// into a replacement glyph. + func testLongTitleIsClippedByCharactersNotUnits() { + let raw = String(repeating: "🙂", count: 60) + + let title = ScratchpadKit.sanitizedTitle(raw) + + XCTAssertEqual(title.count, ScratchpadKit.maximumTitleLength) + XCTAssertEqual(title, String(repeating: "🙂", count: ScratchpadKit.maximumTitleLength)) + } + + func testUniqueTitleLeavesANonCollidingNameAlone() { + XCTAssertEqual(ScratchpadKit.uniqueTitle("Ideas", existing: ["Notes", "Todo"]), "Ideas") + } + + func testUniqueTitleSuffixesCollisionsUntilFree() { + let existing = ["Untitled", "Untitled 2"] + + XCTAssertEqual(ScratchpadKit.uniqueTitle("Untitled", existing: existing), "Untitled 3") + } + + /// Two chips reading "Notes" and "notes" are indistinguishable at a glance, so dedup ignores + /// case rather than letting the switcher show what looks like the same note twice. + func testUniqueTitleTreatsCaseVariantsAsCollisions() { + XCTAssertEqual(ScratchpadKit.uniqueTitle("notes", existing: ["Notes"]), "notes 2") + } + + // MARK: - Persistence round-trips + + func testSavedNoteRoundTripsThroughDisk() { + let note = ScratchpadNote(title: "Ideas", body: "line one\nline two") + + XCTAssertTrue(ScratchpadKit.save(note, to: directory)) + let loaded = ScratchpadKit.loadNotes(from: directory) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.id, note.id) + XCTAssertEqual(loaded.first?.title, "Ideas") + XCTAssertEqual(loaded.first?.body, "line one\nline two") + } + + /// Notes routinely hold emoji and non-Latin scripts; a lossy encode would silently mangle + /// them, and the user would only find out long after the original was gone. + func testUnicodeBodySurvivesTheRoundTripByteForByte() { + let body = "नमस्ते 👨‍👩‍👧‍👦 — “curly” ✅\ntab\there" + let note = ScratchpadNote(title: "Unicode 🌍", body: body) + + ScratchpadKit.save(note, to: directory) + let loaded = ScratchpadKit.loadNotes(from: directory).first + + XCTAssertEqual(loaded?.body, body) + XCTAssertEqual(loaded?.title, "Unicode 🌍") + XCTAssertEqual(ScratchpadKit.counts(for: loaded?.body ?? "").characters, + ScratchpadKit.counts(for: body).characters) + } + + func testResavingANoteReplacesItRatherThanAddingAnother() { + var note = ScratchpadNote(title: "Ideas", body: "first") + ScratchpadKit.save(note, to: directory) + + note.body = "second" + ScratchpadKit.save(note, to: directory) + + let loaded = ScratchpadKit.loadNotes(from: directory) + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.body, "second") + } + + /// The switcher must not reshuffle while the user types, so ordering is by creation date. + func testNotesLoadOldestFirstRegardlessOfWriteOrder() { + let base = Date(timeIntervalSince1970: 1_000_000) + let oldest = ScratchpadNote(title: "Oldest", created: base, modified: base) + let middle = ScratchpadNote(title: "Middle", created: base.addingTimeInterval(60), modified: base) + let newest = ScratchpadNote(title: "Newest", created: base.addingTimeInterval(120), modified: base) + + // Deliberately written newest-first. + [newest, middle, oldest].forEach { ScratchpadKit.save($0, to: directory) } + + XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).map(\.title), ["Oldest", "Middle", "Newest"]) + } + + func testDeletingANoteLeavesTheOthersUntouched() { + let kept = ScratchpadNote(title: "Kept", body: "still here") + let doomed = ScratchpadNote(title: "Doomed", body: "not for long") + ScratchpadKit.save(kept, to: directory) + ScratchpadKit.save(doomed, to: directory) + + ScratchpadKit.deleteNote(id: doomed.id, in: directory) + + let loaded = ScratchpadKit.loadNotes(from: directory) + XCTAssertEqual(loaded.map(\.title), ["Kept"]) + } + + func testLoadingAnEmptyOrMissingDirectoryYieldsNoNotes() { + XCTAssertTrue(ScratchpadKit.loadNotes(from: directory).isEmpty) + XCTAssertTrue(ScratchpadKit.loadNotes(from: directory.appendingPathComponent("nope")).isEmpty) + } + + /// Notes are stored per file precisely so this holds. A shared index would have made the + /// damaged note's neighbours unreadable too. + func testStrayNonNoteFilesAreIgnored() { + let note = ScratchpadNote(title: "Real", body: "hello") + ScratchpadKit.save(note, to: directory) + try? Data("junk".utf8).write(to: directory.appendingPathComponent("README.txt")) + + XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).map(\.title), ["Real"]) + } + + // MARK: - Corrupt-note recovery + + func testACorruptNoteDoesNotPreventTheOthersFromLoading() { + let healthy = ScratchpadNote(title: "Healthy", body: "intact") + ScratchpadKit.save(healthy, to: directory) + + let corruptID = UUID() + let corruptURL = directory.appendingPathComponent(ScratchpadKit.fileName(for: corruptID)) + try? Data("{\"id\":\"\(corruptID.uuidString)\",\"body\":\"half a fi".utf8).write(to: corruptURL) + + let loaded = ScratchpadKit.loadNotes(from: directory) + + XCTAssertEqual(loaded.count, 2) + XCTAssertTrue(loaded.contains { $0.id == healthy.id && $0.body == "intact" }) + } + + /// A truncated file's bytes are still the user's words, so they are salvaged into a note + /// rather than dropped. Reusing the file's UUID means the next save repairs the file in + /// place instead of leaving a second copy behind. + func testATruncatedNoteIsSalvagedUnderItsOwnIdentity() { + let corruptID = UUID() + let rawText = "{\"id\":\"\(corruptID.uuidString)\",\"body\":\"remember the milk" + let corruptURL = directory.appendingPathComponent(ScratchpadKit.fileName(for: corruptID)) + try? Data(rawText.utf8).write(to: corruptURL) + + let loaded = ScratchpadKit.loadNotes(from: directory) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.id, corruptID) + XCTAssertEqual(loaded.first?.title, "Recovered Note") + XCTAssertTrue(loaded.first?.body.contains("remember the milk") == true) + } + + /// Bytes that are not text at all cannot be shown to anyone, so the file is skipped — and + /// deliberately left on disk, because deleting a file we failed to understand is exactly the + /// data loss this tool exists to avoid. + func testUndecodableBytesAreSkippedAndTheFileIsLeftOnDisk() { + let healthy = ScratchpadNote(title: "Healthy", body: "intact") + ScratchpadKit.save(healthy, to: directory) + + let binaryURL = directory.appendingPathComponent(ScratchpadKit.fileName(for: UUID())) + try? Data([0xFF, 0xFE, 0xFF, 0xFE]).write(to: binaryURL) + + let loaded = ScratchpadKit.loadNotes(from: directory) + + XCTAssertEqual(loaded.map(\.title), ["Healthy"]) + XCTAssertTrue(FileManager.default.fileExists(atPath: binaryURL.path)) + } + + /// Rewriting a salvaged note must land on the same file, leaving exactly one note behind. + func testSavingASalvagedNoteRepairsItsFileInPlace() { + let corruptID = UUID() + let corruptURL = directory.appendingPathComponent(ScratchpadKit.fileName(for: corruptID)) + try? Data("{\"body\":\"partial".utf8).write(to: corruptURL) + + guard var salvaged = ScratchpadKit.loadNotes(from: directory).first else { + return XCTFail("Expected the damaged note to be salvaged") + } + salvaged.body = "repaired" + ScratchpadKit.save(salvaged, to: directory) + + let reloaded = ScratchpadKit.loadNotes(from: directory) + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.first?.body, "repaired") + } +} From 2252ad643086163512129368e5f070ef1274e4be Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 14:00:55 +0530 Subject: [PATCH 2/7] review: fix three Scratchpad defects found by adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvage no longer invents an identity for a .json file the tool did not write. It used to fall back to a fresh UUID, which meant such a file was recovered under a new id on every launch while the original stayed on disk — the note count climbed forever and nothing in the UI could clear it. Unidentifiable files are now left alone, like undecodable bytes. A save that never reached disk is now surfaced on the status line instead of being discarded. ScratchpadNoteWriter.write returns whether the bytes landed (a superseded snapshot still counts as success), and all three save paths report a failure. A full volume or an uncreatable store directory previously left the user typing into a note that was not being persisted, with no indication until after they quit. Typing now disarms an armed-but-unconfirmed delete. The arm otherwise outlived a whole editing session and a panel close, so a single stray press much later destroyed the note outright and the two-press confirmation was protecting nothing. Tests: 471 -> 475. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadController.swift | 41 ++++++++++++++---- Sources/DMonteCore/ScratchpadKit.swift | 7 ++- .../ScratchpadControllerTests.swift | 43 +++++++++++++++++++ .../DMonteCoreTests/ScratchpadKitTests.swift | 32 ++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/Sources/DMonteCore/ScratchpadController.swift b/Sources/DMonteCore/ScratchpadController.swift index 7d2743b..142c692 100644 --- a/Sources/DMonteCore/ScratchpadController.swift +++ b/Sources/DMonteCore/ScratchpadController.swift @@ -110,6 +110,12 @@ public final class ScratchpadController: ObservableObject { canUndoClear = false } + // Typing is also the clearest possible signal that an armed-but-unconfirmed delete was + // not meant to go through. The arm otherwise outlives a whole editing session and even a + // panel close, so a single stray press much later would destroy the note with no second + // chance — the confirmation would be protecting nothing. + pendingDeleteNoteID = nil + apply(body: newText, at: index) statusMessage = nil } @@ -244,9 +250,11 @@ public final class ScratchpadController: ObservableObject { saveGeneration &+= 1 let generation = saveGeneration + var allWritten = true for note in notes { - writer.write(note, to: directory, generation: generation) + if !writer.write(note, to: directory, generation: generation) { allWritten = false } } + if !allWritten { reportSaveFailure() } } // MARK: - Private @@ -293,10 +301,11 @@ public final class ScratchpadController: ObservableObject { // Task.detached takes a @Sendable closure, so it does NOT inherit this method's // @MainActor isolation — the encode and write run on a background executor and a very // large note can never stall the keystroke that triggered it. - saveTasks[note.id] = Task.detached(priority: .utility) { + saveTasks[note.id] = Task.detached(priority: .utility) { [weak self] in try? await Task.sleep(nanoseconds: 400_000_000) if Task.isCancelled { return } - writer.write(note, to: directory, generation: generation) + guard !writer.write(note, to: directory, generation: generation) else { return } + await MainActor.run { self?.reportSaveFailure() } } } @@ -304,7 +313,15 @@ public final class ScratchpadController: ObservableObject { /// file's existence — not its contents — is what the UI has already committed to. private func writeNow(_ note: ScratchpadNote) { saveGeneration &+= 1 - writer.write(note, to: directory, generation: saveGeneration) + if !writer.write(note, to: directory, generation: saveGeneration) { reportSaveFailure() } + } + + /// Surfaces a save that never reached disk — a full volume, a store directory that could not + /// be created, revoked permissions. Staying quiet is the worst thing this tool could do here: + /// the user would keep typing into a note that is not being persisted and only discover it + /// after quitting, which is exactly the loss everything else in this file exists to prevent. + private func reportSaveFailure() { + statusMessage = "Couldn’t save to disk — copy anything important out." } private func refreshCounts() { @@ -337,12 +354,18 @@ final class ScratchpadNoteWriter: @unchecked Sendable { private let queue = DispatchQueue(label: "com.havokentity.mactools.scratchpad.note-write", qos: .utility) private var lastWrittenGeneration: [UUID: UInt64] = [:] - func write(_ note: ScratchpadNote, to directory: URL, generation: UInt64) { - guard let data = ScratchpadKit.encode(note) else { return } - queue.sync { - guard generation > (lastWrittenGeneration[note.id] ?? 0) else { return } + /// Returns whether this note is safely on disk. A snapshot dropped for being older than one + /// already written reports success — a newer version of the same note is down, which is what + /// the caller actually cares about. Only bytes that genuinely failed to land report false. + @discardableResult + func write(_ note: ScratchpadNote, to directory: URL, generation: UInt64) -> Bool { + guard let data = ScratchpadKit.encode(note) else { return false } + return queue.sync { + guard generation > (lastWrittenGeneration[note.id] ?? 0) else { return true } + // Claimed even when the write below fails, so a failed save cannot let a stale + // snapshot that arrives afterwards overwrite newer text. lastWrittenGeneration[note.id] = generation - ScratchpadKit.writeEncodedNote(data, id: note.id, to: directory) + return ScratchpadKit.writeEncodedNote(data, id: note.id, to: directory) } } diff --git a/Sources/DMonteCore/ScratchpadKit.swift b/Sources/DMonteCore/ScratchpadKit.swift index 59cb397..3749452 100644 --- a/Sources/DMonteCore/ScratchpadKit.swift +++ b/Sources/DMonteCore/ScratchpadKit.swift @@ -182,9 +182,14 @@ public enum ScratchpadKit { /// file name so a subsequent save overwrites the damaged file rather than leaving a duplicate /// behind; unreadable bytes yield `nil` and the caller leaves the file alone. private static func salvage(_ data: Data, at url: URL) -> ScratchpadNote? { + // Salvage only works when the file name yields the note's real identity, because that is + // what makes the next save repair *this* file. Inventing an id for a foreign .json instead + // would recover it under a fresh UUID, write that to a new file, and leave the original in + // place — so it would be recovered again on every launch, multiplying notes the user has + // no way to get rid of. Anything we cannot identify is left untouched on disk. + guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { return nil } guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return nil } - let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) ?? UUID() // The file's modification date is the closest thing to a real timestamp still available. let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))? .contentModificationDate ?? Date() diff --git a/Tests/DMonteCoreTests/ScratchpadControllerTests.swift b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift index db933c8..2b0cc60 100644 --- a/Tests/DMonteCoreTests/ScratchpadControllerTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift @@ -217,6 +217,49 @@ final class ScratchpadControllerTests: XCTestCase { XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).map(\.body), [""]) } + /// An arm that survives a whole editing session is not a confirmation. Without this, a user + /// who armed a delete, kept writing, and pressed Delete again much later would lose the note + /// on that single press. + func testTypingDisarmsAnArmedDelete() { + let controller = makeController() + controller.addNote() + controller.updateText("valuable work") + controller.deleteSelectedNote() + XCTAssertNotNil(controller.pendingDeleteNoteID, "Precondition: the first press armed it") + + controller.updateText("valuable work, continued") + + XCTAssertNil(controller.pendingDeleteNoteID) + // The next press must only re-arm, leaving both notes intact. + controller.deleteSelectedNote() + XCTAssertEqual(controller.notes.count, 2) + XCTAssertEqual(controller.activeText, "valuable work, continued") + } + + // MARK: - Failed saves + + /// The one failure this tool must never hide: if the bytes did not reach disk, the user has + /// to be told while they can still copy the text out somewhere else. + func testASaveThatCannotReachDiskIsReportedNotSwallowed() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // A regular file standing where the store directory should be: every write below it fails. + let blocker = directory.appendingPathComponent("blocker") + try Data("not a directory".utf8).write(to: blocker) + + let controller = ScratchpadController( + directory: blocker.appendingPathComponent("notes", isDirectory: true), + defaults: defaults + ) + + // updateText clears the status line, so anything left afterwards came from the flush. + controller.updateText("must not vanish quietly") + controller.flushPendingSaves() + + XCTAssertNotNil(controller.statusMessage) + XCTAssertTrue(controller.statusMessage?.contains("save") == true, + "The warning must name the problem, got: \(controller.statusMessage ?? "nil")") + } + // MARK: - Renaming func testRenamingDeduplicatesAgainstTheOtherNotes() throws { diff --git a/Tests/DMonteCoreTests/ScratchpadKitTests.swift b/Tests/DMonteCoreTests/ScratchpadKitTests.swift index 8d48e3b..42c7b78 100644 --- a/Tests/DMonteCoreTests/ScratchpadKitTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadKitTests.swift @@ -254,6 +254,38 @@ final class ScratchpadKitTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: binaryURL.path)) } + /// A `.json` file the tool did not write has no identity to recover it under. Salvaging it + /// under an invented UUID would write a *second* file and leave the original in place, so the + /// same stray file would be recovered again on every launch and the note count would climb + /// forever with nothing the user could delete. It must be left alone instead. + func testAForeignJSONFileIsLeftAloneRatherThanDuplicatingEveryLoad() throws { + let healthy = ScratchpadNote(title: "Healthy", body: "intact") + ScratchpadKit.save(healthy, to: directory) + let stray = directory.appendingPathComponent("notes.json") + try Data("{\"not\":\"one of ours\"".utf8).write(to: stray) + + let first = ScratchpadKit.loadNotes(from: directory) + let second = ScratchpadKit.loadNotes(from: directory) + + XCTAssertEqual(first.map(\.title), ["Healthy"]) + XCTAssertEqual(second.map(\.title), ["Healthy"], "loading twice must not accumulate notes") + XCTAssertTrue(FileManager.default.fileExists(atPath: stray.path), "an unrecognised file is never deleted") + } + + /// A save that cannot reach disk has to say so — the controller turns this into a visible + /// warning, and silence would let someone type into a note that is not being persisted. + func testWritingIntoAnUnwritableLocationReportsFailure() throws { + // A regular file where a directory is expected: writes below it fail with ENOTDIR, with + // no dependence on the test runner's uid (a root runner would ignore chmod). + let blocker = directory.appendingPathComponent("blocker") + try Data("not a directory".utf8).write(to: blocker) + + let unwritable = blocker.appendingPathComponent("notes", isDirectory: true) + + XCTAssertFalse(ScratchpadKit.prepareDirectory(unwritable)) + XCTAssertFalse(ScratchpadKit.save(ScratchpadNote(title: "Doomed", body: "text"), to: unwritable)) + } + /// Rewriting a salvaged note must land on the same file, leaving exactly one note behind. func testSavingASalvagedNoteRepairsItsFileInPlace() { let corruptID = UUID() From 7b13f843d6cf0ecea6934192a1636598bfa2d2e6 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 16:03:57 +0530 Subject: [PATCH 3/7] review: address Copilot inline comments on PR #14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. prepareDirectory(_:) reported success for any filesystem item at the store path, so a regular file squatting there would make the controller believe storage was ready while every enumeration and write below it failed — silently dropping the only copy of the user's notes. It now requires the path to exist AND be a directory. Test added for the plain-file-at-the-directory-path case. 2. ScratchpadNoteWriter.write encoded the note before checking whether the generation was stale, paying a full JSON encode for writes that were going to be dropped (superseded snapshots, and anything for a note whose generation was retired by delete). Added a cheap pre-check that reads the recorded generation inside the serial queue and returns early; the authoritative claim-and-write check stays where it was. Recorded generations only move forward, so a snapshot stale at the pre-check is still stale at the authoritative one — the shortcut can never drop a write that was needed. Test added covering stale-drop and post-delete resurrection. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadController.swift | 7 ++++++ Sources/DMonteCore/ScratchpadKit.swift | 12 ++++++++-- .../ScratchpadControllerTests.swift | 22 +++++++++++++++++++ .../DMonteCoreTests/ScratchpadKitTests.swift | 10 +++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Sources/DMonteCore/ScratchpadController.swift b/Sources/DMonteCore/ScratchpadController.swift index 142c692..dcc3a90 100644 --- a/Sources/DMonteCore/ScratchpadController.swift +++ b/Sources/DMonteCore/ScratchpadController.swift @@ -359,6 +359,13 @@ final class ScratchpadNoteWriter: @unchecked Sendable { /// the caller actually cares about. Only bytes that genuinely failed to land report false. @discardableResult func write(_ note: ScratchpadNote, to directory: URL, generation: UInt64) -> Bool { + // Cheap pre-check so a doomed snapshot — one superseded while it waited, or one for a note + // the user has since deleted — does not pay to encode a large body first. Recorded + // generations only ever move forward, so a snapshot that is stale here is still stale by + // the time the authoritative check below runs; the reverse cannot happen, which is what + // keeps this from ever dropping a write that was needed. + if queue.sync(execute: { generation <= (lastWrittenGeneration[note.id] ?? 0) }) { return true } + guard let data = ScratchpadKit.encode(note) else { return false } return queue.sync { guard generation > (lastWrittenGeneration[note.id] ?? 0) else { return true } diff --git a/Sources/DMonteCore/ScratchpadKit.swift b/Sources/DMonteCore/ScratchpadKit.swift index 3749452..8d47757 100644 --- a/Sources/DMonteCore/ScratchpadKit.swift +++ b/Sources/DMonteCore/ScratchpadKit.swift @@ -129,7 +129,7 @@ public enum ScratchpadKit { /// Creates `directory` if needed, restricted to the current user. Notes routinely hold /// half-written messages and pasted credentials, so 0700 is the right default. Returns false - /// only when the directory could not be created at all. + /// unless a real directory is in place afterwards. @discardableResult public static func prepareDirectory(_ directory: URL) -> Bool { let ownerOnly: [FileAttributeKey: Any] = [.posixPermissions: 0o700] @@ -141,7 +141,15 @@ public enum ScratchpadKit { // Re-asserted separately: createDirectory only applies its attributes to directories it // actually creates, so a directory from an earlier build would keep its old mode. try? FileManager.default.setAttributes(ownerOnly, ofItemAtPath: directory.path) - return FileManager.default.fileExists(atPath: directory.path) + + // A regular file sitting at this path would satisfy a bare existence check while every + // enumeration and write below it failed, so the controller would report storage ready and + // silently drop the user's only copy of their notes. + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory) else { + return false + } + return isDirectory.boolValue } /// Loads every note in `directory`, ordered oldest-first. diff --git a/Tests/DMonteCoreTests/ScratchpadControllerTests.swift b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift index 2b0cc60..36e04d5 100644 --- a/Tests/DMonteCoreTests/ScratchpadControllerTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift @@ -310,4 +310,26 @@ final class ScratchpadControllerTests: XCTestCase { XCTAssertTrue(reloaded.notes.contains { $0.body == "healthy" }) XCTAssertTrue(reloaded.notes.contains { $0.title == "Recovered Note" }) } + + // MARK: - Write ordering + + /// The writer's whole reason to exist: a snapshot that lost the race must never land on top of + /// newer text, and a note the user deleted must stay deleted even if a write was already in + /// flight. Both are dropped early now, so this also guards the pre-encode shortcut. + func testStaleSnapshotsAreDroppedWithoutOverwritingNewerText() throws { + ScratchpadKit.prepareDirectory(directory) + let writer = ScratchpadNoteWriter() + let note = ScratchpadNote(title: "Ideas", body: "newer") + + XCTAssertTrue(writer.write(note, to: directory, generation: 5)) + + var stale = note + stale.body = "older" + XCTAssertTrue(writer.write(stale, to: directory, generation: 3), "a superseded snapshot is not a failure") + XCTAssertEqual(ScratchpadKit.loadNotes(from: directory).first?.body, "newer") + + writer.delete(id: note.id, in: directory) + XCTAssertTrue(writer.write(note, to: directory, generation: 9)) + XCTAssertTrue(ScratchpadKit.loadNotes(from: directory).isEmpty, "a deleted note must not come back") + } } diff --git a/Tests/DMonteCoreTests/ScratchpadKitTests.swift b/Tests/DMonteCoreTests/ScratchpadKitTests.swift index 42c7b78..7278aa1 100644 --- a/Tests/DMonteCoreTests/ScratchpadKitTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadKitTests.swift @@ -286,6 +286,16 @@ final class ScratchpadKitTests: XCTestCase { XCTAssertFalse(ScratchpadKit.save(ScratchpadNote(title: "Doomed", body: "text"), to: unwritable)) } + /// A regular file squatting on the store path is not usable storage, and saying otherwise + /// would let the controller keep accepting text it can never write down. + func testPreparingADirectoryOverAPlainFileFails() throws { + let squatter = directory.appendingPathComponent("squatter", isDirectory: true) + try Data("I am a file".utf8).write(to: squatter) + + XCTAssertFalse(ScratchpadKit.prepareDirectory(squatter)) + XCTAssertFalse(ScratchpadKit.save(ScratchpadNote(title: "Doomed", body: "text"), to: squatter)) + } + /// Rewriting a salvaged note must land on the same file, leaving exactly one note behind. func testSavingASalvagedNoteRepairsItsFileInPlace() { let corruptID = UUID() From 5fe2c02ee39e549bc3392cb00dcd62e75b7799f7 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:14:34 +0530 Subject: [PATCH 4/7] Scratchpad: scale the settings sheet with the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreferencesOverlay centres the settings sheet over the panel, so a sheet wider than the panel is clipped equally on both sides. The panel's size goes through ScratchpadSizing.currentScale (menu bar thickness / 26, screen height / 950, clamped to 0.82...1.0) but the sheet's was a literal 320 x 290, so the two only agreed at scale 1. On this machine the menu bar is 22pt, giving scale 0.846: the panel comes out 322 x 457 while the sheet stayed 320 x 290. That fit, but by 2pt of width — 1pt of margin per side. Any change to either base number, or a Mac reporting a slightly thinner menu bar, would have pushed it over and started clipping the title's first and last characters. Sheet size now lives in ScratchpadSizing.settingsSize(), scaled like everything else and capped at the panel width so it cannot overflow at any scale. At 0.846 the sheet is 271 x 245 inside a 322 x 457 panel, 51pt of width to spare. Putting the size in the enum also makes the invariant testable, which a literal in a SwiftUI modifier was not. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadSizing.swift | 13 ++++++ Sources/DMonteCore/ScratchpadView.swift | 2 +- .../ScratchpadSizingTests.swift | 45 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 Tests/DMonteCoreTests/ScratchpadSizingTests.swift diff --git a/Sources/DMonteCore/ScratchpadSizing.swift b/Sources/DMonteCore/ScratchpadSizing.swift index ad4d2b1..77b9cc7 100644 --- a/Sources/DMonteCore/ScratchpadSizing.swift +++ b/Sources/DMonteCore/ScratchpadSizing.swift @@ -8,6 +8,19 @@ public enum ScratchpadSizing { return NSSize(width: (380 * scale).rounded(), height: (540 * scale).rounded()) } + /// The settings overlay, which is centred *over* the panel and therefore must never be wider + /// than it. A hard-coded width looks right only at `currentScale == 1`; on a Mac whose menu + /// bar is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally + /// on both sides and its first and last characters are clipped. Width is capped at the panel + /// so that cannot happen at any scale. + public static func settingsSize() -> NSSize { + let scale = currentScale + return NSSize( + width: min((320 * scale).rounded(), preferredSize().width), + height: (290 * scale).rounded() + ) + } + static var currentScale: CGFloat { let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let screenScale = visibleFrame.height / 950 diff --git a/Sources/DMonteCore/ScratchpadView.swift b/Sources/DMonteCore/ScratchpadView.swift index d6ea09d..32d91ae 100644 --- a/Sources/DMonteCore/ScratchpadView.swift +++ b/Sources/DMonteCore/ScratchpadView.swift @@ -487,6 +487,6 @@ private struct ScratchpadSettingsView: View { Spacer() } .padding(20) - .frame(width: 320, height: 290) + .frame(width: ScratchpadSizing.settingsSize().width, height: ScratchpadSizing.settingsSize().height) } } diff --git a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift new file mode 100644 index 0000000..60cd3d6 --- /dev/null +++ b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift @@ -0,0 +1,45 @@ +import AppKit +import XCTest +@testable import DMonteCore + +/// The settings sheet is centred over the panel, so a sheet wider than the panel is clipped on +/// both sides — the title loses its first and last characters and the value column runs off the +/// edge. Scratchpad's literal 320pt sheet cleared its 380pt panel by only about a point once the +/// panel was scaled down, so either number moving would have started clipping it. +final class ScratchpadSizingTests: XCTestCase { + + func testSettingsSheetNeverExceedsThePanel() { + let panel = ScratchpadSizing.preferredSize() + let settings = ScratchpadSizing.settingsSize() + + XCTAssertLessThanOrEqual( + settings.width, panel.width, + "A settings sheet wider than the panel is clipped on both sides" + ) + XCTAssertLessThanOrEqual( + settings.height, panel.height, + "A settings sheet taller than the panel cannot show its bottom row — on this sheet, Quit" + ) + } + + /// Both sizes have to move together. Pinning only the panel would let a later edit reintroduce + /// a literal sheet width that passes the check above at today's scale and fails at another. + func testBothSizesScaleTogether() { + let scale = ScratchpadSizing.currentScale + let panel = ScratchpadSizing.preferredSize() + let settings = ScratchpadSizing.settingsSize() + + XCTAssertEqual(panel.width, (380 * scale).rounded(), accuracy: 1) + XCTAssertEqual(panel.height, (540 * scale).rounded(), accuracy: 1) + XCTAssertEqual(settings.width, min((320 * scale).rounded(), panel.width), accuracy: 1) + XCTAssertEqual(settings.height, (290 * scale).rounded(), accuracy: 1) + } + + /// The scale is derived from live screen and menu-bar metrics, so the guarantee has to hold + /// across the whole range rather than at whatever this machine reports today. + func testScaleStaysWithinItsDocumentedBounds() { + let scale = ScratchpadSizing.currentScale + XCTAssertGreaterThanOrEqual(scale, 0.82) + XCTAssertLessThanOrEqual(scale, 1.0) + } +} From c909f43905a4695fc624020fdce5e8c25e06d284 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:17:57 +0530 Subject: [PATCH 5/7] Settings sheet: cap at the panel instead of scaling with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this fix scaled the sheet by `currentScale` alongside the panel. That is right where the sheet is as wide as its panel, but wrong where it is narrower: Scratchpad's 320pt sheet sits in a 380pt panel, so scaling shrank it to 271 inside a 322 panel — 51pt of inset added to a tool that was never clipped. The sheet's contents are laid out with unscaled padding, so squeezing the frame risks clipping inside the sheet rather than outside it. The requirement is only that the sheet never exceeds the panel, so cap it: `min(designSize, panelSize)`. Identical for the four tools whose sheet matches or exceeds its panel, and strictly better for the two where it does not — Scratchpad keeps its designed 320 and Network Info gains 23pt back. The tests that broke were the ones restating the arithmetic; the ones asserting "the sheet fits the panel" passed through the change untouched. Replaced the former with the actual contract: the sheet uses its design size unless the panel is smaller. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadSizing.swift | 19 ++++++++++--------- .../ScratchpadSizingTests.swift | 15 +++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/DMonteCore/ScratchpadSizing.swift b/Sources/DMonteCore/ScratchpadSizing.swift index 77b9cc7..599323c 100644 --- a/Sources/DMonteCore/ScratchpadSizing.swift +++ b/Sources/DMonteCore/ScratchpadSizing.swift @@ -9,16 +9,17 @@ public enum ScratchpadSizing { } /// The settings overlay, which is centred *over* the panel and therefore must never be wider - /// than it. A hard-coded width looks right only at `currentScale == 1`; on a Mac whose menu - /// bar is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally - /// on both sides and its first and last characters are clipped. Width is capped at the panel - /// so that cannot happen at any scale. + /// than it. A hard-coded size looks right only at `currentScale == 1`; on a Mac whose menu bar + /// is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally on + /// both sides and its first and last characters are clipped. + /// + /// Capped at the panel rather than scaled with it. The sheet's contents are laid out at this + /// size with unscaled padding, so shrinking it further than the panel demands would squeeze + /// them for no reason — and on the tools whose sheet is already narrower than their panel, + /// scaling would inset it noticeably while fixing nothing. public static func settingsSize() -> NSSize { - let scale = currentScale - return NSSize( - width: min((320 * scale).rounded(), preferredSize().width), - height: (290 * scale).rounded() - ) + let panel = preferredSize() + return NSSize(width: min(320, panel.width), height: min(290, panel.height)) } static var currentScale: CGFloat { diff --git a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift index 60cd3d6..9cda14e 100644 --- a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift @@ -22,17 +22,16 @@ final class ScratchpadSizingTests: XCTestCase { ) } - /// Both sizes have to move together. Pinning only the panel would let a later edit reintroduce - /// a literal sheet width that passes the check above at today's scale and fails at another. - func testBothSizesScaleTogether() { - let scale = ScratchpadSizing.currentScale + /// The sheet should be as large as it was designed to be, shrinking only as far as the panel + /// forces. Asserting the contract rather than restating the arithmetic: when this rule changed + /// from "scale with the panel" to "cap at the panel", the tests that restated the formula + /// failed while the ones asserting the relationship kept passing. + func testSettingsUsesItsDesignSizeUnlessThePanelIsSmaller() { let panel = ScratchpadSizing.preferredSize() let settings = ScratchpadSizing.settingsSize() - XCTAssertEqual(panel.width, (380 * scale).rounded(), accuracy: 1) - XCTAssertEqual(panel.height, (540 * scale).rounded(), accuracy: 1) - XCTAssertEqual(settings.width, min((320 * scale).rounded(), panel.width), accuracy: 1) - XCTAssertEqual(settings.height, (290 * scale).rounded(), accuracy: 1) + XCTAssertEqual(settings.width, min(320, panel.width), accuracy: 1) + XCTAssertEqual(settings.height, min(290, panel.height), accuracy: 1) } /// The scale is derived from live screen and menu-bar metrics, so the guarantee has to hold From ae5a4e63f7f4738c4bf0789e33da95137513412e Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 01:18:33 +0530 Subject: [PATCH 6/7] Scratchpad: use a distinct icon from Snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tools shipped with `note.text` in the tray, the toolbox tile and the panel header, so the two menu-bar items were indistinguishable and the grid showed the same tile twice. Scratchpad takes `square.and.pencil` — a page you write on, against Snippets' page of stored lines. Verified the symbol resolves on this OS: an unresolvable name renders as a blank menu-bar item. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadView.swift | 2 +- Sources/DMonteCore/ToolboxCatalog.swift | 2 +- Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/DMonteCore/ScratchpadView.swift b/Sources/DMonteCore/ScratchpadView.swift index 32d91ae..847563e 100644 --- a/Sources/DMonteCore/ScratchpadView.swift +++ b/Sources/DMonteCore/ScratchpadView.swift @@ -55,7 +55,7 @@ public struct ScratchpadPopoverView: View { private var header: some View { HStack(spacing: s(8)) { - Image(systemName: "note.text") + Image(systemName: "square.and.pencil") .font(.system(size: s(15), weight: .semibold)) .foregroundStyle(accent) diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift index fe0918e..58a75f2 100644 --- a/Sources/DMonteCore/ToolboxCatalog.swift +++ b/Sources/DMonteCore/ToolboxCatalog.swift @@ -65,7 +65,7 @@ public enum ToolboxCatalog { ToolboxTool(id: "grabText", title: "Grab Text", iconName: "text.viewfinder", tint: contrastGreen, bundleID: prefix + "grabtext", appName: "DMonte Grab Text.app", executableName: "DMonteGrabText", arguments: ["--open"]), ToolboxTool(id: "focusTimer", title: "Focus Timer", iconName: "timer", tint: .red, bundleID: prefix + "focustimer", appName: "DMonte Focus Timer.app", executableName: "DMonteFocusTimer", arguments: ["--open"]), ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]), - ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "note.text", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", arguments: ["--open"]) + ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "square.and.pencil", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", arguments: ["--open"]) ] } diff --git a/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift index a458cf4..83272eb 100644 --- a/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift +++ b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift @@ -58,7 +58,7 @@ final class ScratchpadAppDelegate: NSObject, NSApplicationDelegate { } private static func statusIcon() -> NSImage { - let image = NSImage(systemSymbolName: "note.text", accessibilityDescription: "Scratchpad") ?? NSImage() + let image = NSImage(systemSymbolName: "square.and.pencil", accessibilityDescription: "Scratchpad") ?? NSImage() image.isTemplate = true return image } From 5d31a4767c32e0238a56f4f46a46c0d2ddc395c3 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 02:39:33 +0530 Subject: [PATCH 7/7] Settings sheet: leave room for the overlay's 18pt padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier cap-at-panel-width fix was incomplete. PreferencesOverlay wraps the sheet in 18pt of padding on every side, so a sheet sized to the full panel becomes panel+36 once padded and overflows. That over-wide overlay layer then dragged the panel content behind it off both edges — the header's title and gear spilled past the window and the footer pushed below it — reproduced and fixed by eye on the real NSHostingController path. settingsSize now caps at panel minus 36 (2×18), so the padded sheet fits the panel exactly and the content behind it stays put. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/ScratchpadSizing.swift | 6 +++++- Tests/DMonteCoreTests/ScratchpadSizingTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/DMonteCore/ScratchpadSizing.swift b/Sources/DMonteCore/ScratchpadSizing.swift index 599323c..bf3153a 100644 --- a/Sources/DMonteCore/ScratchpadSizing.swift +++ b/Sources/DMonteCore/ScratchpadSizing.swift @@ -19,7 +19,11 @@ public enum ScratchpadSizing { /// scaling would inset it noticeably while fixing nothing. public static func settingsSize() -> NSSize { let panel = preferredSize() - return NSSize(width: min(320, panel.width), height: min(290, panel.height)) + // Leave room for the 18pt padding PreferencesOverlay adds around the sheet on every + // side: a sheet sized to the full panel becomes panel+36 once padded and spills + // the panel, dragging the content behind it off both edges. + let overlayChrome: CGFloat = 36 + return NSSize(width: min(320, panel.width - overlayChrome), height: min(290, panel.height - overlayChrome)) } static var currentScale: CGFloat { diff --git a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift index 9cda14e..a366c71 100644 --- a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift +++ b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift @@ -30,8 +30,8 @@ final class ScratchpadSizingTests: XCTestCase { let panel = ScratchpadSizing.preferredSize() let settings = ScratchpadSizing.settingsSize() - XCTAssertEqual(settings.width, min(320, panel.width), accuracy: 1) - XCTAssertEqual(settings.height, min(290, panel.height), accuracy: 1) + XCTAssertEqual(settings.width, min(320, panel.width - 36), accuracy: 1) + XCTAssertEqual(settings.height, min(290, panel.height - 36), accuracy: 1) } /// The scale is derived from live screen and menu-bar metrics, so the guarantee has to hold