git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit90aea0d03507846e7c49873400f0aa8d244bb5b2
parent880edceb6b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:52
feat: MarkdownView (widget::markdown) behind a `markdown` feature

A vault note's blocks (cce_vault::markdown) laid out at a width into draw
items and click targets: headings, properties, lists with task
checkboxes, callouts, quotes, code, tables, rules, inline styles, links
(unresolved ones faded) and tags. Text is laid out word by word through
the renderer's own shaping entry (ShapingMeasure), a word running across
styles wraps as one, and same-style words merge back into one prim.
Layout::paint translates and culls; paint_scaled also scales, for a host
that lays out in its own units -- cce-grid's note cards.

Moved here from cce-notes so the desktop's note cards draw notes the same
way. The feature brings in cce-vault; clients that show no notes do not
build it. Cargo.lock re-resolved standalone, which also drops the tokio
entries left over from when cce-ui depended on it.

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

 Cargo.lock             | 467 ++++++++++++++++++++------
 Cargo.toml             |   7 +
 src/widget/markdown.rs | 868 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs      |   2 +
 4 files changed, 1243 insertions(+), 101 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 00d3f58..ae143ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,21 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
+[[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 = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
 [[package]]
 name = "arrayref"
 version = "0.3.9"
@@ -297,12 +312,6 @@ dependencies = [
  "syn 2.0.119",
 ]
 
-[[package]]
-name = "bytes"
-version = "1.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
-
 [[package]]
 name = "calloop"
 version = "0.13.0"
@@ -348,6 +357,7 @@ dependencies = [
  "bytemuck",
  "calloop",
  "calloop-wayland-source",
+ "cce-vault",
  "cosmic-text",
  "glam",
  "gpu-allocator",
@@ -362,7 +372,6 @@ dependencies = [
  "serde",
  "serde_json",
  "smithay-client-toolkit",
- "tokio",
  "wayland-backend",
  "wayland-client",
  "wayland-protocols 0.31.2",
@@ -370,6 +379,24 @@ dependencies = [
  "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.4"
@@ -382,6 +409,19 @@ 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"
@@ -407,6 +447,12 @@ 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"
@@ -520,6 +566,15 @@ version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
 
+[[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"
@@ -630,6 +685,12 @@ 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"
@@ -671,6 +732,15 @@ 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.33"
@@ -793,6 +863,18 @@ 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"
@@ -818,6 +900,30 @@ 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.2.0"
@@ -935,6 +1041,28 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 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.1",
+ "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]]
@@ -971,6 +1099,26 @@ dependencies = [
  "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.1",
+ "libc",
+]
+
 [[package]]
 name = "kurbo"
 version = "0.11.3"
@@ -1022,15 +1170,6 @@ version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
 
-[[package]]
-name = "lock_api"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
-dependencies = [
- "scopeguard",
-]
-
 [[package]]
 name = "log"
 version = "0.4.33"
@@ -1116,6 +1255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
 dependencies = [
  "libc",
+ "log",
  "wasi",
  "windows-sys 0.61.2",
 ]
@@ -1152,6 +1292,33 @@ dependencies = [
  "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.1",
+ "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.1",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -1232,29 +1399,6 @@ version = "2.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
 
-[[package]]
-name = "parking_lot"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
-dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-link",
-]
-
 [[package]]
 name = "percent-encoding"
 version = "2.3.2"
@@ -1365,6 +1509,17 @@ 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.1",
+ "memchr",
+ "unicase",
+]
+
 [[package]]
 name = "quick-xml"
 version = "0.41.0"
@@ -1466,15 +1621,6 @@ dependencies = [
  "font-types",
 ]
 
-[[package]]
-name = "redox_syscall"
-version = "0.5.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
-dependencies = [
- "bitflags 2.13.1",
-]
-
 [[package]]
 name = "resvg"
 version = "0.41.0"
@@ -1609,16 +1755,19 @@ dependencies = [
 ]
 
 [[package]]
-name = "scoped-tls"
-version = "1.0.1"
+name = "same-file"
+version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
 
 [[package]]
-name = "scopeguard"
-version = "1.2.0"
+name = "scoped-tls"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
 
 [[package]]
 name = "self_cell"
@@ -1776,16 +1925,6 @@ dependencies = [
  "xkeysym",
 ]
 
-[[package]]
-name = "socket2"
-version = "0.6.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
 [[package]]
 name = "spirv"
 version = "0.3.0+sdk-1.3.268.0"
@@ -2008,34 +2147,6 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
-[[package]]
-name = "tokio"
-version = "1.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "parking_lot",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
 [[package]]
 name = "toml_datetime"
 version = "1.1.1+spec-1.1.0"
@@ -2120,6 +2231,12 @@ dependencies = [
  "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"
@@ -2255,6 +2372,16 @@ 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"
@@ -2457,19 +2584,81 @@ 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",
+ "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]]
@@ -2487,14 +2676,31 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
 dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "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]]
@@ -2503,48 +2709,96 @@ 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"
@@ -2598,6 +2852,17 @@ 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"
diff --git a/Cargo.toml b/Cargo.toml
index 53862c3..664cdd2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,14 @@ edition = "2021"
 name = "cce_ui"
 path = "src/lib.rs"
 
+[features]
+# `widget::markdown` (MarkdownView): lays out vault notes for reading views
+# and note cards. Brings in cce-vault and its Markdown parser, so only the
+# clients that show notes turn it on.
+markdown = ["dep:cce-vault"]
+
 [dependencies]
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba", optional = true }
 raw-window-handle = "0.6"
 glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
new file mode 100644
index 0000000..7e69c33
--- /dev/null
+++ b/src/widget/markdown.rs
@@ -0,0 +1,868 @@
+//! `MarkdownView`: a note's blocks (`cce_vault::markdown`) laid out at a
+//! width into draw items and click targets — the reading view of
+//! cce-notes, the note cards on cce-grid's desktop, and any other surface
+//! that shows a vault note. Behind the `markdown` feature, which brings in
+//! cce-vault; clients that draw no Markdown do not build it.
+//!
+//! cce-ui's text prim draws one run in one style, so a paragraph is laid
+//! out word by word: each word is measured through the same shaping entry
+//! the renderer draws with ([`ShapingMeasure`]), wrapped greedily (a word
+//! running across styles — `` `code`, `` — wraps as one), and consecutive
+//! words of one style on one line merge back into a single prim. Lay out
+//! once per note and width; [`Layout::paint`] only translates and culls,
+//! [`Layout::paint_scaled`] also scales.
+//!
+//! Colours for links, tags, highlights and callouts are written in sRGB
+//! and converted where used; bullets are discs (`PaintCtx::circle`), since
+//! the squircle corner shape draws a few-px rounded rect as a square.
+//! Moved here from cce-notes (its `reading.rs`) on 2026-10-01.
+
+use std::collections::HashMap;
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{Cap, PaintCtx, TextAttrs};
+pub use cce_vault::markdown::{blocks, 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;
+}
+
+/// [`Measure`] through the renderer's own shaping entry
+/// (`get_text_buffer_attrs`), so laid-out widths are the drawn widths. It
+/// owns its FontSystem: create it with the same font set the app's renderer
+/// loads — `system_fonts` mirroring the app's `load_system_fonts` — or
+/// face ids and widths will not match. Widths are logical px at the
+/// toolkit's current scale factor, cached per run.
+pub struct ShapingMeasure {
+    fs: cosmic_text::FontSystem,
+    cache: HashMap<(String, u32, u32, String, TextAttrs), f32>,
+}
+
+impl ShapingMeasure {
+    pub fn new(system_fonts: bool) -> ShapingMeasure {
+        let fs = if system_fonts { crate::create_font_system_with_system_fonts() } else { crate::create_font_system() };
+        ShapingMeasure { fs, cache: HashMap::new() }
+    }
+}
+
+impl Measure for ShapingMeasure {
+    fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32 {
+        let scale = crate::scale::scale_factor().max(0.01);
+        let key = (text.to_string(), (size * 100.0) as u32, (scale * 1000.0) as u32, font.to_string(), attrs);
+        if let Some(w) = self.cache.get(&key) {
+            return *w;
+        }
+        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+        let w = buf.layout_runs().map(|r| r.line_w).fold(0.0, f32::max) / scale;
+        self.cache.insert(key, w);
+        w
+    }
+}
+
+/// 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] = crate::colors::TEXT_FG;
+const DIM: [f32; 4] = crate::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 (and list item's) first source line and the y it
+    /// starts at, in source 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) {
+        self.paint_scaled(pc, (origin.0, origin.1 - scroll), 1.0, viewport);
+    }
+
+    /// Paint with every length multiplied by `k`, the layout's top-left at
+    /// `origin` — for a host that lays out in its own units (cce-grid's
+    /// virtual desktop) and draws at another resolution. Culled to
+    /// `viewport`, in the painted space.
+    pub fn paint_scaled(&self, pc: &mut PaintCtx, origin: (f32, f32), k: f32, viewport: Rect) {
+        let (ox, oy) = origin;
+        let (top, bottom) = ((viewport.y - oy) / k, (viewport.y + viewport.height - oy) / k);
+        let at = |x: f32, y: f32| (ox + x * k, oy + y * k);
+        let rect = |r: &Rect| {
+            let (x, y) = at(r.x, r.y);
+            Rect { x, y, width: r.width * k, height: r.height * k }
+        };
+        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 } => {
+                    let (x, y) = at(*x, *y);
+                    pc.text_attrs(text.clone(), x, y, size * k, srgb_u8(*color), Some(font.clone()), None, *attrs)
+                }
+                Draw::Quad { rect: r, color } => pc.quad(rect(r), *color),
+                Draw::Round { rect: r, radius, color } => {
+                    pc.rounded_rect(rect(r), radius * k, (true, true, true, true), *color)
+                }
+                Draw::Line { x1, y1, x2, y2, width, color } => {
+                    let ((ax, ay), (bx, by)) = (at(*x1, *y1), at(*x2, *y2));
+                    pc.vector(ax, ay, bx, by, (width * k).max(0.5), *color, Cap::Flat)
+                }
+                Draw::Check { cx, cy, r, checked } => {
+                    let (x, y) = at(*cx, *cy);
+                    crate::widget::Checkbox::paint_round_mark(pc, x, y, r * k, *checked)
+                }
+                Draw::Dot { cx, cy, r, color } => {
+                    let (x, y) = at(*cx, *cy);
+                    pc.circle(x, y, r * k, *color)
+                }
+            }
+        }
+    }
+}
+
+/// An sRGB colour (alpha untouched) in the linear space prims take.
+fn lin(srgb: [f32; 4]) -> [f32; 4] {
+    crate::colors::to_linear(srgb)
+}
+
+pub fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let s = crate::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;
+            }
+            // Items are where a search hit or a task usually points.
+            if self.out.lines.last().is_none_or(|(l, _)| *l < item.line) {
+                self.out.lines.push((item.line, y));
+            }
+            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::*;
+
+    /// 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, 3, 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/widget/mod.rs b/src/widget/mod.rs
index 576a3eb..7a47e5d 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -590,6 +590,8 @@ pub mod container;
 pub mod display;
 pub mod editor;
 pub mod layout_helper;
+#[cfg(feature = "markdown")]
+pub mod markdown;
 pub mod model;
 pub mod scroll_region;
 pub mod scroll_motion;