A writing app in the spirit of iA Writer: one column of text, a warm page, and nothing else competing for attention. Underneath it there is a real spreadsheet engine, a typesetter that produces proper PDFs, and a document format that a browser can open.
Written in Rust. Built for Omarchy (Wayland/Hyprland), works on any Linux desktop.
Writing
- Plain markdown, syntax-highlighted in place — the markers stay where they are, so nothing shifts under the cursor.
- Focus mode dims everything but the current sentence, paragraph or line.
- Typewriter scrolling keeps the caret at the middle of the window.
- The text column is set by measure (characters per line), not pixels.
- Live counts: words, characters, reading time, words written this session, and a progress ring against a word goal.
- Outline, library and recents in a sidebar; a rendered preview beside the text.
- Find and replace, full screen, light/dark/system themes.
Tables that calculate
Tables are objects in the document, placed in the prose with a {{table:ID}}
marker and edited in a spreadsheet pane. They carry formulas, number formats and
cell styling, and they render in the preview, the PDF, the HTML and the markdown
export.
Typography, templates and page setup
- Any font installed on the machine, chosen per document for body, headings and monospace. Size, line height, measure, paragraph spacing, letter spacing, indented or spaced paragraphs, justification.
- Thirteen templates, from Blank to Invoice, Budget and Research Paper.
- Page sizes A3–A5, B5, Letter, Legal, Tabloid or custom in millimetres; portrait or landscape; margins and page numbers.
Files
.nd— the native format. A ZIP holding the document, its tables, its full edit history and a rendered copy of itself.- Export to PDF, a standalone web page, or plain markdown.
- Opens markdown files directly, front matter included.
- Edit history on every save, browsable and restorable.
- Sync: mirror a document to a folder another tool keeps in step, and incoming changes are three-way merged rather than overwriting your work.
./install.sh # builds release, installs into ~/.local, no root
./install.sh --uninstallThis registers the desktop entry, the icon and the application/vnd.nd-writer+zip
MIME type, so .nd files open with a double-click.
For a system package on Arch/Omarchy, packaging/PKGBUILD is ready to use.
Dependencies. Rust (sudo pacman -S rust), and at build time the usual
Wayland/X11 client libraries, all of which Omarchy already has. The defaults are
tuned for the iA Writer typefaces — sudo pacman -S ttf-ia-writer, or an AUR
build of them — but the app falls back to whatever is installed and every font
is switchable.
Optional Hyprland window rules are in packaging/hyprland.conf.
Ctrl+N / Ctrl+Shift+N |
New / new from template |
Ctrl+O Ctrl+S Ctrl+Shift+S |
Open, save, save as |
Ctrl+P |
Export PDF |
Ctrl+Shift+E |
Export web page |
Ctrl+T |
Insert table |
Ctrl+B Ctrl+I Ctrl+E Ctrl+K |
Bold, italic, code, link |
Ctrl+F / Ctrl+H |
Find / find and replace |
Ctrl+D |
Cycle focus mode |
Ctrl+Shift+T |
Typewriter scrolling |
Ctrl+R / Ctrl+L |
Preview / sidebar |
Ctrl+Shift+H |
Edit history |
Ctrl+, |
Preferences |
Ctrl+ + / − / 0 |
Zoom |
F11 |
Full screen |
Esc |
Close whatever is open |
In the table pane: arrows move, Shift+arrows extend the selection, Enter or
F2 edits, Tab moves right, Delete clears, Ctrl+D fills down (rewriting
relative references), Σ sums the run of cells above.
nd-writer notes.nd # open it
nd-writer report.nd --to-pdf out.pdf
nd-writer report.nd --to-html out.html
nd-writer notes.md --to-nd notes.nd
nd-writer --selftest # build every template and export itA cell whose text starts with = is a formula. References are A1-style
(B7, $B$7), ranges are A1:C9. Operators: + - * / ^ %, & to join text,
and = <> < <= > >=. Errors are the familiar ones — #DIV/0!, #VALUE!,
#REF!, #NAME?, #N/A, #NUM! — plus #CYCLE! when references chase their
own tail, and #PARSE! when a formula is malformed.
=SUM(B2:B5)
=(B6-C6)/B6
=IF(C7>B7, "Over", "Within")
=INDEX(B1:B9, MATCH("cherry", A1:A9, 0))
=VLOOKUP(A2, A1:C20, 3) # VLOOKUP(lookup, range, column, [approximate])
=COUNTIF(E2:E9, ">10")
=ROUND(D6 * 0.1, 2)
=TEXT(A1, "0.00") & " kg"
Available: ABS AND AVERAGE AVERAGEIF CEILING CHOOSE CONCAT CONCATENATE COUNT COUNTA COUNTBLANK COUNTIF DATE DAY EXP FLOOR HLOOKUP IF IFERROR IFS INDEX INT ISBLANK ISERROR ISNUMBER ISTEXT LEFT LEN LN LOG LOG10 LOWER MATCH MAX MEDIAN MID MIN MOD MONTH NOT NOW OR PMT POWER PRODUCT PROPER RAND REPLACE REPT RIGHT ROUND ROUNDDOWN ROUNDUP SEARCH SIGN SQRT STDEV SUBSTITUTE SUM SUMIF SUMPRODUCT TEXT TODAY TRIM TRUNC UPPER VAR VLOOKUP XOR YEAR.
Notes on behaviour, since these differ between spreadsheets:
MATCHwith type0is exact and accepts*and?wildcards;1and-1scan an ordered range for the nearest bound.VLOOKUP/HLOOKUPdefault to exact match — the safe default, unlike Excel's legacy approximate one. PassTRUEas the fourth argument for the old behaviour.MODtakes the sign of its divisor, andROUNDrounds halves away from zero, both as in Excel.- Text and blanks inside a range are skipped by numeric aggregates.
- Dates are serial numbers counted from 1899-12-30, so they interoperate with Excel and Sheets.
- Inserting or deleting a row or column rewrites every formula so it keeps
pointing at the same data;
$pins a reference against that.
Number formats: automatic, fixed decimals, thousands separators, currency,
percent, scientific, dates (yyyy-mm-dd, d mmm yyyy, and so on), or forced
text. Cells can be bold, italic, struck through, aligned, coloured and filled.
A .nd file is a ZIP, laid out the way ODF and EPUB are, so it is inspectable
with tools you already have:
mimetype application/vnd.nd-writer+zip (stored first, uncompressed)
nd.json format version, document id, SHA-256 of document.json
document.json metadata, markdown body, tables
history.jsonl one revision per line
content.md plain markdown mirror — greppable, diffable
preview.html a standalone rendering of the document
unzip -l notes.nd
unzip -p notes.nd content.md | lessBecause content.md and preview.html are always current, a .nd file stays
useful even without this app. Writes are atomic — a temp file and a rename — so
a sync daemon watching the folder never sees a half-written document.
web/viewer.html (installed to ~/.local/share/nd-writer/viewer.html) opens
.nd files client-side: drop one in, or pass ?file=… to load one served
alongside it. It unpacks the ZIP with the browser's own inflate, shows the
rendered document, the metadata, and the full revision timeline — and replays any
past revision from the stored patches. Nothing is uploaded and there are no
dependencies; it is one HTML file.
Every save records a revision: who, when, from which device, a one-line summary,
and either a line patch against its parent or a full snapshot. Snapshots are
interleaved every 25 revisions so replaying any point stays cheap. Restoring an
old revision is itself an edit, so going back never loses what you went back
from. Ctrl+Shift+H opens the timeline.
Turn on sync and point it at a folder that something else keeps in step —
Syncthing, Nextcloud, Dropbox, an NFS mount. The document is mirrored there as
<document-id>.nd, pushed after each save and pulled when the mirror changes.
The open file is watched too, so a change made by another machine, another window, or a sync daemon is noticed within about half a second.
Incoming changes are three-way merged against the last state both sides
agreed on. Regions only one side touched are taken as they are; regions both
sides changed differently are kept with <<<<<<< local / >>>>>>> remote
markers rather than silently resolved. Tables merge per table: a table the local
side has not touched takes the remote version. Revisions from the other side are
folded into the local history, so both ends end up with the whole story.
A document server is also supported. The protocol is fixed:
GET {endpoint}/doc/{id}/head -> {"checksum","modified","revisions"}
GET {endpoint}/doc/{id} -> the .nd bytes
PUT {endpoint}/doc/{id} -> body is the .nd, If-Match: {base checksum}
The client, the merge path and the settings for it are all in place; the HTTP transport itself is not enabled in this build, and says so plainly instead of appearing to work. The folder mirror is the working path today.
cargo build --release
cargo test # formula engine, history replay, merge, .nd round-trip
./target/release/nd-writer --selftest--selftest builds all thirteen templates, exports each to .nd, PDF, HTML and
markdown, reopens the .nd, checks every formula still evaluates without error,
and replays a forty-revision history to confirm each revision reproduces exactly.
crates/nd-core no UI dependencies at all
formula/ lexer, parser, evaluator, function library
table.rs sparse grid, recalculation, number formats
doc.rs document model, page setup, typography
ndfile.rs the .nd container
history.rs revisions and patches
sync.rs backends and the three-way merge
export/ markdown, HTML, and the PDF typesetter
crates/nd-app the editor
editor/highlight.rs the markdown scanner that colours the text
ui/ panels: tables, history, preferences, sync, preview
web/viewer.html the browser-side .nd reader
packaging/ desktop entry, MIME type, icon, PKGBUILD, Hyprland rules
The PDF exporter is its own small typesetter: it measures text with
ttf-parser against the very font files it embeds, so the line breaks in the
output match the metrics of the font the reader sees. It handles headings,
emphasis, code, quotes, lists, rules, page breaks, justification, hanging
indents, page numbers and tables with their formatting.
MIT.