git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

commit2de9132c8de053b025dfcccddc6b5d3e038af092
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:46
cce-notes: the vault's notes editor (Obsidian-on-cce milestone 2)

A file tree and one note, shown as rendered Markdown (reading view) or
edited as text (source mode), toggled with Ctrl+E as in Obsidian. Built
on cce-vault: the app embeds an Index and a watcher, no daemon.

- reading view: cce_vault::markdown blocks laid out word by word, each
  word measured through the renderer's own shaping entry and merged back
  into one prim per style run; headings, properties, lists and tasks
  (clickable checkboxes write the file), callouts, quotes, code, tables,
  rules, highlights, tags; resolved and unresolved links coloured apart
- links follow on click (Ctrl+click in source); an unresolved link
  creates the note; back/forward history (Alt+arrows, mouse buttons)
- quick switcher (Ctrl+O): fuzzy names, recent first, "Create …"
- source mode: autosave 1.5 s after typing stops, on leaving the note
  and on exit; an outside edit reloads a clean buffer and flags a dirty
  one as a conflict (Ctrl+S keeps ours, Ctrl+R loads theirs)
- single instance on /tmp/cce-notes-<display>.sock: `cce-notes <note>
  [line]`, `daily [date]`, `search <query>` forward to the running app
- system fonts loaded so the DE sans has real bold/italic faces

