Problem sketch
StakeholderCan it be likeNotepad, but Mac?BuilderSure. Do you wantsurprise rich textwith that?StakeholderAbsolutely not.BuilderGreat. Then boringis now a feature.

Panel 1: Stakeholder says Can it be like Notepad, but Mac? Panel 2: Builder says Sure. Do you want surprise rich text with that? Panel 3: Stakeholder says Absolutely not. Panel 4: Builder says Great. Then boring is now a feature.

Tool

The Mac editor I built because I missed Notepad

I missed Windows Notepad on my Mac, so I built a boring Swift editor and learned what plain text, recovery, Unicode, and private restore require.

Built because I missed itOct 6, 202612 min read

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

I missed Notepad.

Not the idea of a text editor. Not a productivity system. Not a notes database with a feelings panel. I missed the Windows Notepad muscle memory on a Mac: open a text file, type, save, use tabs, find something, glance at the status bar, and trust that nothing clever was happening behind the curtain.

The built-in options were almost right, which is its own special kind of inconvenience. One was too capable. One was too opinionated. One wanted me to think about formatting when all I wanted was a plain file and a cursor.

So I built the boring editor I wanted. Then the boring requirements started raising their hands. Plain text needs boundaries. UTF-8 needs honesty. Recovery needs to avoid pretending it is a save. Unicode makes the status bar earn its lunch. Session restore is helpful right up until it becomes a hidden copy of everything I typed.

This is that story: I missed Windows Notepad on the Mac, so I built a small native version and found out what it takes to make boring trustworthy.

At a glance

The text box had to stay boring

Save had to mean save

Recovery copies could not pretend to be saves

The status bar had to count like AppKit

Session restore was not allowed to keep a diary

Why this exists#

Plain-text editors are supposed to disappear. That is the point. The editor should open the file, let me type, save only when I say save, and avoid developing a personality between keystrokes. I wanted that familiar Notepad feeling on a Mac, with enough native behavior to belong there and enough restraint to stay boring.

  • The itch: I missed the plain, instant, no-surprises Windows Notepad experience while working on a Mac: open a .txt or .md file, type, save, use tabs, find text, check the status bar, and move on with my life.
  • Why I built it: Because I missed it: the familiar Windows Notepad shape on the Mac, without rich text, project folders, hidden document storage, cloud sync, or a tiny IDE quietly asking if I had considered becoming more organized.
  • What you get: You get the reusable pieces to build or borrow the pattern: a plain-text editor wrapper, strict UTF-8 file service, local recovery-copy flow, Unicode-safe metrics, and metadata-only session restore. You can avoid accidental formatting, surprise overwrites, and hidden document storage.
  • Who this is for: Swift and macOS developers building a small desktop utility, and anyone who has opened a Mac text editor while quietly missing Windows Notepad’s plain, immediate, no-drama behavior.
  • Who can skip it: If your editor already needs projects, plugins, syntax trees, and a sidebar civilization, this tiny app is probably too underdressed for the meeting.

The rest of the post turns that itch into five checked, standalone pieces you can inspect without publishing the whole source repository.

The plan#

The bet was plain: make a local, native, tabs-only macOS editor that scratches the Notepad itch without turning into a platform. SwiftUI would own the window, menus, settings, prompts, tabs, and status. AppKit would own the text control through NSTextView, because sometimes the old machinery is old because it earned tenure.

You will want enough Swift to recognize struct, enough macOS to know AppKit did not retire, and enough text-editing vocabulary to follow NSTextView, UTF-8, and selection offsets. You do not need NSDocument; the app avoids it on purpose because direct autosave and versioning conflict with the recovery-copy promise.

Here is what I thought it would look like:

Component flow for the Mac editor: SwiftUI shell, TabStore, AppKit editor, file service, recovery service, session store, and local files
Figure 1. The app keeps UI state, editor behavior, file I/O, recovery, and session metadata in separate pieces

