diff --git a/CHANGELOG.md b/CHANGELOG.md
index c01cab9..dbe68d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,10 @@ adheres to [Semantic Versioning](https://semver.org) and the
stale. Nothing is ever force-unmounted: when a file is open or Spotlight is
still indexing, the disk stays mounted and macOS's own reason is shown inline.
The list updates live as disks are plugged in and removed.
+- **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 d151fdb..c8e9810 100644
--- a/Package.swift
+++ b/Package.swift
@@ -107,6 +107,10 @@ let package = Package(
.executable(
name: "DMonteDriveEjector",
targets: ["DMonteDriveEjector"]
+ ),
+ .executable(
+ name: "DMonteScratchpad",
+ targets: ["DMonteScratchpad"]
)
],
dependencies: [
@@ -286,6 +290,13 @@ let package = Package(
],
path: "Sources/DMonteDriveEjectorApp"
),
+ .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 906da49..f84fa3d 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org);
| **Dev Tools** | JSON, Base64, JWT, URL, hashing, UUID, timestamp, and case utilities |
| **Dev Tools** | JSON, Base64, URL, hashing, UUID, timestamp, and case utilities |
| **Snippets** | A searchable library of reusable text — click one to paste it into the app you came from |
+| **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 a793b3c..c68cedc 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -39,6 +39,7 @@ HELPERS=(
"DMonteMicControl|DMonte Mic Control.app|MicControlInfo.plist"
"DMonteSnippets|DMonte Snippets.app|SnippetsInfo.plist"
"DMonteDriveEjector|DMonte Drive Ejector.app|DriveEjectorInfo.plist"
+ "DMonteScratchpad|DMonte Scratchpad.app|ScratchpadInfo.plist"
)
stamp_version() {
diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift
index 0ec32bc..f4b18bd 100644
--- a/Sources/DMonteCore/AppPreferences.swift
+++ b/Sources/DMonteCore/AppPreferences.swift
@@ -128,7 +128,8 @@ public enum AppDefaults {
DefaultsKey.focusTimerShortBreakMinutes: 5,
DefaultsKey.focusTimerLongBreakMinutes: 15,
DefaultsKey.focusTimerLongBreakInterval: 4,
- DefaultsKey.driveEjectorConfirmsEjectAll: true
+ DefaultsKey.driveEjectorConfirmsEjectAll: true,
+ DefaultsKey.scratchpadFontSize: 13
])
}
}
diff --git a/Sources/DMonteCore/ScratchpadController.swift b/Sources/DMonteCore/ScratchpadController.swift
new file mode 100644
index 0000000..dcc3a90
--- /dev/null
+++ b/Sources/DMonteCore/ScratchpadController.swift
@@ -0,0 +1,387 @@
+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
+ }
+
+ // 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
+ }
+
+ 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
+ var allWritten = true
+ for note in notes {
+ if !writer.write(note, to: directory, generation: generation) { allWritten = false }
+ }
+ if !allWritten { reportSaveFailure() }
+ }
+
+ // 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) { [weak self] in
+ try? await Task.sleep(nanoseconds: 400_000_000)
+ if Task.isCancelled { return }
+ guard !writer.write(note, to: directory, generation: generation) else { return }
+ await MainActor.run { self?.reportSaveFailure() }
+ }
+ }
+
+ /// 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
+ 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() {
+ 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] = [:]
+
+ /// 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 {
+ // 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 }
+ // 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
+ return 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..8d47757
--- /dev/null
+++ b/Sources/DMonteCore/ScratchpadKit.swift
@@ -0,0 +1,245 @@
+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
+ /// unless a real directory is in place afterwards.
+ @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)
+
+ // 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.
+ ///
+ /// 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? {
+ // 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 }
+
+ // 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..bf3153a
--- /dev/null
+++ b/Sources/DMonteCore/ScratchpadSizing.swift
@@ -0,0 +1,35 @@
+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())
+ }
+
+ /// The settings overlay, which is centred *over* the panel and therefore must never be wider
+ /// 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 panel = preferredSize()
+ // 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 {
+ 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..847563e
--- /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: "square.and.pencil")
+ .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: ScratchpadSizing.settingsSize().width, height: ScratchpadSizing.settingsSize().height)
+ }
+}
diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift
index 96df4b8..3ae9953 100644
--- a/Sources/DMonteCore/ToolboxCatalog.swift
+++ b/Sources/DMonteCore/ToolboxCatalog.swift
@@ -67,7 +67,8 @@ public enum ToolboxCatalog {
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: "micControl", title: "Mic Control", iconName: "mic.slash.fill", tint: .orange, bundleID: prefix + "miccontrol", appName: "DMonte Mic Control.app", executableName: "DMonteMicControl", arguments: ["--open"]),
ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"]),
- ToolboxTool(id: "driveEjector", title: "Drive Ejector", iconName: "eject.fill", tint: .teal, bundleID: prefix + "driveejector", appName: "DMonte Drive Ejector.app", executableName: "DMonteDriveEjector", arguments: ["--open"])
+ ToolboxTool(id: "driveEjector", title: "Drive Ejector", iconName: "eject.fill", tint: .teal, bundleID: prefix + "driveejector", appName: "DMonte Drive Ejector.app", executableName: "DMonteDriveEjector", 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
new file mode 100644
index 0000000..83272eb
--- /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: "square.and.pencil", 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..36e04d5
--- /dev/null
+++ b/Tests/DMonteCoreTests/ScratchpadControllerTests.swift
@@ -0,0 +1,335 @@
+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), [""])
+ }
+
+ /// 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 {
+ 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" })
+ }
+
+ // 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
new file mode 100644
index 0000000..7278aa1
--- /dev/null
+++ b/Tests/DMonteCoreTests/ScratchpadKitTests.swift
@@ -0,0 +1,315 @@
+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))
+ }
+
+ /// 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))
+ }
+
+ /// 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()
+ 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")
+ }
+}
diff --git a/Tests/DMonteCoreTests/ScratchpadSizingTests.swift b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift
new file mode 100644
index 0000000..a366c71
--- /dev/null
+++ b/Tests/DMonteCoreTests/ScratchpadSizingTests.swift
@@ -0,0 +1,44 @@
+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"
+ )
+ }
+
+ /// 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(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
+ /// 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)
+ }
+}