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) + } +}