The design rule was simple enough to be dangerous:

  1. The user decides where recovery copies go.
  2. Edits can produce local recovery files.
  3. Real files change only when the user chooses Save or Save As.
  4. Session metadata remembers tabs, not document text.

That rule pushed the app away from SwiftUI TextEditor, away from NSDocument, away from Electron, away from cloud sync, and away from any feature that required a billing dashboard for a text box.

1. The text box had to stay boring#

The first attempt was not to write a text editor from scratch. That way lies syntax trees, coffee depletion, and possibly a custom scrollbar. I used a SwiftUI wrapper around AppKit’s NSTextView, then made the wrapper as boring as I could.

The first attempt was not to write a text editor from scratch.

The surprise was how many conveniences a text control can offer when you do not want them. Rich text, graphics import, quote substitution, dash substitution, and automatic text replacement are all friendly in the right app. In a plain-text editor, they are tiny doors out of the product promise.

So the wrapper became a boundary. It reports text and selection changes up to app state. It does not decide what counts as saved, recovered, dirty, or safe.

Complete snippet: Wrap NSTextView for a plain-text SwiftUI editor

The key lines are the product decision in code:

Plain-text setup
textView.isRichText = false
textView.importsGraphics = false
textView.allowsUndo = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false

2. Save had to mean save#

The next boundary was the file service. I wanted .txt and .md support, but only as plain text. Markdown preview was out. Encoding guessing was out. Heroic huge-file behavior was also out, because an MVP text editor should not pretend to be a log-analysis platform after one optimistic Data(contentsOf:).

The next boundary was the file service.

The file service accepts only supported extensions, decodes valid UTF-8, warns above 10 MB, and writes saves atomically. If a file is not valid UTF-8, it fails instead of inventing characters and calling that a favor.

Complete snippet: Open and save UTF-8 plain-text files safely

The important part is less glamorous than it should be:

Strict UTF-8 open
guard let text = String(data: data, encoding: .utf8) else {
    throw PlainTextFileError.invalidUTF8
}

That small guard keeps the app from silently changing the thing it was asked to protect. The tests back the rule with a 10 MB warning boundary and invalid UTF-8 failure path. Small does not mean “shrug at corruption and hope nobody notices.”

3. Recovery copies could not pretend to be saves#

Direct autosave sounds helpful until a scratch note quietly overwrites the file you opened. The product brief made that tradeoff explicit: autosave means local recovery copies in a user-selected folder, not silent writes over the opened document.

Direct autosave sounds helpful until a scratch note quietly overwrites the file you opened.

The recovery promise becomes real when the app has to say the quiet part out loud. Closing a dirty window is not a magic trick; it is a choice, with the awkward honesty plain text deserves.

Mac Notepad recovery prompt with sample text and Save, Discard, Recover Later, and Cancel choices
Screenshot: the real recovery prompt with sample text, offering explicit save, discard, recover-later, or cancel choices instead of silently overwriting the file

The first-run flow asks for a recovery folder and lets the user skip. If recovery is not set up, editing still works, but the status says so. That matters. A missing safety net is not a secret feature.

User journey from launch to autosave setup, editing, recovery, saving, closing, and relaunch restore
Figure 2. The user chooses recovery setup, edits plain text, saves explicitly, and can recover later

The recovery service writes two local files atomically: the recovery text and a small metadata record. After a real save or an explicit discard, it cleans up matching recovery files. The sequence looks fancier than the implementation, which is a useful sign.

Sequence diagram showing edit events, dirty generation tracking, atomic recovery text and metadata writes, cleanup, and recover later
Figure 3. Recovery copies protect unsaved work without overwriting the user's document

Complete snippet: Write local recovery copies without autosaving over user files

The piece that keeps delayed recovery writes from going backward is the generation recorded with the recovery copy:

