I often spend hours searching for a needle just to throw it back in the haystack when I finish a project. To combat this and help anyone else out unfortunate enough to be out there looking for the same needles. I’ve decided to start this little chain of blog posts.
Today’s needle: SwiftDown
SwiftDown is a WYSIWYG markdown editor package for SwiftUI.
If you’ve ever needed to actually remember things, you’ve likely hit the same wall: you can’t hold it all in your head, so you write it down somewhere. Maybe you chose a million sticky notes, apple’s built-in Stickies or a full-blown note-taking app. I chose raycasts built in notes and it is amazing the only issue is that its once again another subscription. I had the bright idea to just build it myslef a, how hard could it be a thought many a developer has had….
This adventure began optimistically, in Python, as its the langue im most confortable in. I wanted transparency my the notes windows, and PyQt6-Frameless-Window workes flowlessly… unless you actually want to type something in the window. So, after a couple of wasted hours I caved and moved to SwiftUI a langue i have never read let alone written.
First mountain: making my window transparent. Turns out, a handful of NSWindow properties get you most of the way there:
// Window transparency properties
newWindow.titlebarAppearsTransparent = true
newWindow.styleMask.insert(.fullSizeContentView)
newWindow.isOpaque = false
newWindow.backgroundColor = .clear
newWindow.level = .floating
newWindow.collectionBehavior = .canJoinAllSpaces
Success! Now, for that nice blurry glass look, I needed an NSVisualEffectView wrapper:
struct VisualEffectView: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffect = NSVisualEffectView()
visualEffect.blendingMode = .behindWindow
visualEffect.state = .active
visualEffect.material = .dark
return visualEffect
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
Everything was going smoothly, right up until… I realized the scrollbars refused to go transparent on my notes list. The kind of thing you don’t notice until it drives you crazy at 2am. I looked eveywhere for a package todo this as i was trying to avoide learning to much at once Unfotunatly my fix was to roll my own transparent, auto-hiding scrollview:
struct TransparentScrollView<Content: View>: NSViewRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
// Configure scroll view properties for transparency and auto-hiding
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
scrollView.backgroundColor = .clear
scrollView.drawsBackground = false
// Configure the document view
let hostingView = NSHostingView(rootView: content)
hostingView.translatesAutoresizingMaskIntoConstraints = false
scrollView.documentView = hostingView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
if let hostingView = nsView.documentView as? NSHostingView<Content> {
hostingView.rootView = content
}
}
}
One by one I squashed these issue’s:
- The note window finally matched my vision
- The scrollbars ghosted in and out just as they should
But then I noticed: my notes were either stretching off-screen or were far too tiny. Dynamic sizing! Sure, how hard could it be? Turns out, harder than expected, especially if you want to keep the note always sized just right but not fight the user when they try to resize manually. Here’s the basic blueprint:
- Auto-size the window as the note grows, up to a max height
- Allow users to grab a manual resize (auto-sizing off until they re-enable)
- Hover reveals an “Auto Size” button, in case you want to snap things back
- All the event dance of making sure windows, focus, and background tasks all play well together
I’ll just show the a bit of essentials, but if you want to see the full code it up on GitHub.
private func adjustWindowSizeForText() {
guard let window = window, isAutoSizingEnabled, !hasUserManuallyResized else { return }
// Determine content width for text bounding
let horizontalPadding: CGFloat = 20 // adjust according to TextEditor padding
let contentWidth: CGFloat
if let contentViewWidth = window.contentView?.frame.width {
contentWidth = contentViewWidth - horizontalPadding
} else {
contentWidth = window.frame.width - horizontalPadding
}
// Compute bounding rect for text content
let textNSString = noteText as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 14)
]
let boundingRect = textNSString.boundingRect(
with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: attributes
)
let contentHeight = boundingRect.height
// Calculate total and final height
let totalHeight = min(contentHeight + basePadding, maxAutoSizeHeight)
let finalHeight = max(totalHeight, minWindowHeight)
// Get current frame
var newFrame = window.frame
// Adjust height while keeping the top position fixed
let heightDifference = finalHeight - newFrame.height
newFrame.size.height = finalHeight
newFrame.origin.y -= heightDifference // Move window down to keep top edge in place
// Animate the resize
window.setFrame(newFrame, display: true, animate: true)
print("Auto-resized window to height: \(finalHeight) for \(contentHeight) lines")
}
And that was mostly smooth sailing, until: I wanted markdown preview.
My dream was a WYSIWYG markdown editor, like obsidian. But the best SwiftUI markdown package I could find (swift-markdown-ui) wasn’t actually real WYSIWYG, it’s fancy for preview, not editing. Enter my needle of the day: SwiftDown. Almost perfect - except, no support for full window transparency. To continue my pursuit of the perfect floating notes, I forked it, and added support so that if the theme’s background
is set to “clear” in theme.json
, it doesn’t draw a background at all. My hack:
if value.lowercased() == "clear" {
backgroundColor = UniversalColor.clear
} else {
backgroundColor = UniversalColor(hexString: value)
}
// ...
let isClear = theme.backgroundColor.alphaComponent == 0
self.drawsBackground = !isClear
Now my transparent notes window is just that—truly see-through, in all its glory.
Filenames: From Ugly UUIDs to Obsidian-Friendly Titles
Because ~I’m a perfectionist~ insist on doing things properly, I wanted my notes files to play nicely with Obsidian. My first pass: every file name was the first line of the note, plus a UUID, updated every time the user changed the first line (sometimes on every keystroke, oops). This worked until I actually looked at the files. Obsidian and UUIDs don’t mix; it’s ugly and impossible to find anything.
New approach:
- Don’t save a note until it has content.
- Only name a file once the user finishes editing the first line (Enter).
- Sanitise (strip markdown, remove invalid chars, limit length, check for duplicate names).
- “Untitled Note.md” for new and empty notes, “Untitled Note 2.md”, etc., for multiples.
- Renaming happens only when necessary, and deleting is as smooth as hitting backspace until your note’s gone.
Diagram for the visually inclined:
(No change while editing first line)"] D --> E["User presses Enter ⏎"] E --> F["Trigger: Move to next line"] F --> G["Extract first line: 'Shopping List'"] G --> H["Clean title: Remove markdown, sanitize"] H --> I["Check conflicts"] I --> J["No conflict
Rename to: 'Shopping List.md'"] I --> K["Conflict exists
Rename to: 'Shopping List 2.md'"] L["Multiple Untitled Notes"] --> M["Untitled Note.md"] L --> N["Untitled Note 2.md"] L --> O["Untitled Note 3.md"] P["Special Cases"] --> Q["Empty first line
→ Stays 'Untitled Note.md'"] P --> R["Markdown title: '# My Note'
→ Becomes 'My Note.md'"] P --> S["Invalid chars: 'Note/with:chars'
→ Becomes 'Note_with_chars.md'"] P --> T["Long title (>100 chars)
→ Truncated + '.md'"] U["Existing Note Editing"] --> V["Open: 'Meeting Notes.md'"] V --> W["Edit first line: 'Project Meeting'"] W --> X["Filename: Still 'Meeting Notes.md'"] X --> Y["Press Enter ⏎"] Y --> Z["Rename to: 'Project Meeting.md'"] AA["Edge Cases"] --> BB["First line becomes empty
→ Rename to 'Untitled Note.md'"] AA --> CC["User deletes all content
→ Delete the note"] AA --> DD["Content: '**Bold Title**'
→ Becomes 'Bold Title.md'"]
Special cases? Of course. Empty titles? “Untitled Note”. Markdown titles get stripped. Invalid filename characters are replaced. Existing note edits? The file only renames when you commit a new first line. Truly, file naming is a surprisingly deep rabbit hole.
If you want a sticky-note-but-better app that’s designed like this, you can purchase it on the App Store (I do charge to cover the Apple dev license). Or, in true dev spirit, build it yourself off GitHub. If you’re searching for the same needle, hopefully, this helps you find and keep it.
More haystack dives coming soon.