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

commit8bcc0c39eb97b63d986cb117f63b17faaed1683b
parentc5220ad3cf
authorLucas Galante <[email protected]>
date2026-05-20 14:56
Remove taffy, add origin-relative Column layout system

- Rewrite clear-ui/src/layout.rs: replace taffy-based Layout with
  origin-relative Column/Row builder + RenderTarget trait
- clear-system-interface: add RenderTarget impl for PageContent
- Delete local column.rs (replaced by clear-ui layout module)
- Remove taffy dependency from both Cargo.toml files
- Fix bg color label overlap with header (cy: 12 -> 28)
- Fix clear-ui demo: replace taffy Layout with hardcoded positions

 Cargo.lock    | 381 +++++++++++++++++++++++-
 Cargo.toml    |   7 +-
 src/color.rs  |  35 +++
 src/layout.rs | 112 +++++++
 src/lib.rs    |   9 +
 src/main.rs   | 937 ++++++++++++++++------------------------------------------
 src/widget.rs | 675 ++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 1464 insertions(+), 692 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 6e7527e..55463fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -236,8 +236,10 @@ name = "clear-ui"
 version = "0.1.0"
 dependencies = [
  "bytemuck",
+ "glyphon",
  "pollster",
  "taffy",
+ "tokio",
  "wgpu",
  "winit",
 ]