Recovery record generation
let record = RecoveryRecord(
    id: recordID,
    tabID: tab.id,
    recoveryPath: recoveryURL.path,
    metadataPath: metadataURL.path,
    originalFilePath: tab.originalFileURL?.path,
    generation: tab.dirtyGeneration,
    createdAt: now,
    updatedAt: now
)

4. The status bar had to count like AppKit#

The status bar looked harmless: line, column, character count, newline style, document type, encoding, word-wrap state, and recovery state. Then the tests ran where AppKit’s worldview met Swift’s.

The status bar looked harmless: line, column, character count, newline style, document type, encoding, word wrap state, and recovery state.

Swift String is Unicode-correct. AppKit selection ranges are UTF-16 offsets. Both are reasonable. Together they make a fine trap. A macOS validation run exposed exactly that kind of bug: CRLF newline detection did not report what the test expected, mixed newline detection drifted, and a selection offset after an emoji produced the wrong column.

The fix was to scan newline style over Unicode scalars and treat the incoming selection location as an AppKit UTF-16 offset before converting it to a Swift String.Index.

Complete snippet: Calculate editor line, column, and newline style safely

The conversion starts by bounding the AppKit UTF-16 offset and moving toward a valid Swift index:

Unicode offset check
let boundedOffset = max(0, min(selectionLocation, text.utf16.count))
var utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: boundedOffset)
var prefixEnd = String.Index(utf16Index, within: text)

The check still uses EditorTextMetricsCalculator.metrics(text: "hi\n\u{1F44B} world", selectionLocation: 5) and expects line 2, column 2. If it does not, the emoji found your bug. This is annoying, but cheaper than letting users find it for you.

5. Session restore was not allowed to keep a diary#

Session restore is useful. Session files full of document text are less charming. The threat model called out document contents, recovery copies, and session metadata as different assets, so the session store had to remember enough to reopen the workspace without becoming secret document storage.

Session restore is useful.

It stores titles, file paths, dirty flags, selection positions, and recovery record IDs. It does not encode the document body. It also ignores legacy text values on decode, because a privacy fix should not depend on everyone deleting yesterday’s file.

Complete snippet: Persist tab sessions without storing document text

The part that matters is blunt:

Session reconciliation
var copy = tab
copy.transientText = ""

If a saved file exists, reload it. If an unsaved tab has a recovery record, recover it from the recovery flow. If there is no content source, say so. Metadata is not a backup just because JSON was nearby and looked lonely.

The moment it worked#

At some point, the architecture diagram had to stop waving its arms and become a window. This is the boring version of success: three sample tabs, plain text, an emoji in the buffer, and a status bar doing math without making a scene.

Mac Notepad editor with sample tabs, Unicode text, and status bar metrics
Screenshot: the real editor window with sample text while the status bar counts line, column, characters, encoding, newline style, wrap, and recovery state

The satisfying moment was not a dramatic UI flourish. It was opening, typing, saving, closing, and reopening without the app trying to become something larger than the thing I missed.

The macOS workflow had already caught the sort of problems a Linux-only check could not: Swift concurrency around the app delegate, Unicode and newline bugs in tests, and native build behavior. After the remediations, the exact remediation commit passed the macOS validation workflow. Static validation passed. swift test passed. swift build passed.

The standalone publication snippets were checked separately for this post:

Snippet Check performed Result
Plain-text editor wrapper swiftc -typecheck Pass
UTF-8 file service Swift harness opened UTF-8, saved .md, and rejected invalid UTF-8 Pass
Recovery-copy service Swift harness wrote recovery text, metadata, loaded both, and cleaned them up Pass
Unicode editor metrics Swift harness checked CRLF, mixed newlines, UTF-16 offsets, count, and size warning Pass
Private session store Swift harness verified JSON omits text, decode clears text, and empty sessions heal Pass

That is the kind of victory this editor deserved: no confetti, no new account, no telemetry panel, just a plain text box on a Mac acting enough like the Notepad I missed that I could stop thinking about the editor and get back to the note.