Verified in a scale-2 shadow against a copy of the vault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 .gitignore        |    1 +
 CLAUDE.md         |  101 ++
 Cargo.lock        | 3340 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 Cargo.toml        |   14 +
 Makefile          |   16 +
 cce-notes.desktop |   11 +
 src/instance.rs   |  208 ++++
 src/main.rs       | 1441 +++++++++++++++++++++++
 src/reading.rs    |  817 +++++++++++++
 src/tree.rs       |  104 ++
 10 files changed, 6053 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7102585
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,101 @@
+# cce-notes
+
+The vault's notes editor — milestone 2 of the Obsidian-on-cce plan, built
+on `cce-vault` (milestone 1). A file tree on the left and one note on the
+right, shown as rendered Markdown (**reading view**) or edited as text
+(**source mode**), toggled with Ctrl+E as in Obsidian.
+
+Read `../cce-vault/CLAUDE.md` first: the index, watcher, writes and the
+`markdown` document model this app draws all live there, along with their
+invariants. The plan itself is a doc, "Obsidian-style notes on cce:
+proposal".
+
+## Layout
+
+| File | What it owns |
+| --- | --- |
+| `main.rs` | The `Application`: panes, modes, history, quick switcher, autosave, conflicts, input |
+| `reading.rs` | Lays out `cce_vault::markdown::Block`s at a width into draw items + click targets |
+| `tree.rs` | The file tree's rows (folders first, case-insensitive, collapsible) |
+| `instance.rs` | Single instance on `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock`; the CLI's commands |
+
+## Behaviour worth knowing before changing it
+
+- **The files are the source of truth; there is no daemon.** The app
+  embeds an `Index` and a `VaultWatcher`. A watcher batch that touches the
+  open note reloads it silently when the buffer is clean, and raises a
+  **conflict** when it is dirty: autosave stops, Ctrl+S writes ours over
+  the disk copy, Ctrl+R loads theirs. Never overwrite a dirty conflict
+  silently — Obsidian or Dropbox may be writing the same file.
+- **Our own writes come back through the watcher.** They are recognised by
+  comparing the disk text with `saved` (the last text read or written), not
+  by timing.
+- **Source mode autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
+  polled through `idle_poll_interval` only while dirty), on leaving a note,
+  on switching to reading, and in `on_exit`.
+- **Links:** click in reading, Ctrl+click in source (the caret's byte
+  offset is matched against `cce_vault::parse` link spans). An unresolved
+  link creates the note at the vault root and opens it in source mode, as
+  Obsidian does. Ctrl state comes from `UiContext::ctrl_pressed` (the
+  Wayland modifiers event) — a key event's own `ctrl` flag is stale for the
+  Ctrl press itself.
+
+## The reading view (`reading.rs`)
+
+cce-ui's text prim draws one run in one style, so a paragraph is laid out
+**word by word**: each word measured, wrapped greedily, and consecutive
+words of one look merged back into one prim. Things that were bugs once:
+
+- **Measure through the renderer's own shaping entry**
+  (`window_runner::get_text_buffer_attrs`) with a FontSystem that loads the
+  **same fonts** as the renderer. The app returns `load_system_fonts() =
+  true` because the DE sans (Noto Sans) has no bold/italic in the bundle —
+  without it `**bold**` fell back to a serif face — and `measure_fs` is
+  `create_font_system_with_system_fonts()` to match. Startup cost measured
+  at ~330 ms to first map.
+- **A word may span styles** (`` `code`, `` or `**bold**.`): `tokens()`
+  glues them so punctuation never starts a line on its own.
+- **Colours** for links, tags, highlights and callouts are written in sRGB
+  and pass through `lin()`; prims take linear colour. `TEXT_FG`/`TEXT_DIM`
+  are already linear.
+- **Bullets are `Dot`s (`pc.circle`)**, not small rounded rects: the
+  squircle corner shape draws a few-px radius as a square.
+- **Text cannot be hidden under a plate** (the glyph pass runs after all
+  geometry), so the quick switcher simply stops painting the note and tree
+  while it is open rather than registering a popover.
+
+This is the app-local first cut of the `MarkdownView` the proposal puts in
+cce-ui. Move it there when a second surface (grid note cards, graph hover
+previews) needs it, and then shape a whole paragraph as one rich-text
+buffer instead of a buffer per word (each word is a `BUFFER_CACHE` entry
+today, which a long note can churn).
+
+## Commands
+
+```sh
+cce-notes [--vault <dir>] [open] <note>[#heading] [line]   # line is 1-based
+cce-notes daily [YYYY-MM-DD]
+cce-notes search <query>         # opens the quick switcher holding it
+```
+
+A second launch forwards its command to the running instance and exits.
+Keys come from input.kdl's `cce-notes` domain: `quick_switcher` (ctrl+o),
+`toggle_mode` (ctrl+e), `save` (ctrl+s), `reload` (ctrl+r), `back`
+(alt+arrowleft), `forward` (alt+arrowright), `toggle_tree` (ctrl+\\),
+`daily` (alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
+
+## Testing
+
+`cargo test -p cce-notes` covers layout (with a fixed-width `Measure`), the
+tree and the socket commands. Anything visual goes through a shadow at
+`--scale 2` with `CCE_FONTS_DIR` set, against a **copy** of the vault
+(`CCE_VAULT=<copy>`) — never the live vault, which syncs to other devices.
+Fullscreen the window first (`ctl set-mode fullscreen cce-notes`): the
+scale-2 shadow output is only 640×360 logical.
+
+## Not done yet (milestone 2 → 3)
+
+Backlinks and outline panes, `[[` completion, rename, the search pane, tag
+search (a tag click does nothing yet), MCP tools, embedded images, the
+icon (`Icon=cce-notes` has no SVG in cce-icons yet), per-note scroll in
+the history, and the move of the reading view into cce-ui.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..9c2fea0
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,3340 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand",
+ "raw-window-handle",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.32.13",
+ "zbus",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.5",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.5",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.13.2",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cce-notes"
+version = "0.1.0"
+dependencies = [
+ "calloop",
+ "cce-ui",
+ "cce-vault",
+ "chrono",
+ "env_logger",
+ "log",
+ "wayland-client",
+]
+
+[[package]]
+name = "cce-ui"
+version = "0.1.0"
+source = "git+https://github.com/lsgalante/cce-ui.git?rev=3825007457507df4f0ee164f1be8cec626ec41cc#3825007457507df4f0ee164f1be8cec626ec41cc"
+dependencies = [
+ "ash",
+ "bitflags 2.13.2",
+ "bytemuck",
+ "calloop",
+ "calloop-wayland-source",
+ "cosmic-text",
+ "glam",
+ "gpu-allocator",
+ "kdl",
+ "libc",
+ "log",
+ "naga",
+ "png",
+ "raw-window-handle",
+ "resvg",
+ "rfd",
+ "serde",
+ "serde_json",
+ "smithay-client-toolkit",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.31.2",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "cce-vault"
+version = "0.1.0"
+source = "git+https://github.com/lsgalante/cce-vault.git?rev=11a26a5f8e1ff1ac55a951d88afc751ab68a5aba#11a26a5f8e1ff1ac55a951d88afc751ab68a5aba"
+dependencies = [
+ "chrono",
+ "indexmap",
+ "kdl",
+ "log",
+ "notify",
+ "percent-encoding",
+ "pulldown-cmark",
+ "serde",
+ "serde_json",
+ "walkdir",
+ "yaml-rust2",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cosmic-text"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2"
+dependencies = [
+ "bitflags 2.13.2",
+ "fontdb",
+ "log",
+ "rangemap",
+ "rayon",
+ "rustc-hash",
+ "rustybuzz 0.14.1",
+ "self_cell",
+ "swash",
+ "sys-locale",
+ "ttf-parser 0.21.1",
+ "unicode-bidi",
+ "unicode-linebreak",
+ "unicode-script",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "data-url"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "float-cmp"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "fontconfig-parser"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
+dependencies = [
+ "roxmltree 0.20.0",
+]
+
+[[package]]
+name = "fontdb"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2 0.9.11",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser 0.20.0",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gif"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "glam"
+version = "0.29.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
+
+[[package]]
+name = "gpu-allocator"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+dependencies = [
+ "ash",
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hexf-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "imagesize"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags 2.13.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jpeg-decoder"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kdl"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117"
+dependencies = [
+ "miette",
+ "nom",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.13.2",
+ "libc",
+]
+
+[[package]]
+name = "kurbo"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror 1.0.69",
+ "unicode-width",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "naga"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.13.2",
+ "cfg_aliases",
+ "codespan-reporting",
+ "hexf-parse",
+ "indexmap",
+ "log",
+ "rustc-hash",
+ "spirv",
+ "strum",
+ "termcolor",
+ "thiserror 2.0.21",
+ "unicode-xid",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.13.2",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pico-args"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags 2.13.2",
+ "memchr",
+ "unicase",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rangemap"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.22.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f"
+dependencies = [
+ "bytemuck",
+ "font-types",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "resvg"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2327ced609dadeed3e9702fec3e6b2ddd208758a9268d13e06566c6101ba533"
+dependencies = [
+ "gif",
+ "jpeg-decoder",
+ "log",
+ "pico-args",
+ "png",
+ "rgb",
+ "svgtypes",
+ "tiny-skia",
+ "usvg",
+]
+
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2",
+ "dispatch2",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "pollster",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "roxmltree"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
+
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "rustybuzz"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88117946aa1bfb53c2ae0643ceac6506337f44887f8c9fbfb43587b1cc52ba49"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "smallvec",
+ "ttf-parser 0.20.0",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
+ "unicode-properties",
+ "unicode-script",
+]
+
+[[package]]
+name = "rustybuzz"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "libm",
+ "smallvec",
+ "ttf-parser 0.21.1",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
+ "unicode-properties",
+ "unicode-script",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "simplecss"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "skrifa"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "calloop",
+ "calloop-wayland-source",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2 0.9.11",
+ "pkg-config",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols 0.32.13",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkbcommon",
+ "xkeysym",
+]
+
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+dependencies = [
+ "float-cmp",
+]
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "svgtypes"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
+dependencies = [
+ "kurbo",
+ "siphasher",
+]
+
+[[package]]
+name = "swash"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2"
+dependencies = [
+ "skrifa",
+ "yazi",
+ "zeno",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "png",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.15+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+
+[[package]]
+name = "ttf-parser"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
+
+[[package]]
+name = "unicode-ccc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "unicode-script"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-vo"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[package]]
+name = "usvg"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c704361d822337cfc00387672c7b59eaa72a1f0744f62b2a68aa228a0c6927d"
+dependencies = [
+ "base64",
+ "data-url",
+ "flate2",
+ "fontdb",
+ "imagesize",
+ "kurbo",
+ "log",
+ "pico-args",
+ "roxmltree 0.19.0",
+ "rustybuzz 0.13.0",
+ "simplecss",
+ "siphasher",
+ "strict-num",
+ "svgtypes",
+ "tiny-skia-path",
+ "unicode-bidi",
+ "unicode-script",
+ "unicode-vo",
+ "xmlwriter",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.5",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags 2.13.2",
+ "rustix 1.1.5",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.13.2",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.5",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.32.13",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "xcursor"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+
+[[package]]
+name = "xkbcommon"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e"
+dependencies = [
+ "libc",
+ "memmap2 0.8.0",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "xmlwriter"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
+
+[[package]]
+name = "yaml-rust2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
+dependencies = [
+ "arraydeque",
+ "encoding_rs",
+ "hashlink",
+]
+
+[[package]]
+name = "yazi"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.5",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zeno"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "url",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.6",
+ "winnow",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2f57b41
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "cce-notes"
+version = "0.1.0"
+edition = "2021"
+description = "The vault's notes editor: Obsidian-compatible reading view, source editing and links over cce-vault"
+
+[dependencies]
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "3825007457507df4f0ee164f1be8cec626ec41cc" }
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+chrono = "0.4"
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3efa7f6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+# Binaries and desktop entries are enumerated by ccebuild from cargo
+# metadata and the crate root; never name them here.
+install: build
+	@command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+	ccebuild install --no-build cce-notes
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/cce-notes.desktop b/cce-notes.desktop
new file mode 100644
index 0000000..892872f
--- /dev/null
+++ b/cce-notes.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Type=Application
+Name=Notes
+Comment=Read and edit the notes vault
+Exec=cce-notes %f
+Icon=cce-notes
+Categories=Office;Utility;TextEditor;
+Terminal=false
+StartupNotify=true
+StartupWMClass=cce-notes
+MimeType=text/markdown;
diff --git a/src/instance.rs b/src/instance.rs
new file mode 100644
index 0000000..f0d6f64
--- /dev/null
+++ b/src/instance.rs
@@ -0,0 +1,208 @@
+//! Single instance over the cce socket convention, in cce-browser's
+//! claim-or-forward shape: the first `cce-notes` listens on
+//! `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock` (keyed by display, so shadow
+//! sessions stay apart), and every later launch — and any app that wants a
+//! note open — sends it one line and exits.
+//!
+//! Commands, one per line, each answered `ok`:
+//!
+//! - `open <path>[#heading] [line]` — a vault path, an absolute path inside
+//!   the vault, or a note name; `line` is 1-based.
+//! - `daily [YYYY-MM-DD]` — open (creating it from the template) a day's note.
+//! - `search <query>` — open the quick switcher holding `query`.
+//! - `show` — nothing but bringing the instance up.
+//!
+//! Connect before binding, as cce-browser does: a refused connect means a
+//! crashed instance left its socket file, which is removed; losing the bind
+//! to a simultaneous launch falls back to one more connect.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::Mutex;
+
+use crate::Message;
+
+const PREFIX: &str = "cce-notes";
+
+static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
+static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Command {
+    Open { target: String, line: Option<usize> },
+    Daily(Option<chrono::NaiveDate>),
+    Search(String),
+    Show,
+}
+
+impl Command {
+    /// The command line's own arguments as a socket command:
+    /// `cce-notes [open] <path> [line]`, `cce-notes daily [date]`,
+    /// `cce-notes search <query…>`, or nothing.
+    pub fn from_args(args: &[String]) -> Result<Command, String> {
+        let words: Vec<&str> = args.iter().map(String::as_str).collect();
+        match words.as_slice() {
+            [] => Ok(Command::Show),
+            ["daily"] => Ok(Command::Daily(None)),
+            ["daily", d] => parse_date(d).map(|d| Command::Daily(Some(d))),
+            ["search", rest @ ..] => Ok(Command::Search(rest.join(" "))),
+            ["open", target] | [target] => Ok(Command::Open { target: absolute(target), line: None }),
+            ["open", target, line] | [target, line] => {
+                let line = line.parse::<usize>().map_err(|_| format!("not a line number: {line}"))?;
+                Ok(Command::Open { target: absolute(target), line: Some(line) })
+            }
+            _ => Err("usage: cce-notes [open] <note> [line] | daily [YYYY-MM-DD] | search <query>".into()),
+        }
+    }
+
+    pub fn to_line(&self) -> String {
+        match self {
+            Command::Open { target, line: Some(l) } => format!("open {target} {l}"),
+            Command::Open { target, line: None } => format!("open {target}"),
+            Command::Daily(Some(d)) => format!("daily {d}"),
+            Command::Daily(None) => "daily".into(),
+            Command::Search(q) => format!("search {q}"),
+            Command::Show => "show".into(),
+        }
+    }
+
+    pub fn parse(line: &str) -> Option<Command> {
+        let (verb, rest) = line.split_once(' ').unwrap_or((line, ""));
+        let rest = rest.trim();
+        match verb {
+            "open" if !rest.is_empty() => {
+                // A trailing number is a line, unless it is all there is.
+                match rest.rsplit_once(' ') {
+                    Some((t, l)) if l.parse::<usize>().is_ok() => {
+                        Some(Command::Open { target: t.trim().to_string(), line: l.parse().ok() })
+                    }
+                    _ => Some(Command::Open { target: rest.to_string(), line: None }),
+                }
+            }
+            "daily" if rest.is_empty() => Some(Command::Daily(None)),
+            "daily" => parse_date(rest).ok().map(|d| Command::Daily(Some(d))),
+            "search" => Some(Command::Search(rest.to_string())),
+            "show" => Some(Command::Show),
+            _ => None,
+        }
+    }
+}
+
+fn parse_date(s: &str) -> Result<chrono::NaiveDate, String> {
+    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}"))
+}
+
+/// A path that exists here travels absolute: the instance's cwd differs.
+/// Anything else (a note name, a vault path) goes as written.
+fn absolute(target: &str) -> String {
+    let (path, sub) = match target.split_once('#') {
+        Some((p, s)) => (p, Some(s)),
+        None => (target, None),
+    };
+    let p = std::path::Path::new(path);
+    let abs = if p.exists() && !p.is_absolute() {
+        std::fs::canonicalize(p).ok().and_then(|c| c.to_str().map(String::from))
+    } else {
+        None
+    };
+    match (abs, sub) {
+        (Some(a), Some(s)) => format!("{a}#{s}"),
+        (Some(a), None) => a,
+        (None, _) => target.to_string(),
+    }
+}
+
+/// Hand `cmd` to a running instance, or claim the socket. True when a
+/// running instance took it and this process should exit.
+pub fn forward_or_claim(cmd: &Command) -> bool {
+    let path = cce_ui::ipc::socket_path(PREFIX);
+    if try_forward(&path, cmd) {
+        return true;
+    }
+    if std::path::Path::new(&path).exists() {
+        let _ = std::fs::remove_file(&path);
+    }
+    match UnixListener::bind(&path) {
+        Ok(listener) => {
+            *CLAIMED.lock().unwrap() = Some(listener);
+            *OWNED_PATH.lock().unwrap() = Some(path);
+            false
+        }
+        Err(_) => try_forward(&path, cmd),
+    }
+}
+
+fn try_forward(path: &str, cmd: &Command) -> bool {
+    let Ok(mut stream) = UnixStream::connect(path) else {
+        return false;
+    };
+    if stream.write_all(format!("{}\n", cmd.to_line()).as_bytes()).is_err() {
+        return false;
+    }
+    let mut reply = String::new();
+    BufReader::new(stream).read_line(&mut reply).is_ok()
+}
+
+/// Serve the claimed listener on a thread, feeding the app's loop.
+pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+    let Some(listener) = CLAIMED.lock().unwrap().take() else {
+        return;
+    };
+    std::thread::spawn(move || {
+        for conn in listener.incoming() {
+            let Ok(conn) = conn else { continue };
+            let mut reader = BufReader::new(conn);
+            let mut line = String::new();
+            if reader.read_line(&mut line).is_err() {
+                continue;
+            }
+            let reply: &[u8] = match Command::parse(line.trim()) {
+                Some(cmd) => {
+                    if sender.send(Message::Command(cmd)).is_err() {
+                        return;
+                    }
+                    b"ok\n"
+                }
+                None => b"error unknown command\n",
+            };
+            let _ = reader.get_mut().write_all(reply);
+        }
+    });
+}
+
+pub fn cleanup() {
+    if let Some(path) = OWNED_PATH.lock().unwrap().take() {
+        let _ = std::fs::remove_file(path);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn args(a: &[&str]) -> Vec<String> {
+        a.iter().map(|s| s.to_string()).collect()
+    }
+
+    #[test]
+    fn args_and_lines_round_trip() {
+        let cases = [
+            (vec![], Command::Show),
+            (vec!["Note"], Command::Open { target: "Note".into(), line: None }),
+            (vec!["open", "My Note#Sec", "12"], Command::Open { target: "My Note#Sec".into(), line: Some(12) }),
+            (vec!["daily"], Command::Daily(None)),
+            (vec!["daily", "2026-10-01"], Command::Daily(chrono::NaiveDate::from_ymd_opt(2026, 10, 1))),
+            (vec!["search", "two", "words"], Command::Search("two words".into())),
+        ];
+        for (a, want) in cases {
+            let got = Command::from_args(&args(&a)).unwrap();
+            assert_eq!(got, want, "{a:?}");
+            assert_eq!(Command::parse(&got.to_line()), Some(want));
+        }
+        assert!(Command::from_args(&args(&["daily", "tomorrow"])).is_err());
+        assert!(Command::parse("open ").is_none());
+        assert!(Command::parse("bogus").is_none());
+        // A note whose name is a number is a target, not a line.
+        assert_eq!(Command::parse("open 2026"), Some(Command::Open { target: "2026".into(), line: None }));
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0373464
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,1441 @@
+//! `cce-notes` — the vault's notes editor (Obsidian-on-cce, milestone 2).
+//!
+//! A file tree on the left and one note on the right, read as rendered
+//! Markdown (the reading view) or edited as text (source mode), toggled
+//! with Ctrl+E as in Obsidian. Links follow on click — or Ctrl+click in
+//! source — and a link to a note that does not exist creates it. Ctrl+O is
+//! the quick switcher; Alt+←/→ walk the history.
+//!
+//! The files are the source of truth and there is no daemon: the app
+//! embeds a `cce_vault::Index` and a watcher, so an edit from Obsidian,
+//! Dropbox or another cce app arrives like any other change. A clean
+//! buffer reloads silently; a dirty one is flagged as a conflict and never
+//! overwritten. Source mode saves a moment after typing stops, on leaving
+//! the note, and on exit.
+//!
+//! The vault comes from `--vault <dir>`, `$CCE_VAULT`, or
+//! `vault { path "…" }` in `~/.config/cce/config.kdl`. The app runs single
+//! instance (see [`instance`]); `cce-notes <note>` opens a note in it.
+
+mod instance;
+mod reading;
+mod tree;
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::OnceLock;
+use std::time::{Duration, Instant};
+
+use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx, TextAttrs};
+use cce_ui::widget::{
+    Adapted, Bounds, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion,
+    TextBox, WidgetHost,
+};
+use cce_vault::markdown::{Block, SpanLink};
+use cce_vault::{FileKind, Index, VaultWatcher};
+use wayland_client::QueueHandle;
+
+use instance::Command;
+use reading::{srgb_u8, Hit, Layout, Measure, Theme};
+
+const BAND_H: f32 = 40.0;
+const STATUS_H: f32 = 26.0;
+const TREE_W: f32 = 240.0;
+const TREE_ROW_H: f32 = 24.0;
+/// Obsidian's "readable line length": the reading column's widest.
+const READ_MAX_W: f32 = 720.0;
+const READ_PAD: f32 = 28.0;
+const READ_SIZE: f32 = 15.0;
+const SWITCHER_W: f32 = 560.0;
+const SWITCHER_ROW_H: f32 = 28.0;
+const SWITCHER_ROWS: usize = 10;
+/// Source mode saves once typing has paused this long.
+const AUTOSAVE_AFTER: Duration = Duration::from_millis(1500);
+
+const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
+const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
+const ROW_HOVER: [f32; 4] = [1.0, 1.0, 1.0, 0.05];
+const ROW_CURRENT: [f32; 4] = [1.0, 1.0, 1.0, 0.10];
+const CONFLICT: [f32; 4] = [0.95, 0.45, 0.35, 1.0];
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    Command(Command),
+    VaultChanged(Vec<PathBuf>),
+    Exit,
+}
+
+/// Startup arguments, fixed before the engine starts (`Application::new`
+/// takes none).
+struct Startup {
+    vault: Result<PathBuf, String>,
+    command: Command,
+}
+
+static STARTUP: OnceLock<Startup> = OnceLock::new();
+
+/// Shortcuts, from input.kdl's `cce-notes` domain over the `cce-ui` one.
+struct Keys {
+    switcher: String,
+    toggle_mode: String,
+    save: String,
+    reload: String,
+    back: String,
+    forward: String,
+    toggle_tree: String,
+    daily: String,
+    quit: String,
+}
+
+impl Keys {
+    fn load() -> Keys {
+        let get = cce_ui::input::app_chord;
+        Keys {
+            switcher: get("quick_switcher", "ctrl+o"),
+            toggle_mode: get("toggle_mode", "ctrl+e"),
+            save: get("save", "ctrl+s"),
+            reload: get("reload", "ctrl+r"),
+            back: get("back", "alt+arrowleft"),
+            forward: get("forward", "alt+arrowright"),
+            toggle_tree: get("toggle_tree", "ctrl+\\"),
+            daily: get("daily", "alt+d"),
+            quit: get("quit", "ctrl+q"),
+        }
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum Mode {
+    Reading,
+    Source,
+}
+
+/// One quick-switcher result: an existing note, or "create this".
+#[derive(Clone, Debug)]
+enum Choice {
+    Note { path: String, shown: String },
+    Create(String),
+}
+
+struct Switcher {
+    choices: Vec<Choice>,
+    selected: usize,
+    /// The query the choices were computed for.
+    query: String,
+}
+
+/// Widths through the renderer's own shaping entry, cached per run.
+struct RendererMeasure<'a> {
+    fs: &'a mut cce_ui::cosmic_text::FontSystem,
+    scale: f32,
+    cache: &'a mut HashMap<(String, u32, String, TextAttrs), f32>,
+}
+
+impl Measure for RendererMeasure<'_> {
+    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32 {
+        let key = (text.to_string(), (size * 100.0) as u32, font.to_string(), attrs);
+        if let Some(w) = self.cache.get(&key) {
+            return *w;
+        }
+        let buf = cce_ui::backend::window_runner::get_text_buffer_attrs(self.fs, text, size, Some(font), attrs);
+        let w = buf.layout_runs().map(|r| r.line_w).fold(0.0, f32::max) / self.scale;
+        self.cache.insert(key, w);
+        w
+    }
+}
+
+struct NotesApp {
+    keys: Keys,
+    index: Option<Index>,
+    vault_error: Option<String>,
+    _watcher: Option<VaultWatcher>,
+
+    tree: tree::Tree,
+    rows: Vec<tree::Row>,
+    show_tree: bool,
+    tree_scroll: f32,
+    tree_motion: ScrollMotion,
+    tree_hover: Option<usize>,
+
+    /// The open note's vault path.
+    current: Option<String>,
+    /// The note's text as last read from or written to disk.
+    saved: String,
+    blocks: Vec<Block>,
+    theme: Theme,
+    layout: Option<(f32, Layout)>,
+    widths: HashMap<(String, u32, String, TextAttrs), f32>,
+    /// Shapes for layout widths; loads the same fonts as the renderer
+    /// (system fonts included, see `load_system_fonts`). Made on first use.
+    measure_fs: Option<cce_ui::cosmic_text::FontSystem>,
+    mode: Mode,
+    read_scroll: f32,
+    read_motion: ScrollMotion,
+    /// A source line to bring into view once the layout exists.
+    pending_line: Option<usize>,
+    hover_hit: bool,
+
+    editor: Adapted<TextBox>,
+    /// The buffer as last seen by the autosave check, and when it changed.
+    edit_seen: String,
+    edit_changed_at: Instant,
+
+    history: Vec<String>,
+    hist_pos: usize,
+
+    switcher: Option<Switcher>,
+    switcher_input: Adapted<TextBox>,
+
+    conflict: bool,
+    status: Option<(String, bool)>,
+
+    width: u32,
+    height: u32,
+    scale: f64,
+    needs_rebuild: bool,
+    ui_context: cce_ui::context::UiContext,
+    widgets_registered: bool,
+    pointer: (f32, f32),
+}
+
+/// Where everything sits for a window size.
+struct Metrics {
+    tree: Rect,
+    note: Rect,
+    mode_chip: Rect,
+}
+
+impl NotesApp {
+    fn metrics(&self) -> Metrics {
+        let (w, h) = (self.width as f32, self.height as f32);
+        let body_h = (h - BAND_H - STATUS_H).max(0.0);
+        let tree_w = if self.show_tree && self.index.is_some() { TREE_W.min(w * 0.4) } else { 0.0 };
+        let inset = cce_ui::layout::root_plate_inset();
+        Metrics {
+            tree: Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h },
+            note: Rect { x: tree_w, y: BAND_H, width: w - tree_w, height: body_h },
+            mode_chip: Rect { x: w - inset - 84.0, y: (BAND_H - 24.0) / 2.0, width: 84.0, height: 24.0 },
+        }
+    }
+
+    /// The reading column's origin and width inside the note pane.
+    fn reading_frame(&self, m: &Metrics) -> (f32, f32, f32) {
+        let width = (m.note.width - 2.0 * READ_PAD).min(READ_MAX_W).max(80.0);
+        let x = m.note.x + (m.note.width - width) / 2.0;
+        (x, m.note.y + READ_PAD, width)
+    }
+
+    fn editor_rect(&self, m: &Metrics) -> Rect {
+        let gap = cce_ui::layout::root_plate_gap();
+        Rect {
+            x: m.note.x + gap,
+            y: m.note.y + gap,
+            width: (m.note.width - 2.0 * gap).max(50.0),
+            height: (m.note.height - 2.0 * gap).max(50.0),
+        }
+    }
+
+    fn rebuild_rows(&mut self) {
+        let Some(ix) = &self.index else { return };
+        let notes = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str());
+        self.rows = self.tree.rows(notes);
+    }
+
+    fn editor_text(&self) -> &str {
+        if self.editor.editing {
+            &self.editor.edit_buffer
+        } else {
+            &self.editor.text
+        }
+    }
+
+    fn dirty(&self) -> bool {
+        self.mode == Mode::Source && self.current.is_some() && self.editor_text() != self.saved
+    }
+
+    fn set_status(&mut self, msg: impl Into<String>, error: bool) {
+        self.status = Some((msg.into(), error));
+    }
+
+    fn invalidate(&mut self) {
+        self.layout = None;
+        self.needs_rebuild = true;
+    }
+
+    // ---- notes -------------------------------------------------------
+
+    /// Show `text` as the current note's content, as on disk.
+    fn load_text(&mut self, text: String) {
+        self.blocks = cce_vault::markdown::blocks(&text);
+        self.editor.text = text.clone();
+        self.editor.edit_buffer = text.clone();
+        self.editor.cursor_idx = 0;
+        self.editor.select_anchor = None;
+        self.edit_seen = text.clone();
+        self.saved = text;
+        self.conflict = false;
+        self.invalidate();
+    }
+
+    /// Write the source buffer if it differs from disk.
+    fn save(&mut self) -> bool {
+        self.save_now(false)
+    }
+
+    /// [`save`](Self::save), or with `force` write even an unchanged buffer.
+    fn save_now(&mut self, force: bool) -> bool {
+        if !self.dirty() && !(force && self.mode == Mode::Source) {
+            return true;
+        }
+        let text = self.editor_text().to_string();
+        let (Some(cur), Some(ix)) = (self.current.clone(), self.index.as_mut()) else { return false };
+        match ix.write_text(&cur, &text) {
+            Ok(()) => {
+                self.saved = text;
+                self.conflict = false;
+                true
+            }
+            Err(e) => {
+                self.set_status(format!("Could not save {cur}: {e}"), true);
+                false
+            }
+        }
+    }
+
+    /// Open a vault path, recording it in the history unless walking it.
+    fn open_path(&mut self, path: &str, line: Option<usize>, record: bool) {
+        let Some(ix) = &self.index else { return };
+        let kind = ix.entry(path).map(|e| e.kind);
+        match kind {
+            Some(FileKind::Note) | None => {}
+            Some(_) => {
+                // Canvases and attachments open in their own apps.
+                let abs = ix.abs(path);
+                let _ = std::process::Command::new("xdg-open").arg(abs).spawn();
+                return;
+            }
+        }
+        if self.current.as_deref() != Some(path) {
+            if self.conflict {
+                self.set_status("Resolve the conflict first: Ctrl+S keeps yours, Ctrl+R loads the disk copy", true);
+                return;
+            }
+            if !self.save() {
+                return;
+            }
+        }
+        let Some(ix) = &self.index else { return };
+        let text = match ix.read_text(path) {
+            Ok(t) => t,
+            Err(e) => {
+                self.set_status(format!("Could not read {path}: {e}"), true);
+                return;
+            }
+        };
+        let same = self.current.as_deref() == Some(path);
+        self.current = Some(path.to_string());
+        self.load_text(text);
+        if !same {
+            self.read_scroll = 0.0;
+            self.read_motion = ScrollMotion::new();
+        }
+        self.pending_line = line;
+        self.tree.reveal(path);
+        self.rebuild_rows();
+        if record {
+            self.history.truncate(self.hist_pos + 1);
+            if self.history.last().map(String::as_str) != Some(path) {
+                self.history.push(path.to_string());
+            }
+            self.hist_pos = self.history.len() - 1;
+        }
+        self.status = None;
+    }
+
+    /// Open what a command or argv names: an absolute file in the vault,
+    /// a vault path, or a note name; with an optional `#heading`.
+    fn open_target(&mut self, target: &str, line: Option<usize>) {
+        let Some(ix) = &self.index else { return };
+        let (name, sub) = match target.split_once('#') {
+            Some((n, s)) => (n, Some(s)),
+            None => (target, None),
+        };
+        let path = if Path::new(name).is_absolute() {
+            let p = Path::new(name);
+            p.canonicalize().ok().and_then(|c| ix.rel(&c)).or_else(|| ix.rel(p))
+        } else {
+            ix.lookup(name)
+        };
+        match path {
+            Some(p) => {
+                let line = line.map(|l| l.saturating_sub(1)).or_else(|| sub.and_then(|s| self.subpath_line(&p, s)));
+                self.open_path(&p, line, true);
+            }
+            None => self.set_status(format!("No note named {name}"), true),
+        }
+    }
+
+    /// The source line of `#Heading` or `#^block` in a note.
+    fn subpath_line(&self, path: &str, sub: &str) -> Option<usize> {
+        let note = self.index.as_ref()?.note(path)?;
+        if let Some(id) = sub.strip_prefix('^') {
+            return note.blocks.iter().find(|b| b.id == id).map(|b| b.line);
+        }
+        let want = sub.trim().to_lowercase();
+        note.headings.iter().find(|h| h.text.trim().to_lowercase() == want).map(|h| h.line)
+    }
+
+    fn follow(&mut self, link: SpanLink) {
+        match link {
+            SpanLink::Note { target, subpath } | SpanLink::Embed { target, subpath } => {
+                let Some(ix) = &self.index else { return };
+                if target.is_empty() {
+                    // `[[#Heading]]`: within this note.
+                    if let (Some(cur), Some(s)) = (self.current.clone(), subpath) {
+                        self.pending_line = self.subpath_line(&cur, &s);
+                    }
+                    return;
+                }
+                match ix.resolve_text(self.current.as_deref(), &target) {
+                    Some(p) => {
+                        let line = subpath.and_then(|s| self.subpath_line(&p, &s));
+                        self.open_path(&p, line, true);
+                    }
+                    None => self.create_and_open(&target),
+                }
+            }
+            SpanLink::Url(url) => {
+                let _ = std::process::Command::new("xdg-open").arg(url).spawn();
+            }
+            // Tag search comes with the search pane.
+            SpanLink::Tag(_) => {}
+        }
+    }
+
+    /// Create a note for a link or switcher query with no target, and open
+    /// it in source mode, as Obsidian does on a click on an unresolved link.
+    fn create_and_open(&mut self, name: &str) {
+        let Some(ix) = self.index.as_mut() else { return };
+        let name = name.trim().trim_start_matches('/');
+        let path = if name.to_lowercase().ends_with(".md") { name.to_string() } else { format!("{name}.md") };
+        match ix.create(&path, "") {
+            Ok(rel) => {
+                self.open_path(&rel, None, true);
+                self.set_mode(Mode::Source);
+            }
+            Err(e) => self.set_status(format!("Could not create {path}: {e}"), true),
+        }
+    }
+
+    fn toggle_task(&mut self, line: usize, status: char) {
+        let (Some(cur), Some(ix)) = (self.current.clone(), self.index.as_mut()) else { return };
+        let next = if status == ' ' { 'x' } else { ' ' };
+        match ix.set_task(&cur, line, next).and_then(|_| ix.read_text(&cur).map_err(Into::into)) {
+            Ok(text) => {
+                let scroll = self.read_scroll;
+                self.load_text(text);
+                self.read_scroll = scroll;
+            }
+            Err(e) => self.set_status(format!("Could not update the task: {e}"), true),
+        }
+    }
+
+    fn set_mode(&mut self, mode: Mode) {
+        if mode == self.mode || self.current.is_none() {
+            return;
+        }
+        if mode == Mode::Reading {
+            if !self.save() {
+                return;
+            }
+            let text = self.editor_text().to_string();
+            self.blocks = cce_vault::markdown::blocks(&text);
+            self.editor.unfocus();
+            self.invalidate();
+        } else {
+            self.ui_context.set_focused(&mut self.editor);
+            WidgetHost::focus(&mut self.editor);
+        }
+        self.mode = mode;
+        self.needs_rebuild = true;
+    }
+
+    fn go_history(&mut self, delta: isize) {
+        let next = self.hist_pos as isize + delta;
+        if next < 0 || next as usize >= self.history.len() {
+            return;
+        }
+        let path = self.history[next as usize].clone();
+        if self.index.as_ref().is_some_and(|ix| ix.entry(&path).is_some()) {
+            self.hist_pos = next as usize;
+            self.open_path(&path, None, false);
+        } else {
+            // Deleted since: drop it and keep walking.
+            self.history.remove(next as usize);
+            if (next as usize) < self.hist_pos {
+                self.hist_pos -= 1;
+            }
+            self.go_history(delta);
+        }
+    }
+
+    fn open_daily(&mut self, date: Option<chrono::NaiveDate>) {
+        let Some(ix) = self.index.as_mut() else { return };
+        let date = date.unwrap_or_else(|| chrono::Local::now().date_naive());
+        match ix.daily(date, true) {
+            Ok((path, _created)) => self.open_path(&path, None, true),
+            Err(e) => self.set_status(format!("Could not open the daily note: {e}"), true),
+        }
+    }
+
+    fn run_command(&mut self, cmd: Command) {
+        match cmd {
+            Command::Open { target, line } => self.open_target(&target, line),
+            Command::Daily(d) => self.open_daily(d),
+            Command::Search(q) => self.open_switcher(&q),
+            Command::Show => {}
+        }
+        self.needs_rebuild = true;
+    }
+
+    // ---- vault changes -------------------------------------------------
+
+    fn vault_changed(&mut self, paths: Vec<PathBuf>) {
+        let Some(ix) = self.index.as_mut() else { return };
+        ix.apply_changes(&paths);
+        self.rebuild_rows();
+        // Link colours depend on what resolves.
+        self.layout = None;
+        let Some(cur) = self.current.clone() else { return };
+        let Some(ix) = self.index.as_ref() else { return };
+        let abs = ix.abs(&cur);
+        if !paths.iter().any(|p| p == &abs || p.canonicalize().ok().as_deref() == abs.canonicalize().ok().as_deref()) {
+            return;
+        }
+        match ix.read_text(&cur) {
+            Ok(disk) if disk == self.saved => {}
+            Ok(disk) => {
+                if self.dirty() {
+                    self.conflict = true;
+                } else {
+                    let scroll = self.read_scroll;
+                    self.load_text(disk);
+                    self.read_scroll = scroll;
+                }
+            }
+            Err(_) => self.set_status(format!("{cur} was deleted or moved on disk"), true),
+        }
+    }
+
+    // ---- quick switcher -------------------------------------------------
+
+    fn open_switcher(&mut self, query: &str) {
+        self.switcher_input.text = query.to_string();
+        self.switcher_input.edit_buffer = query.to_string();
+        self.switcher_input.cursor_idx = query.chars().count();
+        self.switcher_input.select_anchor = None;
+        self.switcher = Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into() });
+        self.ui_context.set_focused(&mut self.switcher_input);
+        WidgetHost::focus(&mut self.switcher_input);
+        self.refresh_switcher();
+    }
+
+    fn close_switcher(&mut self) {
+        self.switcher = None;
+        self.switcher_input.unfocus();
+        if self.mode == Mode::Source {
+            self.ui_context.set_focused(&mut self.editor);
+            WidgetHost::focus(&mut self.editor);
+        }
+        self.needs_rebuild = true;
+    }
+
+    fn switcher_query(&self) -> String {
+        if self.switcher_input.editing {
+            self.switcher_input.edit_buffer.clone()
+        } else {
+            self.switcher_input.text.clone()
+        }
+    }
+
+    fn refresh_switcher(&mut self) {
+        let query = self.switcher_query();
+        let Some(ix) = &self.index else { return };
+        let Some(sw) = self.switcher.as_mut() else { return };
+        if sw.query == query {
+            return;
+        }
+        sw.choices = if query.trim().is_empty() {
+            // Recent first: the history, then the newest files.
+            let mut seen = std::collections::HashSet::new();
+            let mut out = Vec::new();
+            for p in self.history.iter().rev() {
+                if ix.entry(p).is_some() && seen.insert(p.clone()) {
+                    out.push(p.clone());
+                }
+            }
+            let mut notes: Vec<(&String, u64)> =
+                ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, e)| (p, e.mtime)).collect();
+            notes.sort_by(|a, b| b.1.cmp(&a.1));
+            for (p, _) in notes {
+                if seen.insert(p.clone()) {
+                    out.push(p.clone());
+                }
+            }
+            out.into_iter()
+                .take(SWITCHER_ROWS)
+                .map(|p| Choice::Note { shown: p.clone(), path: p })
+                .collect()
+        } else {
+            let mut c: Vec<Choice> = ix
+                .find(&query, SWITCHER_ROWS)
+                .into_iter()
+                .filter(|m| ix.entry(&m.path).is_some_and(|e| e.kind == FileKind::Note))
+                .map(|m| {
+                    let shown = if m.path.trim_end_matches(".md").ends_with(m.matched.as_str()) {
+                        m.path.clone()
+                    } else {
+                        format!("{}  ← {}", m.path, m.matched)
+                    };
+                    Choice::Note { path: m.path, shown }
+                })
+                .collect();
+            if ix.lookup(&query).is_none() {
+                c.push(Choice::Create(query.trim().to_string()));
+            }
+            c
+        };
+        sw.selected = 0;
+        sw.query = query;
+        self.needs_rebuild = true;
+    }
+
+    fn switcher_pick(&mut self, create: bool) {
+        let Some(sw) = &self.switcher else { return };
+        let query = self.switcher_query();
+        let choice = if create { Some(Choice::Create(query.trim().to_string())) } else { sw.choices.get(sw.selected).cloned() };
+        self.close_switcher();
+        match choice {
+            Some(Choice::Note { path, .. }) => self.open_path(&path, None, true),
+            Some(Choice::Create(name)) if !name.is_empty() => self.create_and_open(&name),
+            _ => {}
+        }
+    }
+
+    fn switcher_rect(&self) -> Rect {
+        let w = SWITCHER_W.min(self.width as f32 - 32.0);
+        let n = self.switcher.as_ref().map_or(0, |s| s.choices.len());
+        Rect {
+            x: (self.width as f32 - w) / 2.0,
+            y: BAND_H + 24.0,
+            width: w,
+            height: 12.0 + 32.0 + 8.0 + n as f32 * SWITCHER_ROW_H + 6.0,
+        }
+    }
+
+    fn switcher_row_at(&self, x: f32, y: f32) -> Option<usize> {
+        let r = self.switcher_rect();
+        let top = r.y + 12.0 + 32.0 + 8.0;
+        if x < r.x || x > r.x + r.width || y < top {
+            return None;
+        }
+        let i = ((y - top) / SWITCHER_ROW_H) as usize;
+        (i < self.switcher.as_ref()?.choices.len()).then_some(i)
+    }
+
+    // ---- layout and scrolling -------------------------------------------
+
+    fn ensure_layout(&mut self, width: f32) {
+        if matches!(&self.layout, Some((w, _)) if (w - width).abs() < 0.5) {
+            return;
+        }
+        let fs = self.measure_fs.get_or_insert_with(cce_ui::create_font_system_with_system_fonts);
+        let mut m = RendererMeasure { fs, scale: self.scale as f32, cache: &mut self.widths };
+        let index = &self.index;
+        let cur = self.current.as_deref();
+        let resolved = |l: &SpanLink| match l {
+            SpanLink::Note { target, .. } | SpanLink::Embed { target, .. } => {
+                target.is_empty() || index.as_ref().is_some_and(|ix| ix.resolve_text(cur, target).is_some())
+            }
+            _ => true,
+        };
+        let laid = reading::layout(&self.blocks, width, &self.theme, &mut m, &resolved);
+        self.layout = Some((width, laid));
+    }
+
+    fn read_max_scroll(&self, m: &Metrics) -> f32 {
+        let h = self.layout.as_ref().map_or(0.0, |(_, l)| l.height);
+        (h + 2.0 * READ_PAD - m.note.height).max(0.0)
+    }
+
+    fn tree_max_scroll(&self, m: &Metrics) -> f32 {
+        (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree.height).max(0.0)
+    }
+
+    fn tree_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
+        if x < m.tree.x || x > m.tree.x + m.tree.width || y < m.tree.y || y > m.tree.y + m.tree.height {
+            return None;
+        }
+        let i = ((y - m.tree.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
+        (i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
+    }
+
+    /// The reading view's click target under a window point.
+    fn reading_hit(&self, x: f32, y: f32) -> Option<Hit> {
+        let m = self.metrics();
+        if self.mode != Mode::Reading || self.switcher.is_some() || !contains(m.note, x, y) {
+            return None;
+        }
+        let (ox, oy, _) = self.reading_frame(&m);
+        let (_, l) = self.layout.as_ref()?;
+        l.hit(x - ox, y - oy + self.read_scroll).cloned()
+    }
+
+    /// In source mode, the link under the editor's caret (after a click).
+    fn link_at_caret(&self) -> Option<SpanLink> {
+        let text = self.editor_text();
+        let byte = text.char_indices().nth(self.editor.cursor_idx).map_or(text.len(), |(b, _)| b);
+        let note = cce_vault::parse::parse(text);
+        let link = note.links.iter().find(|l| l.span.start <= byte && byte < l.span.end)?;
+        let url = link.target.contains("://") || link.target.starts_with("mailto:");
+        Some(if url {
+            SpanLink::Url(link.target.clone())
+        } else {
+            SpanLink::Note { target: link.target.clone(), subpath: link.subpath.clone() }
+        })
+    }
+
+    // ---- painting --------------------------------------------------------
+
+    fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
+        let w = self.width as f32;
+        pc.recess_edges(
+            Rect { x: 0.0, y: 0.0, width: w, height: BAND_H },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (false, false, true, false),
+        );
+        let (family, size) = cce_ui::layout::menubar_font_parsed();
+        let inset = cce_ui::layout::root_plate_inset();
+        let ty = cce_ui::layout::align_text_y(0.0, BAND_H, size, 0.0);
+        let right = m.mode_chip.x - 12.0;
+        match &self.current {
+            Some(cur) => {
+                let (dir, file) = match cur.rsplit_once('/') {
+                    Some((d, f)) => (format!("{d} / "), f),
+                    None => (String::new(), cur.as_str()),
+                };
+                let name = file.strip_suffix(".md").unwrap_or(file);
+                let dirty = if self.dirty() { " •" } else { "" };
+                let bounds = Some([inset, 0.0, right, BAND_H]);
+                let dir_w = dir.chars().count() as f32 * size * 0.6;
+                pc.text_with(dir, inset, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
+                pc.text_with(format!("{name}{dirty}"), inset + dir_w, ty, size, srgb_u8(FG), Some(family.clone()), bounds);
+            }
+            None => {
+                let vault = self.index.as_ref().map(|ix| vault_name(ix.root())).unwrap_or_else(|| "Notes".into());
+                pc.text_with(vault, inset, ty, size, srgb_u8(FG), Some(family.clone()), None);
+            }
+        }
+        if self.current.is_some() {
+            let label = match self.mode {
+                Mode::Reading => "Reading",
+                Mode::Source => "Source",
+            };
+            let c = m.mode_chip;
+            pc.rounded_rect(c, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.06]);
+            let lw = label.len() as f32 * size * 0.6;
+            pc.text_with(
+                label.to_string(),
+                c.x + (c.width - lw) / 2.0,
+                cce_ui::layout::align_text_y(c.y, c.height, size, 0.0),
+                size,
+                srgb_u8(DIM),
+                Some(family),
+                Some([c.x, c.y, c.x + c.width, c.y + c.height]),
+            );
+        }
+    }
+
+    fn paint_status(&self, pc: &mut PaintCtx) {
+        let (w, h) = (self.width as f32, self.height as f32);
+        let y = h - STATUS_H;
+        pc.recess_edges(
+            Rect { x: 0.0, y, width: w, height: STATUS_H },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (true, false, false, false),
+        );
+        let (family, size) = cce_ui::layout::statusbar_font_parsed();
+        let inset = cce_ui::layout::root_plate_inset();
+        let ty = cce_ui::layout::align_text_y(y, STATUS_H, size, 0.0);
+        let (msg, color) = if self.conflict {
+            ("Changed on disk · Ctrl+S keeps yours · Ctrl+R reloads".to_string(), CONFLICT)
+        } else if let Some((msg, err)) = &self.status {
+            (msg.clone(), if *err { CONFLICT } else { DIM })
+        } else if let (Some(cur), Some(ix)) = (&self.current, &self.index) {
+            let text = if self.mode == Mode::Source { self.editor_text() } else { self.saved.as_str() };
+            let words = text.split_whitespace().count();
+            let back = ix.backlinks(cur).len();
+            (format!("{} · {}", plural(words, "word"), plural(back, "backlink")), DIM)
+        } else {
+            (String::new(), DIM)
+        };
+        pc.text_with(msg, inset, ty, size, srgb_u8(color), Some(family.clone()), Some([inset, y, w * 0.75, h]));
+        if let Some(ix) = &self.index {
+            let name = vault_name(ix.root());
+            let nw = name.chars().count() as f32 * size * 0.6;
+            pc.text_with(name, w - inset - nw, ty, size, srgb_u8(DIM), Some(family), None);
+        }
+    }
+
+    fn paint_tree(&self, pc: &mut PaintCtx, m: &Metrics) {
+        if m.tree.width <= 0.0 {
+            return;
+        }
+        let t = m.tree;
+        pc.recess_edges(t, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
+        let (family, size) = cce_ui::layout::tree_font_parsed();
+        pc.clip(t, |pc| {
+            for (i, row) in self.rows.iter().enumerate() {
+                let y = t.y + 4.0 + i as f32 * TREE_ROW_H - self.tree_scroll;
+                if y + TREE_ROW_H < t.y || y > t.y + t.height {
+                    continue;
+                }
+                let r = Rect { x: t.x + 4.0, y, width: t.width - 10.0, height: TREE_ROW_H };
+                if !row.folder && self.current.as_deref() == Some(row.path.as_str()) {
+                    pc.rounded_rect(r, 4.0, (true, true, true, true), ROW_CURRENT);
+                } else if self.tree_hover == Some(i) {
+                    pc.rounded_rect(r, 4.0, (true, true, true, true), ROW_HOVER);
+                }
+                let x = r.x + 8.0 + row.depth as f32 * 14.0;
+                let ty = cce_ui::layout::align_text_y(r.y, r.height, size, 0.0);
+                let (label, color) = if row.folder {
+                    (format!("{} {}", if row.open { "▾" } else { "▸" }, row.name), DIM)
+                } else {
+                    (format!("  {}", row.name), FG)
+                };
+                pc.text_with(
+                    label,
+                    x,
+                    ty,
+                    size,
+                    srgb_u8(color),
+                    Some(family.clone()),
+                    Some([r.x, r.y.max(t.y), r.x + r.width, (r.y + r.height).min(t.y + t.height)]),
+                );
+            }
+        });
+    }
+
+    fn paint_note(&mut self, pc: &mut PaintCtx, m: &Metrics) {
+        if self.switcher.is_some() {
+            // The switcher covers the note; text cannot be hidden under a
+            // plate, so the note is not painted at all while it is open.
+            return;
+        }
+        if self.index.is_none() {
+            let msg = self.vault_error.clone().unwrap_or_default();
+            let (family, size) = cce_ui::layout::list_font_parsed();
+            let r = m.note;
+            for (i, line) in msg.lines().enumerate() {
+                pc.text_with(
+                    line.to_string(),
+                    r.x + READ_PAD,
+                    r.y + READ_PAD + i as f32 * size * 1.6,
+                    size,
+                    srgb_u8(if i == 0 { FG } else { DIM }),
+                    Some(family.clone()),
+                    None,
+                );
+            }
+            return;
+        }
+        if self.current.is_none() {
+            let (family, size) = cce_ui::layout::list_font_parsed();
+            let hint = "Ctrl+O to open a note · Alt+D for today's note";
+            let hw = hint.chars().count() as f32 * size * 0.6;
+            pc.text_with(
+                hint,
+                m.note.x + (m.note.width - hw) / 2.0,
+                m.note.y + m.note.height / 2.0,
+                size,
+                srgb_u8(DIM),
+                Some(family),
+                None,
+            );
+            return;
+        }
+        match self.mode {
+            Mode::Source => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
+            Mode::Reading => {
+                let (ox, oy, width) = self.reading_frame(m);
+                self.ensure_layout(width);
+                if let Some(line) = self.pending_line.take() {
+                    let max = self.read_max_scroll(m);
+                    let y = self.layout.as_ref().map_or(0.0, |(_, l)| l.y_of_line(line));
+                    self.read_scroll = y.clamp(0.0, max);
+                    self.read_motion.y.jump_to(self.read_scroll);
+                }
+                let note = m.note;
+                if let Some((_, l)) = &self.layout {
+                    pc.clip(note, |pc| l.paint(pc, (ox, oy), self.read_scroll, note));
+                }
+            }
+        }
+    }
+
+    fn paint_switcher(&self, pc: &mut PaintCtx) {
+        let Some(sw) = &self.switcher else { return };
+        let r = self.switcher_rect();
+        pc.rounded_rect(r, 10.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_BG);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.switcher_input, pc);
+        let (family, size) = cce_ui::layout::list_font_parsed();
+        let top = r.y + 12.0 + 32.0 + 8.0;
+        for (i, c) in sw.choices.iter().enumerate() {
+            let row = Rect { x: r.x + 6.0, y: top + i as f32 * SWITCHER_ROW_H, width: r.width - 12.0, height: SWITCHER_ROW_H };
+            if i == sw.selected {
+                pc.rounded_rect(row, 5.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
+            }
+            let (label, color) = match c {
+                Choice::Note { shown, .. } => (shown.trim_end_matches(".md").to_string(), FG),
+                Choice::Create(name) => (format!("Create “{name}”"), DIM),
+            };
+            pc.text_with(
+                label,
+                row.x + 10.0,
+                cce_ui::layout::align_text_y(row.y, row.height, size, 0.0),
+                size,
+                srgb_u8(color),
+                Some(family.clone()),
+                Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
+            );
+        }
+    }
+}
+
+fn contains(r: Rect, x: f32, y: f32) -> bool {
+    x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
+}
+
+fn plural(n: usize, what: &str) -> String {
+    format!("{n} {what}{}", if n == 1 { "" } else { "s" })
+}
+
+fn vault_name(root: &Path) -> String {
+    root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| root.display().to_string())
+}
+
+impl Application for NotesApp {
+    type Message = Message;
+
+    fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+        Some(&self.ui_context)
+    }
+
+    fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
+        Some(&mut self.ui_context)
+    }
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        let startup = STARTUP.get().expect("startup set in main");
+        let (index, vault_error, watcher) = match &startup.vault {
+            Ok(root) => match Index::open(root, true) {
+                Ok(mut ix) => {
+                    if let Err(e) = ix.save_cache() {
+                        log::warn!("vault cache: {e}");
+                    }
+                    let tx = sender.clone();
+                    let watcher = VaultWatcher::spawn(root, move |paths| {
+                        let _ = tx.send(Message::VaultChanged(paths));
+                    })
+                    .map_err(|e| log::warn!("vault watcher: {e}"))
+                    .ok();
+                    (Some(ix), None, watcher)
+                }
+                Err(e) => (None, Some(format!("Could not read the vault at {}\n{e}", root.display())), None),
+            },
+            Err(e) => (None, Some(e.clone()), None),
+        };
+        instance::spawn_listener(sender.clone());
+        let _ = sender.send(Message::Command(startup.command.clone()));
+
+        let mut editor = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(true).with_max_width(None);
+        editor.font_family = "monospace".to_string();
+        editor.font_size = 13.0;
+        let switcher_input = TextBox::new(String::new()).with_placeholder("Find or create a note…");
+
+        let mut app = NotesApp {
+            keys: Keys::load(),
+            index,
+            vault_error,
+            _watcher: watcher,
+            tree: tree::Tree::default(),
+            rows: Vec::new(),
+            show_tree: true,
+            tree_scroll: 0.0,
+            tree_motion: ScrollMotion::new(),
+            tree_hover: None,
+            current: None,
+            saved: String::new(),
+            blocks: Vec::new(),
+            theme: Theme { body_font: "sans-serif".into(), mono_font: "monospace".into(), size: READ_SIZE },
+            layout: None,
+            widths: HashMap::new(),
+            measure_fs: None,
+            mode: Mode::Reading,
+            read_scroll: 0.0,
+            read_motion: ScrollMotion::new(),
+            pending_line: None,
+            hover_hit: false,
+            editor,
+            edit_seen: String::new(),
+            edit_changed_at: Instant::now(),
+            history: Vec::new(),
+            hist_pos: 0,
+            switcher: None,
+            switcher_input,
+            conflict: false,
+            status: None,
+            width: 1000,
+            height: 700,
+            scale: 1.0,
+            needs_rebuild: true,
+            ui_context: cce_ui::context::UiContext::new(),
+            widgets_registered: false,
+            pointer: (0.0, 0.0),
+        };
+        app.rebuild_rows();
+        app
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Notes".to_string(),
+            app_id: "cce-notes".to_string(),
+            width: 1000,
+            height: 700,
+            fullscreen: false,
+            min_size: Some((480, 320)),
+        }
+    }
+
+    fn update(&mut self, msg: Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        match msg {
+            Message::Command(cmd) => self.run_command(cmd),
+            Message::VaultChanged(paths) => self.vault_changed(paths),
+            Message::Exit => *_exit = true,
+        }
+        *needs_rebuild = true;
+    }
+
+    fn idle_poll_interval(&self) -> Option<Duration> {
+        // Wakes the autosave check while source mode holds unsaved text.
+        self.dirty().then_some(Duration::from_millis(500))
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.ui_context.tick(dt) {
+            *needs_rebuild = true;
+        }
+        let m = self.metrics();
+        if self.read_motion.is_animating() {
+            let max = self.read_max_scroll(&m);
+            self.read_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+            self.read_scroll = self.read_motion.y.pos();
+            *needs_rebuild = true;
+        }
+        if self.tree_motion.is_animating() {
+            let max = self.tree_max_scroll(&m);
+            self.tree_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+            self.tree_scroll = self.tree_motion.y.pos();
+            *needs_rebuild = true;
+        }
+        if self.dirty() && !self.conflict {
+            let now = Instant::now();
+            if self.editor_text() != self.edit_seen {
+                self.edit_seen = self.editor_text().to_string();
+                self.edit_changed_at = now;
+            } else if now.duration_since(self.edit_changed_at) >= AUTOSAVE_AFTER {
+                self.save();
+                *needs_rebuild = true;
+            }
+        }
+    }
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        if !self.widgets_registered {
+            self.widgets_registered = true;
+            let (id, ptr) = (self.editor.id(), self.editor.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.switcher_input.id(), self.switcher_input.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+        }
+        let size_changed =
+            self.width != size.width as u32 || self.height != size.height as u32 || self.scale != scale;
+        if size_changed {
+            if self.scale != scale {
+                self.widths.clear();
+                self.layout = None;
+            }
+            self.width = size.width as u32;
+            self.height = size.height as u32;
+            self.scale = scale;
+            cce_ui::scale::set_scale_factor(scale as f32);
+        }
+        let m = self.metrics();
+        if self.needs_rebuild || size_changed {
+            let e = self.editor_rect(&m);
+            self.editor.set_rect(e.x, e.y, e.width, e.height);
+            let r = self.switcher_rect();
+            self.switcher_input.set_rect(r.x + 12.0, r.y + 12.0, r.width - 24.0, 32.0);
+            self.needs_rebuild = false;
+            self.ui_context.rebuild_spatial_grid();
+            // Keep scroll offsets in range after a resize or a new layout.
+            self.tree_scroll = self.tree_scroll.min(self.tree_max_scroll(&m));
+        }
+
+        let (w, h) = (self.width as f32, self.height as f32);
+        let mut pc = PaintCtx::new();
+        pc.root_plate(w, h);
+        self.paint_band(&mut pc, &m);
+        if self.switcher.is_none() {
+            self.paint_tree(&mut pc, &m);
+        }
+        self.paint_note(&mut pc, &m);
+        let max = self.read_max_scroll(&m);
+        if self.read_scroll > max {
+            self.read_scroll = max;
+            self.read_motion.y.jump_to(max);
+        }
+        self.paint_status(&mut pc);
+        self.paint_switcher(&mut pc);
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    /// The reading view needs bold and italic faces of the DE's sans
+    /// (Noto Sans by default), which the toolkit's bundle does not carry:
+    /// without system fonts, `**bold**` fell back to a serif face. The
+    /// measuring FontSystem loads the same set ([`NotesApp::measure_fs`]).
+    fn load_system_fonts(&self) -> bool {
+        true
+    }
+
+    fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
+        if self.hover_hit || self.tree_hover.is_some() {
+            return Some(CursorIcon::Pointer);
+        }
+        let m = self.metrics();
+        (self.mode == Mode::Source && self.switcher.is_none() && contains(self.editor_rect(&m), x, y))
+            .then_some(CursorIcon::Text)
+    }
+
+    fn on_exit(&mut self) {
+        if !self.conflict {
+            self.save();
+        }
+        if let Some(ix) = self.index.as_mut() {
+            let _ = ix.save_cache();
+        }
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (x, y) = (pos.x as f32, pos.y as f32);
+        self.pointer = (x, y);
+        let ev = Event::PointerMove { x, y, local_x: x, local_y: y };
+        if self.switcher.is_some() {
+            let row = self.switcher_row_at(x, y);
+            if let (Some(sw), Some(i)) = (self.switcher.as_mut(), row) {
+                if sw.selected != i {
+                    sw.selected = i;
+                    *needs_rebuild = true;
+                }
+            }
+            if self.ui_context.propagate_event(&ev, self.switcher_input.id()) {
+                *needs_rebuild = true;
+            }
+            return;
+        }
+        let m = self.metrics();
+        let hover = self.tree_row_at(&m, x, y);
+        if hover != self.tree_hover {
+            self.tree_hover = hover;
+            *needs_rebuild = true;
+        }
+        let hit = self.reading_hit(x, y).is_some();
+        if hit != self.hover_hit {
+            self.hover_hit = hit;
+            *needs_rebuild = true;
+        }
+        if self.mode == Mode::Source && self.ui_context.propagate_event(&ev, self.editor.id()) {
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Message> {
+        let (x, y) = (pos.x as f32, pos.y as f32);
+        let ev = Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
+        *needs_rebuild = true;
+        let pressed = state == ElementState::Pressed && button == MouseButton::Left;
+
+        if self.switcher.is_some() {
+            if pressed {
+                if let Some(i) = self.switcher_row_at(x, y) {
+                    if let Some(sw) = self.switcher.as_mut() {
+                        sw.selected = i;
+                    }
+                    self.switcher_pick(false);
+                    return None;
+                }
+                if !contains(self.switcher_rect(), x, y) {
+                    self.close_switcher();
+                    return None;
+                }
+            }
+            self.ui_context.propagate_event(&ev, self.switcher_input.id());
+            return None;
+        }
+
+        let m = self.metrics();
+        if pressed && contains(m.mode_chip, x, y) && self.current.is_some() {
+            let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
+            self.set_mode(next);
+            return None;
+        }
+        if pressed {
+            if let Some(i) = self.tree_row_at(&m, x, y) {
+                let row = self.rows[i].clone();
+                if row.folder {
+                    self.tree.toggle(&row.path);
+                    self.rebuild_rows();
+                } else {
+                    self.open_path(&row.path, None, true);
+                }
+                return None;
+            }
+            if let Some(hit) = self.reading_hit(x, y) {
+                match hit {
+                    Hit::Link(link) => self.follow(link),
+                    Hit::Task { line, status } => self.toggle_task(line, status),
+                }
+                self.hover_hit = false;
+                return None;
+            }
+        }
+        // Back/forward mouse buttons walk the history, as in a browser.
+        if state == ElementState::Pressed {
+            match button {
+                MouseButton::Back => {
+                    self.go_history(-1);
+                    return None;
+                }
+                MouseButton::Forward => {
+                    self.go_history(1);
+                    return None;
+                }
+                _ => {}
+            }
+        }
+        if self.mode == Mode::Source {
+            let ctrl_click = pressed && self.ui_context.ctrl_pressed;
+            self.ui_context.propagate_event(&ev, self.editor.id());
+            if ctrl_click && contains(self.editor_rect(&m), x, y) {
+                if let Some(link) = self.link_at_caret() {
+                    self.follow(link);
+                }
+            }
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (x, y) = (pos.x as f32, pos.y as f32);
+        if self.switcher.is_some() {
+            return;
+        }
+        let m = self.metrics();
+        if contains(m.tree, x, y) {
+            let max = self.tree_max_scroll(&m);
+            self.tree_motion.reconcile(0.0, self.tree_scroll);
+            if self.tree_motion.apply(delta, (TREE_ROW_H, TREE_ROW_H), Bounds::max(0.0), Bounds::max(max)) {
+                self.tree_scroll = self.tree_motion.y.pos();
+                *needs_rebuild = true;
+            }
+            return;
+        }
+        if !contains(m.note, x, y) {
+            return;
+        }
+        match self.mode {
+            Mode::Reading => {
+                let max = self.read_max_scroll(&m);
+                self.read_motion.reconcile(0.0, self.read_scroll);
+                let line = READ_SIZE * 1.5;
+                if self.read_motion.apply(delta, (line, line * 2.0), Bounds::max(0.0), Bounds::max(max)) {
+                    self.read_scroll = self.read_motion.y.pos();
+                    *needs_rebuild = true;
+                }
+            }
+            Mode::Source => {
+                let ev = Event::MouseWheel { delta: *delta, x, y, local_x: x, local_y: y };
+                if self.ui_context.propagate_event(&ev, self.editor.id()) {
+                    *needs_rebuild = true;
+                }
+            }
+        }
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
+        *needs_rebuild = true;
+        let pressed = event.state == ElementState::Pressed;
+        let ev = Event::KeyInput(event.clone());
+
+        if self.switcher.is_some() {
+            if pressed {
+                match &event.logical_key {
+                    Key::Named(NamedKey::Escape) => {
+                        self.close_switcher();
+                        return None;
+                    }
+                    Key::Named(NamedKey::Enter) => {
+                        self.switcher_pick(event.shift);
+                        return None;
+                    }
+                    Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::ArrowUp) => {
+                        if let Some(sw) = self.switcher.as_mut() {
+                            let n = sw.choices.len();
+                            if n > 0 {
+                                let down = matches!(event.logical_key, Key::Named(NamedKey::ArrowDown));
+                                sw.selected = if down { (sw.selected + 1) % n } else { (sw.selected + n - 1) % n };
+                            }
+                        }
+                        return None;
+                    }
+                    _ => {}
+                }
+            }
+            self.ui_context.propagate_event(&ev, self.switcher_input.id());
+            self.refresh_switcher();
+            return None;
+        }
+
+        if pressed {
+            let k = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
+            if k(&self.keys.quit) {
+                return Some(Message::Exit);
+            }
+            if k(&self.keys.switcher) && self.index.is_some() {
+                self.open_switcher("");
+                return None;
+            }
+            if k(&self.keys.toggle_mode) {
+                let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
+                self.set_mode(next);
+                return None;
+            }
+            if k(&self.keys.save) {
+                // In a conflict this is "keep mine": it writes over the disk copy.
+                self.save_now(self.conflict);
+                return None;
+            }
+            if k(&self.keys.reload) {
+                if let Some(cur) = self.current.clone() {
+                    let mode = self.mode;
+                    self.mode = Mode::Reading;
+                    self.open_path(&cur, None, false);
+                    self.mode = mode;
+                }
+                return None;
+            }
+            if k(&self.keys.back) {
+                self.go_history(-1);
+                return None;
+            }
+            if k(&self.keys.forward) {
+                self.go_history(1);
+                return None;
+            }
+            if k(&self.keys.toggle_tree) {
+                self.show_tree = !self.show_tree;
+                self.invalidate();
+                return None;
+            }
+            if k(&self.keys.daily) && self.index.is_some() {
+                self.open_daily(None);
+                return None;
+            }
+        }
+
+        match self.mode {
+            Mode::Source => {
+                self.ui_context.propagate_event(&ev, self.editor.id());
+            }
+            Mode::Reading if pressed => {
+                let m = self.metrics();
+                let max = self.read_max_scroll(&m);
+                let page = m.note.height * 0.9;
+                let target = match &event.logical_key {
+                    Key::Named(NamedKey::ArrowDown) => Some(self.read_scroll + READ_SIZE * 3.0),
+                    Key::Named(NamedKey::ArrowUp) => Some(self.read_scroll - READ_SIZE * 3.0),
+                    Key::Named(NamedKey::PageDown) | Key::Named(NamedKey::Space) => Some(self.read_scroll + page),
+                    Key::Named(NamedKey::PageUp) => Some(self.read_scroll - page),
+                    Key::Named(NamedKey::Home) => Some(0.0),
+                    Key::Named(NamedKey::End) => Some(max),
+                    _ => None,
+                };
+                if let Some(t) = target {
+                    let s = cce_ui::widget::scroll_motion::scroll_settings();
+                    self.read_motion.reconcile(0.0, self.read_scroll);
+                    self.read_motion.y.scroll_to(t, Bounds::max(max), &s);
+                }
+            }
+            Mode::Reading => {}
+        }
+        None
+    }
+}
+
+fn main() {
+    env_logger::init();
+    let mut args: Vec<String> = std::env::args().skip(1).collect();
+    let mut explicit: Option<PathBuf> = None;
+    if let Some(i) = args.iter().position(|a| a == "--vault") {
+        if i + 1 >= args.len() {
+            eprintln!("cce-notes: --vault needs a directory");
+            std::process::exit(2);
+        }
+        explicit = Some(PathBuf::from(args.remove(i + 1)));
+        args.remove(i);
+    }
+    let command = match Command::from_args(&args) {
+        Ok(c) => c,
+        Err(e) => {
+            eprintln!("cce-notes: {e}");
+            std::process::exit(2);
+        }
+    };
+    if instance::forward_or_claim(&command) {
+        return;
+    }
+    let vault = cce_vault::config::vault_root(explicit.as_deref()).map_err(|e| {
+        format!(
+            "No vault: {e}\nSet `vault {{ path \"~/Notes\" }}` in ~/.config/cce/config.kdl, \
+             or run with --vault <dir> or CCE_VAULT=<dir>."
+        )
+    });
+    let _ = STARTUP.set(Startup { vault, command });
+    cce_ui::engine::run::<NotesApp>();
+    instance::cleanup();
+}
diff --git a/src/reading.rs b/src/reading.rs
new file mode 100644
index 0000000..336811c
--- /dev/null
+++ b/src/reading.rs
@@ -0,0 +1,817 @@
+//! The reading view: a note's blocks (`cce_vault::markdown`) laid out at a
+//! width into draw items and click targets.
+//!
+//! cce-ui's text prim draws one run in one style, so a paragraph mixing
+//! bold, links and code is laid out here word by word: each word is
+//! measured through the same shaping entry the renderer draws with
+//! ([`Measure`]), wrapped greedily, and consecutive words of one style on
+//! one line merge back into a single prim. Layout runs once per note and
+//! width; painting only translates by the scroll and culls.
+//!
+//! This is the app-local first cut of the `MarkdownView` the proposal puts
+//! in cce-ui. It moves there once a second surface (note cards, graph
+//! previews) needs it — and cce-ui is free to take it.
+
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, PaintCtx, TextAttrs};
+use cce_vault::markdown::{Block, Callout, ListItem, Span, SpanLink, Style};
+
+/// Text widths in logical px, for one run in one style.
+pub trait Measure {
+    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32;
+}
+
+/// Fonts and the body size the layout works from.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Theme {
+    pub body_font: String,
+    pub mono_font: String,
+    pub size: f32,
+}
+
+impl Theme {
+    fn line_h(&self, size: f32) -> f32 {
+        (size * 1.5).ceil()
+    }
+}
+
+// Colours. TODO(style): the toolkit has no link or highlight colour yet;
+// these follow Obsidian's dark theme until it does. FG, DIM and the white
+// washes are already linear; the rest are written in sRGB (as a theme
+// states them) and go through `lin` where they are used.
+const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
+const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
+const LINK: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
+const LINK_UNRESOLVED: [f32; 4] = [0.50, 0.44, 0.70, 1.0];
+const TAG: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
+const TAG_BG: [f32; 4] = [0.66, 0.55, 0.98, 0.15];
+const CODE_BG: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
+const HIGHLIGHT_BG: [f32; 4] = [1.0, 0.82, 0.0, 0.40];
+const RULE: [f32; 4] = [1.0, 1.0, 1.0, 0.12];
+const QUOTE_BAR: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
+
+/// Indent of a list level, and of a quote's body past its bar.
+const INDENT: f32 = 24.0;
+const BLOCK_GAP: f32 = 10.0;
+const CODE_PAD: f32 = 10.0;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Hit {
+    Link(SpanLink),
+    /// A task's checkbox: its source line and current status.
+    Task { line: usize, status: char },
+}
+
+#[derive(Clone, Debug)]
+pub enum Draw {
+    Text { text: String, x: f32, y: f32, size: f32, color: [f32; 4], font: String, attrs: TextAttrs },
+    Quad { rect: Rect, color: [f32; 4] },
+    Round { rect: Rect, radius: f32, color: [f32; 4] },
+    Line { x1: f32, y1: f32, x2: f32, y2: f32, width: f32, color: [f32; 4] },
+    Check { cx: f32, cy: f32, r: f32, checked: bool },
+    /// A filled disc: a list bullet. Not a `Round` — at a few px the
+    /// squircle corner shape draws a small radius as a square.
+    Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
+}
+
+impl Draw {
+    fn top_bottom(&self) -> (f32, f32) {
+        match self {
+            Draw::Text { y, size, .. } => (*y, y + size * 1.3),
+            Draw::Quad { rect, .. } | Draw::Round { rect, .. } => (rect.y, rect.y + rect.height),
+            Draw::Line { y1, y2, width, .. } => (y1.min(*y2) - width, y1.max(*y2) + width),
+            Draw::Check { cy, r, .. } | Draw::Dot { cy, r, .. } => (cy - r, cy + r),
+        }
+    }
+}
+
+#[derive(Default, Debug)]
+pub struct Layout {
+    pub draws: Vec<Draw>,
+    pub hits: Vec<(Rect, Hit)>,
+    /// Each block's first source line and the y it starts at, in order.
+    pub lines: Vec<(usize, f32)>,
+    pub height: f32,
+}
+
+impl Layout {
+    /// The y of the last block starting at or before source `line`.
+    pub fn y_of_line(&self, line: usize) -> f32 {
+        self.lines.iter().take_while(|(l, _)| *l <= line).last().map(|(_, y)| *y).unwrap_or(0.0)
+    }
+
+    pub fn hit(&self, x: f32, y: f32) -> Option<&Hit> {
+        self.hits
+            .iter()
+            .find(|(r, _)| x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height)
+            .map(|(_, h)| h)
+    }
+
+    /// Paint at `origin` scrolled by `scroll`, culled to `viewport`.
+    pub fn paint(&self, pc: &mut PaintCtx, origin: (f32, f32), scroll: f32, viewport: Rect) {
+        let (ox, oy) = (origin.0, origin.1 - scroll);
+        let (top, bottom) = (viewport.y - oy, viewport.y + viewport.height - oy);
+        for d in &self.draws {
+            let (t, b) = d.top_bottom();
+            if b < top || t > bottom {
+                continue;
+            }
+            match d {
+                Draw::Text { text, x, y, size, color, font, attrs } => pc.text_attrs(
+                    text.clone(),
+                    x + ox,
+                    y + oy,
+                    *size,
+                    srgb_u8(*color),
+                    Some(font.clone()),
+                    None,
+                    *attrs,
+                ),
+                Draw::Quad { rect, color } => pc.quad(shift(*rect, ox, oy), *color),
+                Draw::Round { rect, radius, color } => {
+                    pc.rounded_rect(shift(*rect, ox, oy), *radius, (true, true, true, true), *color)
+                }
+                Draw::Line { x1, y1, x2, y2, width, color } => {
+                    pc.vector(x1 + ox, y1 + oy, x2 + ox, y2 + oy, *width, *color, Cap::Flat)
+                }
+                Draw::Check { cx, cy, r, checked } => {
+                    cce_ui::widget::Checkbox::paint_round_mark(pc, cx + ox, cy + oy, *r, *checked)
+                }
+                Draw::Dot { cx, cy, r, color } => pc.circle(cx + ox, cy + oy, *r, *color),
+            }
+        }
+    }
+}
+
+/// An sRGB colour (alpha untouched) in the linear space prims take.
+fn lin(srgb: [f32; 4]) -> [f32; 4] {
+    cce_ui::colors::to_linear(srgb)
+}
+
+fn shift(r: Rect, dx: f32, dy: f32) -> Rect {
+    Rect { x: r.x + dx, y: r.y + dy, width: r.width, height: r.height }
+}
+
+pub fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let s = cce_ui::colors::to_srgb(linear);
+    [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
+}
+
+/// Lay out `blocks` at `width`. `resolved` says whether a note link has a
+/// target, so unresolved ones draw faded as in Obsidian.
+pub fn layout(
+    blocks: &[Block],
+    width: f32,
+    theme: &Theme,
+    m: &mut dyn Measure,
+    resolved: &dyn Fn(&SpanLink) -> bool,
+) -> Layout {
+    let mut l = Layouter { theme, m, resolved, out: Layout::default(), pending: Vec::new() };
+    let y = l.blocks(blocks, 0.0, width.max(80.0), 0.0, true);
+    l.out.height = y;
+    l.out
+}
+
+struct Layouter<'a> {
+    theme: &'a Theme,
+    m: &'a mut dyn Measure,
+    resolved: &'a dyn Fn(&SpanLink) -> bool,
+    out: Layout,
+    /// Runs finished on the current line, emitted when the line ends.
+    pending: Vec<Run>,
+}
+
+/// One inline run's look, before it is placed.
+#[derive(Clone, Copy)]
+struct RunStyle {
+    size: f32,
+    bold: bool,
+    color: Option<[f32; 4]>,
+}
+
+impl<'a> Layouter<'a> {
+    fn blocks(&mut self, blocks: &[Block], x: f32, w: f32, mut y: f32, record: bool) -> f32 {
+        for (i, b) in blocks.iter().enumerate() {
+            if i > 0 {
+                y += BLOCK_GAP;
+            }
+            if record {
+                if let Some(line) = block_line(b) {
+                    self.out.lines.push((line, y));
+                }
+            }
+            y = self.block(b, x, w, y);
+        }
+        y
+    }
+
+    fn block(&mut self, b: &Block, x: f32, w: f32, y: f32) -> f32 {
+        let size = self.theme.size;
+        match b {
+            Block::Properties(props) => self.properties(props, x, w, y),
+            Block::Heading { level, spans, .. } => {
+                let scale = match level {
+                    1 => 1.6,
+                    2 => 1.4,
+                    3 => 1.25,
+                    4 => 1.1,
+                    _ => 1.0,
+                };
+                let y = y + if *level <= 2 { size * 0.4 } else { 0.0 };
+                let style = RunStyle { size: (size * scale).round(), bold: true, color: None };
+                let end = self.inline(spans, x, w, y, style);
+                if *level <= 2 {
+                    let ry = end + 4.0;
+                    self.out.draws.push(Draw::Quad { rect: Rect { x, y: ry, width: w, height: 1.0 }, color: RULE });
+                    return ry + 1.0;
+                }
+                end
+            }
+            Block::Paragraph { spans, .. } => self.inline(spans, x, w, y, self.plain_style()),
+            Block::Embed { target, subpath, .. } => {
+                let shown = match subpath {
+                    Some(s) => format!("↳ {target}#{s}"),
+                    None => format!("↳ {target}"),
+                };
+                let span = Span {
+                    text: shown,
+                    style: Style::default(),
+                    link: Some(SpanLink::Note { target: target.clone(), subpath: subpath.clone() }),
+                };
+                self.inline(std::slice::from_ref(&span), x, w, y, self.plain_style())
+            }
+            Block::List { start, items, .. } => self.list(*start, items, x, w, y),
+            Block::Quote { callout, blocks, .. } => self.quote(callout.as_ref(), blocks, x, w, y),
+            Block::Code { lang, text, .. } => self.code(lang.as_deref(), text, x, w, y),
+            Block::Table { header, rows, .. } => self.table(header, rows, x, w, y),
+            Block::Rule { .. } => {
+                let ry = y + size * 0.5;
+                self.out.draws.push(Draw::Quad { rect: Rect { x, y: ry, width: w, height: 1.0 }, color: RULE });
+                ry + size * 0.5
+            }
+        }
+    }
+
+    fn plain_style(&self) -> RunStyle {
+        RunStyle { size: self.theme.size, bold: false, color: None }
+    }
+
+    fn properties(&mut self, props: &[(String, String)], x: f32, w: f32, mut y: f32) -> f32 {
+        let size = (self.theme.size * 0.9).round();
+        let key_w = 140.0f32.min(w * 0.35);
+        for (k, v) in props {
+            let lh = self.theme.line_h(size);
+            self.text(k, x, y + (lh - size) / 2.0, size, DIM, false, false);
+            let span = Span { text: v.clone(), style: Style::default(), link: None };
+            let style = RunStyle { size, bold: false, color: Some(FG) };
+            y = self.inline(std::slice::from_ref(&span), x + key_w, w - key_w, y, style);
+        }
+        y += 6.0;
+        self.out.draws.push(Draw::Quad { rect: Rect { x, y, width: w, height: 1.0 }, color: RULE });
+        y + 1.0
+    }
+
+    fn list(&mut self, start: Option<u64>, items: &[ListItem], x: f32, w: f32, mut y: f32) -> f32 {
+        let size = self.theme.size;
+        let lh = self.theme.line_h(size);
+        for (i, item) in items.iter().enumerate() {
+            if i > 0 {
+                y += 2.0;
+            }
+            let body_x = x + INDENT;
+            let mid = y + lh / 2.0;
+            match (item.task, start) {
+                (Some(status), _) => {
+                    let r = (size * 0.45).round();
+                    let cx = x + INDENT - r - 6.0;
+                    self.out.draws.push(Draw::Check { cx, cy: mid, r, checked: status != ' ' });
+                    self.out.hits.push((
+                        Rect { x: cx - r - 3.0, y: mid - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 },
+                        Hit::Task { line: item.line, status },
+                    ));
+                }
+                (None, Some(n)) => {
+                    let label = format!("{}.", n + i as u64);
+                    let lw = self.m.width(&label, size, &self.theme.body_font, TextAttrs::default());
+                    self.text(&label, body_x - 6.0 - lw, y + (lh - size) / 2.0, size, DIM, false, false);
+                }
+                (None, None) => {
+                    let r = (size * 0.16).max(2.0);
+                    self.out.draws.push(Draw::Dot { cx: body_x - 12.0, cy: mid, r, color: DIM });
+                }
+            }
+            let done = item.task.is_some_and(|s| s == 'x' || s == 'X');
+            let before = self.out.draws.len();
+            y = self.item_blocks(&item.blocks, body_x, w - INDENT, y);
+            if done {
+                // Obsidian dims a finished task's text rather than striking it.
+                for d in &mut self.out.draws[before..] {
+                    if let Draw::Text { color, .. } = d {
+                        *color = DIM;
+                    }
+                }
+            }
+        }
+        y
+    }
+
+    /// An item's blocks, without the block gap before a nested list.
+    fn item_blocks(&mut self, blocks: &[Block], x: f32, w: f32, mut y: f32) -> f32 {
+        let lh = self.theme.line_h(self.theme.size);
+        if blocks.is_empty() {
+            return y + lh;
+        }
+        for (i, b) in blocks.iter().enumerate() {
+            if i > 0 && !matches!(b, Block::List { .. }) {
+                y += BLOCK_GAP / 2.0;
+            }
+            y = self.block(b, x, w, y);
+        }
+        y
+    }
+
+    fn quote(&mut self, callout: Option<&Callout>, blocks: &[Block], x: f32, w: f32, y: f32) -> f32 {
+        let size = self.theme.size;
+        let start = self.out.draws.len();
+        let pad = if callout.is_some() { 10.0 } else { 0.0 };
+        let mut cy = y + pad;
+        let body_x = x + if callout.is_some() { 14.0 } else { INDENT * 0.6 };
+        let body_w = w - (body_x - x) - pad;
+        if let Some(c) = callout {
+            let style = RunStyle { size, bold: true, color: Some(lin(callout_color(&c.kind))) };
+            let title: Vec<Span> = if c.title.is_empty() {
+                vec![Span { text: title_case(&c.kind), style: Style::default(), link: None }]
+            } else {
+                c.title.clone()
+            };
+            cy = self.inline(&title, body_x, body_w, cy, style);
+            if !blocks.is_empty() && c.folded != Some(true) {
+                cy += BLOCK_GAP / 2.0;
+            }
+        }
+        if callout.is_none_or(|c| c.folded != Some(true)) {
+            cy = self.blocks(blocks, body_x, body_w, cy, false);
+        }
+        let end = cy + pad;
+        let rect = Rect { x, y, width: w, height: end - y };
+        // Backgrounds go under the text laid out above them.
+        match callout {
+            Some(c) => {
+                let mut bg = lin(callout_color(&c.kind));
+                bg[3] = 0.10;
+                self.out.draws.insert(start, Draw::Round { rect, radius: 6.0, color: bg });
+            }
+            None => {
+                self.out.draws.insert(
+                    start,
+                    Draw::Quad { rect: Rect { x, y, width: 2.0, height: end - y }, color: lin(QUOTE_BAR) },
+                );
+            }
+        }
+        end
+    }
+
+    fn code(&mut self, _lang: Option<&str>, text: &str, x: f32, w: f32, y: f32) -> f32 {
+        let size = (self.theme.size * 0.9).round();
+        let lh = self.theme.line_h(size);
+        let n = text.split('\n').count().max(1);
+        let h = n as f32 * lh + 2.0 * CODE_PAD;
+        self.out.draws.push(Draw::Round { rect: Rect { x, y, width: w, height: h }, radius: 6.0, color: CODE_BG });
+        // Code does not wrap; overlong lines are cut at the block's edge
+        // by the paint clip, as a scrolled source view would show them.
+        for (i, line) in text.split('\n').enumerate() {
+            if line.is_empty() {
+                continue;
+            }
+            let ty = y + CODE_PAD + i as f32 * lh + (lh - size) / 2.0;
+            let font = self.theme.mono_font.clone();
+            self.out.draws.push(Draw::Text {
+                text: line.replace('\t', "    "),
+                x: x + CODE_PAD,
+                y: ty,
+                size,
+                color: FG,
+                font,
+                attrs: TextAttrs::default(),
+            });
+        }
+        y + h
+    }
+
+    fn table(&mut self, header: &[Vec<Span>], rows: &[Vec<Vec<Span>>], x: f32, w: f32, mut y: f32) -> f32 {
+        let cols = header.len().max(rows.iter().map(Vec::len).max().unwrap_or(0)).max(1);
+        let col_w = w / cols as f32;
+        let pad = 6.0;
+        let top = y;
+        let all = std::iter::once((header, true)).chain(rows.iter().map(|r| (r.as_slice(), false)));
+        for (ri, (row, is_header)) in all.enumerate() {
+            let style = RunStyle { size: self.theme.size, bold: is_header, color: None };
+            let mut bottom = y;
+            for (ci, cell) in row.iter().enumerate().take(cols) {
+                let cx = x + ci as f32 * col_w + pad;
+                bottom = bottom.max(self.inline(cell, cx, col_w - 2.0 * pad, y + pad, style));
+            }
+            y = bottom.max(y + self.theme.line_h(self.theme.size)) + pad;
+            if ri == 0 || ri < rows.len() {
+                self.out.draws.push(Draw::Quad { rect: Rect { x, y, width: w, height: 1.0 }, color: RULE });
+            }
+        }
+        for c in 1..cols {
+            let lx = x + c as f32 * col_w;
+            self.out.draws.push(Draw::Quad { rect: Rect { x: lx, y: top, width: 1.0, height: y - top }, color: RULE });
+        }
+        y
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4], bold: bool, italic: bool) {
+        let attrs = TextAttrs { italic, weight: bold.then_some(700) };
+        let font = self.theme.body_font.clone();
+        self.out.draws.push(Draw::Text { text: text.to_string(), x, y, size, color, font, attrs });
+    }
+
+    /// Lay out inline spans from `(x, y)` wrapping at `x + w`; returns the
+    /// y below the last line.
+    fn inline(&mut self, spans: &[Span], x: f32, w: f32, y: f32, base: RunStyle) -> f32 {
+        let lh = self.theme.line_h(base.size);
+        let right = x + w;
+        let looks: Vec<Look> = spans.iter().map(|s| self.look(s, base)).collect();
+        let spaces: Vec<f32> = looks.iter().map(|l| self.space_width(l)).collect();
+        let mut cx = x;
+        let mut line_y = y;
+        // A space seen since the last placed word, in the style it was in.
+        let mut gap: Option<f32> = None;
+        let mut run: Option<Run> = None;
+        let mut any = false;
+
+        for tok in tokens(spans) {
+            match tok {
+                Tok::Break => {
+                    self.flush(&mut run, line_y, lh);
+                    line_y += lh;
+                    cx = x;
+                    gap = None;
+                }
+                Tok::Space(si) => {
+                    if cx > x {
+                        gap = Some(spaces[si]);
+                    }
+                }
+                Tok::Word(segs) => {
+                    any = true;
+                    let widths: Vec<f32> = segs
+                        .iter()
+                        .map(|(t, si)| {
+                            let l = &looks[*si];
+                            self.m.width(t, l.size, &l.font, l.attrs)
+                        })
+                        .collect();
+                    let total: f32 = widths.iter().sum();
+                    let mut lead = gap.take().unwrap_or(0.0);
+                    if cx > x && cx + lead + total > right {
+                        self.flush(&mut run, line_y, lh);
+                        line_y += lh;
+                        cx = x;
+                        lead = 0.0;
+                    }
+                    for ((text, si), mut ww) in segs.into_iter().zip(widths) {
+                        let (look, span) = (&looks[si], &spans[si]);
+                        // A word wider than the whole line (a URL) breaks
+                        // by characters rather than overflowing.
+                        let mut rest = text;
+                        while cx + ww > right && rest.chars().count() > 1 {
+                            let cut = self.fit(rest, right - cx, look);
+                            let (head, tail) = rest.split_at(cut);
+                            let hw = self.m.width(head, look.size, &look.font, look.attrs);
+                            self.place(&mut run, head, cx + lead, hw, lead, look, span);
+                            self.flush(&mut run, line_y, lh);
+                            line_y += lh;
+                            cx = x;
+                            lead = 0.0;
+                            rest = tail;
+                            ww = self.m.width(rest, look.size, &look.font, look.attrs);
+                        }
+                        self.place(&mut run, rest, cx + lead, ww, lead, look, span);
+                        cx += lead + ww;
+                        lead = 0.0;
+                    }
+                }
+            }
+        }
+        self.flush(&mut run, line_y, lh);
+        if !any && line_y == y {
+            return y + lh;
+        }
+        line_y + lh
+    }
+
+    /// The longest char prefix of `word` fitting `w` (at least one char).
+    fn fit(&mut self, word: &str, w: f32, look: &Look) -> usize {
+        let mut best = word.chars().next().map(char::len_utf8).unwrap_or(word.len());
+        for (i, c) in word.char_indices().skip(1) {
+            let end = i + c.len_utf8();
+            if self.m.width(&word[..end], look.size, &look.font, look.attrs) > w {
+                break;
+            }
+            best = end;
+        }
+        best
+    }
+
+    fn space_width(&mut self, look: &Look) -> f32 {
+        let two = self.m.width("x x", look.size, &look.font, look.attrs);
+        let one = self.m.width("xx", look.size, &look.font, look.attrs);
+        (two - one).max(look.size * 0.2)
+    }
+
+    fn look(&self, span: &Span, base: RunStyle) -> Look {
+        let s = span.style;
+        let code = s.code || s.math;
+        let size = if code { (base.size * 0.92).round() } else { base.size };
+        let color = match &span.link {
+            Some(SpanLink::Tag(_)) => lin(TAG),
+            Some(l @ (SpanLink::Note { .. } | SpanLink::Embed { .. })) => {
+                if (self.resolved)(l) {
+                    lin(LINK)
+                } else {
+                    lin(LINK_UNRESOLVED)
+                }
+            }
+            Some(SpanLink::Url(_)) => lin(LINK),
+            None => base.color.unwrap_or(FG),
+        };
+        Look {
+            size,
+            font: if code { self.theme.mono_font.clone() } else { self.theme.body_font.clone() },
+            attrs: TextAttrs { italic: s.italic, weight: (s.bold || base.bold).then_some(700) },
+            color,
+            bg: if code {
+                Some(CODE_BG)
+            } else if s.highlight {
+                Some(lin(HIGHLIGHT_BG))
+            } else if matches!(span.link, Some(SpanLink::Tag(_))) {
+                Some(lin(TAG_BG))
+            } else {
+                None
+            },
+            strike: s.strike,
+        }
+    }
+
+    /// Add a word to the current run, or start a new one if the look or
+    /// link changed.
+    #[allow(clippy::too_many_arguments)]
+    fn place(&mut self, run: &mut Option<Run>, word: &str, x: f32, w: f32, lead: f32, look: &Look, span: &Span) {
+        if let Some(r) = run {
+            if r.look == *look && r.link == span.link && (r.x + r.w + lead - x).abs() < 0.5 {
+                if lead > 0.0 {
+                    r.text.push(' ');
+                }
+                r.text.push_str(word);
+                r.w += lead + w;
+                return;
+            }
+        }
+        // The line is the same until flushed; only a look change ends it.
+        if let Some(r) = run.take() {
+            self.pending.push(r);
+        }
+        *run = Some(Run { text: word.to_string(), x, w, look: look.clone(), link: span.link.clone() });
+    }
+
+    fn flush(&mut self, run: &mut Option<Run>, line_y: f32, lh: f32) {
+        if let Some(r) = run.take() {
+            self.pending.push(r);
+        }
+        for r in std::mem::take(&mut self.pending) {
+            let ty = line_y + (lh - r.look.size) / 2.0;
+            let box_ = Rect { x: r.x - 2.0, y: line_y + 2.0, width: r.w + 4.0, height: lh - 4.0 };
+            if let Some(bg) = r.look.bg {
+                self.out.draws.push(Draw::Round { rect: box_, radius: 3.0, color: bg });
+            }
+            self.out.draws.push(Draw::Text {
+                text: r.text,
+                x: r.x,
+                y: ty,
+                size: r.look.size,
+                color: r.look.color,
+                font: r.look.font.clone(),
+                attrs: r.look.attrs,
+            });
+            if r.look.strike {
+                let sy = ty + r.look.size * 0.55;
+                self.out.draws.push(Draw::Line { x1: r.x, y1: sy, x2: r.x + r.w, y2: sy, width: 1.0, color: r.look.color });
+            }
+            if let Some(link) = r.link {
+                self.out.hits.push((Rect { x: r.x, y: line_y, width: r.w, height: lh }, Hit::Link(link)));
+            }
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+struct Look {
+    size: f32,
+    font: String,
+    attrs: TextAttrs,
+    color: [f32; 4],
+    bg: Option<[f32; 4]>,
+    strike: bool,
+}
+
+struct Run {
+    text: String,
+    x: f32,
+    w: f32,
+    look: Look,
+    link: Option<SpanLink>,
+}
+
+enum Piece<'a> {
+    Word(&'a str),
+    Space,
+    Break,
+}
+
+/// Split text into words, whitespace runs and hard line breaks.
+fn pieces(text: &str) -> Vec<Piece<'_>> {
+    let mut out = Vec::new();
+    let mut start: Option<usize> = None;
+    for (i, c) in text.char_indices() {
+        if c.is_whitespace() {
+            if let Some(s) = start.take() {
+                out.push(Piece::Word(&text[s..i]));
+            }
+            if c == '\n' {
+                out.push(Piece::Break);
+            } else if !matches!(out.last(), Some(Piece::Space)) {
+                out.push(Piece::Space);
+            }
+        } else if start.is_none() {
+            start = Some(i);
+        }
+    }
+    if let Some(s) = start {
+        out.push(Piece::Word(&text[s..]));
+    }
+    out
+}
+
+/// What wraps as a unit: a word may run across spans (`**bold**,` is one
+/// word in two styles), and never breaks between them.
+enum Tok<'a> {
+    /// Segments of one word, each with the index of its span.
+    Word(Vec<(&'a str, usize)>),
+    /// A space, in the style of the span it sits in.
+    Space(usize),
+    Break,
+}
+
+fn tokens(spans: &[Span]) -> Vec<Tok<'_>> {
+    let mut out: Vec<Tok> = Vec::new();
+    for (si, span) in spans.iter().enumerate() {
+        for (pi, piece) in pieces(&span.text).into_iter().enumerate() {
+            match piece {
+                Piece::Word(w) => match out.last_mut() {
+                    // Glued: the previous span ended mid-word.
+                    Some(Tok::Word(segs)) if pi == 0 => segs.push((w, si)),
+                    _ => out.push(Tok::Word(vec![(w, si)])),
+                },
+                Piece::Space => out.push(Tok::Space(si)),
+                Piece::Break => out.push(Tok::Break),
+            }
+        }
+    }
+    out
+}
+
+fn block_line(b: &Block) -> Option<usize> {
+    match b {
+        Block::Properties(_) => Some(0),
+        Block::Heading { line, .. }
+        | Block::Paragraph { line, .. }
+        | Block::Embed { line, .. }
+        | Block::List { line, .. }
+        | Block::Quote { line, .. }
+        | Block::Code { line, .. }
+        | Block::Table { line, .. }
+        | Block::Rule { line } => Some(*line),
+    }
+}
+
+fn callout_color(kind: &str) -> [f32; 4] {
+    match kind {
+        "warning" | "caution" | "attention" => [0.93, 0.60, 0.25, 1.0],
+        "danger" | "error" | "bug" | "failure" | "fail" | "missing" => [0.93, 0.35, 0.35, 1.0],
+        "tip" | "hint" | "important" | "success" | "check" | "done" => [0.30, 0.78, 0.60, 1.0],
+        "question" | "help" | "faq" => [0.93, 0.75, 0.25, 1.0],
+        "quote" | "cite" => [0.60, 0.60, 0.65, 1.0],
+        "example" => [0.62, 0.55, 0.98, 1.0],
+        _ => [0.35, 0.62, 0.95, 1.0],
+    }
+}
+
+fn title_case(s: &str) -> String {
+    let mut c = s.chars();
+    match c.next() {
+        Some(f) => f.to_uppercase().chain(c).collect(),
+        None => String::new(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use cce_vault::markdown::blocks;
+
+    /// Every char 10 px at size 10, scaled with the size.
+    struct Fixed;
+    impl Measure for Fixed {
+        fn width(&mut self, text: &str, size: f32, _font: &str, _attrs: TextAttrs) -> f32 {
+            text.chars().count() as f32 * size
+        }
+    }
+
+    fn theme() -> Theme {
+        Theme { body_font: "sans-serif".into(), mono_font: "monospace".into(), size: 10.0 }
+    }
+
+    fn texts(l: &Layout) -> Vec<(String, f32, f32)> {
+        l.draws
+            .iter()
+            .filter_map(|d| match d {
+                Draw::Text { text, x, y, .. } => Some((text.clone(), *x, *y)),
+                _ => None,
+            })
+            .collect()
+    }
+
+    #[test]
+    fn wraps_words_and_merges_runs() {
+        // "aaa bbb ccc" at 10px/char: 110 px; at width 80 the third word wraps.
+        let doc = blocks("aaa bbb ccc\n");
+        let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
+        let t = texts(&l);
+        assert_eq!(t.len(), 2, "{t:?}");
+        assert_eq!(t[0].0, "aaa bbb");
+        assert_eq!(t[1].0, "ccc");
+        assert_eq!(t[1].1, 0.0);
+        assert!(t[1].2 > t[0].2);
+        assert_eq!(l.height, 30.0);
+    }
+
+    #[test]
+    fn styles_split_runs_and_links_hit() {
+        let doc = blocks("go **bold** to [[Note]] now\n");
+        let l = layout(&doc, 1000.0, &theme(), &mut Fixed, &|_| true);
+        let t: Vec<String> = texts(&l).into_iter().map(|t| t.0).collect();
+        assert_eq!(t, ["go", "bold", "to", "Note", "now"]);
+        let (r, hit) = &l.hits[0];
+        assert_eq!(hit, &Hit::Link(SpanLink::Note { target: "Note".into(), subpath: None }));
+        assert!(l.hit(r.x + 1.0, r.y + 1.0).is_some());
+        assert!(l.hit(0.0, 1.0).is_none());
+    }
+
+    #[test]
+    fn punctuation_glued_to_a_styled_word_wraps_with_it() {
+        // Code is 9 px/char. At width 80 (layout's minimum) "aaaa bbb"
+        // fits (77 px) but "aaaa bbb," does not (87): unglued, the comma
+        // alone would start the next line.
+        let doc = blocks("aaaa `bbb`, cc\n");
+        let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
+        let t = texts(&l);
+        let bbb = t.iter().find(|t| t.0 == "bbb").unwrap();
+        let comma = t.iter().find(|t| t.0.starts_with(',')).unwrap();
+        assert_eq!((bbb.1, comma.1), (0.0, 27.0), "{t:?}");
+        assert!(bbb.2 > t[0].2, "{t:?}");
+    }
+
+    #[test]
+    fn long_word_breaks_by_chars() {
+        let doc = blocks("abcdefghij\n");
+        let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
+        let t: Vec<String> = texts(&l).into_iter().map(|t| t.0).collect();
+        assert_eq!(t, ["abcdefgh", "ij"]);
+    }
+
+    #[test]
+    fn tasks_hit_with_their_line_and_blocks_map_lines() {
+        let doc = blocks("# Head\n\n- [ ] one\n- [x] two\n\nend\n");
+        let l = layout(&doc, 400.0, &theme(), &mut Fixed, &|_| true);
+        let tasks: Vec<_> = l.hits.iter().filter_map(|(_, h)| match h {
+            Hit::Task { line, status } => Some((*line, *status)),
+            _ => None,
+        }).collect();
+        assert_eq!(tasks, [(2, ' '), (3, 'x')]);
+        assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 5]);
+        assert!(l.y_of_line(5) > l.y_of_line(2));
+    }
+
+    #[test]
+    fn hard_breaks_and_empty_input() {
+        let doc = blocks("one\ntwo\n");
+        let l = layout(&doc, 400.0, &theme(), &mut Fixed, &|_| true);
+        assert_eq!(texts(&l).len(), 2);
+        assert_eq!(layout(&[], 400.0, &theme(), &mut Fixed, &|_| true).height, 0.0);
+    }
+}
diff --git a/src/tree.rs b/src/tree.rs
new file mode 100644
index 0000000..afd57a2
--- /dev/null
+++ b/src/tree.rs
@@ -0,0 +1,104 @@
+//! The file tree pane's model: the vault's notes as folder and note rows,
+//! folders first and each level sorted case-insensitively, as Obsidian's
+//! file explorer shows them. Folders exist only as the parents of indexed
+//! files, so an empty folder does not appear.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Row {
+    /// The folder's or note's vault path.
+    pub path: String,
+    /// What the row shows: a folder's name, or a note's name without `.md`.
+    pub name: String,
+    pub depth: usize,
+    pub folder: bool,
+    pub open: bool,
+}
+
+#[derive(Default)]
+pub struct Tree {
+    open: BTreeSet<String>,
+}
+
+#[derive(Default)]
+struct Dir<'a> {
+    dirs: BTreeMap<String, Dir<'a>>,
+    files: Vec<&'a str>,
+}
+
+impl Tree {
+    pub fn toggle(&mut self, folder: &str) {
+        if !self.open.remove(folder) {
+            self.open.insert(folder.to_string());
+        }
+    }
+
+    /// Open every folder above `path`, so its row is visible.
+    pub fn reveal(&mut self, path: &str) {
+        let mut at = 0;
+        while let Some(i) = path[at..].find('/') {
+            self.open.insert(path[..at + i].to_string());
+            at += i + 1;
+        }
+    }
+
+    /// The visible rows for these note paths.
+    pub fn rows<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<Row> {
+        let mut root = Dir::default();
+        for p in paths {
+            let mut dir = &mut root;
+            let mut parts: Vec<&str> = p.split('/').collect();
+            parts.pop();
+            for part in parts {
+                dir = dir.dirs.entry(part.to_string()).or_default();
+            }
+            dir.files.push(p);
+        }
+        let mut out = Vec::new();
+        self.walk(&root, "", 0, &mut out);
+        out
+    }
+
+    fn walk(&self, dir: &Dir, prefix: &str, depth: usize, out: &mut Vec<Row>) {
+        let mut dirs: Vec<(&String, &Dir)> = dir.dirs.iter().collect();
+        dirs.sort_by_key(|(name, _)| name.to_lowercase());
+        for (name, sub) in dirs {
+            let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
+            let open = self.open.contains(&path);
+            out.push(Row { path: path.clone(), name: name.clone(), depth, folder: true, open });
+            if open {
+                self.walk(sub, &path, depth + 1, out);
+            }
+        }
+        let mut files = dir.files.clone();
+        files.sort_by_key(|p| p.to_lowercase());
+        for p in files {
+            let file = p.rsplit('/').next().unwrap_or(p);
+            let name = file.strip_suffix(".md").unwrap_or(file).to_string();
+            out.push(Row { path: p.to_string(), name, depth, folder: false, open: false });
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn names(rows: &[Row]) -> Vec<String> {
+        rows.iter().map(|r| format!("{}{}{}", "  ".repeat(r.depth), r.name, if r.folder { "/" } else { "" })).collect()
+    }
+
+    #[test]
+    fn folders_first_sorted_and_collapsed() {
+        let paths = ["b.md", "A.md", "Tasks/x.md", "archive/old/y.md"];
+        let mut t = Tree::default();
+        assert_eq!(names(&t.rows(paths)), ["archive/", "Tasks/", "A", "b"]);
+        t.toggle("Tasks");
+        assert_eq!(names(&t.rows(paths)), ["archive/", "Tasks/", "  x", "A", "b"]);
+        t.toggle("Tasks");
+        t.reveal("archive/old/y.md");
+        assert_eq!(names(&t.rows(paths)), ["archive/", "  old/", "    y", "Tasks/", "A", "b"]);
+        assert!(t.rows(paths)[1].open);
+    }
+}