@@ -311,6 +313,48 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "cosmic-text"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2"
+dependencies = [
+ "bitflags 2.11.1",
+ "fontdb",
+ "log",
+ "rangemap",
+ "rayon",
+ "rustc-hash 1.1.0",
+ "rustybuzz",
+ "self_cell",
+ "swash",
+ "sys-locale",
+ "ttf-parser 0.21.1",
+ "unicode-bidi",
+ "unicode-linebreak",
+ "unicode-script",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "crossbeam-utils"
 version = "0.8.21"
@@ -359,6 +403,12 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
 
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -375,6 +425,25 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "etagere"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342"
+dependencies = [
+ "euclid",
+ "svg_fmt",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
@@ -387,6 +456,38 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[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",
+]
+
+[[package]]
+name = "fontdb"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser 0.20.0",
+]
+
 [[package]]
 name = "foreign-types"
 version = "0.5.0"
@@ -492,6 +593,19 @@ dependencies = [
  "gl_generator",
 ]
 
+[[package]]
+name = "glyphon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc83cc30ed66a0a9e77b7974c91360a13cad5e7ab65aa51e299c383749a018ab"
+dependencies = [
+ "cosmic-text",
+ "etagere",
+ "lru",
+ "rustc-hash 2.1.2",
+ "wgpu",
+]
+
 [[package]]
 name = "gpu-alloc"
 version = "0.6.0"
@@ -705,6 +819,12 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
 [[package]]
 name = "libredox"
 version = "0.1.16"
@@ -750,6 +870,12 @@ version = "0.4.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
 
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+
 [[package]]
 name = "malloc_buf"
 version = "0.0.6"
@@ -789,6 +915,17 @@ dependencies = [
  "paste",
 ]
 
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "naga"
 version = "24.0.0"
@@ -803,7 +940,7 @@ dependencies = [
  "hexf-parse",
  "indexmap",
  "log",
- "rustc-hash",
+ "rustc-hash 1.1.0",
  "spirv",
  "strum",
  "termcolor",
@@ -1124,7 +1261,7 @@ version = "0.25.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
 dependencies = [
- "ttf-parser",
+ "ttf-parser 0.25.1",
 ]
 
 [[package]]
@@ -1280,12 +1417,48 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
 
+[[package]]
+name = "rangemap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+
 [[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 = "redox_syscall"
 version = "0.4.1"
@@ -1319,12 +1492,24 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
 
+[[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 = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
 [[package]]
 name = "rustc_version"
 version = "0.4.1"
@@ -1366,6 +1551,23 @@ version = "1.0.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
 
+[[package]]
+name = "rustybuzz"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
+dependencies = [
+ "bitflags 2.11.1",
+ "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"
@@ -1400,6 +1602,12 @@ dependencies = [
  "tiny-skia",
 ]
 
+[[package]]
+name = "self_cell"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+
 [[package]]
 name = "semver"
 version = "1.0.28"
@@ -1442,6 +1650,16 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
+[[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_cesu8"
 version = "1.1.1"
@@ -1458,6 +1676,16 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 
+[[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"
@@ -1513,6 +1741,16 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "spirv"
 version = "0.3.0+sdk-1.3.268.0"
@@ -1556,6 +1794,23 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "svg_fmt"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
+
+[[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.117"
@@ -1567,6 +1822,15 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "taffy"
 version = "0.10.1"
@@ -1653,6 +1917,49 @@ dependencies = [
  "strict-num",
 ]
 
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+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.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "toml_datetime"
 version = "1.1.1+spec-1.1.0"
@@ -1699,18 +2006,66 @@ version = "0.1.36"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
 
+[[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 = "ttf-parser"
 version = "0.25.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
 
+[[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.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
+[[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.2"
@@ -1745,6 +2100,12 @@ dependencies = [
  "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.3+wasi-0.2.9"
@@ -1982,7 +2343,7 @@ dependencies = [
  "parking_lot",
  "profiling",
  "raw-window-handle",
- "rustc-hash",
+ "rustc-hash 1.1.0",
  "smallvec",
  "thiserror 2.0.18",
  "wgpu-hal",
@@ -2025,7 +2386,7 @@ dependencies = [
  "range-alloc",
  "raw-window-handle",
  "renderdoc-sys",
- "rustc-hash",
+ "rustc-hash 1.1.0",
  "smallvec",
  "thiserror 2.0.18",
  "wasm-bindgen",
@@ -2347,6 +2708,18 @@ version = "0.8.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
 
+[[package]]
+name = "yazi"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
+
+[[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.48"
diff --git a/Cargo.toml b/Cargo.toml
index 6c06c0a..e0bd151 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,9 +3,14 @@ name = "clear-ui"
 version = "0.1.0"
 edition = "2021"
 
+[lib]
+name = "clear_ui"
+path = "src/lib.rs"
+
 [dependencies]
 winit = "0.30"
 wgpu = "24"
 bytemuck = { version = "1", features = ["derive"] }
 pollster = "0.4"
-taffy = "0.10.1"
+glyphon = "0.8"
+tokio = { version = "1", features = ["full"] }
diff --git a/src/color.rs b/src/color.rs
new file mode 100644
index 0000000..3dd05c3
--- /dev/null
+++ b/src/color.rs
@@ -0,0 +1,35 @@
+pub const HEADER_BG: [f32; 4] = [0.08, 0.08, 0.12, 1.0];
+pub const HEADER_ACCENT: [f32; 4] = [0.60, 0.40, 0.20, 1.0];
+pub const SIDEBAR_BG: [f32; 4] = [0.10, 0.10, 0.13, 1.0];
+pub const CONTENT_BG: [f32; 4] = [0.13, 0.13, 0.16, 1.0];
+pub const PANEL_IDLE: [f32; 4] = [0.14, 0.70, 0.38, 1.0];
+pub const PANEL_DRAG: [f32; 4] = [0.24, 0.85, 0.50, 1.0];
+pub const BUTTON_IDLE: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
+pub const BUTTON_HOVER: [f32; 4] = [0.30, 0.52, 0.78, 1.0];
+pub const BUTTON_PRESS: [f32; 4] = [0.12, 0.28, 0.50, 1.0];
+pub const STATUS_BG: [f32; 4] = [0.06, 0.06, 0.10, 1.0];
+pub const STATUS_ACCENT: [f32; 4] = [0.20, 0.20, 0.25, 1.0];
+pub const RESET_BTN_IDLE: [f32; 4] = [0.55, 0.20, 0.20, 1.0];
+pub const RESET_BTN_HOVER: [f32; 4] = [0.70, 0.30, 0.30, 1.0];
+pub const RESET_BTN_PRESS: [f32; 4] = [0.40, 0.12, 0.12, 1.0];
+pub const CHECKBOX_BG: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
+pub const CHECKBOX_CHECKED: [f32; 4] = [0.20, 0.50, 0.75, 1.0];
+pub const CHECKBOX_HOVER: [f32; 4] = [0.25, 0.25, 0.30, 1.0];
+pub const TOGGLE_OFF: [f32; 4] = [0.25, 0.25, 0.30, 1.0];
+pub const TOGGLE_ON: [f32; 4] = [0.14, 0.70, 0.38, 1.0];
+pub const TOGGLE_HOVER: [f32; 4] = [0.30, 0.30, 0.35, 1.0];
+pub const SLIDER_TRACK: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
+pub const SLIDER_THUMB: [f32; 4] = [0.60, 0.60, 0.65, 1.0];
+pub const SLIDER_THUMB_DRAG: [f32; 4] = [0.80, 0.80, 0.85, 1.0];
+pub const PROGRESS_BG: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
+pub const PROGRESS_FILL: [f32; 4] = [0.20, 0.50, 0.75, 1.0];
+
+pub const SPINBOX_BG: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
+pub const SPINBOX_BUTTON: [f32; 4] = [0.25, 0.25, 0.32, 1.0];
+pub const SPINBOX_BUTTON_HOVER: [f32; 4] = [0.35, 0.35, 0.42, 1.0];
+pub const SPINBOX_DISPLAY: [f32; 4] = [0.12, 0.12, 0.16, 1.0];
+
+pub const TEXT_FG: [f32; 4] = [0.80, 0.80, 0.85, 1.0];
+pub const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+pub const TEXT_HEADER: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
+pub const TEXT_ACCENT: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
diff --git a/src/layout.rs b/src/layout.rs
new file mode 100644
index 0000000..8a167c6
--- /dev/null
+++ b/src/layout.rs
@@ -0,0 +1,112 @@
+use crate::widget::Widget;
+
+pub trait RenderTarget {
+    fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32);
+    fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]);
+}
+
+fn render_widget<T: Widget>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32) {
+    w.set_rect(x, y, ww, wh);
+    for (qx, qy, qw, qh, qc) in w.extra_quads() {
+        pc.rect(qc, qx, qy, qw, qh);
+    }
+    for label in w.text_labels() {
+        pc.text(
+            &label.text,
+            label.x,
+            label.y,
+            label.font_size,
+            [
+                label.color[0] as f32 / 255.0,
+                label.color[1] as f32 / 255.0,
+                label.color[2] as f32 / 255.0,
+                1.0,
+            ],
+        );
+    }
+}
+
+pub struct Column<'a> {
+    pc: &'a mut dyn RenderTarget,
+    ox: f32,
+    oy: f32,
+    cx: f32,
+    pub y: f32,
+    pub cw: f32,
+}
+
+impl<'a> Column<'a> {
+    pub fn new(pc: &'a mut dyn RenderTarget, ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
+        Self { pc, ox, oy, cx, y: cy, cw }
+    }
+
+    fn ax(&self, x_off: f32) -> f32 {
+        self.ox + self.cx + x_off
+    }
+
+    fn ay(&self) -> f32 {
+        self.oy + self.y
+    }
+
+    pub fn spacing(&mut self, dy: f32) {
+        self.y += dy;
+    }
+
+    pub fn separator(&mut self) {
+        let x = self.ax(8.0);
+        let y = self.ay();
+        self.pc
+            .rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
+        self.y += 8.0;
+    }
+
+    pub fn header(&mut self, text: &str, x_off: f32) {
+        let x = self.ax(x_off);
+        let y = self.ay();
+        self.pc
+            .text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
+        self.y += 22.0;
+    }
+
+    pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+        let x = self.ax(x_off);
+        let y = self.ay() + y_off;
+        self.pc
+            .text(text, x, y, font_size, color);
+    }
+
+    pub fn widget<T: Widget>(&mut self, w: &mut T, x_off: f32, ww: f32, wh: f32) {
+        let x = self.ax(x_off);
+        let y = self.ay();
+        render_widget(self.pc, w, x, y, ww, wh);
+        self.y += wh;
+    }
+
+    pub fn row<F: FnOnce(&mut Row)>(&mut self, h: f32, f: F) {
+        let row_y = self.ay();
+        let mut row = Row {
+            pc: &mut *self.pc,
+            base_x: self.ox + self.cx,
+            y: row_y,
+        };
+        f(&mut row);
+        self.y = self.y + h;
+    }
+}
+
+pub struct Row<'a> {
+    pc: &'a mut dyn RenderTarget,
+    base_x: f32,
+    y: f32,
+}
+
+impl<'a> Row<'a> {
+    pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+        self.pc
+            .text(text, self.base_x + x_off, self.y + y_off, font_size, color);
+    }
+
+    pub fn widget<T: Widget>(&mut self, w: &mut T, x_off: f32, ww: f32, wh: f32) {
+        render_widget(self.pc, w, self.base_x + x_off, self.y, ww, wh);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..b6515ea
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod color;
+pub mod widget;
+pub mod layout;
+
+pub mod colors {
+    pub use crate::color::*;
+}
+
+pub const SHADER: &str = include_str!("shader.wgsl");
diff --git a/src/main.rs b/src/main.rs
index d5d17a7..5b69fc1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,13 @@ use winit::application::ApplicationHandler;
 use winit::event::{ElementState, MouseButton, WindowEvent};
 use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
 use winit::window::{Window, WindowAttributes};
-use taffy::prelude::TaffyMaxContent;
+
+use clear_ui::widget::{Button, Checkbox, ContentBg, Header, Panel, ProgressBar, Sidebar, Slider, Spinbox, StatusBar, Toggle, Widget};
+
+use glyphon::{
+    Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
+    TextBounds, TextRenderer, Viewport,
+};
 
 #[repr(C)]
 #[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -48,658 +54,17 @@ fn quad_vertices(
     ]
 }
 
-mod colors {
-    pub const HEADER_BG: [f32; 4] = [0.08, 0.08, 0.12, 1.0];
-    pub const HEADER_ACCENT: [f32; 4] = [0.60, 0.40, 0.20, 1.0];
-    pub const SIDEBAR_BG: [f32; 4] = [0.10, 0.10, 0.13, 1.0];
-    pub const CONTENT_BG: [f32; 4] = [0.13, 0.13, 0.16, 1.0];
-    pub const PANEL_IDLE: [f32; 4] = [0.14, 0.70, 0.38, 1.0];
-    pub const PANEL_DRAG: [f32; 4] = [0.24, 0.85, 0.50, 1.0];
-    pub const BUTTON_IDLE: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
-    pub const BUTTON_HOVER: [f32; 4] = [0.30, 0.52, 0.78, 1.0];
-    pub const BUTTON_PRESS: [f32; 4] = [0.12, 0.28, 0.50, 1.0];
-    pub const STATUS_BG: [f32; 4] = [0.06, 0.06, 0.10, 1.0];
-    pub const STATUS_ACCENT: [f32; 4] = [0.20, 0.20, 0.25, 1.0];
-    pub const RESET_BTN_IDLE: [f32; 4] = [0.55, 0.20, 0.20, 1.0];
-    pub const RESET_BTN_HOVER: [f32; 4] = [0.70, 0.30, 0.30, 1.0];
-    pub const RESET_BTN_PRESS: [f32; 4] = [0.40, 0.12, 0.12, 1.0];
-    pub const CHECKBOX_BG: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
-    pub const CHECKBOX_CHECKED: [f32; 4] = [0.20, 0.50, 0.75, 1.0];
-    pub const CHECKBOX_HOVER: [f32; 4] = [0.25, 0.25, 0.30, 1.0];
-    pub const TOGGLE_OFF: [f32; 4] = [0.25, 0.25, 0.30, 1.0];
-    pub const TOGGLE_ON: [f32; 4] = [0.14, 0.70, 0.38, 1.0];
-    pub const TOGGLE_HOVER: [f32; 4] = [0.30, 0.30, 0.35, 1.0];
-    pub const SLIDER_TRACK: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
-    pub const SLIDER_THUMB: [f32; 4] = [0.60, 0.60, 0.65, 1.0];
-    pub const SLIDER_THUMB_DRAG: [f32; 4] = [0.80, 0.80, 0.85, 1.0];
-    pub const PROGRESS_BG: [f32; 4] = [0.18, 0.18, 0.22, 1.0];
-    pub const PROGRESS_FILL: [f32; 4] = [0.20, 0.50, 0.75, 1.0];
-}
-
-trait Widget {
-    fn rect(&self) -> (f32, f32, f32, f32);
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32);
-
-    fn hit_test(&self, px: f32, py: f32) -> bool {
-        let (x, y, w, h) = self.rect();
-        px >= x && px <= x + w && py >= y && py <= y + h
-    }
-
-    fn cursor_moved(&mut self, _px: f32, _py: f32) -> bool { false }
-    fn mouse_input(&mut self, _button: MouseButton, _state: ElementState, _px: f32, _py: f32) -> bool { false }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        quad_vertices(x, y, w, h, sw, sh, self.color()).to_vec()
-    }
-
-    fn color(&self) -> [f32; 4];
-
-    fn is_dragging(&self) -> bool { false }
-    fn drag_update(&mut self, _px: f32, _py: f32) -> bool { false }
-    fn drag_begin(&mut self, _px: f32, _py: f32) {}
-    fn drag_end(&mut self) {}
-    fn take_click(&mut self) -> bool { false }
-    fn draggable(&self) -> bool { false }
-}
-
-struct Header {
-    x: f32, y: f32, w: f32, h: f32,
-}
-
-impl Header {
-    fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
-}
-
-impl Widget for Header {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::HEADER_BG }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        verts.extend(quad_vertices(x, y, w, h, sw, sh, colors::HEADER_BG));
-        verts.extend(quad_vertices(x, y + h - 2.0, w, 2.0, sw, sh, colors::HEADER_ACCENT));
-        verts
-    }
-}
-
-struct StatusBar {
-    x: f32, y: f32, w: f32, h: f32,
-}
-
-impl StatusBar {
-    fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
-}
-
-impl Widget for StatusBar {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::STATUS_BG }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        verts.extend(quad_vertices(x, y, w, h, sw, sh, colors::STATUS_BG));
-        verts.extend(quad_vertices(x, y, w, 2.0, sw, sh, colors::STATUS_ACCENT));
-        verts
-    }
-}
-
-struct Panel {
-    x: f32, y: f32, w: f32, h: f32,
-    dragging: bool,
-    drag_ox: f32, drag_oy: f32,
-    drag_start_x: f32, drag_start_y: f32,
-}
-
-impl Panel {
-    fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self { x, y, w, h, dragging: false, drag_ox: 0.0, drag_oy: 0.0, drag_start_x: 0.0, drag_start_y: 0.0 }
-    }
-}
-
-impl Widget for Panel {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE } }
-
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {
-                if self.hit_test(px, py) {
-                    self.drag_begin(px, py);
-                    return true;
-                }
-            }
-            ElementState::Released => {
-                if self.dragging { self.drag_end(); return true; }
-            }
-        }
-        false
-    }
-
-    fn is_dragging(&self) -> bool { self.dragging }
-    fn draggable(&self) -> bool { true }
-
-    fn drag_update(&mut self, px: f32, py: f32) -> bool {
-        let nx = px - self.drag_ox;
-        let ny = py - self.drag_oy;
-        if (nx - self.x).abs() > 0.01 || (ny - self.y).abs() > 0.01 {
-            self.x = nx;
-            self.y = ny;
-            return true;
-        }
-        false
-    }
-
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        self.dragging = true;
-        self.drag_ox = px - self.x;
-        self.drag_oy = py - self.y;
-        self.drag_start_x = self.x;
-        self.drag_start_y = self.y;
-    }
-
-    fn drag_end(&mut self) { self.dragging = false; }
-}
-
-enum ButtonKind {
-    Primary,
-    Reset,
-}
-
-struct Button {
-    x: f32, y: f32, w: f32, h: f32,
-    hovering: bool,
-    pressed: bool,
-    just_clicked: bool,
-    kind: ButtonKind,
+fn widget_vertices(w: &dyn Widget, sw: f32, sh: f32) -> Vec<Vertex> {
+    let (x, y, ww, h) = w.rect();
+    quad_vertices(x, y, ww, h, sw, sh, w.color()).to_vec()
 }
 
-impl Button {
-    fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self { x, y, w, h, hovering: false, pressed: false, just_clicked: false, kind: ButtonKind::Primary }
-    }
-
-    fn new_reset(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self { x, y, w, h, hovering: false, pressed: false, just_clicked: false, kind: ButtonKind::Reset }
-    }
-}
-
-impl Widget for Button {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] {
-        match self.kind {
-            ButtonKind::Primary => {
-                if self.pressed { colors::BUTTON_PRESS }
-                else if self.hovering { colors::BUTTON_HOVER }
-                else { colors::BUTTON_IDLE }
-            }
-            ButtonKind::Reset => {
-                if self.pressed { colors::RESET_BTN_PRESS }
-                else if self.hovering { colors::RESET_BTN_HOVER }
-                else { colors::RESET_BTN_IDLE }
-            }
-        }
-    }
-
-    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
-        let was = self.hovering;
-        self.hovering = self.hit_test(px, py);
-        was != self.hovering
-    }
-
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {
-                if self.hit_test(px, py) {
-                    self.pressed = true;
-                    return true;
-                }
-            }
-            ElementState::Released => {
-                if self.pressed && self.hit_test(px, py) {
-                    self.just_clicked = true;
-                }
-                let was = self.pressed;
-                self.pressed = false;
-                return was;
-            }
-        }
-        false
-    }
-
-    fn take_click(&mut self) -> bool {
-        if self.just_clicked { self.just_clicked = false; true } else { false }
-    }
-}
-
-struct Sidebar {
-    x: f32, y: f32, w: f32, h: f32,
-}
-
-impl Sidebar {
-    fn new(w: f32) -> Self { Self { x: 0.0, y: 0.0, w, h: 0.0 } }
-}
-
-impl Widget for Sidebar {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::SIDEBAR_BG }
-}
-
-struct ContentBg {
-    x: f32, y: f32, w: f32, h: f32,
-}
-
-impl ContentBg {
-    fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
-}
-
-impl Widget for ContentBg {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::CONTENT_BG }
-}
-
-struct Checkbox {
-    x: f32, y: f32, w: f32, h: f32,
-    hovering: bool,
-    checked: bool,
-    just_clicked: bool,
-}
-
-impl Checkbox {
-    fn new() -> Self {
-        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, checked: false, just_clicked: false }
-    }
-}
-
-impl Widget for Checkbox {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::CHECKBOX_BG }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        let bg = if self.hovering { colors::CHECKBOX_HOVER } else { colors::CHECKBOX_BG };
-        verts.extend(quad_vertices(x, y, w, h, sw, sh, bg));
-        if self.checked {
-            let inset = w * 0.2;
-            verts.extend(quad_vertices(x + inset, y + inset, w - inset * 2.0, h - inset * 2.0, sw, sh, colors::CHECKBOX_CHECKED));
-        }
-        verts
-    }
-
-    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
-        let was = self.hovering;
-        self.hovering = self.hit_test(px, py);
-        was != self.hovering
-    }
-
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {}
-            ElementState::Released => {
-                if self.hit_test(px, py) {
-                    self.checked = !self.checked;
-                    self.just_clicked = true;
-                    return true;
-                }
-            }
-        }
-        false
-    }
-
-    fn take_click(&mut self) -> bool {
-        if self.just_clicked { self.just_clicked = false; true } else { false }
-    }
-}
-
-struct Toggle {
-    x: f32, y: f32, w: f32, h: f32,
-    hovering: bool,
-    toggled: bool,
-    just_toggled: bool,
-}
-
-impl Toggle {
-    fn new() -> Self {
-        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, toggled: false, just_toggled: false }
-    }
-}
-
-impl Widget for Toggle {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { if self.toggled { colors::TOGGLE_ON } else { colors::TOGGLE_OFF } }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        let bg = if self.toggled { colors::TOGGLE_ON } else if self.hovering { colors::TOGGLE_HOVER } else { colors::TOGGLE_OFF };
-        verts.extend(quad_vertices(x, y, w, h, sw, sh, bg));
-        let thumb_size = h * 0.7;
-        let thumb_y = y + (h - thumb_size) * 0.5;
-        let thumb_x = if self.toggled { x + w - thumb_size - (h * 0.15) } else { x + (h * 0.15) };
-        verts.extend(quad_vertices(thumb_x, thumb_y, thumb_size, thumb_size, sw, sh, [0.95, 0.95, 0.97, 1.0]));
-        verts
-    }
-
-    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
-        let was = self.hovering;
-        self.hovering = self.hit_test(px, py);
-        was != self.hovering
-    }
-
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {}
-            ElementState::Released => {
-                if self.hit_test(px, py) {
-                    self.toggled = !self.toggled;
-                    self.just_toggled = true;
-                    return true;
-                }
-            }
-        }
-        false
-    }
-}
-
-struct Slider {
-    x: f32, y: f32, w: f32, h: f32,
-    dragging: bool,
-    value: f32,
-    drag_offset: f32,
-}
-
-impl Slider {
-    fn new() -> Self {
-        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, dragging: false, value: 0.5, drag_offset: 0.0 }
-    }
-}
-
-impl Widget for Slider {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::SLIDER_TRACK }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        let track_h = h * 0.3;
-        let track_y = y + (h - track_h) * 0.5;
-        verts.extend(quad_vertices(x, track_y, w, track_h, sw, sh, colors::SLIDER_TRACK));
-
-        let thumb_size = h * 0.9;
-        let range = w - thumb_size;
-        let thumb_x = x + self.value * range;
-        let thumb_y = y + (h - thumb_size) * 0.5;
-        let thumb_color = if self.dragging { colors::SLIDER_THUMB_DRAG } else { colors::SLIDER_THUMB };
-        verts.extend(quad_vertices(thumb_x, thumb_y, thumb_size, thumb_size, sw, sh, thumb_color));
-        verts
-    }
-
-    fn draggable(&self) -> bool { true }
-    fn is_dragging(&self) -> bool { self.dragging }
-
-    fn drag_update(&mut self, px: f32, _py: f32) -> bool {
-        let (sx, _, sw, _) = self.rect();
-        let thumb_size = self.h * 0.9;
-        let range = sw - thumb_size;
-        let raw = (px - self.drag_offset - sx) / range;
-        let new_val = raw.clamp(0.0, 1.0);
-        if (new_val - self.value).abs() > 0.001 {
-            self.value = new_val;
-            return true;
-        }
-        false
-    }
-
-    fn drag_begin(&mut self, px: f32, _py: f32) {
-        self.dragging = true;
-        let thumb_size = self.h * 0.9;
-        let thumb_x = self.x + self.value * (self.w - thumb_size);
-        self.drag_offset = px - thumb_x;
-    }
-
-    fn drag_end(&mut self) { self.dragging = false; }
-}
-
-struct ProgressBar {
-    x: f32, y: f32, w: f32, h: f32,
-    value: f32,
-}
-
-impl ProgressBar {
-    fn new(value: f32) -> Self {
-        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value }
-    }
-}
-
-impl Widget for ProgressBar {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::PROGRESS_BG }
-
-    fn vertices(&self, sw: f32, sh: f32) -> Vec<Vertex> {
-        let (x, y, w, h) = self.rect();
-        let mut verts = Vec::new();
-        verts.extend(quad_vertices(x, y, w, h, sw, sh, colors::PROGRESS_BG));
-        let fill_w = w * self.value;
-        if fill_w > 0.0 {
-            verts.extend(quad_vertices(x, y, fill_w, h, sw, sh, colors::PROGRESS_FILL));
-        }
-        verts
-    }
-}
-
-use taffy::geometry::{Rect, Size};
-use taffy::style::{Dimension, FlexDirection, LengthPercentage, Style};
-
-struct Layout {
-    tree: taffy::TaffyTree<()>,
-    root: taffy::NodeId,
-    widget_nodes: Vec<taffy::NodeId>,
-}
-
-impl Layout {
-    fn new(width: f32, height: f32) -> Self {
-        let mut tree = taffy::TaffyTree::new();
-
-        let header = tree.new_leaf(Style {
-            size: Size { width: Dimension::percent(1.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let sidebar = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(60.0), height: Dimension::percent(1.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let content_bg = tree.new_leaf(Style {
-            flex_grow: 1.0,
-            size: Size { width: Dimension::percent(1.0), height: Dimension::percent(1.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let btn_a = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(140.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let btn_b = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(140.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let btn_c = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(140.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let panel = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(400.0), height: Dimension::length(250.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let click_me = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(140.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let reset = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(140.0), height: Dimension::length(40.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let checkbox = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(24.0), height: Dimension::length(24.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let toggle = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(48.0), height: Dimension::length(24.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let progress_bar = tree.new_leaf(Style {
-            flex_grow: 1.0,
-            size: Size { width: Dimension::length(160.0), height: Dimension::length(24.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let slider = tree.new_leaf(Style {
-            size: Size { width: Dimension::length(300.0), height: Dimension::length(32.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let status_bar = tree.new_leaf(Style {
-            size: Size { width: Dimension::percent(1.0), height: Dimension::length(28.0) },
-            ..Default::default()
-        }).unwrap();
-
-        let btn_row_1 = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Row,
-                gap: Size { width: LengthPercentage::length(10.0), height: LengthPercentage::length(0.0) },
-                ..Default::default()
-            },
-            &[btn_a, btn_b, btn_c],
-        ).unwrap();
-
-        let btn_row_2 = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Row,
-                gap: Size { width: LengthPercentage::length(10.0), height: LengthPercentage::length(0.0) },
-                ..Default::default()
-            },
-            &[click_me, reset],
-        ).unwrap();
-
-        let controls_row = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Row,
-                align_items: Some(taffy::style::AlignItems::Center),
-                gap: Size { width: LengthPercentage::length(16.0), height: LengthPercentage::length(0.0) },
-                ..Default::default()
-            },
-            &[checkbox, toggle, progress_bar],
-        ).unwrap();
-
-        let _content = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Column,
-                flex_grow: 1.0,
-                padding: Rect {
-                    top: LengthPercentage::length(20.0),
-                    left: LengthPercentage::length(20.0),
-                    bottom: LengthPercentage::length(20.0),
-                    right: LengthPercentage::length(20.0),
-                },
-                gap: Size { width: LengthPercentage::length(0.0), height: LengthPercentage::length(12.0) },
-                ..Default::default()
-            },
-            &[btn_row_1, panel, btn_row_2, controls_row, slider],
-        ).unwrap();
-
-        let body = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Row,
-                flex_grow: 1.0,
-                size: Size { width: Dimension::percent(1.0), height: Dimension::auto() },
-                ..Default::default()
-            },
-            &[sidebar, content_bg],
-        ).unwrap();
-
-        let root = tree.new_with_children(
-            Style {
-                display: taffy::style::Display::Flex,
-                flex_direction: FlexDirection::Column,
-                size: Size { width: Dimension::length(width), height: Dimension::length(height) },
-                ..Default::default()
-            },
-            &[header, body, status_bar],
-        ).unwrap();
-
-        let widget_nodes = vec![
-            header,
-            sidebar,
-            content_bg,
-            btn_a,
-            btn_b,
-            btn_c,
-            panel,
-            click_me,
-            reset,
-            checkbox,
-            toggle,
-            progress_bar,
-            slider,
-            status_bar,
-        ];
-
-        let mut layout = Self { tree, root, widget_nodes };
-        layout.compute(width, height);
-        layout
-    }
-
-    fn compute(&mut self, width: f32, height: f32) {
-        self.tree.set_style(self.root, Style {
-            size: Size { width: Dimension::length(width), height: Dimension::length(height) },
-            ..Default::default()
-        }).unwrap();
-
-        self.tree.compute_layout(self.root, Size::MAX_CONTENT).unwrap();
-    }
-
-    fn widget_rect(&self, node_id: taffy::NodeId) -> (f32, f32, f32, f32) {
-        let layout = self.tree.layout(node_id).unwrap();
-        let x = layout.location.x;
-        let y = layout.location.y;
-        let w = layout.size.width;
-        let h = layout.size.height;
-        (x, y, w, h)
-    }
-
-    fn resize(&mut self, width: f32, height: f32) {
-        self.compute(width, height);
-    }
+fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
+    let metrics = Metrics::new(size, size * 1.4);
+    let mut buffer = Buffer::new(font_system, metrics);
+    buffer.set_text(font_system, text, Attrs::new(), glyphon::Shaping::Advanced);
+    buffer.shape_until_scroll(font_system, true);
+    buffer
 }
 
 struct State {
@@ -713,23 +78,41 @@ struct State {
     vertex_count: u32,
 
     widgets: Vec<Box<dyn Widget>>,
-    layout: Layout,
+    positions: Vec<(f32, f32, f32, f32)>,
+
+    font_system: FontSystem,
+    swash_cache: SwashCache,
+    text_atlas: TextAtlas,
+    text_renderer: TextRenderer,
+    text_viewport: Viewport,
+
+    label_buffer: Buffer,
+    status_buffer: Buffer,
 
     drag_widget: Option<usize>,
+    focused_widget: Option<usize>,
     click_count: u32,
 
     cursor_x: f32,
     cursor_y: f32,
 
-    width: u32,
-    height: u32,
+    width: f32,
+    height: f32,
+    physical_width: u32,
+    physical_height: u32,
+    scale: f64,
 }
 
 impl State {
     async fn new(window: Arc<Window>) -> Self {
-        let size = window.inner_size();
-        let sw = size.width as f32;
-        let sh = size.height as f32;
+        let scale = window.scale_factor();
+        let physical_size = window.inner_size();
+        let pw = physical_size.width.max(1);
+        let ph = physical_size.height.max(1);
+        let lw = pw as f32 / scale as f32;
+        let lh = ph as f32 / scale as f32;
+        let sw = lw;
+        let sh = lh;
 
         let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
             backends: wgpu::Backends::VULKAN,
@@ -764,7 +147,7 @@ impl State {
             .expect("Failed to create device");
 
         let config = surface
-            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
+            .get_default_config(&adapter, pw, ph)
             .expect("Failed to get surface config");
         surface.configure(&device, &config);
 
@@ -817,25 +200,39 @@ impl State {
             cache: None,
         });
 
-        let layout = Layout::new(sw, sh);
+        // Initialize text rendering
+        let mut font_system = FontSystem::new();
+        let swash_cache = SwashCache::new();
+        let cache = Cache::new(&device);
+        let mut text_atlas = TextAtlas::new(&device, &queue, &cache, config.format);
+        let text_renderer = TextRenderer::new(&mut text_atlas, &device, wgpu::MultisampleState::default(), None);
+
+        let mut text_viewport = Viewport::new(&device, &cache);
+        text_viewport.update(&queue, Resolution { width: pw, height: ph });
+
+        let label_buffer = make_text_buffer(&mut font_system, "Hello, Clear UI!", 16.0);
+        let status_buffer = make_text_buffer(&mut font_system, "Click a button to interact", 12.0);
 
         let widgets: Vec<Box<dyn Widget>> = vec![
-            Box::new(Header::new()),                        // 0
-            Box::new(Sidebar::new(60.0)),                   // 1
-            Box::new(ContentBg::new()),                     // 2
-            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),  // 3
-            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),  // 4
-            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),  // 5
-            Box::new(Panel::new(0.0, 0.0, 400.0, 250.0)),  // 6
-            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),  // 7
-            Box::new(Button::new_reset(0.0, 0.0, 140.0, 40.0)), // 8
-            Box::new(Checkbox::new()),                       // 9
-            Box::new(Toggle::new()),                         // 10
-            Box::new(ProgressBar::new(0.65)),                // 11
-            Box::new(Slider::new()),                         // 12
-            Box::new(StatusBar::new()),                      // 13
+            Box::new(Header::new()),
+            Box::new(Sidebar::new(60.0)),
+            Box::new(ContentBg::new()),
+            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),
+            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),
+            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),
+            Box::new(Panel::new(0.0, 0.0, 400.0, 250.0)),
+            Box::new(Button::new(0.0, 0.0, 140.0, 40.0)),
+            Box::new(Button::new_reset(0.0, 0.0, 140.0, 40.0)),
+            Box::new(Checkbox::new()),
+            Box::new(Toggle::new()),
+            Box::new(ProgressBar::new(0.65)),
+            Box::new(Slider::new()),
+            Box::new(StatusBar::new()),
+            Box::new(Spinbox::new(0, -10, 10, 1)),
         ];
 
+        let positions = demo_positions(sw, sh);
+
         let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("Vertex Buffer"),
             size: 1,
@@ -853,13 +250,24 @@ impl State {
             vertex_buffer,
             vertex_count: 0,
             widgets,
-            layout,
+            positions,
+            font_system,
+            swash_cache,
+            text_atlas,
+            text_renderer,
+            text_viewport,
+            label_buffer,
+            status_buffer,
             drag_widget: None,
+            focused_widget: None,
             click_count: 0,
             cursor_x: 0.0,
             cursor_y: 0.0,
-            width: size.width,
-            height: size.height,
+            width: lw,
+            height: lh,
+            physical_width: pw,
+            physical_height: ph,
+            scale,
         };
 
         state.apply_layout();
@@ -868,23 +276,26 @@ impl State {
     }
 
     fn apply_layout(&mut self) {
-        for (i, node_id) in self.layout.widget_nodes.iter().enumerate() {
+        for (i, pos) in self.positions.iter().enumerate() {
             if let Some(widget) = self.widgets.get_mut(i) {
                 if widget.is_dragging() {
                     continue;
                 }
-                let (x, y, w, h) = self.layout.widget_rect(*node_id);
+                let (x, y, w, h) = *pos;
                 widget.set_rect(x, y, w, h);
             }
         }
     }
 
     fn collect_vertices(&self) -> Vec<Vertex> {
-        let sw = self.width as f32;
-        let sh = self.height as f32;
+        let sw = self.width;
+        let sh = self.height;
         let mut verts = Vec::new();
         for w in &self.widgets {
-            verts.extend(w.vertices(sw, sh));
+            verts.extend(widget_vertices(w.as_ref(), sw, sh));
+            for (qx, qy, qw, qh, qc) in w.extra_quads() {
+                verts.extend(quad_vertices(qx, qy, qw, qh, sw, sh, qc));
+            }
         }
         verts
     }
@@ -905,14 +316,104 @@ impl State {
         self.queue.write_buffer(&self.vertex_buffer, 0, data);
     }
 
+    fn update_status_text(&mut self, text: &str) {
+        self.status_buffer = make_text_buffer(&mut self.font_system, text, 12.0);
+    }
+
+    fn prepare_text(&mut self) {
+        let Self {
+            ref mut text_renderer,
+            ref device,
+            ref queue,
+            ref mut font_system,
+            ref mut text_atlas,
+            ref mut text_viewport,
+            ref mut swash_cache,
+            ref label_buffer,
+            ref status_buffer,
+            physical_width,
+            physical_height,
+            scale,
+            ..
+        } = self;
+
+        let viewport = Resolution { width: *physical_width, height: *physical_height };
+        text_viewport.update(queue, viewport);
+
+        let scale_f32 = *scale as f32;
+
+        let mut areas: Vec<TextArea> = vec![
+            TextArea {
+                buffer: label_buffer,
+                left: 80.0 * scale_f32,
+                top: 12.0 * scale_f32,
+                scale: scale_f32,
+                bounds: TextBounds {
+                    left: 0,
+                    top: 0,
+                    right: *physical_width as i32,
+                    bottom: *physical_height as i32,
+                },
+                default_color: glyphon::Color::rgb(0xcc, 0xcc, 0xd4),
+                custom_glyphs: &[],
+            },
+            TextArea {
+                buffer: status_buffer,
+                left: 12.0 * scale_f32,
+                top: *physical_height as f32 - 24.0 * scale_f32,
+                scale: scale_f32,
+                bounds: TextBounds {
+                    left: 0,
+                    top: 0,
+                    right: *physical_width as i32,
+                    bottom: *physical_height as i32,
+                },
+                default_color: glyphon::Color::rgb(0x55, 0x55, 0x66),
+                custom_glyphs: &[],
+            },
+        ];
+
+        let mut widget_buffers: Vec<Buffer> = Vec::new();
+        let mut widget_labels: Vec<TextLabel> = Vec::new();
+        for w in self.widgets.iter() {
+            for label in w.text_labels() {
+                widget_buffers.push(make_text_buffer(font_system, &label.text, label.font_size));
+                widget_labels.push(label);
+            }
+        }
+
+        for (buf, label) in widget_buffers.iter().zip(widget_labels.iter()) {
+            areas.push(TextArea {
+                buffer: buf,
+                left: label.x * scale_f32,
+                top: label.y * scale_f32,
+                scale: scale_f32,
+                bounds: TextBounds {
+                    left: 0,
+                    top: 0,
+                    right: *physical_width as i32,
+                    bottom: *physical_height as i32,
+                },
+                default_color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
+                custom_glyphs: &[],
+            });
+        }
+
+        text_renderer
+            .prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache)
+            .unwrap();
+    }
+
     fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
         if new_size.width > 0 && new_size.height > 0 {
-            self.width = new_size.width;
-            self.height = new_size.height;
+            self.physical_width = new_size.width;
+            self.physical_height = new_size.height;
+            self.width = new_size.width as f32 / self.scale as f32;
+            self.height = new_size.height as f32 / self.scale as f32;
             self.config.width = new_size.width;
             self.config.height = new_size.height;
             self.surface.configure(&self.device, &self.config);
-            self.layout.resize(new_size.width as f32, new_size.height as f32);
+            self.positions = demo_positions(self.width, self.height);
             self.apply_layout();
             self.upload_vertices();
         }
@@ -921,8 +422,8 @@ impl State {
     fn handle_event(&mut self, event: &WindowEvent) -> bool {
         match event {
             WindowEvent::CursorMoved { position, .. } => {
-                self.cursor_x = position.x as f32;
-                self.cursor_y = position.y as f32;
+                self.cursor_x = position.x as f32 / self.scale as f32;
+                self.cursor_y = position.y as f32 / self.scale as f32;
                 let mut changed = false;
 
                 if let Some(idx) = self.drag_widget {
@@ -946,6 +447,9 @@ impl State {
 
                 match btn_state {
                     ElementState::Pressed => {
+                        if let Some(old) = self.focused_widget.take() {
+                            self.widgets[old].unfocus();
+                        }
                         for i in (0..self.widgets.len()).rev() {
                             if self.widgets[i].hit_test(self.cursor_x, self.cursor_y) {
                                 if self.widgets[i].mouse_input(*button, *btn_state, self.cursor_x, self.cursor_y) {
@@ -955,6 +459,8 @@ impl State {
                                     self.widgets[i].drag_begin(self.cursor_x, self.cursor_y);
                                     self.drag_widget = Some(i);
                                 }
+                                self.widgets[i].focus();
+                                self.focused_widget = Some(i);
                                 break;
                             }
                         }
@@ -970,20 +476,40 @@ impl State {
                                 changed = true;
                             }
                         }
+                        let mut clicked = false;
                         for w in &mut self.widgets {
                             if w.take_click() {
-                                self.click_count += 1;
+                                clicked = true;
                             }
                         }
+                        if clicked {
+                            self.click_count += 1;
+                            self.update_status_text(&format!("Clicks: {}", self.click_count));
+                        }
                     }
                 }
                 changed
             }
+            WindowEvent::KeyboardInput { event, .. } => {
+                if let Some(idx) = self.focused_widget {
+                    let val = self.widgets[idx].value();
+                    let changed = self.widgets[idx].keyboard_input(event);
+                    if self.widgets[idx].value() != val {
+                        changed || true
+                    } else {
+                        changed
+                    }
+                } else {
+                    false
+                }
+            }
             _ => false,
         }
     }
 
     fn render(&mut self) {
+        self.prepare_text();
+
         let output = match self.surface.get_current_texture() {
             Ok(t) => t,
             Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
@@ -1021,9 +547,13 @@ impl State {
                 occlusion_query_set: None,
             });
 
+            // Draw colored quads
             pass.set_pipeline(&self.render_pipeline);
             pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
             pass.draw(0..self.vertex_count, 0..1);
+
+            // Draw text
+            self.text_renderer.render(&self.text_atlas, &self.text_viewport, &mut pass).unwrap();
         }
 
         self.queue.submit(std::iter::once(encoder.finish()));
@@ -1083,6 +613,19 @@ impl ApplicationHandler for App {
                 }
                 true
             }
+            WindowEvent::ScaleFactorChanged { scale_factor, mut inner_size_writer } => {
+                if let Some(state) = &mut self.state {
+                    let new_physical = winit::dpi::PhysicalSize::new(
+                        (state.width as f64 * scale_factor) as u32,
+                        (state.height as f64 * scale_factor) as u32,
+                    );
+                    let _ = inner_size_writer.request_inner_size(new_physical);
+                    state.scale = scale_factor;
+                    state.resize(new_physical);
+                    state.window.request_redraw();
+                }
+                true
+            }
             _ => {
                 if let Some(state) = &mut self.state {
                     let prev = state.click_count;
@@ -1110,6 +653,26 @@ impl ApplicationHandler for App {
     }
 }
 
+fn demo_positions(sw: f32, sh: f32) -> Vec<(f32, f32, f32, f32)> {
+    vec![
+        (0.0, 0.0, sw, 40.0),                        // 0 header
+        (0.0, 40.0, 60.0, sh - 68.0),                // 1 sidebar
+        (60.0, 40.0, sw - 60.0, sh - 68.0),          // 2 content_bg
+        (70.0, 50.0, 140.0, 40.0),                   // 3 btn_a
+        (220.0, 50.0, 140.0, 40.0),                  // 4 btn_b
+        (370.0, 50.0, 140.0, 40.0),                  // 5 btn_c
+        (70.0, 100.0, 400.0, 250.0),                 // 6 panel
+        (70.0, 360.0, 140.0, 40.0),                  // 7 click_me
+        (220.0, 360.0, 140.0, 40.0),                 // 8 reset
+        (70.0, 410.0, 24.0, 24.0),                   // 9 checkbox
+        (104.0, 410.0, 48.0, 24.0),                  // 10 toggle
+        (162.0, 410.0, 160.0, 24.0),                 // 11 progress_bar
+        (70.0, 444.0, 300.0, 32.0),                  // 12 slider
+        (70.0, 486.0, 120.0, 32.0),                  // 13 spinbox
+        (0.0, sh - 28.0, sw, 28.0),                  // 14 status_bar
+    ]
+}
+
 fn main() {
     let event_loop = EventLoop::new().unwrap();
     event_loop.set_control_flow(ControlFlow::Poll);
diff --git a/src/widget.rs b/src/widget.rs
new file mode 100644
index 0000000..45af781
--- /dev/null
+++ b/src/widget.rs
@@ -0,0 +1,675 @@
+use winit::event::{ElementState, KeyEvent, MouseButton};
+use winit::keyboard::{Key, NamedKey};
+
+use crate::colors;
+
+pub struct TextLabel {
+    pub text: String,
+    pub x: f32,
+    pub y: f32,
+    pub font_size: f32,
+    pub color: [u8; 3],
+}
+
+pub trait Widget {
+    fn rect(&self) -> (f32, f32, f32, f32);
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32);
+
+    fn hit_test(&self, px: f32, py: f32) -> bool {
+        let (x, y, w, h) = self.rect();
+        px >= x && px <= x + w && py >= y && py <= y + h
+    }
+
+    fn cursor_moved(&mut self, _px: f32, _py: f32) -> bool { false }
+    fn mouse_input(&mut self, _button: MouseButton, _state: ElementState, _px: f32, _py: f32) -> bool { false }
+
+    fn color(&self) -> [f32; 4];
+
+    fn is_dragging(&self) -> bool { false }
+    fn drag_update(&mut self, _px: f32, _py: f32) -> bool { false }
+    fn drag_begin(&mut self, _px: f32, _py: f32) {}
+    fn drag_end(&mut self) {}
+    fn take_click(&mut self) -> bool { false }
+    fn draggable(&self) -> bool { false }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { Vec::new() }
+    fn text_labels(&self) -> Vec<TextLabel> { Vec::new() }
+    fn value(&self) -> i32 { 0 }
+
+    fn focus(&mut self) {}
+    fn unfocus(&mut self) {}
+    fn keyboard_input(&mut self, _event: &KeyEvent) -> bool { false }
+}
+
+pub struct Header {
+    x: f32, y: f32, w: f32, h: f32,
+}
+
+impl Header {
+    pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+}
+
+impl Widget for Header {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::HEADER_BG }
+}
+
+pub struct ContentBg {
+    x: f32, y: f32, w: f32, h: f32,
+}
+
+impl ContentBg {
+    pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+}
+
+impl Widget for ContentBg {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::CONTENT_BG }
+}
+
+pub enum ButtonKind {
+    Primary,
+    Reset,
+}
+
+pub struct Button {
+    x: f32, y: f32, w: f32, h: f32,
+    hovering: bool,
+    pressed: bool,
+    just_clicked: bool,
+    kind: ButtonKind,
+}
+
+impl Button {
+    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+        Self { x, y, w, h, hovering: false, pressed: false, just_clicked: false, kind: ButtonKind::Primary }
+    }
+
+    pub fn new_reset(x: f32, y: f32, w: f32, h: f32) -> Self {
+        Self { x, y, w, h, hovering: false, pressed: false, just_clicked: false, kind: ButtonKind::Reset }
+    }
+}
+
+impl Widget for Button {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] {
+        match self.kind {
+            ButtonKind::Primary => {
+                if self.pressed { colors::BUTTON_PRESS }
+                else if self.hovering { colors::BUTTON_HOVER }
+                else { colors::BUTTON_IDLE }
+            }
+            ButtonKind::Reset => {
+                if self.pressed { colors::RESET_BTN_PRESS }
+                else if self.hovering { colors::RESET_BTN_HOVER }
+                else { colors::RESET_BTN_IDLE }
+            }
+        }
+    }
+
+    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        let was = self.hovering;
+        self.hovering = self.hit_test(px, py);
+        was != self.hovering
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        match state {
+            ElementState::Pressed => {
+                if self.hit_test(px, py) {
+                    self.pressed = true;
+                    return true;
+                }
+            }
+            ElementState::Released => {
+                if self.pressed && self.hit_test(px, py) {
+                    self.just_clicked = true;
+                }
+                let was = self.pressed;
+                self.pressed = false;
+                return was;
+            }
+        }
+        false
+    }
+
+    fn take_click(&mut self) -> bool {
+        if self.just_clicked { self.just_clicked = false; true } else { false }
+    }
+}
+
+pub enum PageButton {
+    Active,
+    Inactive,
+}
+
+pub struct Sidebar {
+    x: f32, y: f32, w: f32, h: f32,
+}
+
+impl Sidebar {
+    pub fn new(w: f32) -> Self { Self { x: 0.0, y: 0.0, w, h: 0.0 } }
+}
+
+impl Widget for Sidebar {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::SIDEBAR_BG }
+}
+
+pub struct Panel {
+    x: f32, y: f32, w: f32, h: f32,
+    dragging: bool,
+    drag_ox: f32, drag_oy: f32,
+    drag_start_x: f32, drag_start_y: f32,
+}
+
+impl Panel {
+    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+        Self { x, y, w, h, dragging: false, drag_ox: 0.0, drag_oy: 0.0, drag_start_x: 0.0, drag_start_y: 0.0 }
+    }
+}
+
+impl Widget for Panel {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE } }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        match state {
+            ElementState::Pressed => {
+                if self.hit_test(px, py) {
+                    self.drag_begin(px, py);
+                    return true;
+                }
+            }
+            ElementState::Released => {
+                if self.dragging { self.drag_end(); return true; }
+            }
+        }
+        false
+    }
+
+    fn is_dragging(&self) -> bool { self.dragging }
+    fn draggable(&self) -> bool { true }
+
+    fn drag_update(&mut self, px: f32, py: f32) -> bool {
+        let nx = px - self.drag_ox;
+        let ny = py - self.drag_oy;
+        if (nx - self.x).abs() > 0.01 || (ny - self.y).abs() > 0.01 {
+            self.x = nx;
+            self.y = ny;
+            return true;
+        }
+        false
+    }
+
+    fn drag_begin(&mut self, px: f32, py: f32) {
+        self.dragging = true;
+        self.drag_ox = px - self.x;
+        self.drag_oy = py - self.y;
+        self.drag_start_x = self.x;
+        self.drag_start_y = self.y;
+    }
+
+    fn drag_end(&mut self) { self.dragging = false; }
+}
+
+pub struct Checkbox {
+    x: f32, y: f32, w: f32, h: f32,
+    hovering: bool,
+    checked: bool,
+    just_clicked: bool,
+}
+
+impl Checkbox {
+    pub fn new() -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, checked: false, just_clicked: false }
+    }
+}
+
+impl Widget for Checkbox {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::CHECKBOX_BG }
+
+    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        let was = self.hovering;
+        self.hovering = self.hit_test(px, py);
+        was != self.hovering
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        match state {
+            ElementState::Released => {
+                if self.hit_test(px, py) {
+                    self.checked = !self.checked;
+                    self.just_clicked = true;
+                    return true;
+                }
+            }
+            _ => {}
+        }
+        false
+    }
+
+    fn take_click(&mut self) -> bool {
+        if self.just_clicked { self.just_clicked = false; true } else { false }
+    }
+}
+
+pub struct Toggle {
+    x: f32, y: f32, w: f32, h: f32,
+    hovering: bool,
+    toggled: bool,
+    just_toggled: bool,
+}
+
+impl Toggle {
+    pub fn new() -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, toggled: false, just_toggled: false }
+    }
+}
+
+impl Widget for Toggle {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { if self.toggled { colors::TOGGLE_ON } else { colors::TOGGLE_OFF } }
+
+    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        let was = self.hovering;
+        self.hovering = self.hit_test(px, py);
+        was != self.hovering
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        match state {
+            ElementState::Released => {
+                if self.hit_test(px, py) {
+                    self.toggled = !self.toggled;
+                    self.just_toggled = true;
+                    return true;
+                }
+            }
+            _ => {}
+        }
+        false
+    }
+}
+
+pub struct Slider {
+    x: f32, y: f32, w: f32, h: f32,
+    dragging: bool,
+    value: f32,
+    drag_offset: f32,
+}
+
+impl Slider {
+    pub fn new() -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, dragging: false, value: 0.5, drag_offset: 0.0 }
+    }
+}
+
+impl Widget for Slider {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::SLIDER_TRACK }
+
+    fn draggable(&self) -> bool { true }
+    fn is_dragging(&self) -> bool { self.dragging }
+
+    fn drag_update(&mut self, px: f32, _py: f32) -> bool {
+        let (sx, _, sw, _) = self.rect();
+        let thumb_size = self.h * 0.9;
+        let range = sw - thumb_size;
+        let raw = (px - self.drag_offset - sx) / range;
+        let new_val = raw.clamp(0.0, 1.0);
+        if (new_val - self.value).abs() > 0.001 {
+            self.value = new_val;
+            return true;
+        }
+        false
+    }
+
+    fn drag_begin(&mut self, px: f32, _py: f32) {
+        self.dragging = true;
+        let thumb_size = self.h * 0.9;
+        let thumb_x = self.x + self.value * (self.w - thumb_size);
+        self.drag_offset = px - thumb_x;
+    }
+
+    fn drag_end(&mut self) { self.dragging = false; }
+}
+
+pub struct ProgressBar {
+    x: f32, y: f32, w: f32, h: f32,
+    value: f32,
+}
+
+impl ProgressBar {
+    pub fn new(value: f32) -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value }
+    }
+}
+
+impl Widget for ProgressBar {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::PROGRESS_BG }
+}
+
+pub struct StatusBar {
+    x: f32, y: f32, w: f32, h: f32,
+}
+
+impl StatusBar {
+    pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+}
+
+impl Widget for StatusBar {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::STATUS_BG }
+}
+
+#[derive(Debug, Clone)]
+pub struct Spinbox {
+    x: f32, y: f32, w: f32, h: f32,
+    pub value: i32,
+    min: i32,
+    max: i32,
+    step: i32,
+    editing: bool,
+    edit_buffer: String,
+    hover_dec: bool,
+    hover_inc: bool,
+}
+
+impl Spinbox {
+    pub fn new(value: i32, min: i32, max: i32, step: i32) -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value, min, max, step, editing: false, edit_buffer: String::new(), hover_dec: false, hover_inc: false }
+    }
+}
+
+impl Widget for Spinbox {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { colors::SPINBOX_BG }
+    fn value(&self) -> i32 { self.value }
+
+    fn cursor_moved(&mut self, px: f32, _py: f32) -> bool {
+        if !self.hit_test(px, _py) {
+            let changed = self.hover_dec || self.hover_inc;
+            self.hover_dec = false;
+            self.hover_inc = false;
+            return changed;
+        }
+        let split = self.x + self.w * 0.55;
+        let hd = px >= split && px < split + self.w * 0.225;
+        let hi = px >= split + self.w * 0.225;
+        let changed = hd != self.hover_dec || hi != self.hover_inc;
+        self.hover_dec = hd;
+        self.hover_inc = hi;
+        changed
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        if !self.hit_test(px, py) { return false; }
+        match state {
+            ElementState::Pressed => {
+                let split = self.x + self.w * 0.55;
+                if px >= split && px < split + self.w * 0.225 {
+                    self.value = (self.value + self.step).min(self.max);
+                    true
+                } else if px >= split + self.w * 0.225 {
+                    self.value = (self.value - self.step).max(self.min);
+                    true
+                } else if px < split {
+                    self.editing = true;
+                    self.edit_buffer = self.value.to_string();
+                    true
+                } else {
+                    false
+                }
+            }
+            ElementState::Released => {
+                false
+            }
+        }
+    }
+
+    fn focus(&mut self) {
+        self.editing = true;
+        self.edit_buffer = self.value.to_string();
+    }
+
+    fn unfocus(&mut self) {
+        if self.editing {
+            self.editing = false;
+            if let Ok(val) = self.edit_buffer.parse::<i32>() {
+                self.value = val.clamp(self.min, self.max);
+            }
+        }
+    }
+
+    fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+        if !self.editing { return false; }
+        if event.state != ElementState::Pressed { return false; }
+        match &event.logical_key {
+            Key::Named(NamedKey::Backspace) => {
+                self.edit_buffer.pop();
+                true
+            }
+            Key::Named(NamedKey::Enter) => {
+                if let Ok(val) = self.edit_buffer.parse::<i32>() {
+                    self.value = val.clamp(self.min, self.max);
+                }
+                self.editing = false;
+                true
+            }
+            Key::Named(NamedKey::Escape) => {
+                self.editing = false;
+                true
+            }
+            _ => {
+                if let Some(text) = &event.text {
+                    if !event.repeat {
+                        for ch in text.chars() {
+                            match ch {
+                                '-' if self.edit_buffer.is_empty() => self.edit_buffer.push('-'),
+                                '0'..='9' => self.edit_buffer.push(ch),
+                                _ => {}
+                            }
+                        }
+                    }
+                }
+                true
+            }
+        }
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let split = self.x + self.w * 0.55;
+        let btn_w = self.w * 0.225;
+        let inc_col = if self.hover_inc { colors::SPINBOX_BUTTON_HOVER } else { colors::SPINBOX_BUTTON };
+        let dec_col = if self.hover_dec { colors::SPINBOX_BUTTON_HOVER } else { colors::SPINBOX_BUTTON };
+        vec![
+            (self.x, self.y, self.w * 0.55, self.h, colors::SPINBOX_DISPLAY),
+            (split, self.y, btn_w, self.h, inc_col),
+            (split + btn_w, self.y, btn_w, self.h, dec_col),
+        ]
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        let value_text = if self.editing { self.edit_buffer.clone() } else { self.value.to_string() };
+        vec![
+            TextLabel {
+                text: value_text,
+                x: self.x + 4.0,
+                y: self.y + 2.0,
+                font_size: 14.0,
+                color: [0xcc, 0xcc, 0xd4],
+            },
+            TextLabel {
+                text: "+".to_string(),
+                x: self.x + self.w * 0.6625 - 4.0,
+                y: self.y + 2.0,
+                font_size: 12.0,
+                color: [0xcc, 0xcc, 0xd4],
+            },
+            TextLabel {
+                text: "-".to_string(),
+                x: self.x + self.w * 0.8875 - 4.0,
+                y: self.y + 2.0,
+                font_size: 12.0,
+                color: [0xcc, 0xcc, 0xd4],
+            },
+        ]
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ColorPicker {
+    x: f32, y: f32, w: f32, h: f32,
+    pub color: [u8; 3],
+    just_clicked: bool,
+    editing: bool,
+    edit_buffer: String,
+    label: Option<String>,
+}
+
+impl ColorPicker {
+    pub fn new(color: [u8; 3]) -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, color, just_clicked: false, editing: false, edit_buffer: String::new(), label: None }
+    }
+
+    pub fn with_label(mut self, label: &str) -> Self {
+        self.label = Some(label.to_string());
+        self
+    }
+}
+
+impl Widget for ColorPicker {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+
+    fn color(&self) -> [f32; 4] {
+        [self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0]
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if button != MouseButton::Left { return false; }
+        if state != ElementState::Pressed { return false; }
+        if !self.hit_test(px, py) { return false; }
+        if px >= self.x + self.w * 0.65 {
+            self.just_clicked = true;
+            return true;
+        }
+        self.focus();
+        true
+    }
+
+    fn take_click(&mut self) -> bool {
+        if self.just_clicked { self.just_clicked = false; true } else { false }
+    }
+
+    fn focus(&mut self) {
+        self.editing = true;
+        self.edit_buffer = format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2]);
+    }
+
+    fn unfocus(&mut self) {
+        if self.editing {
+            self.editing = false;
+            if let Some(c) = parse_hex(&self.edit_buffer) {
+                self.color = c;
+            }
+        }
+    }
+
+    fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+        if !self.editing { return false; }
+        if event.state != ElementState::Pressed { return false; }
+        match &event.logical_key {
+            Key::Named(NamedKey::Backspace) => {
+                self.edit_buffer.pop();
+                true
+            }
+            Key::Named(NamedKey::Enter) => {
+                if let Some(c) = parse_hex(&self.edit_buffer) {
+                    self.color = c;
+                }
+                self.editing = false;
+                true
+            }
+            Key::Named(NamedKey::Escape) => {
+                self.editing = false;
+                true
+            }
+            _ => {
+                if let Some(text) = &event.text {
+                    if !event.repeat {
+                        for ch in text.chars() {
+                            match ch {
+                                '#' if self.edit_buffer.is_empty() => self.edit_buffer.push('#'),
+                                '0'..='9' | 'a'..='f' | 'A'..='F' => {
+                                    if self.edit_buffer.len() < 7 { self.edit_buffer.push(ch.to_ascii_lowercase()); }
+                                }
+                                _ => {}
+                            }
+                        }
+                    }
+                }
+                true
+            }
+        }
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let pick_x = self.x + self.w * 0.65;
+        let pick_w = self.w * 0.35;
+        let (r, g, b, _) = (self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0);
+        vec![
+            (pick_x, self.y, pick_w, self.h, [r, g, b, 1.0]),
+        ]
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        let mut labels = Vec::new();
+        if let Some(ref label) = self.label {
+            labels.push(TextLabel {
+                text: label.clone(),
+                x: self.x + 4.0,
+                y: self.y - 16.0,
+                font_size: 12.0,
+                color: [0x83, 0x83, 0x8a],
+            });
+        }
+        let hex = if self.editing { self.edit_buffer.clone() } else { format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2]) };
+        labels.push(TextLabel {
+            text: hex,
+            x: self.x + 4.0,
+            y: self.y + 3.0,
+            font_size: 12.0,
+            color: [0xcc, 0xcc, 0xd4],
+        });
+        labels
+    }
+}
+
+fn parse_hex(s: &str) -> Option<[u8; 3]> {
+    let s = s.trim_start_matches('#');
+    if s.len() != 6 { return None; }
+    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
+    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
+    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
+    Some([r, g, b])
+}