Turning the panel into the window
Step 13 of 13. Steps 2 to 12 built a complete notes app — commands across the IPC boundary, SQLite storage, autosave, FTS5 search, file import, tray and menus, export, signed installers — and every one of those features was driven from a single column of controls: two number fields, Add / Divide / Summarise, a title box, a textarea, a search box with a Search button, three export buttons, then the list of notes. That column was a test harness. A command needs something to call it, and an input plus a button is the cheapest caller there is.
Step 1 drew the window the app was aiming at — notes on the left, the Markdown in the middle, the
rendered preview on the right — and called it “the shape of it, not a screenshot”, because there was
no screenshot to take. This step builds it: three panes, a preview rendered by the same Rust function
that writes the HTML export, and a search box that narrows the list as you type. The harness goes
away. It starts from tauri-ch11, the tag step 12 left behind, and ends at tauri-ch12.
Why the column survived twelve steps
Section titled “Why the column survived twelve steps”Two reasons, and neither is laziness. The first is that the work was on the other side of the
boundary: each step added a command or a migration, and a command needs a caller more urgently than
it needs a layout. The second is that layout is the one change no compiler checks. Reordering
<main>’s children, replacing a form with a sidebar — the types are identical either way, tsc
passes, the window still opens. Nothing tells you the app looks wrong until a person looks at it.
So the features arrived in a column, and the window arrives in one step at the end, when there is nothing left to add.
Three columns
Section titled “Three columns”The whole layout is one grid rule:
html,body,#root { height: 100%; margin: 0;}
.app { display: grid; grid-template-columns: 220px minmax(0, 1fr) minmax(0, 1fr); height: 100%; min-height: 0;}A fixed 220px for the note list, and the rest split evenly between the source and the preview.
minmax(0, 1fr) is not the same as 1fr, and that difference is the reason the panes fit. A track
sized 1fr is really minmax(auto, 1fr), and auto as a minimum means “never narrower than the
content’s intrinsic minimum”. A <textarea> is born with cols="20" and will not go below twenty
characters unless something forces it — the old panel used rows={6} cols={40}, and forty characters
of monospace is wider than half a window. In a 1fr track the grid then grows past the window edge:
the panes keep their width, the window scrolls sideways, and the preview’s right edge lives off
screen. minmax(0, 1fr) lets the track shrink to nothing, the textarea shrinks with it, and the text
wraps instead.
The height: 100% chain is the vertical half of the same problem. Without it on html, body,
#root and .app, the grid is only as tall as its content, so the sidebar’s scroll bar never
appears and the window grows instead. min-height: 0 on the panes answers the equivalent question
inside a flex column: a flex item’s default min-height: auto is its content height, so a long note
pushes the pane open rather than scrolling inside it.
The sidebar
Section titled “The sidebar”The note list is one pane of that grid, and it is a flex column in its own right:
<aside className="sidebar"> <div className="sidebar-top"> <span className="brand">rustnote</span> <span className="count">{notes.length} notes</span> </div> <input className="search" value={query} aria-label="search notes" onChange={(e) => setQuery(e.currentTarget.value)} /> <button className="new-note" onClick={newNote}> + new note </button> <ul className="notes">{/* one <li> per note, or per hit */}</ul> </aside>.sidebar { display: flex; flex-direction: column; min-height: 0; gap: 8px; background: #ececec; border-right: 1px solid rgba(0, 0, 0, 0.08);}
.notes { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; min-height: 0;}flex: 1 hands the list whatever height is left after the header, the search field and the button,
and overflow-y: auto keeps two hundred notes inside the pane. This is the same min-height: 0 trap
as above, one level down: leave it out and the list’s content height wins, and the bottom of the
sidebar slides off the window.
+ new note and the count come with the move. The old panel created a note by typing a title and
pressing Save; a window with a list needs to create an empty note first and fill it in place, which
is what newNote() does — editingId back to null, both buffers cleared, cursor in the title.
The editor, and where the buttons went
Section titled “The editor, and where the buttons went”.editor { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 14px 18px 10px; gap: 8px;}
.note-body { flex: 1; min-height: 0; resize: none; font-family: ui-monospace, "SF Mono", Menlo, monospace; line-height: 1.65;} <div className="editor-foot"> <span className="status">{saved}</span> <div className="actions"> {editingId === null && ( <button onClick={saveNote} disabled={title.trim() === ""}> Save note </button> )} <button onClick={importFile}>Import .md</button> {editingId !== null && <button onClick={exportHtml}>Export HTML</button>} {editingId !== null && <button onClick={printNote}>Print</button>} </div> </div>Three things changed beyond the wrapping. The textarea takes flex: 1 and resize: none — inside a
grid cell the browser’s resize handle is a way to break the layout, and there is no reason to keep
it. Save only exists while a note is new, because an existing note saves itself (step 7). And the
export buttons only appear when there is a note to export, which is cheaper than the old
if (editingId === null) return setSaved("open a note first") guards.
The status line moved to a footer with a top border, at the left, with the buttons on the right: it is read constantly and it was previously a paragraph that pushed everything below it down a line every time the text changed from “saving…” to “saved just now”.
add, divide and stats stay registered in generate_handler!, and nothing in the window invokes
them any more. They were step 4’s demonstration, their article still calls them, and deleting them
would leave that article describing commands the project no longer has. If you are building your own
app from this track rather than following along, delete them — three functions nobody calls are
three functions to maintain.
The preview is a command
Section titled “The preview is a command”The preview is the only thing in the window without a command behind it. Step 11 already wrote the renderer for the HTML export, so the command is a wrapper around it:
/// The preview and the HTML export share one renderer, so what the window shows and what the/// file contains cannot drift apart.#[tauri::command]fn render_note(body: String) -> String { render_markdown(&body)}and one more name in the registry:
.invoke_handler(tauri::generate_handler![ add, divide, stats, create_note, list_notes, update_note, search_notes, render_note, export_note, print_main ])It takes the body, not a note id, and that is the design decision in the command. With an id, the preview would render the last saved body — the one in SQLite — so it would sit half a second behind the cursor, which is exactly where the delay is most visible. With the body, the preview renders what is in the textarea right now, saved or not; the save catches up on its own.
The front end calls it one debounce behind the keyboard:
useEffect(() => { if (body.trim() === "") { setPreview(""); return; } const timer = setTimeout(() => { invoke<string>("render_note", { body }) .then(setPreview) .catch((e) => setSaved(`Error: ${e}`)); }, 150); return () => clearTimeout(timer); }, [body]);150 ms, not the 500 ms autosave uses: this call writes nothing, so the cost is a round trip through
IPC plus one pulldown-cmark pass over a few kilobytes, both finished well before the next
keystroke. The cleanup function is what makes a debounce out of a setTimeout — each keystroke
cancels the previous timer, and clearTimeout on a timer that already fired does nothing, so the
last keystroke of a burst still renders.
The preview then drops the HTML into the pane:
<section className="preview"> <div className="preview-head">Preview</div> {preview === "" ? ( <p className="preview-empty">Nothing to preview yet.</p> ) : ( <div className="preview-body" dangerouslySetInnerHTML={{ __html: preview }} /> )} </section>dangerouslySetInnerHTML is the correct tool here and worth being explicit about: the string was
produced by pulldown-cmark’s escaper, which is why step 11 could promise that a note containing
& or < cannot produce broken markup. React is being told “this is already-sanitised HTML”, which
is true only because nothing else in the app builds HTML by hand.
Search that answers while you type
Section titled “Search that answers while you type”The Search button from step 8 is gone, along with Clear. Typing is the trigger:
useEffect(() => { if (query.trim() === "") { setHits(null); return; } const timer = setTimeout(async () => { try { setHits(await invoke<SearchHit[]>("search_notes", { query })); } catch (e) { setSaved(`Error: ${e}`); } }, 250); return () => clearTimeout(timer); }, [query]);250 ms, longer than the preview’s 150, because each query runs MATCH over the FTS5 index and then
sorts by rank; the extra hundred milliseconds keeps a fast typist from queueing six queries for a
five-letter word.
The list is one expression — hits ?? notes — which gives three states for free. While the box is
empty hits is null, so the full list shows. A query that matches nothing sets hits to [], and
[] is not null, so the list shows “no matches” instead of every note. Clearing the field puts
hits back to null and the full list returns, which is why Clear had nothing left to do.
The «» markers are unchanged from step 8 — search_notes still calls highlight(notes_fts, 0, '«', '»') for the title and snippet(notes_fts, 1, '«', '»', '…', 12) for the body, and the
Highlighted component still splits on them and wraps each match in <mark>. What changed is where
they are printed: the sidebar’s second line used to be the note’s date, and during a search it
becomes the snippet, so the reason a note matched is visible without opening it.
<li key={hit.id} className={hit.id === editingId ? "active" : ""}> <span className="note-title"> <Highlighted text={hit.title} /> </span> <span className="note-meta"> <Highlighted text={hit.snippet} /> </span> </li>One consequence worth knowing: a hit carries its own title, but clicking it looks the note up in
notes to fill the editor — const note = notes.find((n) => n.id === hit.id). That works because
the list is always loaded, and it would stop working if the sidebar ever showed notes the editor has
never seen.
Dark mode
Section titled “Dark mode”A desktop app that ignores the system appearance looks unfinished on macOS, and there is no config for it: the webview is a browser, so the media query is the whole mechanism.
@media (prefers-color-scheme: dark) { :root { color: #f5f5f7; background-color: #1e1e1e; }
.sidebar { background: #262626; border-right-color: rgba(255, 255, 255, 0.08); }
mark { background-color: #7a5b00; color: #fff; }}Most of the stylesheet needs nothing, because it was written in terms of color: inherit and
rgba(0, 0, 0, α) borders, both of which follow the text colour. The surfaces that do need
overriding are the ones with a literal light background — the sidebar, the preview pane, the buttons
— plus mark: the yellow highlight that reads well on white is unreadable glare on a dark window, so
it becomes dark amber with white text. Tauri’s own window chrome follows the OS on its own; only the
pixels inside the webview are your job.
What the window does not do
Section titled “What the window does not do”- No draggable splitter. The grid is
220px 1fr 1frand stays that way; making the columns resizable means storing three widths and a pointer handler, which is a feature, not a detail. - No syntax highlighting in the source pane. It is a plain
<textarea>; the rendered output is highlighted by the preview, the input is not. - No way to delete a note. The FTS5
DELETEtrigger from step 8 still has nothing to fire on.
Each of those is a small change on top of the window that now exists, which is the point of doing the layout last: there is something to add to.
Where the code stands
Section titled “Where the code stands”The step is tagged tauri-ch12 in the examples repository:
git clone https://github.com/hackingwithrust/examplescd examples/tauri-desktop-appgit checkout tauri-ch12npm installnpm run tauri devIt touched four files: src/App.tsx and src/App.css (both rewritten around the three panes),
src-tauri/src/lib.rs (the render_note command and its registry line), and
src-tauri/tauri.conf.json (the window is 960×620 — create-tauri-app’s 800×600 was sized for a
demo, and three panes of text need more width than that). Nothing in the Rust core changed shape:
the window calls the commands the track already built, and the only new one is render_note.
The window, at the end
Section titled “The window, at the end”Thirteen steps, one app, and the last one is the only step a reader can judge from across the room. The interesting work was all in the other twelve — a database behind typed commands, an index that answers in milliseconds, a tray icon, a signed bundle — and none of it was visible in a shape anyone would want to use. That gap between “it works” and “it is a product” is most of what separates a prototype from software, and it is almost always the part that gets skipped.
The track overview lists every article, and the tags tauri-ch02
through tauri-ch12 in the examples repository hold the project’s complete history, one runnable
state per step.