notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
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);
+ }
+}