What I would tell you before you try this#

  • Use NSTextView when you need real text-editor control. SwiftUI can host it without owning every decision.
  • Turn off rich text, graphics imports, and smart substitutions on purpose. Defaults are not a product strategy.
  • Treat invalid UTF-8 as an error unless the user chooses conversion. Silent conversion is a bug with a friendly hat.
  • Warn on huge files before pretending your MVP is a log-analysis platform. The app warns above 10 MB and stays honest about scope.
  • Separate recovery copies from explicit saves. Users should know when the original file changes.
  • Store recovery files in a folder the user can see and change. Local safety should not feel like a hidden side channel.
  • Track dirty generations so delayed recovery writes cannot go backward. Time travel is more fun in fiction.
  • Convert AppKit UTF-16 selection offsets before slicing Swift strings. Emoji will audit you for free.
  • Persist session metadata, not document bodies. Recovery is the content path; metadata is only a map.
  • Keep the tool local until a real user need justifies adding a network. Privacy is easiest before the first endpoint appears.

Put it together#

If you want to build your own version, start with the pieces in the order the app needs them: editor control, file I/O, recovery, metrics, then session restore. This is a local desktop app. It needs no Azure resources, no database, no storage account, no Key Vault, and no cloud telemetry.

The estimated monthly cost to run it locally is $0.00, checked on 2026-10-06. Your Mac’s electricity bill remains outside this architecture, because some boundaries must be respected.

Cost item Minimum SKU or resource Estimated monthly cost
Azure resources None $0.00
Database None $0.00
Cloud storage None $0.00
Telemetry or analytics None $0.00
Local recovery folder User-selected folder $0.00
macOS development machine Existing Mac Not estimated

What you need locally#

Tool or runtime Minimum version or setting
macOS macOS 15 or later for the app target
Xcode Xcode 27 for the validation environment used here
Swift Swift 6.4 for the snippets checked here
Frameworks SwiftUI, AppKit, Foundation, UniformTypeIdentifiers
Network None required
Accounts None required

Build the idea#

  1. Start with the plain-text editor wrapper inside a SwiftUI macOS window.
  2. Use the UTF-8 file service for open and save.
  3. Add the recovery-copy service and call it from a debounced edit path.
  4. Show status information with the Unicode-safe metrics utility.
  5. Persist tab metadata with the private session store, and keep text out of it.

The useful thing I learned was not how to make a text editor exciting. It was how many small decisions it takes to recreate the Windows Notepad feeling on a Mac without breaking the reason I missed it in the first place.

That is enough for this itch. The text box may now return to being a text box.

Remember these

The text box had to stay boring
Save had to mean save
Recovery copies could not pretend to be saves
The status bar had to count like AppKit
Session restore was not allowed to keep a diary

From this post

Snippet

Wrap NSTextView for a plain-text SwiftUI editor

A complete SwiftUI wrapper around AppKit NSTextView with plain-text enforcement, word wrap, selection callbacks, and first-responder focus.

Type-checked and run with swiftc 6.4 on macOS 26, including clamped update-time selections, focus, and selection-change callbacks.

Snippet

Persist tab sessions without storing document text

A complete Swift session store that remembers tab metadata and selected tab while keeping document contents out of the session file.

Type-checked and run with swift 6.4 on macOS 26, including damaged JSON decode, negative selection clamping, and stale selected-tab repair.

Snippet

Write local recovery copies without autosaving over user files

A complete Swift recovery-copy service that writes atomic text and metadata files into a user-selected folder and cleans them up after save or discard.

Type-checked and run with swift 6.4 on macOS 26, including /tmp and /private/tmp folder aliases, no-follow reads, symlink rejection, cleanup, and tamper refusal.

Snippet

Open and save UTF-8 plain-text files safely

A complete Swift file service for .txt and .md documents with strict UTF-8 decoding, atomic saves, and a 10 MB warning.

Type-checked and run with swift 6.4 on macOS 26, including extensionless URL rejection and Save As filename traversal rejection.