GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor: split layout.rs and color.rs; vertical text leaves the crate root
layout.rs (7.4k lines) is layout/: mod.rs keeps the sizing constants,
the style getters/setters, reload_config and font resolution — and no
longer names WidgetHost or UiContext; registry.rs is the style registry
and its test overlay; bridge.rs the flat-host render bridge
(RenderTarget, PopoverCollector, render_widget, render_popovers); and
legacy.rs the old layout engines (Column, Section, Grid, the
LayoutStrategy family, PageLayoutBuilder, VStack) that
cce-system-interface, cce-files and cce-gallery still use.
color.rs is color/: mod.rs the constants, statics and getters; load.rs
reading the config into them and retired_surface_keys; math.rs
sRGB/linear, OKLab and the perceptual fade; materials.rs the named
materials and rung bindings; chords.rs the tree/list search keys.
Each mod.rs re-exports its submodules, so every crate::layout:: and
crate::color:: path is unchanged; the moves are git mv'd so history
follows the getters.
IS_VERTICAL / BAR_THICKNESS, two bare pub statics at the crate root that
only the status bar sets, are backend::text::set_vertical_text /
vertical_text beside the shaping that reads them. Still process-wide:
the mode is the app's (every label stacks, text shapes at 1.05), and the
bar flips it at runtime as its shape changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 32 +-
src/backend/text.rs | 27 +-
src/color/chords.rs | 31 +
src/color/load.rs | 602 ++++++++
src/color/materials.rs | 53 +
src/color/math.rs | 141 ++
src/{color.rs => color/mod.rs} | 820 +---------
src/layout/bridge.rs | 563 +++++++
src/layout/legacy.rs | 1959 ++++++++++++++++++++++++
src/{layout.rs => layout/mod.rs} | 3058 +-------------------------------------
src/layout/registry.rs | 469 ++++++
src/lib.rs | 2 -
src/widget/display/label.rs | 10 +-
src/widget/input/text_box.rs | 2 +-
14 files changed, 3932 insertions(+), 3837 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a64d330..776bf1c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,7 +52,7 @@ cargo run -p cce-ui # run the demo/reference app (n
```
Tests are headless unit tests colocated in `#[cfg(test)]` modules — concentrated in `src/scene/*`
-(the arena/layout/paint/anim engine) and `src/color.rs`, `src/layout.rs`, plus a
+(the arena/layout/paint/anim engine) and `src/color/`, `src/layout/`, plus a
scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When touching the scene
engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
anything else.
@@ -856,7 +856,7 @@ the seam with the arrow at the trigger's usual right-hand place, and its left si
narrower than its right.) A textpick row's TextBox ends where its picker begins, so its
text stops at the well. Never grouped into a host
plate (its profile is not a monotonic step); the overlay's host-box slot carries `split`.
-The legacy banded path and flat hosts (`layout.rs`'s bridge) draw the two-box form.
+The legacy banded path and flat hosts (`layout::bridge`) draw the two-box form.
**Every flush control wears the run's edge** (since 2026-10-02): `PaintCtx::inset_plate`
— the one flush control plate, which `ControlPlate`'s Flush stance, every widget and the
@@ -1511,7 +1511,7 @@ lock or screensaver surface, the desktop grid overlay — says so with a
**Spacing is a ladder, and an app never names a number.** Three rungs, each a
config key read through the style registry (so a nested KDL key works and
-live-reloads), each with a getter in `layout.rs`:
+live-reloads), each with a getter in `layout`:
| rung | inset from the rim | gap between siblings |
|---|---|---|
@@ -1638,9 +1638,29 @@ cce-system-interface) to confirm behavior, not just the test suite.
## Module map (where things live)
-- `src/layout.rs` (largest file, ~6.9k lines) — fonts + sizing; many `*_font_parsed()` getters and
- the `read_preferred_fonts` / font-family resolution used by the cosmic-text path.
-- `color.rs` — color model and named colors (`colors` re-export module in `lib.rs`).
+- `layout/` — the style getters and the code that was filed beside them, one module whose
+ `mod.rs` re-exports every submodule, so `crate::layout::…` paths are unchanged (split
+ 2026-10-07 from one 7.4k-line file):
+ - `src/layout/mod.rs` (~4.4k lines) — the sizing constants and the style getters/setters
+ (heights, radii, fonts — many `*_font_parsed()` — gaps, the relief and bevel profile
+ state), `reload_config`, and `read_preferred_fonts` / font-family resolution used by the
+ cosmic-text path. It names no widget: what does is in the two modules below.
+ - `registry.rs` — the style registry (config flattened to one map of keys), its test overlay,
+ the font-string helpers.
+ - `bridge.rs` — the flat-host render bridge: `RenderTarget`, `PopoverCollector`,
+ `render_widget`, `render_popovers`, the carve types that cross it.
+ - `src/layout/legacy.rs` (~2k lines) — the legacy layout engines (`Column`, `Section`, `Grid`,
+ the `LayoutStrategy` family, `PageLayoutBuilder`, `VStack`), still used by
+ cce-system-interface, cce-files and cce-gallery. New layout is `scene::layout`.
+- `color/` — the colour model and named colours (`colors` re-export module in `lib.rs`),
+ split the same way: `mod.rs` the constants, statics and getters; `load.rs` reading the
+ config into them (and `retired_surface_keys`); `math.rs` sRGB/linear, OKLab and the
+ perceptual fade; `materials.rs` the named materials and rung bindings; `chords.rs` the
+ tree/list search keys (input.kdl chords with a legacy colour-file fallback).
+- **Vertical text** is `backend::text::set_vertical_text(Some(bar_thickness))` /
+ `vertical_text()` — the status bar's mode when it stands on a screen edge: labels stack
+ their characters and text shapes at a 1.05 line height. Process-wide on purpose (a property
+ of the app); it was two bare `pub static`s at the crate root until 2026-10-07.
- **`config`, `input`, `motion`, `units`, `relief_spec`, `ipc`** — re-exported from
`cce-core` (see "The GUI-free half is cce-core"), as are `color`'s hex/sRGB helpers,
`scene::paint::DropletSpec` and the ramp spec functions (`widget::{format,parse}_ramp_spec`,
diff --git a/src/backend/text.rs b/src/backend/text.rs
index 9d0c9cd..47b524c 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -75,6 +75,29 @@ fn buffer_tick() -> u64 {
})
}
+/// Vertical text, for a process whose window is a vertical bar (the status bar on a
+/// screen edge): while it is on, a [`TextLabel`](crate::widget::display::TextLabel) stacks
+/// its characters one per line, centred in a column `bar_thickness` px wide, and text is
+/// shaped at a looser 1.05 line height. Process-wide because it is a property of the app,
+/// not of one window or one label, and shaping may run on any thread. It was two bare
+/// `pub static`s at the crate root (`IS_VERTICAL`, `BAR_THICKNESS`) until 2026-10-07.
+pub fn set_vertical_text(bar_thickness: Option<u32>) {
+ use std::sync::atomic::Ordering::Relaxed;
+ if let Some(t) = bar_thickness {
+ VERTICAL_BAR_THICKNESS.store(t, Relaxed);
+ }
+ VERTICAL_TEXT.store(bar_thickness.is_some(), Relaxed);
+}
+
+/// The vertical bar's thickness while vertical text is on (see [`set_vertical_text`]).
+pub fn vertical_text() -> Option<u32> {
+ use std::sync::atomic::Ordering::Relaxed;
+ VERTICAL_TEXT.load(Relaxed).then(|| VERTICAL_BAR_THICKNESS.load(Relaxed))
+}
+
+static VERTICAL_TEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+static VERTICAL_BAR_THICKNESS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(24);
+
/// The cached buffer for `text` under `key`, and its vertical offset.
fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
BUFFER_CACHE.with(|cache| {
@@ -417,7 +440,7 @@ pub(crate) fn shared_text_buffer(
};
let physical_size = font_size * scale;
- let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
+ let is_vertical = vertical_text().is_some();
let key = BufferKey {
size_milli: (physical_size * 1000.0).round() as u32,
font: family_name,
@@ -630,7 +653,7 @@ pub(crate) fn shared_laid_out_buffer(
let key = BufferKey {
size_milli: (physical_size * 1000.0).round() as u32,
font: family,
- is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
+ is_vertical: vertical_text().is_some(),
attrs: text_attrs,
scale_bits: scale.to_bits(),
layout: Some(layout),
diff --git a/src/color/chords.rs b/src/color/chords.rs
new file mode 100644
index 0000000..a6baead
--- /dev/null
+++ b/src/color/chords.rs
@@ -0,0 +1,31 @@
+//! The search keys the tree and list widgets open and close their search with: input.kdl's
+//! `open_search` / `close_search` chords, falling back to the legacy colour-file keys.
+
+use super::*;
+
+pub fn tree_open_search_key() -> String {
+ load_colors_once();
+ let legacy = TREE_OPEN_SEARCH_KEY.read().unwrap().clone();
+ crate::input::widget_chord("open_search", &legacy, "ctrl+f")
+}
+pub fn set_tree_open_search_key(k: String) {
+ style_write(&TREE_OPEN_SEARCH_KEY, k);
+}
+
+pub fn list_open_search_key() -> String {
+ load_colors_once();
+ let legacy = LIST_OPEN_SEARCH_KEY.read().unwrap().clone();
+ crate::input::widget_chord("open_search", &legacy, "ctrl+f")
+}
+pub fn set_list_open_search_key(k: String) {
+ style_write(&LIST_OPEN_SEARCH_KEY, k);
+}
+
+pub fn list_close_search_key() -> String {
+ load_colors_once();
+ let legacy = LIST_CLOSE_SEARCH_KEY.read().unwrap().clone();
+ crate::input::widget_chord("close_search", &legacy, "escape")
+}
+pub fn set_list_close_search_key(k: String) {
+ style_write(&LIST_CLOSE_SEARCH_KEY, k);
+}
diff --git a/src/color/load.rs b/src/color/load.rs
new file mode 100644
index 0000000..45d6567
--- /dev/null
+++ b/src/color/load.rs
@@ -0,0 +1,602 @@
+//! Loading the colour and surface config: reading `config.kdl` into the colour statics
+//! (`parse_and_set_colors`), reporting retired surface keys, and the reload entry points.
+
+use super::*;
+
+fn read_config() -> Option<String> {
+ let path = crate::config::get_config_path();
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ return Some(content);
+ }
+ None
+}
+
+fn parse_and_set_colors(content: &str) {
+ let val = crate::config::parse_kdl_to_json(content);
+
+ let parse_hex = |hex_str: &str| -> Option<[f32; 4]> {
+ let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
+ let hex = hex.trim_start_matches('#');
+ if hex.len() >= 8 {
+ if let (Ok(r), Ok(g), Ok(b), Ok(a)) = (
+ u8::from_str_radix(&hex[0..2], 16),
+ u8::from_str_radix(&hex[2..4], 16),
+ u8::from_str_radix(&hex[4..6], 16),
+ u8::from_str_radix(&hex[6..8], 16),
+ ) {
+ let r_f = srgb_to_linear(r as f32 / 255.0);
+ let g_f = srgb_to_linear(g as f32 / 255.0);
+ let b_f = srgb_to_linear(b as f32 / 255.0);
+ let a_f = a as f32 / 255.0;
+ return Some([r_f, g_f, b_f, a_f]);
+ }
+ } else if hex.len() >= 6 {
+ if let (Ok(r), Ok(g), Ok(b)) = (
+ u8::from_str_radix(&hex[0..2], 16),
+ u8::from_str_radix(&hex[2..4], 16),
+ u8::from_str_radix(&hex[4..6], 16),
+ ) {
+ let r_f = srgb_to_linear(r as f32 / 255.0);
+ let g_f = srgb_to_linear(g as f32 / 255.0);
+ let b_f = srgb_to_linear(b as f32 / 255.0);
+ return Some([r_f, g_f, b_f, 1.0]);
+ }
+ }
+ None
+ };
+
+ let get_color = |pointer: &str| -> Option<[f32; 4]> {
+ val.pointer(pointer).and_then(|v| v.as_str()).and_then(parse_hex)
+ };
+
+ let get_color_u8 = |pointer: &str| -> Option<[u8; 3]> {
+ val.pointer(pointer).and_then(|v| v.as_str()).and_then(|hex_str| {
+ let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
+ let hex = hex.trim_start_matches('#');
+ if hex.len() >= 6 {
+ if let (Ok(r), Ok(g), Ok(b)) = (
+ u8::from_str_radix(&hex[0..2], 16),
+ u8::from_str_radix(&hex[2..4], 16),
+ u8::from_str_radix(&hex[4..6], 16),
+ ) {
+ return Some([r, g, b]);
+ }
+ }
+ None
+ })
+ };
+
+ if let Some(opacity) = val.pointer("/layout/menubar_opacity").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = OPACITY.write() {
+ *lock = Some(opacity as f32);
+ }
+ }
+
+ // `/style/surface/plate/root/...` is the one spelling of the root-plate
+ // style (RFC Phase 7a; the legacy `root plate` read-alias was removed
+ // 2026-09-06 once every live config had migrated).
+ if let Some(radius) = val.pointer("/style/surface/plate/root/corner_radius")
+ .and_then(|v| v.as_f64())
+ {
+ if let Ok(mut lock) = ROOT_PLATE_CORNER_RADIUS.write() {
+ *lock = radius as f32;
+ }
+ }
+
+ if let Some(c) = get_color("/style/surface/plate/root/color") {
+ if let Ok(mut lock) = PAGE_LOW_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = ROOT_PLATE_OPACITY.write() { *lock = Some(c[3]); }
+ }
+
+ if let Some(c) = get_color("/style/surface/plate/root/menubar/color") {
+ if let Ok(mut lock) = ROOT_PLATE_MENUBAR_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/plate/root/menubar/text_color") {
+ if let Ok(mut lock) = ROOT_PLATE_MENUBAR_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(o) = val.pointer("/style/surface/menu/opacity").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = MENU_OPACITY.write() { *lock = (o as f32).clamp(0.0, 1.0); }
+ }
+ if let Some(c) = val.pointer("/style/surface/menu/compression").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = MENU_COMPRESSION.write() { *lock = (c as f32).clamp(0.0, 1.0); }
+ }
+ if let Some(c) = get_color("/style/surface/menu/color") {
+ if let Ok(mut lock) = MENU_COLOR.write() { *lock = Some(c); }
+ }
+
+ let menubar_blur_ptr = val.pointer("/style/surface/plate/root/menubar/blur");
+ if let Some(blur) = menubar_blur_ptr.and_then(|v| v.as_bool()) {
+ if let Ok(mut lock) = ROOT_PLATE_MENUBAR_BLUR.write() { *lock = blur; }
+ } else if let Some(blur_val) = menubar_blur_ptr.and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = ROOT_PLATE_MENUBAR_BLUR.write() { *lock = blur_val > 0.001; }
+ }
+
+ if let Some(c) = get_color("/style/surface/statusbar/color") {
+ if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/statusbar/text_color") {
+ if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(blur) = val.pointer("/style/surface/statusbar/blur").and_then(|v| v.as_bool()) {
+ if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_BLUR.write() { *lock = blur; }
+ } else if let Some(blur_val) = val.pointer("/style/surface/statusbar/blur").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_BLUR.write() { *lock = blur_val > 0.001; }
+ }
+ if let Some(c) = get_color("/layout/color_borders_color") {
+ if let Ok(mut lock) = COLOR_BORDERS_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/layout/paginator_sidebar_color") {
+ if let Ok(mut lock) = SIDEBAR_BG_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/highlight/primary") {
+ if let Ok(mut lock) = HIGHLIGHT_PRIMARY_COLOR.write() { *lock = [c[0], c[1], c[2], 0.12]; }
+ }
+ if let Some(c) = get_color("/style/control/button/background").or_else(|| get_color("/layout/button_background_color")) {
+ if let Ok(mut lock) = BUTTON_BACKGROUND_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/dropdown/color") {
+ if let Ok(mut lock) = DROPDOWN_BACKGROUND_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color_u8("/style/control/textbox/placeholder_text_color")
+ .or_else(|| get_color_u8("/style/textbox/placeholder_text_color"))
+ .or_else(|| get_color_u8("/style/data/textbox/placeholder_text_color")) {
+ if let Ok(mut lock) = TEXTBOX_PLACEHOLDER_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/textbox/background_color")
+ .or_else(|| get_color("/style/textbox/background_color"))
+ .or_else(|| get_color("/style/data/textbox/background_color")) {
+ if let Ok(mut lock) = TEXTBOX_BACKGROUND_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/textbox/background_edit_color")
+ .or_else(|| get_color("/style/textbox/background_edit_color"))
+ .or_else(|| get_color("/style/data/textbox/background_edit_color")) {
+ if let Ok(mut lock) = TEXTBOX_BACKGROUND_EDIT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/layout/menubar_tab_label_color").or_else(|| get_color("/layout/paginator_tab_label_color")) {
+ if let Ok(mut lock) = MENUBAR_TAB_LABEL_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/label/color") {
+ if let Ok(mut lock) = CONTROL_LABEL_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/label/color_detached") {
+ if let Ok(mut lock) = CONTROL_LABEL_COLOR_DETACHED.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/label/hover_color") {
+ if let Ok(mut lock) = CONTROL_LABEL_HOVER_COLOR.write() { *lock = Some(c); }
+ }
+ if let Some(c) = get_color("/style/control/label/focus_color") {
+ if let Ok(mut lock) = CONTROL_LABEL_FOCUS_COLOR.write() { *lock = Some(c); }
+ }
+ // The toggle state colors (enabled/disabled/gradient/background) are not
+ // read: the control inherits its plate — see the note by the retired
+ // getters below.
+ if let Some(c) = get_color("/style/control/ramp/background") {
+ if let Ok(mut lock) = RAMP_BACKGROUND_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/ramp/border_color") {
+ if let Ok(mut lock) = RAMP_BORDER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/control_panel/color") {
+ if let Ok(mut lock) = CONTROL_PANEL_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/control_panel/border_color") {
+ if let Ok(mut lock) = CONTROL_PANEL_BORDER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/spinbox/background_color") {
+ if let Ok(mut lock) = SPINBOX_DISPLAY_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/spinbox/button_color") {
+ if let Ok(mut lock) = SPINBOX_BUTTON_COLOR.write() { *lock = c; }
+ if let Ok(mut lock_hover) = SPINBOX_BUTTON_HOVER_COLOR.write() {
+ *lock_hover = [
+ (c[0] + 0.1).min(1.0),
+ (c[1] + 0.1).min(1.0),
+ (c[2] + 0.1).min(1.0),
+ c[3]
+ ];
+ }
+ }
+ if let Some(c) = get_color("/style/control/spinbox/button_hover_color") {
+ if let Ok(mut lock) = SPINBOX_BUTTON_HOVER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/spinbox/text_color") {
+ if let Ok(mut lock) = SPINBOX_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/button/border_color").or_else(|| get_color("/style/button/border_color")) {
+ if let Ok(mut lock) = BUTTON_BORDER_COLOR.write() { *lock = Some(c); }
+ }
+ if let Some(c) = get_color("/style/control/button/hover_color").or_else(|| get_color("/style/button/hover_color")) {
+ if let Ok(mut lock) = BUTTON_HOVER_COLOR.write() { *lock = Some(c); }
+ }
+ if let Some(c) = get_color("/style/control/dropdown/border_color").or_else(|| get_color("/style/dropdown/border_color")) {
+ if let Ok(mut lock) = DROPDOWN_BORDER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/dropdown/text_color").or_else(|| get_color("/style/dropdown/text_color")) {
+ if let Ok(mut lock) = DROPDOWN_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/slider/thumb_color").or_else(|| get_color("/style/slider/thumb_color")) {
+ if let Ok(mut lock) = SLIDER_THUMB_COLOR.write() { *lock = c; }
+ if let Ok(mut lock_drag) = SLIDER_THUMB_DRAG_COLOR.write() {
+ *lock_drag = [
+ (c[0] + 0.15).min(1.0),
+ (c[1] + 0.15).min(1.0),
+ (c[2] + 0.15).min(1.0),
+ c[3]
+ ];
+ }
+ }
+ if let Some(c) = get_color("/style/control/rangeslider/thumb_color").or_else(|| get_color("/style/rangeslider/thumb_color")) {
+ if let Ok(mut lock) = RANGE_SLIDER_THUMB_COLOR.write() { *lock = c; }
+ if let Ok(mut lock_drag) = RANGE_SLIDER_THUMB_DRAG_COLOR.write() {
+ *lock_drag = [
+ (c[0] + 0.15).min(1.0),
+ (c[1] + 0.15).min(1.0),
+ (c[2] + 0.15).min(1.0),
+ c[3]
+ ];
+ }
+ }
+ if let Some(c) = get_color("/style/control/progressbar/background") {
+ if let Ok(mut lock) = PROGRESS_BG_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/progressbar/fill") {
+ if let Ok(mut lock) = PROGRESS_FILL_COLOR.write() { *lock = c; }
+ }
+ let parsed_list_bg = get_color("/layout/list_bg_color");
+ let parsed_breadcrumb_bg = get_color("/layout/breadcrumb_bg_color");
+ let parsed_popover_bg = get_color("/layout/popover_bg_color");
+
+ if let Some(c) = get_color("/layout/page_color") {
+ if let Ok(mut lock) = PAGE_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/layout/layer_color") {
+ if let Ok(mut lock) = LAYER_COLOR.write() { *lock = c; }
+ }
+
+ if let Some(c) = parsed_list_bg {
+ if let Ok(mut lock) = LIST_BG_COLOR.write() {
+ *lock = [c[0], c[1], c[2], 0.3];
+ }
+ }
+ if let Some(c) = get_color("/layout/list_entry_bg_color") {
+ if let Ok(mut lock) = LIST_ENTRY_BG_COLOR.write() {
+ *lock = c;
+ }
+ }
+ if let Some(c) = get_color("/layout/list_entry_highlight_color") {
+ if let Ok(mut lock) = LIST_ENTRY_HIGHLIGHT_COLOR.write() {
+ *lock = c;
+ }
+ }
+ if let Some(c) = get_color("/style/data/list/font_color").or_else(|| get_color("/layout/list_font_color")) {
+ if let Ok(mut lock) = LIST_FONT_COLOR.write() {
+ *lock = c;
+ }
+ }
+ if let Some(c) = parsed_breadcrumb_bg {
+ if let Ok(mut lock) = BREADCRUMB_BG_COLOR.write() {
+ *lock = [c[0], c[1], c[2], 1.0];
+ }
+ } else if let Some(c) = parsed_list_bg {
+ if let Ok(mut lock) = BREADCRUMB_BG_COLOR.write() {
+ *lock = [c[0], c[1], c[2], 1.0];
+ }
+ }
+ if let Some(c) = parsed_popover_bg {
+ if let Ok(mut lock) = POPOVER_BG_COLOR.write() {
+ *lock = [c[0], c[1], c[2], 1.0];
+ }
+ } else if let Some(c) = parsed_list_bg {
+ if let Ok(mut lock) = POPOVER_BG_COLOR.write() {
+ *lock = [c[0], c[1], c[2], 1.0];
+ }
+ }
+
+ // The lattice's lines. The cells between them have no colour of their
+ // own: a graph shows whatever it is painted on. `cell_color` and
+ // `gap_color`, the cell model's pair, are retired (`retired_surface_keys`).
+ if let Some(c) = get_color("/style/surface/graph/grid_color") {
+ if let Ok(mut lock) = GRAPH_GRID_COLOR.write() { *lock = [c[0], c[1], c[2]]; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/color") {
+ if let Ok(mut lock) = GRAPH_NODE_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = NODE_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/selected_color") {
+ if let Ok(mut lock) = GRAPH_NODE_SELECTED_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = NODE_SELECTED_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/drag_color") {
+ if let Ok(mut lock) = GRAPH_NODE_DRAG_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = NODE_DRAG_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/wire_color") {
+ if let Ok(mut lock) = GRAPH_WIRE_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/wire_highlight_color") {
+ if let Ok(mut lock) = GRAPH_WIRE_HIGHLIGHT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/connector_color") {
+ if let Ok(mut lock) = GRAPH_CONNECTOR_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/graph/node/connector_highlight_color") {
+ if let Ok(mut lock) = GRAPH_CONNECTOR_HIGHLIGHT_COLOR.write() { *lock = c; }
+ }
+ if let Some(opacity) = val.pointer("/style/surface/graph/opacity").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = GRAPH_OPACITY.write() { *lock = opacity as f32; }
+ }
+ if let Some(opacity) = val.pointer("/style/surface/graph/node/opacity").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = GRAPH_NODE_OPACITY.write() { *lock = opacity as f32; }
+ }
+
+ if let Some(c) = get_color("/style/data/tree/background_color") {
+ if let Ok(mut lock) = TREE_BACKGROUND_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/border_color") {
+ if let Ok(mut lock) = TREE_BORDER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/border_hover_color") {
+ if let Ok(mut lock) = TREE_BORDER_HOVER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/border_focus_color") {
+ if let Ok(mut lock) = TREE_BORDER_FOCUS_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/section_bg_color") {
+ if let Ok(mut lock) = TREE_SECTION_BG_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/section_bg_hover_color") {
+ if let Ok(mut lock) = TREE_SECTION_BG_HOVER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_bg_even_color") {
+ if let Ok(mut lock) = TREE_LEAF_BG_EVEN_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_bg_odd_color") {
+ if let Ok(mut lock) = TREE_LEAF_BG_ODD_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_bg_hover_color") {
+ if let Ok(mut lock) = TREE_LEAF_BG_HOVER_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_bg_selected_color") {
+ if let Ok(mut lock) = TREE_LEAF_BG_SELECTED_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/section_text_color") {
+ if let Ok(mut lock) = TREE_SECTION_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_text_color") {
+ if let Ok(mut lock) = TREE_LEAF_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/leaf_text_selected_color") {
+ if let Ok(mut lock) = TREE_LEAF_TEXT_SELECTED_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/type_text_color") {
+ if let Ok(mut lock) = TREE_TYPE_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/value_text_color") {
+ if let Ok(mut lock) = TREE_VALUE_TEXT_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/data/tree/separator_color") {
+ if let Ok(mut lock) = TREE_SEPARATOR_COLOR.write() { *lock = c; }
+ }
+ if let Some(k) = val.pointer("/style/data/tree/open_search").and_then(|v| v.as_str()) {
+ if let Ok(mut lock) = TREE_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
+ }
+ if let Some(k) = val.pointer("/style/data/list/open_search").and_then(|v| v.as_str()) {
+ if let Ok(mut lock) = LIST_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
+ }
+ if let Some(k) = val.pointer("/style/data/list/close_search").and_then(|v| v.as_str()) {
+ if let Ok(mut lock) = LIST_CLOSE_SEARCH_KEY.write() { *lock = k.to_string(); }
+ }
+ if let Some(c) = get_color("/style/control/scrollbar/track_color") {
+ if let Ok(mut lock) = SCROLLBAR_TRACK_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/control/scrollbar/thumb_color") {
+ if let Ok(mut lock) = SCROLLBAR_THUMB_COLOR.write() { *lock = c; }
+ }
+ if let Some(c) = get_color("/style/surface/plate/color") {
+ if let Ok(mut lock) = PLATE_COLOR.write() { *lock = Some(c); }
+ }
+ if let Some(c) = get_color("/style/surface/plate/border_color") {
+ if let Ok(mut lock) = PLATE_BORDER_COLOR.write() { *lock = Some(c); }
+ }
+ if let Some(t) = val.pointer("/style/surface/plate/border_thickness").and_then(|v| v.as_f64()) {
+ if let Ok(mut lock) = PLATE_BORDER_THICKNESS.write() { *lock = t as f32; }
+ }
+ if let Some(c) = get_color("/style/surface/param/color") {
+ if let Ok(mut lock) = PARAM_BG_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = PANE_COLOR_WHOLE.write() { *lock = false; }
+ }
+ // The clear spelling of the same tint: the PANE plate's colour, whole —
+ // its alpha is the tint strength and nothing multiplies it.
+ if let Some(c) = get_color("/style/surface/plate/pane/color") {
+ if let Ok(mut lock) = PARAM_BG_COLOR.write() { *lock = c; }
+ if let Ok(mut lock) = PANE_COLOR_WHOLE.write() { *lock = true; }
+ }
+ // The default plate's frost is ONE block, the same shape a named
+ // material's `frost` child has: `plate { frost radius=5.5 compression=0
+ // refraction=0 }` is frosted with those knobs, a bare `frost` is frosted
+ // at the defaults, `frost (bool)false` is not frosted. The four scattered
+ // keys it replaced on 2026-09-28 (`blur` as the switch, `radius`,
+ // `backdrop_compression`, `refraction`) were read as aliases for the rest
+ // of that day and are retired: a config still carrying one is reported
+ // (`retired_surface_keys`) and the key does nothing, because an alias that
+ // keeps working is a second spelling forever. The block is absent → not
+ // frosted, which is what `blur=true` alone now amounts to; the report
+ // is what tells the user why their plates went sharp.
+ let retired = retired_surface_keys(&val);
+ if !retired.is_empty() {
+ log::warn!(
+ "retired style.surface keys in config: {} — the frost is `plate {{ frost radius= compression= refraction= }}` (a material's `frost` child spells `compression`), every roll's width is `relief width=`, the light strength is `relief light=` (a material's `finish light=`), and the relief's geometry is `relief {{ wall height= profile= ; edge height= profile= }}` (the window_manager bevel_* spellings are the same relief keys, and its bevel_shader is `relief shader=`); a graph's lines are `graph {{ grid_color }}` and its cells take no colour",
+ retired.join(", ")
+ );
+ }
+ match val.pointer("/style/surface/plate/frost") {
+ Some(serde_json::Value::Object(fo)) => {
+ let num = |k: &str| fo.get(k).and_then(|v| v.as_f64()).map(|v| v as f32);
+ if let Ok(mut lock) = PLATE_BLUR.write() { *lock = true; }
+ if let Some(c) = num("compression") {
+ if let Ok(mut lock) = PLATE_BACKDROP_COMPRESSION.write() { *lock = c.clamp(0.0, 1.0); }
+ }
+ if let Some(r) = num("refraction") {
+ if let Ok(mut lock) = PLATE_REFRACTION.write() { *lock = r.clamp(0.0, 1.0); }
+ }
+ if let Some(r) = num("radius") {
+ if let Ok(mut lock) = PLATE_FROST_RADIUS.write() { *lock = r.max(0.0); }
+ }
+ }
+ Some(serde_json::Value::Null) => {
+ if let Ok(mut lock) = PLATE_BLUR.write() { *lock = true; }
+ }
+ Some(serde_json::Value::Bool(on)) => {
+ if let Ok(mut lock) = PLATE_BLUR.write() { *lock = *on; }
+ }
+ _ => {}
+ }
+
+ // The DE finish beyond its strength (`relief.depth` / `light`, which
+ // lives in the style registry): the three terms that were literals.
+ let f = |k: &str| val.pointer(&format!("/style/surface/relief/{k}")).and_then(|v| v.as_f64()).map(|v| v as f32);
+ if let Some(v) = f("spec") {
+ if let Ok(mut lock) = FINISH_SPEC.write() { *lock = v.max(0.0); }
+ }
+ if let Some(v) = f("shininess") {
+ if let Ok(mut lock) = FINISH_SHININESS.write() { *lock = v.max(1.0); }
+ }
+ if let Some(v) = f("curvature") {
+ if let Ok(mut lock) = FINISH_CURVATURE.write() { *lock = v.max(0.0); }
+ }
+
+ // Named materials and the rung bindings (RFC material § 5), replaced
+ // wholesale so a reload forgets what config no longer says.
+ {
+ use crate::scene::material::{FrostDef, MaterialDef};
+ let num = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_f64()).map(|v| v as f32);
+ let mut map: Vec<(String, MaterialDef)> = Vec::new();
+ if let Some(obj) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
+ for (name, node) in obj {
+ let Some(node) = node.as_object() else { continue };
+ let frost = node.get("frost").map(|fr| match fr.as_object() {
+ Some(fo) => FrostDef {
+ compression: num(fo.get("compression")),
+ refraction: num(fo.get("refraction")),
+ radius: num(fo.get("radius")),
+ },
+ // A bare `frost` node (no knobs) is frosted at the defaults.
+ None => FrostDef::default(),
+ });
+ let fin = node.get("finish").and_then(|v| v.as_object());
+ let fk = |k: &str| fin.and_then(|fo| num(fo.get(k)));
+ map.push((
+ name.clone(),
+ MaterialDef {
+ tint: node.get("color").and_then(|v| v.as_str()).and_then(parse_hex),
+ frost,
+ light: fk("light"),
+ spec: fk("spec"),
+ shininess: fk("shininess"),
+ curvature: fk("curvature"),
+ },
+ ));
+ }
+ }
+ if let Ok(mut lock) = NAMED_MATERIALS.write() { *lock = map; }
+ let bind = |p: &str| val.pointer(p).and_then(|v| v.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty());
+ let bindings = [
+ bind("/style/surface/plate/root/material"),
+ bind("/style/surface/plate/material"),
+ bind("/style/control/material"),
+ ];
+ if let Ok(mut lock) = MATERIAL_BINDINGS.write() { *lock = bindings; }
+ }
+}
+
+/// The retired `style.surface` spellings a parsed config still carries, as
+/// the dotted paths a user would grep for: the four flat frost keys under
+/// `plate` (`blur`, `radius`, `backdrop_compression`, `refraction`),
+/// `backdrop_compression` inside any `frost` child (the plate's, or a named
+/// material's), whose one name is `compression`; `plate.bevel_width`, the
+/// pane roll's former width of its own (every roll is `relief.width`); and
+/// `relief.depth` with `depth` inside any material's `finish` child, the
+/// light strength's former name (`light`, since it is not a length); and
+/// the relief's flat geometry keys `height` / `profile` (the wall's, now
+/// `wall.height` / `wall.profile`) and `edge_height` / `edge_profile` (the
+/// edge's, now `edge.height` / `edge.profile`); and `window_manager.bevel_depth`
+/// / `bevel_width` / `bevel_shader`, the block the relief keys were born in
+/// before `style.surface.relief` existed (the compositor never read them;
+/// the shader toggle is `relief.shader` now). Each was an alias for part
+/// of 2026-09-28 and is not read now. And the graph's `cell_color`,
+/// `gap_color` and `uniform_background` (2026-09-29): a graph's cells have
+/// no colour, so there is no fill to switch, and its lines are
+/// `graph.grid_color`. Empty for a clean config.
+pub fn retired_surface_keys(val: &serde_json::Value) -> Vec<String> {
+ let mut found = Vec::new();
+ for k in ["blur", "radius", "backdrop_compression", "refraction", "bevel_width"] {
+ if val.pointer(&format!("/style/surface/plate/{k}")).is_some() {
+ found.push(format!("style.surface.plate.{k}"));
+ }
+ }
+ if val.pointer("/style/surface/plate/frost/backdrop_compression").is_some() {
+ found.push("style.surface.plate.frost.backdrop_compression".to_string());
+ }
+ for k in ["depth", "height", "profile", "edge_height", "edge_profile"] {
+ if val.pointer(&format!("/style/surface/relief/{k}")).is_some() {
+ found.push(format!("style.surface.relief.{k}"));
+ }
+ }
+ for k in ["bevel_depth", "bevel_width", "bevel_shader"] {
+ if val.pointer(&format!("/window_manager/{k}")).is_some() {
+ found.push(format!("window_manager.{k}"));
+ }
+ }
+ if let Some(mats) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
+ for (name, node) in mats {
+ if node.pointer("/frost/backdrop_compression").is_some() {
+ found.push(format!("style.surface.material.{name}.frost.backdrop_compression"));
+ }
+ if node.pointer("/finish/depth").is_some() {
+ found.push(format!("style.surface.material.{name}.finish.depth"));
+ }
+ }
+ }
+ for k in ["cell_color", "gap_color", "uniform_background"] {
+ if val.pointer(&format!("/style/surface/graph/{k}")).is_some() {
+ found.push(format!("style.surface.graph.{k}"));
+ }
+ }
+ found
+}
+
+pub(super) fn load_colors_once() {
+ use std::sync::Once;
+ static INIT: Once = Once::new();
+ INIT.call_once(|| {
+ if let Some(content) = read_config() {
+ parse_and_set_colors(&content);
+ }
+ });
+}
+
+pub fn reload_colors(content: &str) {
+ parse_and_set_colors(content);
+}
+
+/// Test-only: the color state is process-global, so any test that calls
+/// [`reload_colors`] and then asserts getter values races every other such
+/// test on the parallel harness. Each of those tests must hold this lock
+/// across its reload + asserts, and should fire the once-per-process config
+/// loads ([`load_colors_once`] via any getter, and
+/// `layout::lazy_init_style_registry`) inside the lock BEFORE its reload, so
+/// neither can rewrite the state from the live config file mid-assert.
+#[cfg(test)]
+pub(crate) fn test_color_state_lock() -> std::sync::MutexGuard<'static, ()> {
+ static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+ LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+pub fn dropdown_background_color() -> [f32; 4] {
+ load_colors_once();
+ style_read(&DROPDOWN_BACKGROUND_COLOR)
+}
+
+pub fn set_dropdown_background_color(color: [f32; 4]) {
+ style_write(&DROPDOWN_BACKGROUND_COLOR, color);
+}
diff --git a/src/color/materials.rs b/src/color/materials.rs
new file mode 100644
index 0000000..e2c929c
--- /dev/null
+++ b/src/color/materials.rs
@@ -0,0 +1,53 @@
+//! The named materials config defines and the rung bindings (`docs/rfc-material.md` § 5).
+
+use super::*;
+use std::sync::RwLock;
+
+/// The named materials config defines (`style.surface.material { <name> {…} }`)
+/// and the rung bindings (`plate material=`, `plate { root material= }`,
+/// `control material=`) — see `docs/rfc-material.md` § 5. Replaced wholesale
+/// on every config load, so a node or binding removed from config is gone
+/// after a reload.
+pub(super) static NAMED_MATERIALS: RwLock<Vec<(String, crate::scene::material::MaterialDef)>> = RwLock::new(Vec::new());
+pub(super) static MATERIAL_BINDINGS: RwLock<[Option<String>; 3]> = RwLock::new([None, None, None]);
+
+fn rung_index(rung: crate::scene::material::PlateRung) -> usize {
+ match rung {
+ crate::scene::material::PlateRung::Root => 0,
+ crate::scene::material::PlateRung::Pane => 1,
+ crate::scene::material::PlateRung::Control => 2,
+ }
+}
+
+pub fn named_material(name: &str) -> Option<crate::scene::material::MaterialDef> {
+ load_colors_once();
+ style_read(&NAMED_MATERIALS).iter().find(|(n, _)| n == name).map(|(_, d)| d.clone())
+}
+
+/// Every defined material's name, sorted.
+pub fn material_names() -> Vec<String> {
+ load_colors_once();
+ let mut v: Vec<String> = style_read(&NAMED_MATERIALS).into_iter().map(|(n, _)| n).collect();
+ v.sort();
+ v
+}
+
+pub fn set_named_material(name: &str, def: Option<crate::scene::material::MaterialDef>) {
+ let mut list = style_read(&NAMED_MATERIALS);
+ list.retain(|(n, _)| n != name);
+ if let Some(d) = def {
+ list.push((name.to_string(), d));
+ }
+ style_write(&NAMED_MATERIALS, list);
+}
+
+pub fn material_binding(rung: crate::scene::material::PlateRung) -> Option<String> {
+ load_colors_once();
+ style_read(&MATERIAL_BINDINGS)[rung_index(rung)].clone()
+}
+
+pub fn set_material_binding(rung: crate::scene::material::PlateRung, name: Option<String>) {
+ let mut b = style_read(&MATERIAL_BINDINGS);
+ b[rung_index(rung)] = name;
+ style_write(&MATERIAL_BINDINGS, b);
+}
diff --git a/src/color/math.rs b/src/color/math.rs
new file mode 100644
index 0000000..f91f723
--- /dev/null
+++ b/src/color/math.rs
@@ -0,0 +1,141 @@
+//! Colour arithmetic: sRGB/linear conversion (the hex parsers come from `cce_core::color`),
+//! OKLab, and the perceptual fade.
+
+use super::*;
+
+pub use cce_core::color::{linear_to_srgb, parse_hex_bytes, parse_hex_rgb, parse_hex_rgba, parse_hex_rgba_linear, srgb_to_linear};
+
+pub fn to_linear(color: [f32; 4]) -> [f32; 4] {
+ [
+ srgb_to_linear(color[0]),
+ srgb_to_linear(color[1]),
+ srgb_to_linear(color[2]),
+ color[3],
+ ]
+}
+
+pub fn to_srgb(color: [f32; 4]) -> [f32; 4] {
+ [
+ linear_to_srgb(color[0]),
+ linear_to_srgb(color[1]),
+ linear_to_srgb(color[2]),
+ color[3],
+ ]
+}
+
+pub fn to_linear_rgb(color: [f32; 3]) -> [f32; 3] {
+ [
+ srgb_to_linear(color[0]),
+ srgb_to_linear(color[1]),
+ srgb_to_linear(color[2]),
+ ]
+}
+
+pub fn to_srgb_rgb(color: [f32; 3]) -> [f32; 3] {
+ [
+ linear_to_srgb(color[0]),
+ linear_to_srgb(color[1]),
+ linear_to_srgb(color[2]),
+ ]
+}
+
+pub fn linear_srgb_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
+ let l = 0.4122214708 * rgb[0] + 0.5363325363 * rgb[1] + 0.0514459929 * rgb[2];
+ let m = 0.2119034982 * rgb[0] + 0.6806995451 * rgb[1] + 0.1073969566 * rgb[2];
+ let s = 0.0883024619 * rgb[0] + 0.2817188376 * rgb[1] + 0.6299787005 * rgb[2];
+
+ let l_ = l.max(0.0).powf(1.0 / 3.0);
+ let m_ = m.max(0.0).powf(1.0 / 3.0);
+ let s_ = s.max(0.0).powf(1.0 / 3.0);
+
+ [
+ 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
+ 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
+ ]
+}
+
+pub fn oklab_to_linear_srgb(lab: [f32; 3]) -> [f32; 3] {
+ let l_ = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2];
+ let m_ = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2];
+ let s_ = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2];
+
+ let l = l_ * l_ * l_;
+ let m = m_ * m_ * m_;
+ let s = s_ * s_ * s_;
+
+ [
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+ ]
+}
+
+/// The alpha that makes an overlay's fade-out read as an EVEN fade to the eye.
+///
+/// `t` runs 0 (overlay at `max_alpha`) to 1 (fully faded); `overlay` and `backdrop` are
+/// linear-light RGB, the backdrop being whatever the fade composites onto.
+///
+/// A linear alpha ramp is not a linear fade: the surface is an sRGB attachment, so the GPU
+/// blends in LINEAR light, and perceived lightness goes as roughly the cube root of that. A
+/// straight alpha ramp therefore hangs bright through the middle and then dives near the
+/// end. This walks perceived lightness linearly instead and solves back for the alpha that
+/// lands on it — exactly, since the composite is linear in alpha:
+///
+/// ```text
+/// Y(a) = a·Y_overlay + (1 - a)·Y_backdrop (blending, in linear light)
+/// L ≈ cbrt(Y) (perception — OKLab's L, and CIE L* to within a hair)
+/// ```
+///
+/// Solved on luminance rather than per channel because a single alpha can only satisfy one
+/// axis, and lightness is the one the eye reads a fade by. With no lightness contrast to
+/// shape (a fade between equally-bright colors) it falls back to the linear ramp.
+pub fn perceptual_fade_alpha(t: f32, overlay: [f32; 3], backdrop: [f32; 3], max_alpha: f32) -> f32 {
+ let t = t.clamp(0.0, 1.0);
+ let luminance = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
+ let y_over = luminance(overlay);
+ let y_back = luminance(backdrop);
+ let contrast = y_over - y_back;
+ if contrast.abs() < 1e-4 {
+ return max_alpha * (1.0 - t);
+ }
+ // Walk L from the fully-applied composite down to the bare backdrop.
+ let l = |y: f32| y.max(0.0).cbrt();
+ let y_full = max_alpha * y_over + (1.0 - max_alpha) * y_back;
+ let l_t = l(y_full) + (l(y_back) - l(y_full)) * t;
+ let y_t = l_t * l_t * l_t;
+ ((y_t - y_back) / contrast).clamp(0.0, max_alpha)
+}
+
+pub fn sidebar_bg_color() -> [f32; 4] {
+ load_colors_once();
+ let mut color = *SIDEBAR_BG_COLOR.read().unwrap();
+ if let Some(opacity) = read_opacity_if_configured() {
+ color[3] = opacity;
+ }
+ color
+}
+
+pub fn set_sidebar_bg_color(color: [f32; 4]) {
+ style_write(&SIDEBAR_BG_COLOR, color);
+}
+
+pub fn highlight_primary_color() -> [f32; 4] {
+ load_colors_once();
+ style_read(&HIGHLIGHT_PRIMARY_COLOR)
+}
+
+pub fn set_highlight_primary_color(color: [f32; 4]) {
+ if let Ok(mut lock) = HIGHLIGHT_PRIMARY_COLOR.write() {
+ *lock = [color[0], color[1], color[2], 0.12];
+ }
+}
+
+pub fn menubar_tab_label_color() -> [f32; 4] {
+ load_colors_once();
+ style_read(&MENUBAR_TAB_LABEL_COLOR)
+}
+
+pub fn set_menubar_tab_label_color(color: [f32; 4]) {
+ style_write(&MENUBAR_TAB_LABEL_COLOR, color);
+}
diff --git a/src/color.rs b/src/color/mod.rs
similarity index 59%
rename from src/color.rs
rename to src/color/mod.rs
index c98090f..3ebb2db 100644
--- a/src/color.rs
+++ b/src/color/mod.rs
@@ -1,3 +1,12 @@
+mod load;
+pub use load::*;
+mod math;
+pub use math::*;
+mod chords;
+pub use chords::*;
+mod materials;
+pub use materials::*;
+
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];
@@ -260,604 +269,6 @@ pub fn set_node_drag_color(color: [f32; 4]) {
style_write(&NODE_DRAG_COLOR, color);
}
-fn read_config() -> Option<String> {
- let path = crate::config::get_config_path();
- if let Ok(content) = std::fs::read_to_string(&path) {
- return Some(content);
- }
- None
-}
-
-fn parse_and_set_colors(content: &str) {
- let val = crate::config::parse_kdl_to_json(content);
-
- let parse_hex = |hex_str: &str| -> Option<[f32; 4]> {
- let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
- let hex = hex.trim_start_matches('#');
- if hex.len() >= 8 {
- if let (Ok(r), Ok(g), Ok(b), Ok(a)) = (
- u8::from_str_radix(&hex[0..2], 16),
- u8::from_str_radix(&hex[2..4], 16),
- u8::from_str_radix(&hex[4..6], 16),
- u8::from_str_radix(&hex[6..8], 16),
- ) {
- let r_f = srgb_to_linear(r as f32 / 255.0);
- let g_f = srgb_to_linear(g as f32 / 255.0);
- let b_f = srgb_to_linear(b as f32 / 255.0);
- let a_f = a as f32 / 255.0;
- return Some([r_f, g_f, b_f, a_f]);
- }
- } else if hex.len() >= 6 {
- if let (Ok(r), Ok(g), Ok(b)) = (
- u8::from_str_radix(&hex[0..2], 16),
- u8::from_str_radix(&hex[2..4], 16),
- u8::from_str_radix(&hex[4..6], 16),
- ) {
- let r_f = srgb_to_linear(r as f32 / 255.0);
- let g_f = srgb_to_linear(g as f32 / 255.0);
- let b_f = srgb_to_linear(b as f32 / 255.0);
- return Some([r_f, g_f, b_f, 1.0]);
- }
- }
- None
- };
-
- let get_color = |pointer: &str| -> Option<[f32; 4]> {
- val.pointer(pointer).and_then(|v| v.as_str()).and_then(parse_hex)
- };
-
- let get_color_u8 = |pointer: &str| -> Option<[u8; 3]> {
- val.pointer(pointer).and_then(|v| v.as_str()).and_then(|hex_str| {
- let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
- let hex = hex.trim_start_matches('#');
- if hex.len() >= 6 {
- if let (Ok(r), Ok(g), Ok(b)) = (
- u8::from_str_radix(&hex[0..2], 16),
- u8::from_str_radix(&hex[2..4], 16),
- u8::from_str_radix(&hex[4..6], 16),
- ) {
- return Some([r, g, b]);
- }
- }
- None
- })
- };
-
- if let Some(opacity) = val.pointer("/layout/menubar_opacity").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = OPACITY.write() {
- *lock = Some(opacity as f32);
- }
- }
-
- // `/style/surface/plate/root/...` is the one spelling of the root-plate
- // style (RFC Phase 7a; the legacy `root plate` read-alias was removed
- // 2026-09-06 once every live config had migrated).
- if let Some(radius) = val.pointer("/style/surface/plate/root/corner_radius")
- .and_then(|v| v.as_f64())
- {
- if let Ok(mut lock) = ROOT_PLATE_CORNER_RADIUS.write() {
- *lock = radius as f32;
- }
- }
-
- if let Some(c) = get_color("/style/surface/plate/root/color") {
- if let Ok(mut lock) = PAGE_LOW_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = ROOT_PLATE_OPACITY.write() { *lock = Some(c[3]); }
- }
-
- if let Some(c) = get_color("/style/surface/plate/root/menubar/color") {
- if let Ok(mut lock) = ROOT_PLATE_MENUBAR_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/plate/root/menubar/text_color") {
- if let Ok(mut lock) = ROOT_PLATE_MENUBAR_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(o) = val.pointer("/style/surface/menu/opacity").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = MENU_OPACITY.write() { *lock = (o as f32).clamp(0.0, 1.0); }
- }
- if let Some(c) = val.pointer("/style/surface/menu/compression").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = MENU_COMPRESSION.write() { *lock = (c as f32).clamp(0.0, 1.0); }
- }
- if let Some(c) = get_color("/style/surface/menu/color") {
- if let Ok(mut lock) = MENU_COLOR.write() { *lock = Some(c); }
- }
-
- let menubar_blur_ptr = val.pointer("/style/surface/plate/root/menubar/blur");
- if let Some(blur) = menubar_blur_ptr.and_then(|v| v.as_bool()) {
- if let Ok(mut lock) = ROOT_PLATE_MENUBAR_BLUR.write() { *lock = blur; }
- } else if let Some(blur_val) = menubar_blur_ptr.and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = ROOT_PLATE_MENUBAR_BLUR.write() { *lock = blur_val > 0.001; }
- }
-
- if let Some(c) = get_color("/style/surface/statusbar/color") {
- if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/statusbar/text_color") {
- if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(blur) = val.pointer("/style/surface/statusbar/blur").and_then(|v| v.as_bool()) {
- if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_BLUR.write() { *lock = blur; }
- } else if let Some(blur_val) = val.pointer("/style/surface/statusbar/blur").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = ROOT_PLATE_STATUSBAR_BLUR.write() { *lock = blur_val > 0.001; }
- }
- if let Some(c) = get_color("/layout/color_borders_color") {
- if let Ok(mut lock) = COLOR_BORDERS_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/layout/paginator_sidebar_color") {
- if let Ok(mut lock) = SIDEBAR_BG_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/highlight/primary") {
- if let Ok(mut lock) = HIGHLIGHT_PRIMARY_COLOR.write() { *lock = [c[0], c[1], c[2], 0.12]; }
- }
- if let Some(c) = get_color("/style/control/button/background").or_else(|| get_color("/layout/button_background_color")) {
- if let Ok(mut lock) = BUTTON_BACKGROUND_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/dropdown/color") {
- if let Ok(mut lock) = DROPDOWN_BACKGROUND_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color_u8("/style/control/textbox/placeholder_text_color")
- .or_else(|| get_color_u8("/style/textbox/placeholder_text_color"))
- .or_else(|| get_color_u8("/style/data/textbox/placeholder_text_color")) {
- if let Ok(mut lock) = TEXTBOX_PLACEHOLDER_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/textbox/background_color")
- .or_else(|| get_color("/style/textbox/background_color"))
- .or_else(|| get_color("/style/data/textbox/background_color")) {
- if let Ok(mut lock) = TEXTBOX_BACKGROUND_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/textbox/background_edit_color")
- .or_else(|| get_color("/style/textbox/background_edit_color"))
- .or_else(|| get_color("/style/data/textbox/background_edit_color")) {
- if let Ok(mut lock) = TEXTBOX_BACKGROUND_EDIT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/layout/menubar_tab_label_color").or_else(|| get_color("/layout/paginator_tab_label_color")) {
- if let Ok(mut lock) = MENUBAR_TAB_LABEL_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/label/color") {
- if let Ok(mut lock) = CONTROL_LABEL_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/label/color_detached") {
- if let Ok(mut lock) = CONTROL_LABEL_COLOR_DETACHED.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/label/hover_color") {
- if let Ok(mut lock) = CONTROL_LABEL_HOVER_COLOR.write() { *lock = Some(c); }
- }
- if let Some(c) = get_color("/style/control/label/focus_color") {
- if let Ok(mut lock) = CONTROL_LABEL_FOCUS_COLOR.write() { *lock = Some(c); }
- }
- // The toggle state colors (enabled/disabled/gradient/background) are not
- // read: the control inherits its plate — see the note by the retired
- // getters below.
- if let Some(c) = get_color("/style/control/ramp/background") {
- if let Ok(mut lock) = RAMP_BACKGROUND_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/ramp/border_color") {
- if let Ok(mut lock) = RAMP_BORDER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/control_panel/color") {
- if let Ok(mut lock) = CONTROL_PANEL_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/control_panel/border_color") {
- if let Ok(mut lock) = CONTROL_PANEL_BORDER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/spinbox/background_color") {
- if let Ok(mut lock) = SPINBOX_DISPLAY_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/spinbox/button_color") {
- if let Ok(mut lock) = SPINBOX_BUTTON_COLOR.write() { *lock = c; }
- if let Ok(mut lock_hover) = SPINBOX_BUTTON_HOVER_COLOR.write() {
- *lock_hover = [
- (c[0] + 0.1).min(1.0),
- (c[1] + 0.1).min(1.0),
- (c[2] + 0.1).min(1.0),
- c[3]
- ];
- }
- }
- if let Some(c) = get_color("/style/control/spinbox/button_hover_color") {
- if let Ok(mut lock) = SPINBOX_BUTTON_HOVER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/spinbox/text_color") {
- if let Ok(mut lock) = SPINBOX_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/button/border_color").or_else(|| get_color("/style/button/border_color")) {
- if let Ok(mut lock) = BUTTON_BORDER_COLOR.write() { *lock = Some(c); }
- }
- if let Some(c) = get_color("/style/control/button/hover_color").or_else(|| get_color("/style/button/hover_color")) {
- if let Ok(mut lock) = BUTTON_HOVER_COLOR.write() { *lock = Some(c); }
- }
- if let Some(c) = get_color("/style/control/dropdown/border_color").or_else(|| get_color("/style/dropdown/border_color")) {
- if let Ok(mut lock) = DROPDOWN_BORDER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/dropdown/text_color").or_else(|| get_color("/style/dropdown/text_color")) {
- if let Ok(mut lock) = DROPDOWN_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/slider/thumb_color").or_else(|| get_color("/style/slider/thumb_color")) {
- if let Ok(mut lock) = SLIDER_THUMB_COLOR.write() { *lock = c; }
- if let Ok(mut lock_drag) = SLIDER_THUMB_DRAG_COLOR.write() {
- *lock_drag = [
- (c[0] + 0.15).min(1.0),
- (c[1] + 0.15).min(1.0),
- (c[2] + 0.15).min(1.0),
- c[3]
- ];
- }
- }
- if let Some(c) = get_color("/style/control/rangeslider/thumb_color").or_else(|| get_color("/style/rangeslider/thumb_color")) {
- if let Ok(mut lock) = RANGE_SLIDER_THUMB_COLOR.write() { *lock = c; }
- if let Ok(mut lock_drag) = RANGE_SLIDER_THUMB_DRAG_COLOR.write() {
- *lock_drag = [
- (c[0] + 0.15).min(1.0),
- (c[1] + 0.15).min(1.0),
- (c[2] + 0.15).min(1.0),
- c[3]
- ];
- }
- }
- if let Some(c) = get_color("/style/control/progressbar/background") {
- if let Ok(mut lock) = PROGRESS_BG_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/progressbar/fill") {
- if let Ok(mut lock) = PROGRESS_FILL_COLOR.write() { *lock = c; }
- }
- let parsed_list_bg = get_color("/layout/list_bg_color");
- let parsed_breadcrumb_bg = get_color("/layout/breadcrumb_bg_color");
- let parsed_popover_bg = get_color("/layout/popover_bg_color");
-
- if let Some(c) = get_color("/layout/page_color") {
- if let Ok(mut lock) = PAGE_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/layout/layer_color") {
- if let Ok(mut lock) = LAYER_COLOR.write() { *lock = c; }
- }
-
- if let Some(c) = parsed_list_bg {
- if let Ok(mut lock) = LIST_BG_COLOR.write() {
- *lock = [c[0], c[1], c[2], 0.3];
- }
- }
- if let Some(c) = get_color("/layout/list_entry_bg_color") {
- if let Ok(mut lock) = LIST_ENTRY_BG_COLOR.write() {
- *lock = c;
- }
- }
- if let Some(c) = get_color("/layout/list_entry_highlight_color") {
- if let Ok(mut lock) = LIST_ENTRY_HIGHLIGHT_COLOR.write() {
- *lock = c;
- }
- }
- if let Some(c) = get_color("/style/data/list/font_color").or_else(|| get_color("/layout/list_font_color")) {
- if let Ok(mut lock) = LIST_FONT_COLOR.write() {
- *lock = c;
- }
- }
- if let Some(c) = parsed_breadcrumb_bg {
- if let Ok(mut lock) = BREADCRUMB_BG_COLOR.write() {
- *lock = [c[0], c[1], c[2], 1.0];
- }
- } else if let Some(c) = parsed_list_bg {
- if let Ok(mut lock) = BREADCRUMB_BG_COLOR.write() {
- *lock = [c[0], c[1], c[2], 1.0];
- }
- }
- if let Some(c) = parsed_popover_bg {
- if let Ok(mut lock) = POPOVER_BG_COLOR.write() {
- *lock = [c[0], c[1], c[2], 1.0];
- }
- } else if let Some(c) = parsed_list_bg {
- if let Ok(mut lock) = POPOVER_BG_COLOR.write() {
- *lock = [c[0], c[1], c[2], 1.0];
- }
- }
-
- // The lattice's lines. The cells between them have no colour of their
- // own: a graph shows whatever it is painted on. `cell_color` and
- // `gap_color`, the cell model's pair, are retired (`retired_surface_keys`).
- if let Some(c) = get_color("/style/surface/graph/grid_color") {
- if let Ok(mut lock) = GRAPH_GRID_COLOR.write() { *lock = [c[0], c[1], c[2]]; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/color") {
- if let Ok(mut lock) = GRAPH_NODE_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = NODE_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/selected_color") {
- if let Ok(mut lock) = GRAPH_NODE_SELECTED_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = NODE_SELECTED_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/drag_color") {
- if let Ok(mut lock) = GRAPH_NODE_DRAG_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = NODE_DRAG_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/wire_color") {
- if let Ok(mut lock) = GRAPH_WIRE_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/wire_highlight_color") {
- if let Ok(mut lock) = GRAPH_WIRE_HIGHLIGHT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/connector_color") {
- if let Ok(mut lock) = GRAPH_CONNECTOR_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/graph/node/connector_highlight_color") {
- if let Ok(mut lock) = GRAPH_CONNECTOR_HIGHLIGHT_COLOR.write() { *lock = c; }
- }
- if let Some(opacity) = val.pointer("/style/surface/graph/opacity").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = GRAPH_OPACITY.write() { *lock = opacity as f32; }
- }
- if let Some(opacity) = val.pointer("/style/surface/graph/node/opacity").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = GRAPH_NODE_OPACITY.write() { *lock = opacity as f32; }
- }
-
- if let Some(c) = get_color("/style/data/tree/background_color") {
- if let Ok(mut lock) = TREE_BACKGROUND_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/border_color") {
- if let Ok(mut lock) = TREE_BORDER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/border_hover_color") {
- if let Ok(mut lock) = TREE_BORDER_HOVER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/border_focus_color") {
- if let Ok(mut lock) = TREE_BORDER_FOCUS_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/section_bg_color") {
- if let Ok(mut lock) = TREE_SECTION_BG_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/section_bg_hover_color") {
- if let Ok(mut lock) = TREE_SECTION_BG_HOVER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_bg_even_color") {
- if let Ok(mut lock) = TREE_LEAF_BG_EVEN_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_bg_odd_color") {
- if let Ok(mut lock) = TREE_LEAF_BG_ODD_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_bg_hover_color") {
- if let Ok(mut lock) = TREE_LEAF_BG_HOVER_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_bg_selected_color") {
- if let Ok(mut lock) = TREE_LEAF_BG_SELECTED_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/section_text_color") {
- if let Ok(mut lock) = TREE_SECTION_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_text_color") {
- if let Ok(mut lock) = TREE_LEAF_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/leaf_text_selected_color") {
- if let Ok(mut lock) = TREE_LEAF_TEXT_SELECTED_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/type_text_color") {
- if let Ok(mut lock) = TREE_TYPE_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/value_text_color") {
- if let Ok(mut lock) = TREE_VALUE_TEXT_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/data/tree/separator_color") {
- if let Ok(mut lock) = TREE_SEPARATOR_COLOR.write() { *lock = c; }
- }
- if let Some(k) = val.pointer("/style/data/tree/open_search").and_then(|v| v.as_str()) {
- if let Ok(mut lock) = TREE_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
- }
- if let Some(k) = val.pointer("/style/data/list/open_search").and_then(|v| v.as_str()) {
- if let Ok(mut lock) = LIST_OPEN_SEARCH_KEY.write() { *lock = k.to_string(); }
- }
- if let Some(k) = val.pointer("/style/data/list/close_search").and_then(|v| v.as_str()) {
- if let Ok(mut lock) = LIST_CLOSE_SEARCH_KEY.write() { *lock = k.to_string(); }
- }
- if let Some(c) = get_color("/style/control/scrollbar/track_color") {
- if let Ok(mut lock) = SCROLLBAR_TRACK_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/control/scrollbar/thumb_color") {
- if let Ok(mut lock) = SCROLLBAR_THUMB_COLOR.write() { *lock = c; }
- }
- if let Some(c) = get_color("/style/surface/plate/color") {
- if let Ok(mut lock) = PLATE_COLOR.write() { *lock = Some(c); }
- }
- if let Some(c) = get_color("/style/surface/plate/border_color") {
- if let Ok(mut lock) = PLATE_BORDER_COLOR.write() { *lock = Some(c); }
- }
- if let Some(t) = val.pointer("/style/surface/plate/border_thickness").and_then(|v| v.as_f64()) {
- if let Ok(mut lock) = PLATE_BORDER_THICKNESS.write() { *lock = t as f32; }
- }
- if let Some(c) = get_color("/style/surface/param/color") {
- if let Ok(mut lock) = PARAM_BG_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = PANE_COLOR_WHOLE.write() { *lock = false; }
- }
- // The clear spelling of the same tint: the PANE plate's colour, whole —
- // its alpha is the tint strength and nothing multiplies it.
- if let Some(c) = get_color("/style/surface/plate/pane/color") {
- if let Ok(mut lock) = PARAM_BG_COLOR.write() { *lock = c; }
- if let Ok(mut lock) = PANE_COLOR_WHOLE.write() { *lock = true; }
- }
- // The default plate's frost is ONE block, the same shape a named
- // material's `frost` child has: `plate { frost radius=5.5 compression=0
- // refraction=0 }` is frosted with those knobs, a bare `frost` is frosted
- // at the defaults, `frost (bool)false` is not frosted. The four scattered
- // keys it replaced on 2026-09-28 (`blur` as the switch, `radius`,
- // `backdrop_compression`, `refraction`) were read as aliases for the rest
- // of that day and are retired: a config still carrying one is reported
- // (`retired_surface_keys`) and the key does nothing, because an alias that
- // keeps working is a second spelling forever. The block is absent → not
- // frosted, which is what `blur=true` alone now amounts to; the report
- // is what tells the user why their plates went sharp.
- let retired = retired_surface_keys(&val);
- if !retired.is_empty() {
- log::warn!(
- "retired style.surface keys in config: {} — the frost is `plate {{ frost radius= compression= refraction= }}` (a material's `frost` child spells `compression`), every roll's width is `relief width=`, the light strength is `relief light=` (a material's `finish light=`), and the relief's geometry is `relief {{ wall height= profile= ; edge height= profile= }}` (the window_manager bevel_* spellings are the same relief keys, and its bevel_shader is `relief shader=`); a graph's lines are `graph {{ grid_color }}` and its cells take no colour",
- retired.join(", ")
- );
- }
- match val.pointer("/style/surface/plate/frost") {
- Some(serde_json::Value::Object(fo)) => {
- let num = |k: &str| fo.get(k).and_then(|v| v.as_f64()).map(|v| v as f32);
- if let Ok(mut lock) = PLATE_BLUR.write() { *lock = true; }
- if let Some(c) = num("compression") {
- if let Ok(mut lock) = PLATE_BACKDROP_COMPRESSION.write() { *lock = c.clamp(0.0, 1.0); }
- }
- if let Some(r) = num("refraction") {
- if let Ok(mut lock) = PLATE_REFRACTION.write() { *lock = r.clamp(0.0, 1.0); }
- }
- if let Some(r) = num("radius") {
- if let Ok(mut lock) = PLATE_FROST_RADIUS.write() { *lock = r.max(0.0); }
- }
- }
- Some(serde_json::Value::Null) => {
- if let Ok(mut lock) = PLATE_BLUR.write() { *lock = true; }
- }
- Some(serde_json::Value::Bool(on)) => {
- if let Ok(mut lock) = PLATE_BLUR.write() { *lock = *on; }
- }
- _ => {}
- }
-
- // The DE finish beyond its strength (`relief.depth` / `light`, which
- // lives in the style registry): the three terms that were literals.
- let f = |k: &str| val.pointer(&format!("/style/surface/relief/{k}")).and_then(|v| v.as_f64()).map(|v| v as f32);
- if let Some(v) = f("spec") {
- if let Ok(mut lock) = FINISH_SPEC.write() { *lock = v.max(0.0); }
- }
- if let Some(v) = f("shininess") {
- if let Ok(mut lock) = FINISH_SHININESS.write() { *lock = v.max(1.0); }
- }
- if let Some(v) = f("curvature") {
- if let Ok(mut lock) = FINISH_CURVATURE.write() { *lock = v.max(0.0); }
- }
-
- // Named materials and the rung bindings (RFC material § 5), replaced
- // wholesale so a reload forgets what config no longer says.
- {
- use crate::scene::material::{FrostDef, MaterialDef};
- let num = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_f64()).map(|v| v as f32);
- let mut map: Vec<(String, MaterialDef)> = Vec::new();
- if let Some(obj) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
- for (name, node) in obj {
- let Some(node) = node.as_object() else { continue };
- let frost = node.get("frost").map(|fr| match fr.as_object() {
- Some(fo) => FrostDef {
- compression: num(fo.get("compression")),
- refraction: num(fo.get("refraction")),
- radius: num(fo.get("radius")),
- },
- // A bare `frost` node (no knobs) is frosted at the defaults.
- None => FrostDef::default(),
- });
- let fin = node.get("finish").and_then(|v| v.as_object());
- let fk = |k: &str| fin.and_then(|fo| num(fo.get(k)));
- map.push((
- name.clone(),
- MaterialDef {
- tint: node.get("color").and_then(|v| v.as_str()).and_then(parse_hex),
- frost,
- light: fk("light"),
- spec: fk("spec"),
- shininess: fk("shininess"),
- curvature: fk("curvature"),
- },
- ));
- }
- }
- if let Ok(mut lock) = NAMED_MATERIALS.write() { *lock = map; }
- let bind = |p: &str| val.pointer(p).and_then(|v| v.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty());
- let bindings = [
- bind("/style/surface/plate/root/material"),
- bind("/style/surface/plate/material"),
- bind("/style/control/material"),
- ];
- if let Ok(mut lock) = MATERIAL_BINDINGS.write() { *lock = bindings; }
- }
-}
-
-/// The retired `style.surface` spellings a parsed config still carries, as
-/// the dotted paths a user would grep for: the four flat frost keys under
-/// `plate` (`blur`, `radius`, `backdrop_compression`, `refraction`),
-/// `backdrop_compression` inside any `frost` child (the plate's, or a named
-/// material's), whose one name is `compression`; `plate.bevel_width`, the
-/// pane roll's former width of its own (every roll is `relief.width`); and
-/// `relief.depth` with `depth` inside any material's `finish` child, the
-/// light strength's former name (`light`, since it is not a length); and
-/// the relief's flat geometry keys `height` / `profile` (the wall's, now
-/// `wall.height` / `wall.profile`) and `edge_height` / `edge_profile` (the
-/// edge's, now `edge.height` / `edge.profile`); and `window_manager.bevel_depth`
-/// / `bevel_width` / `bevel_shader`, the block the relief keys were born in
-/// before `style.surface.relief` existed (the compositor never read them;
-/// the shader toggle is `relief.shader` now). Each was an alias for part
-/// of 2026-09-28 and is not read now. And the graph's `cell_color`,
-/// `gap_color` and `uniform_background` (2026-09-29): a graph's cells have
-/// no colour, so there is no fill to switch, and its lines are
-/// `graph.grid_color`. Empty for a clean config.
-pub fn retired_surface_keys(val: &serde_json::Value) -> Vec<String> {
- let mut found = Vec::new();
- for k in ["blur", "radius", "backdrop_compression", "refraction", "bevel_width"] {
- if val.pointer(&format!("/style/surface/plate/{k}")).is_some() {
- found.push(format!("style.surface.plate.{k}"));
- }
- }
- if val.pointer("/style/surface/plate/frost/backdrop_compression").is_some() {
- found.push("style.surface.plate.frost.backdrop_compression".to_string());
- }
- for k in ["depth", "height", "profile", "edge_height", "edge_profile"] {
- if val.pointer(&format!("/style/surface/relief/{k}")).is_some() {
- found.push(format!("style.surface.relief.{k}"));
- }
- }
- for k in ["bevel_depth", "bevel_width", "bevel_shader"] {
- if val.pointer(&format!("/window_manager/{k}")).is_some() {
- found.push(format!("window_manager.{k}"));
- }
- }
- if let Some(mats) = val.pointer("/style/surface/material").and_then(|v| v.as_object()) {
- for (name, node) in mats {
- if node.pointer("/frost/backdrop_compression").is_some() {
- found.push(format!("style.surface.material.{name}.frost.backdrop_compression"));
- }
- if node.pointer("/finish/depth").is_some() {
- found.push(format!("style.surface.material.{name}.finish.depth"));
- }
- }
- }
- for k in ["cell_color", "gap_color", "uniform_background"] {
- if val.pointer(&format!("/style/surface/graph/{k}")).is_some() {
- found.push(format!("style.surface.graph.{k}"));
- }
- }
- found
-}
-
-fn load_colors_once() {
- use std::sync::Once;
- static INIT: Once = Once::new();
- INIT.call_once(|| {
- if let Some(content) = read_config() {
- parse_and_set_colors(&content);
- }
- });
-}
-
-pub fn reload_colors(content: &str) {
- parse_and_set_colors(content);
-}
-
-/// Test-only: the color state is process-global, so any test that calls
-/// [`reload_colors`] and then asserts getter values races every other such
-/// test on the parallel harness. Each of those tests must hold this lock
-/// across its reload + asserts, and should fire the once-per-process config
-/// loads ([`load_colors_once`] via any getter, and
-/// `layout::lazy_init_style_registry`) inside the lock BEFORE its reload, so
-/// neither can rewrite the state from the live config file mid-assert.
-#[cfg(test)]
-pub(crate) fn test_color_state_lock() -> std::sync::MutexGuard<'static, ()> {
- static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
- LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
-}
-
-pub fn dropdown_background_color() -> [f32; 4] {
- load_colors_once();
- style_read(&DROPDOWN_BACKGROUND_COLOR)
-}
-
-pub fn set_dropdown_background_color(color: [f32; 4]) {
- style_write(&DROPDOWN_BACKGROUND_COLOR, color);
-}
-
pub fn textbox_placeholder_text_color() -> [u8; 3] {
load_colors_once();
style_read(&TEXTBOX_PLACEHOLDER_TEXT_COLOR)
@@ -1115,143 +526,6 @@ pub fn well_frame_color(hovered: bool, active: bool) -> [f32; 4] {
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];
-pub use cce_core::color::{linear_to_srgb, parse_hex_bytes, parse_hex_rgb, parse_hex_rgba, parse_hex_rgba_linear, srgb_to_linear};
-
-pub fn to_linear(color: [f32; 4]) -> [f32; 4] {
- [
- srgb_to_linear(color[0]),
- srgb_to_linear(color[1]),
- srgb_to_linear(color[2]),
- color[3],
- ]
-}
-
-pub fn to_srgb(color: [f32; 4]) -> [f32; 4] {
- [
- linear_to_srgb(color[0]),
- linear_to_srgb(color[1]),
- linear_to_srgb(color[2]),
- color[3],
- ]
-}
-
-pub fn to_linear_rgb(color: [f32; 3]) -> [f32; 3] {
- [
- srgb_to_linear(color[0]),
- srgb_to_linear(color[1]),
- srgb_to_linear(color[2]),
- ]
-}
-
-pub fn to_srgb_rgb(color: [f32; 3]) -> [f32; 3] {
- [
- linear_to_srgb(color[0]),
- linear_to_srgb(color[1]),
- linear_to_srgb(color[2]),
- ]
-}
-
-pub fn linear_srgb_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
- let l = 0.4122214708 * rgb[0] + 0.5363325363 * rgb[1] + 0.0514459929 * rgb[2];
- let m = 0.2119034982 * rgb[0] + 0.6806995451 * rgb[1] + 0.1073969566 * rgb[2];
- let s = 0.0883024619 * rgb[0] + 0.2817188376 * rgb[1] + 0.6299787005 * rgb[2];
-
- let l_ = l.max(0.0).powf(1.0 / 3.0);
- let m_ = m.max(0.0).powf(1.0 / 3.0);
- let s_ = s.max(0.0).powf(1.0 / 3.0);
-
- [
- 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
- 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
- 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
- ]
-}
-
-pub fn oklab_to_linear_srgb(lab: [f32; 3]) -> [f32; 3] {
- let l_ = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2];
- let m_ = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2];
- let s_ = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2];
-
- let l = l_ * l_ * l_;
- let m = m_ * m_ * m_;
- let s = s_ * s_ * s_;
-
- [
- 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
- -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
- ]
-}
-
-/// The alpha that makes an overlay's fade-out read as an EVEN fade to the eye.
-///
-/// `t` runs 0 (overlay at `max_alpha`) to 1 (fully faded); `overlay` and `backdrop` are
-/// linear-light RGB, the backdrop being whatever the fade composites onto.
-///
-/// A linear alpha ramp is not a linear fade: the surface is an sRGB attachment, so the GPU
-/// blends in LINEAR light, and perceived lightness goes as roughly the cube root of that. A
-/// straight alpha ramp therefore hangs bright through the middle and then dives near the
-/// end. This walks perceived lightness linearly instead and solves back for the alpha that
-/// lands on it — exactly, since the composite is linear in alpha:
-///
-/// ```text
-/// Y(a) = a·Y_overlay + (1 - a)·Y_backdrop (blending, in linear light)
-/// L ≈ cbrt(Y) (perception — OKLab's L, and CIE L* to within a hair)
-/// ```
-///
-/// Solved on luminance rather than per channel because a single alpha can only satisfy one
-/// axis, and lightness is the one the eye reads a fade by. With no lightness contrast to
-/// shape (a fade between equally-bright colors) it falls back to the linear ramp.
-pub fn perceptual_fade_alpha(t: f32, overlay: [f32; 3], backdrop: [f32; 3], max_alpha: f32) -> f32 {
- let t = t.clamp(0.0, 1.0);
- let luminance = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
- let y_over = luminance(overlay);
- let y_back = luminance(backdrop);
- let contrast = y_over - y_back;
- if contrast.abs() < 1e-4 {
- return max_alpha * (1.0 - t);
- }
- // Walk L from the fully-applied composite down to the bare backdrop.
- let l = |y: f32| y.max(0.0).cbrt();
- let y_full = max_alpha * y_over + (1.0 - max_alpha) * y_back;
- let l_t = l(y_full) + (l(y_back) - l(y_full)) * t;
- let y_t = l_t * l_t * l_t;
- ((y_t - y_back) / contrast).clamp(0.0, max_alpha)
-}
-
-pub fn sidebar_bg_color() -> [f32; 4] {
- load_colors_once();
- let mut color = *SIDEBAR_BG_COLOR.read().unwrap();
- if let Some(opacity) = read_opacity_if_configured() {
- color[3] = opacity;
- }
- color
-}
-
-pub fn set_sidebar_bg_color(color: [f32; 4]) {
- style_write(&SIDEBAR_BG_COLOR, color);
-}
-
-pub fn highlight_primary_color() -> [f32; 4] {
- load_colors_once();
- style_read(&HIGHLIGHT_PRIMARY_COLOR)
-}
-
-pub fn set_highlight_primary_color(color: [f32; 4]) {
- if let Ok(mut lock) = HIGHLIGHT_PRIMARY_COLOR.write() {
- *lock = [color[0], color[1], color[2], 0.12];
- }
-}
-
-pub fn menubar_tab_label_color() -> [f32; 4] {
- load_colors_once();
- style_read(&MENUBAR_TAB_LABEL_COLOR)
-}
-
-pub fn set_menubar_tab_label_color(color: [f32; 4]) {
- style_write(&MENUBAR_TAB_LABEL_COLOR, color);
-}
-
pub fn read_opacity_if_configured() -> Option<f32> {
load_colors_once();
style_read(&OPACITY)
@@ -1706,33 +980,6 @@ pub fn set_scrollbar_thumb_color(c: [f32; 4]) {
style_write(&SCROLLBAR_THUMB_COLOR, c);
}
-pub fn tree_open_search_key() -> String {
- load_colors_once();
- let legacy = TREE_OPEN_SEARCH_KEY.read().unwrap().clone();
- crate::input::widget_chord("open_search", &legacy, "ctrl+f")
-}
-pub fn set_tree_open_search_key(k: String) {
- style_write(&TREE_OPEN_SEARCH_KEY, k);
-}
-
-pub fn list_open_search_key() -> String {
- load_colors_once();
- let legacy = LIST_OPEN_SEARCH_KEY.read().unwrap().clone();
- crate::input::widget_chord("open_search", &legacy, "ctrl+f")
-}
-pub fn set_list_open_search_key(k: String) {
- style_write(&LIST_OPEN_SEARCH_KEY, k);
-}
-
-pub fn list_close_search_key() -> String {
- load_colors_once();
- let legacy = LIST_CLOSE_SEARCH_KEY.read().unwrap().clone();
- crate::input::widget_chord("close_search", &legacy, "escape")
-}
-pub fn set_list_close_search_key(k: String) {
- style_write(&LIST_CLOSE_SEARCH_KEY, k);
-}
-
pub fn root_plate_menubar_color() -> [f32; 4] {
load_colors_once();
style_read(&ROOT_PLATE_MENUBAR_COLOR)
@@ -1918,55 +1165,6 @@ pub fn set_plate_frost_radius(r: f32) {
style_write(&PLATE_FROST_RADIUS, r.max(0.0));
}
-/// The named materials config defines (`style.surface.material { <name> {…} }`)
-/// and the rung bindings (`plate material=`, `plate { root material= }`,
-/// `control material=`) — see `docs/rfc-material.md` § 5. Replaced wholesale
-/// on every config load, so a node or binding removed from config is gone
-/// after a reload.
-static NAMED_MATERIALS: RwLock<Vec<(String, crate::scene::material::MaterialDef)>> = RwLock::new(Vec::new());
-static MATERIAL_BINDINGS: RwLock<[Option<String>; 3]> = RwLock::new([None, None, None]);
-
-fn rung_index(rung: crate::scene::material::PlateRung) -> usize {
- match rung {
- crate::scene::material::PlateRung::Root => 0,
- crate::scene::material::PlateRung::Pane => 1,
- crate::scene::material::PlateRung::Control => 2,
- }
-}
-
-pub fn named_material(name: &str) -> Option<crate::scene::material::MaterialDef> {
- load_colors_once();
- style_read(&NAMED_MATERIALS).iter().find(|(n, _)| n == name).map(|(_, d)| d.clone())
-}
-
-/// Every defined material's name, sorted.
-pub fn material_names() -> Vec<String> {
- load_colors_once();
- let mut v: Vec<String> = style_read(&NAMED_MATERIALS).into_iter().map(|(n, _)| n).collect();
- v.sort();
- v
-}
-
-pub fn set_named_material(name: &str, def: Option<crate::scene::material::MaterialDef>) {
- let mut list = style_read(&NAMED_MATERIALS);
- list.retain(|(n, _)| n != name);
- if let Some(d) = def {
- list.push((name.to_string(), d));
- }
- style_write(&NAMED_MATERIALS, list);
-}
-
-pub fn material_binding(rung: crate::scene::material::PlateRung) -> Option<String> {
- load_colors_once();
- style_read(&MATERIAL_BINDINGS)[rung_index(rung)].clone()
-}
-
-pub fn set_material_binding(rung: crate::scene::material::PlateRung, name: Option<String>) {
- let mut b = style_read(&MATERIAL_BINDINGS);
- b[rung_index(rung)] = name;
- style_write(&MATERIAL_BINDINGS, b);
-}
-
pub fn finish_spec() -> f32 {
style_read(&FINISH_SPEC)
}
diff --git a/src/layout/bridge.rs b/src/layout/bridge.rs
new file mode 100644
index 0000000..38f6e41
--- /dev/null
+++ b/src/layout/bridge.rs
@@ -0,0 +1,563 @@
+//! The flat-host render bridge: [`RenderTarget`], the target a host that does not build a
+//! display list paints into, [`render_widget`] / [`render_popovers`] which replay a widget
+//! onto one, and the carve and popover types that cross it. Legacy: new code paints
+//! through `scene::paint::PaintCtx`.
+
+use crate::widget::WidgetHost;
+use crate::context::UiContext;
+
+/// One step carve a widget's `paint` draws, handed to a flat-path host through
+/// [`RenderTarget::relief_carve`] so it can re-emit it as a real prim.
+///
+/// Geometry always comes from the WIDGET (`TextBox::well`, `Toggle::well` /
+/// `Toggle::slide_plate`), never re-derived here — a second copy of that math
+/// in the bridge is exactly how the flat host's carve and the drawn one drift
+/// apart.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ReliefCarve {
+ pub kind: CarveKind,
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ /// Per-corner radii, clockwise from top-left.
+ pub radii: (f32, f32, f32, f32),
+ /// Full width of the step's transition band.
+ pub depth: f32,
+ /// Which walls the carve has (top, right, bottom, left). A suppressed wall
+ /// means the step runs flush to its neighbour there — a Spinbox's field
+ /// running into its button column, where the two meet in ONE step rather
+ /// than two facing walls.
+ pub edges: (bool, bool, bool, bool),
+}
+
+/// Which way a [`ReliefCarve`] steps.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum CarveKind {
+ /// Interior one step DOWN ([`crate::scene::paint::PaintCtx::recess_edges`]).
+ /// `tint` lights the rim in the focus accent (`recess_tinted`).
+ Recess { tint: Option<[f32; 3]> },
+ /// Interior one step UP ([`crate::scene::paint::PaintCtx::boss_edges`]).
+ Boss { tint: Option<[f32; 3]> },
+ /// A FLUSH inset ([`crate::scene::paint::PaintCtx::trough_edges`]): the
+ /// interior stays level with the surface and a valley seam runs the
+ /// boundary — the closed dropdown's chrome, for a control that is part
+ /// of the plate rather than a step up or down from it.
+ Trough,
+}
+
+impl ReliefCarve {
+ /// This carve shifted vertically — the page-scroll adjustment a host
+ /// applies when it re-emits collected carves.
+ pub fn shifted_y(self, dy: f32) -> Self {
+ Self { y: self.y + dy, ..self }
+ }
+}
+
+pub trait RenderTarget {
+ fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32);
+ fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, _radius: f32) {
+ self.rect(color, x, y, w, h);
+ }
+ fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _corners: (bool, bool, bool, bool)) {
+ self.rect_with_radius(color, x, y, w, h, radius);
+ }
+ fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]);
+ fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _font: &str) {
+ self.text(content, x, y, size, color);
+ }
+ fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _bounds: Option<[f32; 4]>) {
+ self.text(content, x, y, size, color);
+ }
+ fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, _bounds: Option<[f32; 4]>) {
+ self.text_with_font(content, x, y, size, color, font);
+ }
+ fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
+ fn pop_clip_rect(&mut self) {}
+ /// A bundled cce-icons glyph — see `PaintCtx::icon`. A target that
+ /// cannot draw images (the legacy `PopoverCollector`) draws nothing.
+ fn icon(&mut self, _name: &str, _rect: crate::scene::layout::Rect, _color: [f32; 4]) {}
+ /// A straight stroke — see `PaintCtx::vector`. What a graph's wires are
+ /// made of; a target that draws none (the default) shows no wires.
+ fn line(&mut self, _x1: f32, _y1: f32, _x2: f32, _y2: f32, _thickness: f32, _color: [f32; 4], _cap: crate::scene::paint::Cap) {}
+ /// A stroked arc, `radius` its OUTER edge — see `PaintCtx::arc`. A
+ /// rounded wire's bends.
+ fn arc(&mut self, _cx: f32, _cy: f32, _radius: f32, _thickness: f32, _start: f32, _end: f32, _color: [f32; 4]) {}
+ /// A filled disc — see `PaintCtx::circle`. A graph's ports.
+ fn circle(&mut self, _cx: f32, _cy: f32, _radius: f32, _color: [f32; 4]) {}
+ /// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
+ /// control surface (groove ring down, beveled lip back up). Lets a popover
+ /// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
+ /// Hosts without relief prims degrade to a flat rounded fill.
+ fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
+ self.rect_with_radius(color, x, y, w, h, radius);
+ }
+ /// [`inset_plate`](Self::inset_plate) with the rim lit — the focused
+ /// control plate's ring (`ControlPlate::with_tint`). Hosts without relief
+ /// prims draw the plain plate.
+ fn inset_plate_tinted(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, _tint: [f32; 3]) {
+ self.inset_plate(color, x, y, w, h, radius, depth);
+ }
+ /// One step carve from a widget's `paint` ([`ReliefCarve`]) — offered here
+ /// for the same reason as `inset_plate`: the legacy `all_quads` stream
+ /// carries no relief prims, so a flat-path host never sees them.
+ ///
+ /// The default is deliberately a NO-OP, not a fill. These controls have
+ /// transparent faces by design (the host surface IS the well floor / the
+ /// plate's face), so the carve is their entire decoration — a host that
+ /// can't carve has nothing truthful to draw, and a solid box here would
+ /// paint every text field a flat slab it never had.
+ fn relief_carve(&mut self, _carve: &ReliefCarve) {}
+ /// Whether this host renders sections as sunken wells (the designer idiom).
+ /// `SectionContext` then lays the title out left-aligned over its tab box
+ /// instead of centered on the top border.
+ fn section_relief_style(&self) -> bool {
+ false
+ }
+ /// The section frame hatch: `SectionContext::finish` offers the frame here
+ /// before falling back to the legacy 1px outline. A relief-capable host
+ /// returns true and carves the section into its plate instead (the
+ /// designer sunken-well idiom); the tuple hosts keep the default.
+ fn section_relief(&mut self, _frame: &SectionFrame) -> bool {
+ false
+ }
+}
+
+/// A section frame offered to [`RenderTarget::section_relief`]: the content
+/// body box plus, under [`RenderTarget::section_relief_style`], the title tab
+/// box the label was laid out in — the tab sits flush on the body's top edge
+/// (the designer union-carve shape).
+pub struct SectionFrame {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ pub tab: Option<(f32, f32, f32, f32)>,
+ pub focused: bool,
+ pub is_child: bool,
+}
+
+pub struct PopoverCollector {
+ pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
+ pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
+}
+
+impl PopoverCollector {
+ pub fn new() -> Self {
+ Self { rects: Vec::new(), texts: Vec::new() }
+ }
+}
+
+impl RenderTarget for PopoverCollector {
+ fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
+ self.rects.push((color, x, y, w, h));
+ }
+
+ fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
+ self.texts.push((content.to_string(), size, x, y, color, None, None));
+ }
+
+ fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
+ self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
+ }
+
+ fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
+ self.texts.push((content.to_string(), size, x, y, color, None, bounds));
+ }
+
+ fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
+ self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), bounds));
+ }
+}
+
+
+pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32, ctx: &mut UiContext) {
+ ctx.register_host(w);
+ // The flat-host contract, the same block `set_rect` takes: `(x, y)` is the top of
+ // the detached label and `wh` the block height, label strip included. `layout`
+ // takes the CONTENT origin and height, so step down by the strip.
+ let strip = w.label_strip();
+ let content_h = (wh - strip).max(0.0);
+ w.layout(crate::widget::Point { x, y: y + strip }, crate::widget::LayoutConstraints::new(ww, ww, content_h, content_h), ctx);
+
+ // Shape, which on this path nobody else does. A flat host consumes
+ // `all_quads`, so `prepare_text` — where a TextBox records the per-glyph x
+ // offsets its selection highlight, caret and click->index mapping all read
+ // — was never called for the widgets it draws. Those three then fell back
+ // to `measure_text_width("M")`, an SVG-rasterized INKED extent rather than
+ // an advance, so the highlight under-ran the glyphs by a few px per
+ // character (a full glyph by the end of "example.com"). Hosts that shape
+ // for themselves (cce-files, the TreeList) just re-read the shared buffer
+ // cache here.
+ if let Ok(mut fs) = crate::geometry_font_system().lock() {
+ w.prepare_text(&mut fs);
+ }
+ let (style_r, corners) = w.corner_style();
+ let r = if corners != (false, false, false, false) { style_r } else { 0.0 };
+ let (wx, mut wy, www, mut whh) = w.rect();
+ let top_room = w.label_strip();
+ wy += top_room;
+ whh -= top_room;
+
+ // ONE ordered replay of the paint walk — the same walk the live display-
+ // list render runs (`scene::painter`), every prim in the order the widget
+ // painted it, each mapped onto the flat host's RenderTarget surface.
+ //
+ // This used to be three passes over three typed views of the same paint:
+ // the relief prims (downcast per widget type and re-derived from the
+ // widget's accessors — the Dropdown's inset plate, the TextBox's well, the
+ // Button's face, the Toggle's faces and steps), then every plain quad
+ // (`all_quads`), then every rounded quad (`all_rounded_quads`). Splitting
+ // one paint into typed streams loses the order between them, and the
+ // order is the picture: a TextBox draws its rounded background, carves
+ // its well, THEN lays the selection highlight and caret on top — the
+ // three-pass replay put the well under the highlight and the background
+ // over both. A Dropdown's hovered row is drawn after its menu plate; a
+ // host that replays plates after rects buries the highlight. The prim
+ // walk keeps the widget's order, covers every widget instead of the four
+ // that had a special case, and carries the relief prims' own per-corner
+ // radii and depth (the re-derivations rounded those off).
+ //
+ // Mapping onto the tuple surface: Quad and RoundedRect are the two
+ // native fills (the root's plain background keeps its solid-border
+ // expansion and window-corner resolution); a zero-stroke Border is an
+ // inset plate's FACE and is held until the Trough that follows it, so the
+ // pair reaches the host as ONE `inset_plate` call (a relief host carves
+ // it for real; the default degrades to the flat fill); Recess/Boss go to
+ // `relief_carve`; a Bevel degrades to its fill — what a flat host can
+ // draw of a raised plate. Ridges, circles, arcs, vectors and images have
+ // no flat-surface counterpart and are skipped, as they always were.
+ let solid_border = w.solid_border();
+ let mut text_scratch = crate::scene::paint::PaintCtx::new();
+ crate::scene::painter::paint_root_into(&*ctx, &*w, &mut text_scratch);
+
+ // A zero-stroke Border waiting for its Trough: (rect, radii, fill).
+ let mut pending_face: Option<(crate::scene::layout::Rect, crate::scene::paint::Radii, [f32; 4])> = None;
+ fn same_rect(a: crate::scene::layout::Rect, b: crate::scene::layout::Rect) -> bool {
+ (a.x - b.x).abs() < 0.1 && (a.y - b.y).abs() < 0.1 && (a.width - b.width).abs() < 0.1 && (a.height - b.height).abs() < 0.1
+ }
+ fn emit_rounded(pc: &mut dyn RenderTarget, rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, color: [f32; 4]) {
+ let (r1, r2, r3, r4) = radii;
+ let radius = r1.max(r2).max(r3).max(r4);
+ let mask = (r1 > 0.0, r2 > 0.0, r3 > 0.0, r4 > 0.0);
+ pc.rect_with_radius_corners(color, rect.x, rect.y, rect.width, rect.height, radius, mask);
+ }
+
+ for item in text_scratch.finish().items {
+ use crate::scene::paint::Prim;
+ let trough_for_face = match (&item.prim, &pending_face) {
+ (Prim::Trough { rect, .. }, Some((face_rect, _, _))) => same_rect(*rect, *face_rect),
+ // `inset_plate`'s edge: a field that is all run.
+ (Prim::Field { rect, split, end, .. }, Some((face_rect, _, _))) => {
+ *split <= rect.x && *end >= rect.x + rect.width && same_rect(*rect, *face_rect)
+ }
+ _ => false,
+ };
+ if !trough_for_face {
+ if let Some((frect, fradii, fill)) = pending_face.take() {
+ emit_rounded(pc, frect, fradii, fill);
+ }
+ }
+ match item.prim {
+ Prim::Quad { rect, color: qc } => {
+ let (qx, qy, qw, qh) = (rect.x, rect.y, rect.width, rect.height);
+ let extra_corners = (
+ corners.0 && qx <= wx + 1.5 && qy <= wy + 1.5,
+ corners.1 && qx + qw >= wx + www - 1.5 && qy <= wy + 1.5,
+ corners.2 && qx + qw >= wx + www - 1.5 && qy + qh >= wy + whh - 1.5,
+ corners.3 && qx <= wx + 1.5 && qy + qh >= wy + whh - 1.5,
+ );
+
+ let (resolved_r, resolved_corners) = if r <= 0.1 || corners == (false, false, false, false) || extra_corners == (false, false, false, false) {
+ (0.0, (false, false, false, false))
+ } else {
+ (r, extra_corners)
+ };
+
+ // The widget's own background quad with a solid border: full-size
+ // border quad, then the inset background over it.
+ let mut border_drawn = false;
+ let is_bg_quad = (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - www).abs() < 0.1 && (qh - whh).abs() < 0.1;
+ if is_bg_quad {
+ if let Some((border_color, thickness)) = solid_border {
+ if thickness > 0.0 {
+ pc.rect_with_radius_corners(border_color, qx, qy, qw, qh, resolved_r, resolved_corners);
+ pc.rect_with_radius_corners(
+ qc,
+ qx + thickness,
+ qy + thickness,
+ (qw - 2.0 * thickness).max(0.0),
+ (qh - 2.0 * thickness).max(0.0),
+ (resolved_r - thickness).max(0.0),
+ resolved_corners,
+ );
+ border_drawn = true;
+ }
+ }
+ }
+
+ if !border_drawn {
+ pc.rect_with_radius_corners(qc, qx, qy, qw, qh, resolved_r, resolved_corners);
+ }
+ }
+ Prim::RoundedRect { rect, radius, corners: qcorners, color: qc } => {
+ pc.rect_with_radius_corners(qc, rect.x, rect.y, rect.width, rect.height, radius, qcorners);
+ }
+ Prim::Border { rect, radii, fill, border, thickness } => {
+ if thickness > 0.0 && border[3].abs() > 0.001 {
+ emit_rounded(pc, rect, radii, border);
+ if fill[3].abs() > 0.001 {
+ let inner = crate::scene::layout::Rect {
+ x: rect.x + thickness,
+ y: rect.y + thickness,
+ width: (rect.width - 2.0 * thickness).max(0.0),
+ height: (rect.height - 2.0 * thickness).max(0.0),
+ };
+ let (r1, r2, r3, r4) = radii;
+ let shrink = |v: f32| if v > 0.0 { (v - thickness).max(0.0) } else { 0.0 };
+ emit_rounded(pc, inner, (shrink(r1), shrink(r2), shrink(r3), shrink(r4)), fill);
+ }
+ } else if fill[3].abs() > 0.001 {
+ // abs(): a negative alpha is the frost sentinel, a real face.
+ pending_face = Some((rect, radii, fill));
+ }
+ }
+ // A flat host has no blended outline: the two-box form
+ // `Prim::Field` replaced — the well to the seam, the run's flush
+ // plate past it.
+ Prim::Field { rect, radii, depth, split, end, tint } => {
+ let (fl, fr) = (rect.x, rect.x + rect.width);
+ let (well_l, well_r) = (split > fl, end < fr);
+ // All run and no well (`PaintCtx::inset_plate`'s edge): the
+ // plate alone, with the face that came before it.
+ let face = if !well_l && !well_r { pending_face.take().map(|(_, _, fill)| fill) } else { None }.unwrap_or([0.0; 4]);
+ let rx = split.max(fl);
+ let rw = (end.min(fr) - rx).max(0.0);
+ let mut well = |x: f32, w: f32, radii: (f32, f32, f32, f32)| {
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Recess { tint },
+ x,
+ y: rect.y,
+ w,
+ h: rect.height,
+ radii,
+ depth,
+ edges: (true, true, true, true),
+ });
+ };
+ if well_l {
+ well(fl, rx - fl, (radii.0, 0.0, 0.0, radii.3));
+ }
+ if well_r {
+ well(rx + rw, fr - rx - rw, (0.0, radii.1, radii.2, 0.0));
+ }
+ let r = if well_r { radii.0.max(radii.3) } else { radii.1.max(radii.2) };
+ match tint {
+ Some(t) => pc.inset_plate_tinted(face, rx, rect.y, rw, rect.height, r, depth, t),
+ None => pc.inset_plate(face, rx, rect.y, rw, rect.height, r, depth),
+ }
+ }
+ Prim::Trough { rect, radii, depth, tint, .. } => {
+ let face = pending_face.take().map(|(_, _, fill)| fill).unwrap_or([0.0; 4]);
+ let (r1, r2, r3, r4) = radii;
+ let r = r1.max(r2).max(r3).max(r4);
+ match tint {
+ Some(t) => pc.inset_plate_tinted(face, rect.x, rect.y, rect.width, rect.height, r, depth, t),
+ None => pc.inset_plate(face, rect.x, rect.y, rect.width, rect.height, r, depth),
+ }
+ }
+ Prim::Bevel { rect, radii, material, .. } => {
+ let color = material.fill(crate::scene::material::PlateRole::Nested);
+ if color[3].abs() > 0.001 {
+ emit_rounded(pc, rect, radii, color);
+ }
+ }
+ Prim::Recess { rect, radii, depth, edges, tint } => {
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Recess { tint },
+ x: rect.x,
+ y: rect.y,
+ w: rect.width,
+ h: rect.height,
+ radii,
+ depth,
+ edges,
+ });
+ }
+ Prim::Boss { rect, radii, depth, edges, tint } => {
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Boss { tint },
+ x: rect.x,
+ y: rect.y,
+ w: rect.width,
+ h: rect.height,
+ radii,
+ depth,
+ edges,
+ });
+ }
+ // Text via the paint walk: each widget's Text prims (content font + scroll-ancestor
+ // clip) exactly as the live display-list render does; the prim already carries the
+ // per-widget font + bounds.
+ Prim::Text { text, x, y, font_size, color, font, bounds, .. } => {
+ let color_f32 = [
+ color[0] as f32 / 255.0,
+ color[1] as f32 / 255.0,
+ color[2] as f32 / 255.0,
+ 1.0,
+ ];
+ // Compose the walk's container clip with the prim's own bounds (the engine's dl-text
+ // merge), so a clipping ancestor still bounds the text.
+ let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
+ let merged = match (clip, bounds) {
+ (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
+ (Some(a), None) => Some(a),
+ (None, b) => b,
+ };
+ match font {
+ Some(ref f) => pc.text_with_font_and_bounds(&text, x, y, font_size, color_f32, f, merged),
+ None => pc.text_with_bounds(&text, x, y, font_size, color_f32, merged),
+ }
+ }
+ // Strokes, arcs and discs — a graph's wires and ports, chiefly.
+ // Until 2026-10-06 these were dropped too, so a graph in a flat
+ // host (cce-files' Graph page) drew its nodes and no wires.
+ Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
+ if let Some(c) = item.clip {
+ pc.push_clip_rect(c.x, c.y, c.width, c.height);
+ }
+ pc.line(x1, y1, x2, y2, thickness, color, cap);
+ if item.clip.is_some() {
+ pc.pop_clip_rect();
+ }
+ }
+ Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
+ if let Some(c) = item.clip {
+ pc.push_clip_rect(c.x, c.y, c.width, c.height);
+ }
+ pc.arc(cx, cy, radius, thickness, start, end, color);
+ if item.clip.is_some() {
+ pc.pop_clip_rect();
+ }
+ }
+ Prim::Circle { cx, cy, radius, color } => {
+ if let Some(c) = item.clip {
+ pc.push_clip_rect(c.x, c.y, c.width, c.height);
+ }
+ pc.circle(cx, cy, radius, color);
+ if item.clip.is_some() {
+ pc.pop_clip_rect();
+ }
+ }
+ // A widget's GLYPH (a dropdown's arrow, a spinbox's −/+, a menu
+ // mark): drawn by name through the host's `RenderTarget::icon`,
+ // since an image id means nothing to a flat host. Until this arm
+ // every widget glyph vanished from a flat host — the toolkit drew
+ // its symbols as text before 2026-10-05, and this replay dropped
+ // images. An image that is not a bundled glyph is still dropped.
+ Prim::Image { image, rect, alpha } => {
+ if let Some((name, _, tint)) = crate::icon_source(image) {
+ let [r, g, b] = tint.unwrap_or([255, 255, 255]);
+ let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, alpha];
+ if let Some(c) = item.clip {
+ pc.push_clip_rect(c.x, c.y, c.width, c.height);
+ }
+ pc.icon(&name, rect, color);
+ if item.clip.is_some() {
+ pc.pop_clip_rect();
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ if let Some((frect, fradii, fill)) = pending_face.take() {
+ emit_rounded(pc, frect, fradii, fill);
+ }
+ if w.popover_rect().is_some() {
+ ctx.register_popover(w);
+ }
+}
+
+pub fn render_popovers(pc: &mut dyn RenderTarget, ctx: &UiContext) {
+ for &pop_id in &ctx.active_popovers {
+ if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
+ unsafe {
+ (*ptr).render_popover(pc);
+ }
+ }
+ }
+ // Registry sweep for open popovers the app never registered (popover
+ // registration is optional and spotty) — the same fallback the coverage
+ // check and the engine's outside-press close use.
+ for (id, ptr) in ctx.tree.iter_registered() {
+ if ctx.active_popovers.contains(&id) {
+ continue;
+ }
+ unsafe {
+ if let Some(w) = ptr.as_ref() {
+ if w.visible() && w.popover_rect().is_some() {
+ w.render_popover(pc);
+ }
+ }
+ }
+ }
+}
+
+pub fn partition_concentric_corners(
+ x: f32, y: f32, w: f32, h: f32,
+ _r_std: f32,
+ r_adjust: [f32; 4],
+ color: [f32; 4],
+) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
+ let mut quads = Vec::new();
+
+ let r0 = r_adjust[0];
+ let r1 = r_adjust[1];
+ let r2 = r_adjust[2];
+ let r3 = r_adjust[3];
+
+ let max_left = r0.max(r3);
+ let max_right = r1.max(r2);
+
+ let mut push_valid_quad = |qx: f32, qy: f32, qw: f32, qh: f32, qr: f32, qcorners: (bool, bool, bool, bool)| {
+ if qw > 0.001 && qh > 0.001 {
+ quads.push((qx, qy, qw, qh, qr, color, qcorners));
+ }
+ };
+
+ // 1. Center vertical block
+ push_valid_quad(x + max_left, y, w - max_left - max_right, h, 0.0, (false, false, false, false));
+
+ // 2. Left block
+ push_valid_quad(x, y + r0, max_left, h - r0 - r3, 0.0, (false, false, false, false));
+
+ // 3. Top-left transition
+ push_valid_quad(x + r0, y, max_left - r0, r0, 0.0, (false, false, false, false));
+
+ // 4. Bottom-left transition
+ push_valid_quad(x + r3, y + h - r3, max_left - r3, r3, 0.0, (false, false, false, false));
+
+ // 5. Right block
+ push_valid_quad(x + w - max_right, y + r1, max_right, h - r1 - r2, 0.0, (false, false, false, false));
+
+ // 6. Top-right transition
+ push_valid_quad(x + w - max_right, y, max_right - r1, r1, 0.0, (false, false, false, false));
+
+ // 7. Bottom-right transition
+ push_valid_quad(x + w - max_right, y + h - r2, max_right - r2, r2, 0.0, (false, false, false, false));
+
+ // 8. Corner 0 (top-left)
+ push_valid_quad(x, y, r0, r0, r0, (true, false, false, false));
+
+ // 9. Corner 1 (top-right)
+ push_valid_quad(x + w - r1, y, r1, r1, r1, (false, true, false, false));
+
+ // 10. Corner 2 (bottom-right)
+ push_valid_quad(x + w - r2, y + h - r2, r2, r2, r2, (false, false, true, false));
+
+ // 11. Corner 3 (bottom-left)
+ push_valid_quad(x, y + h - r3, r3, r3, r3, (false, false, false, true));
+
+ quads
+}
diff --git a/src/layout/legacy.rs b/src/layout/legacy.rs
new file mode 100644
index 0000000..3107722
--- /dev/null
+++ b/src/layout/legacy.rs
@@ -0,0 +1,1959 @@
+//! The legacy layout engines — `Column`, `Row`, `Section`, `Grid`, the `LayoutStrategy`
+//! family (`FlexLayout`, `ColumnLayout`, `AdaptiveGrid`, `RadialLayout`), `PageLayoutBuilder`
+//! and `VStack` — from before `scene::layout`'s box model. Still used by
+//! cce-system-interface, cce-files and cce-gallery; new layout goes through
+//! `scene::layout`.
+
+use super::*;
+use crate::widget::WidgetHost;
+use crate::context::UiContext;
+
+pub struct UiFrame;
+
+impl UiFrame {
+ pub fn start(scroll_offset: f32) -> Self {
+ crate::widget::hover_animation::reset_frame_registration();
+ crate::widget::hover_animation::set_scroll_offset(scroll_offset);
+ Self
+ }
+
+ pub fn finish(self, pc: &mut dyn RenderTarget) {
+ crate::widget::hover_animation::post_render_check();
+ if let Some((qx, qy, qw, qh, qc)) = crate::widget::hover_animation::get_quad() {
+ pc.rect(qc, qx, qy, qw, qh);
+ }
+ // render_popovers(pc);
+ }
+}
+
+pub struct Column {
+ ox: f32,
+ oy: f32,
+ cx: f32,
+ pub y: f32,
+ pub cw: f32,
+}
+
+impl Column {
+ pub fn new(ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
+ Self { ox, oy, cx, y: cy, cw }
+ }
+
+ pub fn ax(&self, x_off: f32) -> f32 {
+ self.ox + self.cx + x_off
+ }
+
+ pub fn ay(&self) -> f32 {
+ self.oy + self.y
+ }
+
+ pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ pc.rect(color, self.ax(x_off), self.ay(), w, h);
+ self.y += h;
+ }
+
+ pub fn advance(&mut self, dy: f32) {
+ self.y += dy;
+ }
+
+ pub fn spacing(&mut self, dy: f32) {
+ self.y += dy;
+ }
+
+ pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
+ let x = self.ax(8.0);
+ let y = self.ay();
+ 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, pc: &mut dyn RenderTarget, text: &str, x_off: f32) {
+ let x = self.ax(x_off);
+ let y = self.ay();
+ pc.text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
+ self.y += 22.0;
+ }
+
+ pub fn text(&mut self, pc: &mut dyn RenderTarget, 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;
+ pc.text(text, x, y, font_size, color);
+ }
+
+ pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, mut wh: f32, ctx: &mut UiContext) {
+ if let Some(pref) = w.preferred_height() {
+ wh = pref;
+ }
+ let top_room = w.label_strip();
+ let total_h = wh + top_room;
+ let x = self.ax(x_off);
+ let y = self.ay();
+ w.set_row_rect(self.ox + self.cx + 8.0, self.cw - 16.0);
+ let clamped_w = ww.min((self.cw - x_off).max(0.0));
+ render_widget(pc, w, x, y, clamped_w, total_h, ctx);
+ self.y += total_h;
+ }
+
+ pub fn row<F: FnOnce(&mut Row)>(&mut self, pc: &mut dyn RenderTarget, h: f32, f: F) {
+ let row_y = self.ay();
+ let mut row = Row {
+ pc: &mut *pc,
+ base_x: self.ox + self.cx,
+ y: row_y,
+ cursor_x: 0.0,
+ spacing: CONTROL_GAP,
+ };
+ f(&mut row);
+ self.y = self.y + h;
+ }
+}
+
+pub struct Row<'a> {
+ pc: &'a mut dyn RenderTarget,
+ base_x: f32,
+ y: f32,
+ pub cursor_x: f32,
+ pub spacing: f32,
+}
+
+impl<'a> Row<'a> {
+ pub fn set_spacing(&mut self, spacing: f32) {
+ self.spacing = spacing;
+ }
+
+ pub fn gap(&mut self, width: f32) {
+ self.cursor_x += width;
+ }
+
+ pub fn text(&mut self, text: &str, y_off: f32, font_size: f32, color: [f32; 4], width: f32) {
+ self.pc
+ .text(text, self.base_x + self.cursor_x, self.y + y_off, font_size, color);
+ self.cursor_x += width + self.spacing;
+ }
+
+ pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, mut wh: f32, ctx: &mut UiContext) {
+ if let Some(pref) = w.preferred_height() {
+ wh = pref;
+ }
+ render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
+ self.cursor_x += ww + self.spacing;
+ }
+}
+
+fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
+ let fam_lower = font_fam.to_lowercase();
+ let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
+ if is_mono {
+ label.chars().count() as f32 * font_size * 0.60
+ } else {
+ let mut width = 0.0;
+ for c in label.chars() {
+ let factor = match c {
+ 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
+ 'f' | 'j' | 't' => 0.35,
+ 'r' | 's' | 'c' | 'z' => 0.50,
+ 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
+ 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
+ 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
+ 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
+ '0'..='9' => 0.60,
+ _ => 0.60,
+ };
+ width += factor * font_size;
+ }
+ width
+ }
+}
+
+pub struct Section {
+ pub left: f32,
+ pub top: f32,
+ pub content_y: f32,
+ pub cw: f32,
+ pub label_width: f32,
+ pub is_child: bool,
+ pub grid: Grid,
+ pub last_col: usize,
+}
+
+impl Section {
+ pub const DEFAULT_MARGIN_X: f32 = 12.0;
+ pub const DEFAULT_ROW_GAP: f32 = 8.0;
+
+ fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
+ estimate_label_width_helper(label, font_size, font_fam)
+ }
+
+ pub fn padding(&self) -> f32 {
+ section_padding()
+ }
+
+ pub fn new(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str) -> Self {
+ Self::new_opt(pc, left, top, cw, label, false)
+ }
+
+ pub fn new_opt(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str, is_child: bool) -> Self {
+ let font_setting = if is_child {
+ nested_section_label_font()
+ } else {
+ section_label_font()
+ };
+ let (font_fam, font_size_opt) = parse_font_string(&font_setting);
+ let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
+ let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
+ let label_width = Self::estimate_label_width(label, font_size, &font_fam);
+ let label_x = if is_child {
+ let base_x = match nested_section_label_alignment() {
+ 0 => left + 12.0,
+ 1 => left + (cw - label_width) / 2.0,
+ 2 => left + cw - 12.0 - label_width,
+ _ => left + 12.0,
+ };
+ base_x + nested_section_label_offset()
+ } else {
+ left + (cw - label_width) / 2.0
+ };
+ pc.text_with_font(label, label_x, top, font_size, font_color, &font_fam);
+
+ let pad = section_padding();
+ let margin_x = 2.0 * pad + 12.0;
+ let usable_w = (cw - 2.0 * margin_x).max(1.0);
+ let min_col_width = 130.0;
+ let gap = 8.0;
+ let max_cols = if is_child {
+ 1
+ } else {
+ ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
+ };
+ let content_start_y = top + pad + 19.0;
+ let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
+
+ Self { left, top, content_y: content_start_y, cw, label_width, is_child, grid, last_col: usize::MAX }
+ }
+
+ /// Horizontal inset of content from this section's left edge — the same
+ /// one `row_layout`, the column `Grid` and `widget` use, so everything in
+ /// a section lines up. See `SectionContext::content_margin`.
+ pub fn content_margin(&self) -> f32 {
+ 2.0 * self.padding() + 12.0
+ }
+
+ pub fn content_left(&self) -> f32 {
+ self.left + self.content_margin()
+ }
+
+ pub fn content_width(&self) -> f32 {
+ (self.cw - 2.0 * self.content_margin()).max(0.0)
+ }
+
+ /// See `SectionContext::ax` — same mapping, same reason it is no longer
+ /// stepped at `x_off == 12.0`.
+ pub fn ax(&self, x_off: f32) -> f32 {
+ self.left + 2.0 * self.padding() + x_off
+ }
+
+ pub fn ay(&self) -> f32 { self.content_y }
+
+ pub fn spacing(&mut self, dy: f32) {
+ if self.grid.col_heights.len() >= 2 {
+ if self.last_col == usize::MAX {
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ } else if self.last_col < self.grid.col_heights.len() {
+ self.grid.col_heights[self.last_col] += dy;
+ }
+ self.content_y = self.grid.max_height();
+ } else {
+ self.content_y += dy;
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ }
+ }
+
+ pub fn text(&mut self, pc: &mut dyn RenderTarget, 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;
+ // Bounded to the content box, like `SectionContext::text` — text is
+ // the only thing a section draws that is not already sized to fit it.
+ let right = self.content_left() + self.content_width();
+ let bounds = if right > x {
+ Some([x, y - font_size, right, y + 2.0 * font_size])
+ } else {
+ None
+ };
+ pc.text_with_bounds(text, x, y, font_size, color, bounds);
+ }
+
+ pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
+ if let Some(pref) = w.preferred_height() {
+ wh = pref;
+ }
+ let pad = self.padding();
+ let top_room = w.label_strip();
+ let total_h = wh + top_room;
+
+ let name = w.type_name();
+ let span_full = name == "Trackpad"
+ || name == "Canvas"
+ || name == "UsageBar"
+ || name == "ProgressBar"
+ || name == "ButtonStrip"
+ || name == "Spreadsheet"
+ || name == "Graph";
+
+ if span_full {
+ let margin_x = 2.0 * pad + 12.0;
+ let x = self.left + margin_x;
+ let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
+ let max_h = self.grid.max_height().max(self.content_y);
+ let y = max_h;
+
+ w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
+ render_widget(pc, w, x, y, clamped_w, total_h, ctx);
+
+ let new_bottom = y + total_h;
+ self.content_y = new_bottom;
+ for h in &mut self.grid.col_heights {
+ *h = new_bottom;
+ }
+ } else {
+ let max_h = self.grid.max_height();
+ if self.content_y > max_h {
+ for h in &mut self.grid.col_heights {
+ *h = self.content_y;
+ }
+ }
+
+ let col = self.grid.next_column();
+ self.last_col = col;
+ let x = self.grid.col_lefts[col];
+ let y = self.grid.col_heights[col];
+
+ w.set_row_rect(x, self.grid.col_width);
+ render_widget(pc, w, x, y, self.grid.col_width, total_h, ctx);
+ self.grid.col_heights[col] += total_h;
+ self.content_y = self.grid.max_height();
+ }
+ }
+
+ pub fn widget_full<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, wh: f32, ctx: &mut UiContext) {
+ let x_off = 12.0;
+ let ww = self.cw - 2.0 * (self.padding() + x_off);
+ self.widget(pc, w, x_off, ww, wh, ctx);
+ }
+
+ pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
+ let pad = self.padding();
+ let x = self.ax(pad);
+ let max_h = self.grid.max_height().max(self.content_y);
+ let y = max_h;
+ pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
+ self.content_y = max_h + 8.0;
+ for h in &mut self.grid.col_heights {
+ *h = self.content_y;
+ }
+ }
+
+ pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ let max_h = self.grid.max_height().max(self.content_y);
+ pc.rect(color, self.ax(x_off), max_h, w, h);
+ self.content_y = max_h + h;
+ for col_h in &mut self.grid.col_heights {
+ *col_h = self.content_y;
+ }
+ }
+
+ pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
+ let margin_x = self.content_margin();
+ let usable_w = self.content_width();
+ if count == 0 {
+ return Vec::new();
+ }
+ let total_gap = gap * (count - 1) as f32;
+ let col_w = (usable_w - total_gap).max(0.0) / count as f32;
+
+ let mut cols = Vec::with_capacity(count);
+ for i in 0..count {
+ let x = self.left + margin_x + i as f32 * (col_w + gap);
+ cols.push((x, col_w));
+ }
+ cols
+ }
+
+ pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(usize, f32, f32),
+ {
+ let max_h = self.grid.max_height().max(self.content_y);
+ for col_h in &mut self.grid.col_heights {
+ *col_h = max_h;
+ }
+ self.content_y = max_h;
+
+ let cols = self.row_layout(count, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ f(i, x, w);
+ }
+ self.content_y += h;
+
+ for col_h in &mut self.grid.col_heights {
+ *col_h = self.content_y;
+ }
+ }
+
+ pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
+ self.finish_focused(pc, false)
+ }
+
+ pub fn finish_focused(&mut self, pc: &mut dyn RenderTarget, focused: bool) -> f32 {
+ let border: [f32; 4] = if self.is_child {
+ if focused {
+ [0.22, 0.38, 0.24, 1.0]
+ } else {
+ [0.18, 0.18, 0.25, 1.0]
+ }
+ } else {
+ if focused {
+ [0.30, 0.50, 0.32, 1.0]
+ } else {
+ [0.25, 0.25, 0.35, 1.0]
+ }
+ };
+ let pad = self.padding();
+ let x = self.left + pad;
+ let y = self.top + 7.0;
+ let w = self.cw - 2.0 * pad;
+ let h = self.content_y - y;
+
+ let left_edge = x;
+ let right_edge = x + w;
+ if self.label_width > 0.0 {
+ let label_x = if self.is_child {
+ let base_x = match nested_section_label_alignment() {
+ 0 => self.left + 12.0,
+ 1 => self.left + (self.cw - self.label_width) / 2.0,
+ 2 => self.left + self.cw - 12.0 - self.label_width,
+ _ => self.left + 12.0,
+ };
+ base_x + nested_section_label_offset()
+ } else {
+ self.left + (self.cw - self.label_width) / 2.0
+ };
+ let gap_margin = 6.0;
+ let gap_start = label_x - gap_margin;
+ let gap_end = label_x + self.label_width + gap_margin;
+ if gap_start > left_edge {
+ pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
+ }
+ if right_edge > gap_end {
+ pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
+ }
+ } else {
+ pc.rect(border, left_edge, y, w, 1.0);
+ }
+
+ let extra_bottom = pad + 12.0;
+ pc.rect(border, x, y + h + extra_bottom, w, 1.0);
+ pc.rect(border, x, y, 1.0, h + extra_bottom);
+ pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
+ self.content_y + extra_bottom + 8.0
+ }
+
+ pub fn vstack<'a>(&'a mut self, pc: &'a mut dyn RenderTarget, spacing: f32) -> SectionVStack<'a> {
+ SectionVStack {
+ section: self,
+ pc,
+ spacing,
+ }
+ }
+}
+
+pub struct SectionVStack<'a> {
+ section: &'a mut Section,
+ pc: &'a mut dyn RenderTarget,
+ spacing: f32,
+}
+
+impl<'a> SectionVStack<'a> {
+ pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, wh: f32, ctx: &mut UiContext) {
+ self.section.widget(self.pc, w, Section::DEFAULT_MARGIN_X, ww, wh, ctx);
+ self.section.spacing(self.spacing);
+ }
+
+ pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, f: F)
+ where
+ F: FnMut(usize, f32, f32),
+ {
+ self.section.row(count, gap, h, f);
+ self.section.spacing(self.spacing);
+ }
+}
+
+
+pub struct SplitterLayout {
+ pub splitter1_x: f32,
+ pub splitter2_x: f32,
+ pub splitter_width: f32,
+ pub min_column_width: f32,
+}
+
+impl SplitterLayout {
+ pub fn new(width: f32, splitter_width: f32, min_column_width: f32) -> Self {
+ let s1 = (width - 2.0 * splitter_width) / 3.0;
+ let s2 = s1 + splitter_width + (width - 2.0 * splitter_width) / 3.0;
+ Self {
+ splitter1_x: s1,
+ splitter2_x: s2,
+ splitter_width,
+ min_column_width,
+ }
+ }
+
+ pub fn clamp(&mut self, total_width: f32, detached_circular_network: bool) {
+ if detached_circular_network {
+ let min_s2 = self.min_column_width;
+ let max_s2 = (total_width - self.min_column_width).max(min_s2);
+ self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
+ } else {
+ let min_s1 = self.min_column_width;
+ let max_s1 = (self.splitter2_x - self.splitter_width - self.min_column_width).max(min_s1);
+ self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
+ let min_s2 = self.splitter1_x + self.splitter_width + self.min_column_width;
+ let max_s2 = (total_width - self.min_column_width).max(min_s2);
+ self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
+ }
+ }
+
+ pub fn scale(&mut self, factor: f32) {
+ self.splitter1_x *= factor;
+ self.splitter2_x *= factor;
+ }
+
+ pub fn left_col(&self) -> (f32, f32) { // (x, width)
+ (0.0, self.splitter1_x)
+ }
+
+ pub fn center_col(&self) -> (f32, f32) { // (x, width)
+ let x = self.splitter1_x + self.splitter_width;
+ (x, self.splitter2_x - x)
+ }
+
+ pub fn right_col(&self, total_width: f32) -> (f32, f32) { // (x, width)
+ let x = self.splitter2_x + self.splitter_width;
+ (x, (total_width - x).max(0.0))
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Grid {
+ pub left: f32,
+ pub top: f32,
+ pub width: f32,
+ pub col_width: f32,
+ pub gap: f32,
+ pub col_heights: Vec<f32>,
+ pub col_lefts: Vec<f32>,
+}
+
+impl Grid {
+ pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
+ let total_gap = gap * (count - 1) as f32;
+ let col_width = if count > 0 {
+ (width - total_gap).max(0.0) / count as f32
+ } else {
+ min_col_width
+ };
+ let left_offset = 0.0;
+
+ let mut col_lefts = Vec::with_capacity(count);
+ let col_heights = vec![top; count];
+ for i in 0..count {
+ col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
+ }
+
+ Self {
+ left,
+ top,
+ width,
+ col_width,
+ gap,
+ col_heights,
+ col_lefts,
+ }
+ }
+
+ pub fn next_column(&self) -> usize {
+ let mut min_idx = 0;
+ let mut min_h = self.col_heights[0];
+ for i in 1..self.col_heights.len() {
+ if self.col_heights[i] < min_h {
+ min_h = self.col_heights[i];
+ min_idx = i;
+ }
+ }
+ min_idx
+ }
+
+ pub fn max_height(&self) -> f32 {
+ let mut max_h = self.col_heights[0];
+ for i in 1..self.col_heights.len() {
+ if self.col_heights[i] > max_h {
+ max_h = self.col_heights[i];
+ }
+ }
+ max_h
+ }
+}
+
+pub struct CircularPaneLayout {
+ pub x: f32,
+ pub y: f32,
+ pub r: f32,
+}
+
+impl CircularPaneLayout {
+ pub fn new(x: f32, y: f32, r: f32) -> Self {
+ Self { x, y, r }
+ }
+
+ pub fn hit_test_content(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist_sq = dx * dx + dy * dy;
+ dist_sq <= self.r * self.r && cy >= self.y - self.r + 45.0 + menubar_h + breadcrumb_h
+ }
+
+ pub fn hit_test_menubar(&self, cx: f32, cy: f32, menubar_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist = (dx * dx + dy * dy).sqrt();
+ dist >= self.r - menubar_h && dist <= self.r && cy < self.y
+ }
+
+ pub fn hit_test_breadcrumb(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist_sq = dx * dx + dy * dy;
+ dist_sq <= self.r * self.r && cy >= self.y - self.r + menubar_h && cy < self.y - self.r + 45.0 + menubar_h + breadcrumb_h
+ }
+
+ pub fn hit_test_border(&self, cx: f32, cy: f32, border_thickness: f32) -> bool {
+ let dx = cx - self.x;
+ let dy = cy - self.y;
+ let dist = (dx * dx + dy * dy).sqrt();
+ dist >= self.r - border_thickness && dist <= self.r
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Radial {
+ pub center_x: f32,
+ pub center_y: f32,
+ pub aspect_ratio: f32,
+ pub base_spacing: f32,
+}
+
+impl Radial {
+ pub fn new(center_x: f32, center_y: f32, aspect_ratio: f32, base_spacing: f32) -> Self {
+ Self { center_x, center_y, aspect_ratio, base_spacing }
+ }
+
+ pub fn widget_rect(&self, idx: usize, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+ if idx == 0 {
+ (self.center_x - ww / 2.0, self.center_y - wh / 2.0, ww, wh)
+ } else {
+ let mut ring = 1;
+ let mut ring_start = 1;
+ loop {
+ let ring_capacity = ring * 6;
+ if idx < ring_start + ring_capacity {
+ let pos_in_ring = idx - ring_start;
+ let angle = (pos_in_ring as f32) * (2.0 * std::f32::consts::PI / ring_capacity as f32);
+ let radius = (ring as f32) * self.base_spacing;
+
+ let x_offset = radius * angle.cos() * self.aspect_ratio;
+ let y_offset = radius * angle.sin();
+
+ return (
+ self.center_x + x_offset - ww / 2.0,
+ self.center_y + y_offset - wh / 2.0,
+ ww,
+ wh,
+ );
+ }
+ ring_start += ring_capacity;
+ ring += 1;
+ }
+ }
+ }
+
+}
+
+
+
+pub trait LayoutStrategy: std::fmt::Debug {
+ fn init(&mut self, left: f32, top: f32, width: f32, height: f32);
+ fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32);
+ fn set_section_count(&mut self, _count: usize) {}
+ fn get_column_width(&self) -> Option<f32> { None }
+ fn get_gap(&self) -> f32 { 20.0 }
+
+ fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32;
+ fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size;
+ fn box_clone(&self) -> Box<dyn LayoutStrategy>;
+}
+
+impl Clone for Box<dyn LayoutStrategy> {
+ fn clone(&self) -> Self {
+ self.box_clone()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FlexDirection {
+ Row,
+ Column,
+}
+
+#[derive(Debug, Clone)]
+pub struct FlexLayout {
+ left: f32,
+ top: f32,
+ width: f32,
+ height: f32,
+ direction: FlexDirection,
+ spacing: f32,
+ current_x: f32,
+ current_y: f32,
+}
+
+impl FlexLayout {
+ pub fn new(direction: FlexDirection, spacing: f32) -> Self {
+ Self {
+ left: 0.0,
+ top: 0.0,
+ width: 0.0,
+ height: 0.0,
+ direction,
+ spacing,
+ current_x: 0.0,
+ current_y: 0.0,
+ }
+ }
+}
+
+impl LayoutStrategy for FlexLayout {
+ fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
+ self.left = left;
+ self.top = top;
+ self.width = width;
+ self.height = height;
+ self.current_x = left;
+ self.current_y = top;
+ }
+
+ fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+ match self.direction {
+ FlexDirection::Row => {
+ let rx = self.current_x;
+ let ry = self.current_y;
+ self.current_x += ww + self.spacing;
+ (rx, ry, ww, wh)
+ }
+ FlexDirection::Column => {
+ let rx = self.current_x;
+ let ry = self.current_y;
+ self.current_y += wh + self.spacing;
+ (rx, ry, ww, wh)
+ }
+ }
+ }
+
+ fn get_gap(&self) -> f32 {
+ self.spacing
+ }
+
+ fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
+ let (cur_x, cur_y) = (x, y);
+ // Blocks: the label row (`label_lead`) above every child's content, the
+ // gap between blocks, so a row's controls are level and a carve-out tab
+ // sits a full gap from its neighbour.
+ let lead = crate::widget::container::container_layout::label_lead(children);
+ match self.direction {
+ FlexDirection::Row => {
+ let mut cur_x = cur_x;
+ for &child_ptr in children {
+ unsafe {
+ let child = &mut *child_ptr;
+ let child_w = child.rect().2;
+ let child_h = crate::widget::container::container_layout::content_height(child);
+ let use_h = if child_h > 0.0 { child_h } else { h };
+ child.layout(
+ crate::widget::Point { x: cur_x, y: cur_y + lead },
+ crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
+ ctx,
+ );
+ cur_x += child_w + self.spacing;
+ }
+ }
+ (cur_x - x).max(0.0)
+ }
+ FlexDirection::Column => {
+ let mut cur_y = cur_y;
+ for &child_ptr in children {
+ unsafe {
+ let child = &mut *child_ptr;
+ let child_h = crate::widget::container::container_layout::content_height(child);
+ let use_h = if child_h > 0.0 { child_h } else { 44.0 };
+ child.layout(
+ crate::widget::Point { x, y: cur_y + lead },
+ crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
+ ctx,
+ );
+ cur_y += lead + use_h + self.spacing;
+ }
+ }
+ (cur_y - y).max(0.0)
+ }
+ }
+ }
+
+ fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
+ match self.direction {
+ FlexDirection::Row => {
+ let mut total_w = 0.0f32;
+ let mut max_h = 0.0f32;
+ for (i, &child_ptr) in children.iter().enumerate() {
+ unsafe {
+ let size = (*child_ptr).measure(constraints, ctx);
+ total_w += size.width;
+ max_h = max_h.max(size.height);
+ if i > 0 {
+ total_w += self.spacing;
+ }
+ }
+ }
+ crate::widget::Size {
+ width: total_w.clamp(constraints.min_width, constraints.max_width),
+ height: max_h.clamp(constraints.min_height, constraints.max_height),
+ }
+ }
+ FlexDirection::Column => {
+ let mut total_h = 0.0f32;
+ let mut max_w = 0.0f32;
+ for (i, &child_ptr) in children.iter().enumerate() {
+ unsafe {
+ let size = (*child_ptr).measure(constraints, ctx);
+ total_h += size.height;
+ max_w = max_w.max(size.width);
+ if i > 0 {
+ total_h += self.spacing;
+ }
+ }
+ }
+ crate::widget::Size {
+ width: max_w.clamp(constraints.min_width, constraints.max_width),
+ height: total_h.clamp(constraints.min_height, constraints.max_height),
+ }
+ }
+ }
+ }
+
+ fn box_clone(&self) -> Box<dyn LayoutStrategy> {
+ Box::new(self.clone())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct ColumnLayout {
+ left: f32,
+ top: f32,
+ width: f32,
+ current_y: f32,
+ gap: f32,
+}
+
+impl ColumnLayout {
+ pub fn new(gap: f32) -> Self {
+ Self {
+ left: 0.0,
+ top: 0.0,
+ width: 0.0,
+ current_y: 0.0,
+ gap,
+ }
+ }
+}
+
+impl LayoutStrategy for ColumnLayout {
+ fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
+ self.left = left;
+ self.top = top;
+ self.width = width;
+ self.current_y = top;
+ }
+
+ fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+ let x = self.left;
+ let y = self.current_y;
+ self.current_y += wh + self.gap;
+ (x, y, ww, wh)
+ }
+
+ fn get_column_width(&self) -> Option<f32> {
+ Some(self.width)
+ }
+
+ fn get_gap(&self) -> f32 {
+ self.gap
+ }
+
+ fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
+ let mut cur_y = y;
+ for &child_ptr in children {
+ unsafe {
+ let child = &mut *child_ptr;
+ let child_h = child.preferred_height().unwrap_or(child.rect().3);
+ let use_h = if child_h > 0.0 { child_h } else { 44.0 };
+ child.set_rect(x, cur_y, w, use_h);
+ cur_y += use_h + self.gap;
+ }
+ }
+ (cur_y - y).max(0.0)
+ }
+
+ fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
+ let mut total_h = 0.0f32;
+ let mut max_w = 0.0f32;
+ for (i, &child_ptr) in children.iter().enumerate() {
+ unsafe {
+ let size = (*child_ptr).measure(constraints, ctx);
+ total_h += size.height;
+ max_w = max_w.max(size.width);
+ if i > 0 {
+ total_h += self.gap;
+ }
+ }
+ }
+ crate::widget::Size {
+ width: max_w.clamp(constraints.min_width, constraints.max_width),
+ height: total_h.clamp(constraints.min_height, constraints.max_height),
+ }
+ }
+
+ fn box_clone(&self) -> Box<dyn LayoutStrategy> {
+ Box::new(self.clone())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct AdaptiveGrid {
+ grid: Option<Grid>,
+ #[allow(dead_code)]
+ min_col_width: f32,
+ #[allow(dead_code)]
+ gap: f32,
+ num_sections: Option<usize>,
+}
+
+impl AdaptiveGrid {
+ pub fn new(min_col_width: f32, gap: f32) -> Self {
+ Self {
+ grid: None,
+ min_col_width,
+ gap,
+ num_sections: None,
+ }
+ }
+}
+
+impl LayoutStrategy for AdaptiveGrid {
+ fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
+ let min_col_width = crate::layout::grid_min_col_width();
+ let gap = crate::layout::grid_gap();
+ let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
+ let count = if let Some(n) = self.num_sections {
+ n.min(max_cols).max(1)
+ } else {
+ max_cols
+ };
+ self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
+ }
+
+ fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+ if let Some(ref mut grid) = self.grid {
+ let num_cols = grid.col_heights.len();
+ let num_cols_spanned = (((ww + grid.gap) / (grid.col_width + grid.gap)).round() as usize)
+ .min(num_cols)
+ .max(1);
+
+ if num_cols_spanned >= num_cols {
+ let y = grid.max_height();
+ let x = grid.left;
+ let allocated_w = grid.width;
+ for col_h in &mut grid.col_heights {
+ *col_h = y + wh + grid.gap;
+ }
+ (x, y, allocated_w, wh)
+ } else if num_cols_spanned == 1 {
+ let col = grid.next_column();
+ let x = grid.col_lefts[col];
+ let y = grid.col_heights[col];
+ grid.col_heights[col] += wh + grid.gap;
+ (x, y, grid.col_width, wh)
+ } else {
+ let n = num_cols_spanned;
+ let mut best_start_col = 0;
+ let mut min_max_h = f32::MAX;
+ for c in 0..=(num_cols - n) {
+ let mut max_h = 0.0f32;
+ for i in 0..n {
+ if grid.col_heights[c + i] > max_h {
+ max_h = grid.col_heights[c + i];
+ }
+ }
+ if max_h < min_max_h {
+ min_max_h = max_h;
+ best_start_col = c;
+ }
+ }
+ let x = grid.col_lefts[best_start_col];
+ let y = min_max_h;
+ let allocated_w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
+ for i in 0..n {
+ grid.col_heights[best_start_col + i] = y + wh + grid.gap;
+ }
+ (x, y, allocated_w, wh)
+ }
+ } else {
+ (0.0, 0.0, 0.0, wh)
+ }
+ }
+
+ fn set_section_count(&mut self, count: usize) {
+ self.num_sections = Some(count);
+ }
+
+ fn get_column_width(&self) -> Option<f32> {
+ self.grid.as_ref().map(|g| g.col_width)
+ }
+
+ fn get_gap(&self) -> f32 {
+ crate::layout::grid_gap()
+ }
+
+ fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
+ let usable_w = w.max(1.0);
+ let min_col_width = crate::layout::grid_min_col_width();
+ let gap = crate::layout::grid_gap();
+ let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
+ let count = if let Some(n) = self.num_sections {
+ n.min(cols).max(1)
+ } else {
+ cols
+ };
+
+ let total_gap = gap * (count - 1) as f32;
+ let available_w = (w - total_gap).max(1.0);
+ let col_w = available_w / count as f32;
+
+ let mut col_heights = vec![y; count];
+
+ for &child_ptr in children {
+ unsafe {
+ let child = &mut *child_ptr;
+ let ch = child.preferred_height().unwrap_or(child.rect().3);
+ let use_h = if ch > 0.0 { ch } else { 44.0 };
+
+ let mut min_col = 0;
+ let mut min_h = col_heights[0];
+ for i in 1..count {
+ if col_heights[i] < min_h {
+ min_h = col_heights[i];
+ min_col = i;
+ }
+ }
+
+ let cx = x + min_col as f32 * (col_w + gap);
+ let cy = col_heights[min_col];
+ child.set_rect(cx, cy, col_w, use_h);
+ col_heights[min_col] += use_h + gap;
+ }
+ }
+
+ let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
+ (max_h - y).max(0.0)
+ }
+
+ fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
+ let usable_w = constraints.max_width.max(1.0);
+ let min_col_width = crate::layout::grid_min_col_width();
+ let gap = crate::layout::grid_gap();
+ let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
+ let count = if let Some(n) = self.num_sections {
+ n.min(cols).max(1)
+ } else {
+ cols
+ };
+
+ let mut col_heights = vec![0.0f32; count];
+ let total_gap = gap * (count - 1) as f32;
+ let available_w = (constraints.max_width - total_gap).max(1.0);
+ let col_w = available_w / count as f32;
+
+ let child_constraints = crate::widget::LayoutConstraints::new(col_w, col_w, constraints.min_height, constraints.max_height);
+
+ for &child_ptr in children {
+ unsafe {
+ let size = (*child_ptr).measure(child_constraints, ctx);
+ let mut min_col = 0;
+ let mut min_h = col_heights[0];
+ for i in 1..count {
+ if col_heights[i] < min_h {
+ min_h = col_heights[i];
+ min_col = i;
+ }
+ }
+ col_heights[min_col] += size.height + gap;
+ }
+ }
+
+ let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
+ crate::widget::Size {
+ width: constraints.max_width,
+ height: max_h.clamp(constraints.min_height, constraints.max_height),
+ }
+ }
+
+ fn box_clone(&self) -> Box<dyn LayoutStrategy> {
+ Box::new(self.clone())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RadialLayout {
+ radial: Option<Radial>,
+ aspect_ratio: f32,
+ base_spacing: f32,
+ idx: usize,
+}
+
+impl RadialLayout {
+ pub fn new(aspect_ratio: f32, base_spacing: f32) -> Self {
+ Self {
+ radial: None,
+ aspect_ratio,
+ base_spacing,
+ idx: 0,
+ }
+ }
+}
+
+impl LayoutStrategy for RadialLayout {
+ fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
+ let cx = left + width / 2.0;
+ let cy = top + height / 2.0;
+ let aspect = if self.aspect_ratio > 0.0 {
+ self.aspect_ratio
+ } else {
+ let screen_aspect = (width / height.max(1.0)).max(0.1);
+ 1.0 + (screen_aspect - 1.0) * 0.4
+ };
+ self.radial = Some(Radial::new(cx, cy, aspect, self.base_spacing));
+ self.idx = 0;
+ }
+
+ fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
+ if let Some(ref radial) = self.radial {
+ let rect = radial.widget_rect(self.idx, ww, wh);
+ self.idx += 1;
+ rect
+ } else {
+ (0.0, 0.0, ww, wh)
+ }
+ }
+
+ fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
+ let cx = x + w / 2.0;
+ let cy = y + h / 2.0;
+ let aspect = if self.aspect_ratio > 0.0 {
+ self.aspect_ratio
+ } else {
+ let screen_aspect = (w / h.max(1.0)).max(0.1);
+ 1.0 + (screen_aspect - 1.0) * 0.4
+ };
+ let radial = Radial::new(cx, cy, aspect, self.base_spacing);
+ for (idx, &child_ptr) in children.iter().enumerate() {
+ unsafe {
+ let child = &mut *child_ptr;
+ let cw = child.rect().2;
+ let ch = child.preferred_height().unwrap_or(child.rect().3);
+ let use_h = if ch > 0.0 { ch } else { 44.0 };
+ let (rx, ry, rw, rh) = radial.widget_rect(idx, cw, use_h);
+ child.set_rect(rx, ry, rw, rh);
+ }
+ }
+ h
+ }
+
+ fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
+ let cx = constraints.max_width / 2.0;
+ let cy = constraints.max_height / 2.0;
+ let aspect = if self.aspect_ratio > 0.0 {
+ self.aspect_ratio
+ } else {
+ let screen_aspect = (constraints.max_width / constraints.max_height.max(1.0)).max(0.1);
+ 1.0 + (screen_aspect - 1.0) * 0.4
+ };
+ let radial = Radial::new(cx, cy, aspect, self.base_spacing);
+ let mut max_w = 0.0f32;
+ let mut max_h = 0.0f32;
+ for (idx, &child_ptr) in children.iter().enumerate() {
+ unsafe {
+ let size = (*child_ptr).measure(constraints, ctx);
+ let (rx, ry, rw, rh) = radial.widget_rect(idx, size.width, size.height);
+ max_w = max_w.max(rx + rw);
+ max_h = max_h.max(ry + rh);
+ }
+ }
+ crate::widget::Size {
+ width: max_w.clamp(constraints.min_width, constraints.max_width),
+ height: max_h.clamp(constraints.min_height, constraints.max_height),
+ }
+ }
+
+ fn box_clone(&self) -> Box<dyn LayoutStrategy> {
+ Box::new(self.clone())
+ }
+}
+
+/// Vertical slack for a section's content clip. The clip exists to stop
+/// content escaping its section SIDEWAYS, which is the axis a section's width
+/// actually fixes; a section's height is only known once its content has been
+/// placed, so bounding that axis too would risk cutting content off rather
+/// than keeping it in. Deliberately far larger than any section.
+const SECTION_CLIP_SLACK: f32 = 100_000.0;
+
+pub struct PageLayoutBuilder<'a, P> {
+ pub strategy: &'a mut dyn LayoutStrategy,
+ pub cx: f32,
+ pub cy: f32,
+ pub cw: f32,
+ pub ch: f32,
+ pub section_width: f32,
+ pub idx: usize,
+ _phantom: std::marker::PhantomData<P>,
+}
+
+impl<'a, P: RenderTarget + Default> PageLayoutBuilder<'a, P> {
+ pub fn new(
+ strategy: &'a mut dyn LayoutStrategy,
+ cx: f32,
+ cy: f32,
+ cw: f32,
+ ch: f32,
+ section_width: f32,
+ ) -> Self {
+ strategy.init(cx, cy, cw, ch);
+ Self {
+ strategy,
+ cx,
+ cy,
+ cw,
+ ch,
+ section_width,
+ idx: 0,
+ _phantom: std::marker::PhantomData,
+ }
+ }
+
+ pub fn with_section_count(self, count: usize) -> Self {
+ self.strategy.set_section_count(count);
+ self.strategy.init(self.cx, self.cy, self.cw, self.ch);
+ self
+ }
+
+ pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, mut render_fn: F)
+ where
+ F: FnMut(&mut SectionContext<'_, P>),
+ {
+ let width = self.strategy.get_column_width().unwrap_or(self.section_width);
+ let mut dummy = P::default();
+ let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
+ render_fn(&mut dummy_ctx);
+ let wh = dummy_ctx.finish();
+ let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
+ let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
+ let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
+ real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
+ render_fn(&mut real_ctx);
+ real_ctx.pc.pop_clip_rect();
+ real_ctx.finish();
+ self.idx += 1;
+ }
+
+ pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, mut render_fn: F)
+ where
+ F: FnMut(&mut SectionContext<'_, P>),
+ {
+ let mut dummy = P::default();
+ let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
+ render_fn(&mut dummy_ctx);
+ let wh = dummy_ctx.finish();
+ let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
+ let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
+ let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
+ real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
+ render_fn(&mut real_ctx);
+ real_ctx.pc.pop_clip_rect();
+ real_ctx.finish();
+ self.idx += 1;
+ }
+
+ pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
+ where
+ F: FnMut(&mut SectionContext<'_, P>),
+ {
+ let col_width = self.strategy.get_column_width().unwrap_or(self.section_width);
+ let gap = self.strategy.get_gap();
+ let width = span as f32 * col_width + (span - 1) as f32 * gap;
+ self.add_section_with_width(final_pc, width, label, focused, render_fn);
+ }
+}
+
+pub struct SectionContext<'a, P> {
+ pub pc: &'a mut P,
+ pub left: f32,
+ pub top: f32,
+ pub content_y: f32,
+ pub cw: f32,
+ pub label_width: f32,
+ pub label_x: f32,
+ /// Whether the host renders sections as sunken wells (`section_relief_style`).
+ pub relief_style: bool,
+ /// The title tab box (x, y, w, h) when the host's relief styling laid the
+ /// label out left-aligned — `finish` offers it with the section carve.
+ /// None under relief styling means a label-less section: the well carves
+ /// tabless, flush with the allocation top.
+ pub relief_tab: Option<(f32, f32, f32, f32)>,
+ pub focused: bool,
+ pub is_child: bool,
+ pub grid: Grid,
+ pub last_col: usize,
+ pub content_start_y: f32,
+ pub row_gap: f32,
+}
+
+impl<'a, P: RenderTarget> SectionContext<'a, P> {
+ pub const DEFAULT_MARGIN_X: f32 = 12.0;
+ pub const DEFAULT_ROW_GAP: f32 = 8.0;
+
+ fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
+ estimate_label_width_helper(label, font_size, font_fam)
+ }
+
+ pub fn padding(&self) -> f32 {
+ section_padding()
+ }
+
+ pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
+ let font_setting = if is_child {
+ nested_section_label_font()
+ } else {
+ section_label_font()
+ };
+ let (font_fam, font_size_opt) = parse_font_string(&font_setting);
+ let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
+ let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
+ let label_width = Self::estimate_label_width(label, font_size, &font_fam);
+ let relief_style = pc.section_relief_style();
+ let label_x = if is_child {
+ let base_x = match nested_section_label_alignment() {
+ 0 => left + 12.0,
+ 1 => left + (cw - label_width) / 2.0,
+ 2 => left + cw - 12.0 - label_width,
+ _ => left + 12.0,
+ };
+ base_x + nested_section_label_offset()
+ } else if relief_style {
+ // Sunken style: the title sits in a tab flush with the well's left
+ // edge (the designer look), not centered on the border.
+ left + 12.0
+ } else {
+ left + (cw - label_width) / 2.0
+ };
+ // Under relief styling the well fills the whole allocated rect (so the
+ // page's gaps and margins are the visual gaps) — the tab tops the
+ // allocation and the label centers inside it.
+ let label_y = if relief_style { top + 4.0 } else { top };
+ pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
+
+ // The tab wraps the label, flush on the well's top-left corner; clamped
+ // so off-default child alignments can't push it outside the well.
+ let relief_tab = if relief_style && label_width > 0.0 {
+ let tab_x = (label_x - 12.0).max(left);
+ Some((tab_x, top, label_width + 24.0, font_size + 10.0))
+ } else {
+ None
+ };
+
+ let pad = section_padding();
+ let margin_x = 2.0 * pad + 12.0;
+ let usable_w = (cw - 2.0 * margin_x).max(1.0);
+ let min_col_width = 130.0;
+ let gap = 8.0;
+ let max_cols = if is_child {
+ 1
+ } else {
+ ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
+ };
+ // Under relief styling the content stands off the well's top wall by
+ // the same inset it keeps from the side walls (`margin_x`, which is
+ // also what `finish` leaves below it), so a well reads as one even
+ // frame. The outline style's `pad + 19` predates the title tab: under
+ // relief the tab alone is `font_size + 10` tall, which left the first
+ // line touching the well's top wall at the default padding while the
+ // sides kept 20px and more.
+ let content_start_y = if relief_style {
+ relief_tab.map(|t| t.1 + t.3).unwrap_or(top) + margin_x
+ } else {
+ top + pad + 19.0
+ };
+ let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
+
+ Self {
+ pc,
+ left,
+ top,
+ content_y: content_start_y,
+ cw,
+ label_width,
+ label_x,
+ relief_style,
+ relief_tab,
+ focused,
+ is_child,
+ grid,
+ last_col: usize::MAX,
+ content_start_y,
+ row_gap: Self::DEFAULT_ROW_GAP,
+ }
+ }
+
+ pub fn with_row_gap(mut self, gap: f32) -> Self {
+ self.row_gap = gap;
+ self
+ }
+
+ /// The section's content-box top edge: the body well's top (the tab's
+ /// bottom) under relief styling, the outline's border line otherwise. Lets
+ /// a page place content at an exact inset from the well's walls.
+ pub fn well_top(&self) -> f32 {
+ if self.relief_style {
+ self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
+ } else {
+ self.top + 7.0
+ }
+ }
+
+ pub fn set_row_gap(&mut self, gap: f32) {
+ self.row_gap = gap;
+ }
+
+ /// Horizontal inset of section CONTENT from the section's left edge.
+ ///
+ /// One number, used by every content placer in here — `row_layout`, the
+ /// column `Grid`, `widget`, `VStack` and `ax` (so `text`) — because they
+ /// share a section and have to line up inside it. The section's border is
+ /// drawn at `left + padding()` (see `finish`), so content clears the
+ /// border by `padding() + 12`.
+ pub fn content_margin(&self) -> f32 {
+ 2.0 * self.padding() + 12.0
+ }
+
+ /// Left edge of the content box: where a row, a widget or a `text(_, 12.0,
+ /// ..)` starts.
+ pub fn content_left(&self) -> f32 {
+ self.left + self.content_margin()
+ }
+
+ /// Width of the content box — the section's width less the inset on both
+ /// sides. Nothing a section draws should extend past `content_left() +
+ /// content_width()`.
+ pub fn content_width(&self) -> f32 {
+ (self.cw - 2.0 * self.content_margin()).max(0.0)
+ }
+
+ /// `x_off` px into the content box's coordinate space, where 12.0 is the
+ /// content's own left edge — the offset 46 of the ~55 call sites in the
+ /// tree already pass, and the one that lines text up with the buttons and
+ /// widgets beside it.
+ ///
+ /// This used to add `padding()` only when `x_off >= 12.0`, which made the
+ /// mapping DISCONTINUOUS: asking for 11 instead of 12 moved the text 5px
+ /// LEFT rather than 1px, and silently dropped it out of alignment with
+ /// every row in the same section. `cce-mail` and two others sit on the
+ /// wrong side of that cliff today.
+ pub fn ax(&self, x_off: f32) -> f32 {
+ self.left + 2.0 * self.padding() + x_off
+ }
+
+ pub fn ay(&self) -> f32 {
+ self.content_y
+ }
+
+ pub fn spacing(&mut self, dy: f32) {
+ if self.grid.col_heights.len() >= 2 {
+ if self.last_col == usize::MAX {
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ } else if self.last_col < self.grid.col_heights.len() {
+ self.grid.col_heights[self.last_col] += dy;
+ }
+ self.content_y = self.grid.max_height();
+ } else {
+ self.content_y += dy;
+ for h in &mut self.grid.col_heights {
+ *h += dy;
+ }
+ }
+ }
+
+ pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+ let mut y = self.content_y + y_off;
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+ let x = self.ax(x_off);
+ // Bound it to the content box. A section's text was drawn unbounded,
+ // so a string wider than its section simply kept going — over the
+ // border, over whatever sat to the right, and off the window (the
+ // settings app's GPU names did all three). Rows and widgets have
+ // always been sized to the section; text was the one thing that could
+ // leave it. The vertical band is generous on purpose: it is the
+ // horizontal overrun that has to be cut, and a tight band would
+ // shave descenders.
+ let right = self.content_left() + self.content_width();
+ let bounds = if right > x {
+ Some([x, y - font_size, right, y + 2.0 * font_size])
+ } else {
+ None
+ };
+ self.pc.text_with_bounds(text, x, y, font_size, color, bounds);
+ let new_bottom = y + font_size + 4.0;
+ self.content_y = new_bottom;
+ for h in &mut self.grid.col_heights {
+ *h = new_bottom;
+ }
+ }
+
+ pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
+ if let Some(pref) = w.preferred_height() {
+ wh = pref;
+ }
+ let pad = self.padding();
+ let top_room = w.label_strip();
+ let total_h = wh + top_room;
+
+ let name = w.type_name();
+ let span_full = name == "Trackpad"
+ || name == "Canvas"
+ || name == "UsageBar"
+ || name == "ProgressBar"
+ || name == "ButtonStrip"
+ || name == "Spreadsheet"
+ || name == "Graph";
+
+ if span_full {
+ let margin_x = 2.0 * pad + 12.0;
+ let x = self.left + margin_x;
+ let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
+ let mut max_h = self.grid.max_height().max(self.content_y);
+ if max_h > self.content_start_y {
+ max_h += self.row_gap;
+ }
+ let y = max_h;
+
+ w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
+ render_widget(self.pc, w, x, y, clamped_w, total_h, ctx);
+
+ let new_bottom = y + total_h;
+ self.content_y = new_bottom;
+ for h in &mut self.grid.col_heights {
+ *h = new_bottom;
+ }
+ } else {
+ let max_h = self.grid.max_height();
+ if self.content_y > max_h {
+ for h in &mut self.grid.col_heights {
+ *h = self.content_y;
+ }
+ }
+
+ let col = self.grid.next_column();
+ self.last_col = col;
+ let x = self.grid.col_lefts[col];
+ let mut y = self.grid.col_heights[col];
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+
+ let aligned_x = x;
+ let aligned_w = self.grid.col_width;
+
+ w.set_row_rect(aligned_x, aligned_w);
+ render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
+ self.grid.col_heights[col] = y + total_h;
+ self.content_y = self.grid.max_height();
+ }
+ }
+
+ pub fn widget_full<T: WidgetHost + 'static>(&mut self, w: &mut T, wh: f32, ctx: &mut UiContext) {
+ let x_off = 12.0;
+ let ww = self.cw - 2.0 * (self.padding() + x_off); // cw - 40.0
+ self.widget(w, x_off, ww, wh, ctx);
+ }
+
+ pub fn separator(&mut self) {
+ let pad = self.padding();
+ let x = self.ax(pad);
+ let max_h = self.grid.max_height().max(self.content_y);
+ let y = max_h;
+ self.pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
+ self.content_y = max_h + 8.0;
+ for h in &mut self.grid.col_heights {
+ *h = self.content_y;
+ }
+ }
+
+ pub fn rect(&mut self, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ let max_h = self.grid.max_height().max(self.content_y);
+ self.pc.rect(color, self.ax(x_off), max_h, w, h);
+ self.content_y = max_h + h;
+ for col_h in &mut self.grid.col_heights {
+ *col_h = self.content_y;
+ }
+ }
+
+ pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
+ let margin_x = self.content_margin();
+ let usable_w = self.content_width();
+ if count == 0 {
+ return Vec::new();
+ }
+ let total_gap = gap * (count - 1) as f32;
+ let col_w = (usable_w - total_gap).max(0.0) / count as f32;
+
+ let mut cols = Vec::with_capacity(count);
+ for i in 0..count {
+ let x = self.left + margin_x + i as f32 * (col_w + gap);
+ cols.push((x, col_w));
+ }
+ cols
+ }
+
+ /// A row whose columns are sized to what goes IN them: each gets the width
+ /// it asked for in `needs`, and whatever is left over is shared equally.
+ ///
+ /// `row_layout` splits a row evenly and knows nothing about content, so it
+ /// hands "Reboot" and "Hibernate" the same width — one floats in slack
+ /// while the other is cut off, which is what a row of mismatched labels
+ /// looks like. Sharing the SLACK equally rather than sizing proportionally
+ /// is deliberate: proportional widths would make a two-character label a
+ /// sliver, where what is wanted is "everyone fits, then everyone gets the
+ /// same bonus".
+ ///
+ /// When the needs do not fit, every column is scaled by the same factor, so
+ /// the row still cannot overflow its section and the shortfall is shared
+ /// rather than landing entirely on the last column.
+ pub fn row_layout_for(&self, needs: &[f32], gap: f32) -> Vec<(f32, f32)> {
+ let count = needs.len();
+ if count == 0 {
+ return Vec::new();
+ }
+ let total_gap = gap * (count - 1) as f32;
+ let room = (self.content_width() - total_gap).max(0.0);
+ let total_need: f32 = needs.iter().map(|n| n.max(0.0)).sum();
+
+ let widths: Vec<f32> = if total_need <= room {
+ let extra = (room - total_need) / count as f32;
+ needs.iter().map(|n| n.max(0.0) + extra).collect()
+ } else if total_need > 0.0 {
+ let scale = room / total_need;
+ needs.iter().map(|n| n.max(0.0) * scale).collect()
+ } else {
+ vec![room / count as f32; count]
+ };
+
+ let mut cols = Vec::with_capacity(count);
+ let mut x = self.content_left();
+ for w in widths {
+ cols.push((x, w));
+ x += w + gap;
+ }
+ cols
+ }
+
+
+ pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(usize, f32, f32),
+ {
+ let max_h = self.grid.max_height().max(self.content_y);
+ for col_h in &mut self.grid.col_heights {
+ *col_h = max_h;
+ }
+ self.content_y = max_h;
+
+ let cols = self.row_layout(count, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ f(i, x, w);
+ }
+ self.content_y += h;
+
+ for col_h in &mut self.grid.col_heights {
+ *col_h = self.content_y;
+ }
+ }
+
+ pub fn vstack(&mut self, spacing: f32) -> VStack<'_, 'a, P> {
+ VStack {
+ context: self,
+ spacing,
+ }
+ }
+
+ pub fn add_section<F>(&mut self, label: &str, focused: bool, mut render_fn: F)
+ where
+ F: FnMut(&mut SectionContext<'_, P>),
+ {
+ let pad = self.padding();
+ let (left, top, cw, is_side_by_side) = if self.grid.col_heights.len() >= 2 {
+ let col = self.grid.next_column();
+ self.last_col = col;
+ let x = self.grid.col_lefts[col];
+ let mut y = self.grid.col_heights[col];
+ if y > self.content_start_y {
+ y += self.row_gap;
+ }
+ (x, y, self.grid.col_width, true)
+ } else {
+ let left = self.ax(0.0) + pad;
+ let mut max_h = self.grid.max_height().max(self.content_y);
+ if max_h > self.content_start_y {
+ max_h += self.row_gap;
+ }
+ let top = max_h;
+ let cw = self.cw - 2.0 * pad;
+ (left, top, cw, false)
+ };
+
+ let mut sub_ctx = SectionContext::new(self.pc, left, top, cw, label, focused, true);
+ render_fn(&mut sub_ctx);
+ let new_bottom = sub_ctx.finish();
+
+ if is_side_by_side {
+ let col = self.last_col;
+ self.grid.col_heights[col] = new_bottom;
+ self.content_y = self.grid.max_height();
+ } else {
+ self.content_y = new_bottom;
+ for h in &mut self.grid.col_heights {
+ *h = new_bottom;
+ }
+ }
+ }
+
+ pub fn finish(self) -> f32 {
+ let border: [f32; 4] = if self.is_child {
+ if self.focused {
+ [0.22, 0.38, 0.24, 1.0]
+ } else {
+ [0.18, 0.18, 0.25, 1.0]
+ }
+ } else {
+ if self.focused {
+ [0.30, 0.50, 0.32, 1.0] // Focused green
+ } else {
+ [0.25, 0.25, 0.35, 1.0] // Default gray
+ }
+ };
+ let pad = self.padding();
+ let x = self.left + pad;
+ let y = self.top + 7.0;
+ let w = self.cw - 2.0 * pad;
+ let h = self.content_y - y;
+ // Under relief the well's walls are the allocation's edges, so the
+ // floor below the content matches the inset beside and above it (see
+ // `new`). The outline frame sits `pad` in from its allocation and
+ // keeps its own slack.
+ let extra_bottom = if self.relief_style { self.content_margin() } else { pad + 12.0 };
+ let bottom = y + h + extra_bottom;
+
+ if self.relief_style {
+ // Sunken style: the well spans the full allocated rect — body top
+ // edge at the tab's bottom (the tab is flush ON the body, the
+ // designer union shape; label-less sections carve tabless from the
+ // allocation top), walls on the allocation's edges, and no
+ // trailing slack so the layout gap IS the visual gap.
+ let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
+ let frame = SectionFrame {
+ x: self.left,
+ y: body_y,
+ w: self.cw,
+ h: bottom - body_y,
+ tab: self.relief_tab,
+ focused: self.focused,
+ is_child: self.is_child,
+ };
+ if self.pc.section_relief(&frame) {
+ return self.content_y + extra_bottom;
+ }
+ }
+
+ let left_edge = x;
+ let right_edge = x + w;
+ if self.label_width > 0.0 {
+ let label_x = self.label_x;
+ let gap_margin = 6.0;
+ let gap_start = label_x - gap_margin;
+ let gap_end = label_x + self.label_width + gap_margin;
+ if gap_start > left_edge {
+ self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
+ }
+ if right_edge > gap_end {
+ self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
+ }
+ } else {
+ self.pc.rect(border, left_edge, y, w, 1.0);
+ }
+
+ self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
+ self.pc.rect(border, x, y, 1.0, h + extra_bottom);
+ self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
+ self.content_y + extra_bottom + 8.0
+ }
+}
+
+
+pub struct VStack<'b, 'a, P> {
+ pub context: &'b mut SectionContext<'a, P>,
+ pub spacing: f32,
+}
+
+impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
+ pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
+ let pad = self.context.padding();
+ let margin_x = 2.0 * pad + 12.0;
+ let x = self.context.left + margin_x;
+ let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
+
+ let mut max_h = self.context.grid.max_height().max(self.context.content_y);
+ if max_h > self.context.content_start_y {
+ max_h += self.context.row_gap;
+ }
+ let y = max_h;
+
+ let pref_h = w.preferred_height().unwrap_or(wh);
+ let top_room = w.label_strip();
+ let total_h = pref_h + top_room;
+
+ w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
+ render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
+
+ let new_bottom = y + total_h;
+ self.context.content_y = new_bottom;
+ for h in &mut self.context.grid.col_heights {
+ *h = new_bottom;
+ }
+ self.context.spacing(self.spacing);
+ }
+
+ /// [`add_row`](Self::add_row) with per-column widths from `needs` — see
+ /// [`SectionContext::row_layout_for`]. For a row of buttons, `needs` is
+ /// each label's measured width plus the plate's own inset.
+ pub fn add_row_for<F>(&mut self, needs: &[f32], gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
+ {
+ let max_h = self.context.grid.max_height().max(self.context.content_y);
+ for col_h in &mut self.context.grid.col_heights {
+ *col_h = max_h;
+ }
+ self.context.content_y = max_h;
+
+ let cols = self.context.row_layout_for(needs, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ self.context.content_y = max_h;
+ f(self.context, i, x, w);
+ }
+
+ let new_bottom = max_h + h;
+ self.context.content_y = new_bottom;
+ for col_h in &mut self.context.grid.col_heights {
+ *col_h = new_bottom;
+ }
+ self.context.spacing(self.spacing);
+ }
+
+ pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
+ where
+ F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
+ {
+ let max_h = self.context.grid.max_height().max(self.context.content_y);
+ for col_h in &mut self.context.grid.col_heights {
+ *col_h = max_h;
+ }
+ self.context.content_y = max_h;
+
+ let cols = self.context.row_layout(count, gap);
+ for (i, &(x, w)) in cols.iter().enumerate() {
+ self.context.content_y = max_h;
+ f(self.context, i, x, w);
+ }
+
+ let new_bottom = max_h + h;
+ self.context.content_y = new_bottom;
+ for col_h in &mut self.context.grid.col_heights {
+ *col_h = new_bottom;
+ }
+ self.context.spacing(self.spacing);
+ }
+}
+
+pub fn get_system_monospace_font() -> &'static str {
+ static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+ MONOSPACE_FONT.get_or_init(|| {
+ if let Ok(output) = std::process::Command::new("fc-match")
+ .args(["-f", "%{family}", "monospace"])
+ .output()
+ {
+ let name = String::from_utf8_lossy(&output.stdout);
+ let parsed = name.split(',').next().unwrap_or("monospace").trim();
+ if !parsed.is_empty() {
+ return parsed.to_string();
+ }
+ }
+ "monospace".to_string()
+ })
+}
+
+pub fn get_system_sans_serif_font() -> &'static str {
+ static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+ SANS_SERIF_FONT.get_or_init(|| {
+ if let Ok(output) = std::process::Command::new("fc-match")
+ .args(["-f", "%{family}", "sans-serif"])
+ .output()
+ {
+ let name = String::from_utf8_lossy(&output.stdout);
+ let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
+ if !parsed.is_empty() {
+ return parsed.to_string();
+ }
+ }
+ "sans-serif".to_string()
+ })
+}
diff --git a/src/layout.rs b/src/layout/mod.rs
similarity index 59%
rename from src/layout.rs
rename to src/layout/mod.rs
index dac56e1..0749dee 100644
--- a/src/layout.rs
+++ b/src/layout/mod.rs
@@ -1,469 +1,11 @@
-use crate::widget::WidgetHost;
-use crate::context::UiContext;
use std::sync::RwLock;
-use std::collections::HashMap;
-use std::sync::OnceLock;
-/// Per-thread overrides for runtime style writes, under `cfg(test)` only.
-///
-/// Every `graph_*`, corner-radius and control-height slot in this file is
-/// backed by the one process-wide [`STYLE_REGISTRY`], so a test that pins any
-/// of them pins it for every test running beside it. That is the same defect
-/// as the font flake fixed in 1dc0ab1, and it was live:
-/// `test_graph_style_configuration` sets ~20 style values and restores none,
-/// while `dual_geometry_views_stay_consistent` bakes quads with
-/// `graph_node_corner_radius` and then re-reads that getter to compare — so a
-/// write landing between the two makes them disagree. Widening the write
-/// window to 300ms reproduced it on demand.
-///
-/// Fixing it at the registry rather than per accessor covers every slot it
-/// holds in one place, including ones no test pins yet.
-#[cfg(test)]
-mod test_overlay {
- use std::cell::RefCell;
- use std::collections::HashMap;
- thread_local! {
- static FLOATS: RefCell<HashMap<String, f32>> = RefCell::new(HashMap::new());
- static LENS: RefCell<HashMap<String, crate::units::Len>> = RefCell::new(HashMap::new());
- static STRINGS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
- }
- pub fn set_float(k: &str, v: f32) {
- LENS.with(|m| m.borrow_mut().remove(k));
- FLOATS.with(|m| m.borrow_mut().insert(k.to_string(), v));
- }
- pub fn set_len(k: &str, v: crate::units::Len) {
- FLOATS.with(|m| m.borrow_mut().remove(k));
- LENS.with(|m| m.borrow_mut().insert(k.to_string(), v));
- }
- pub fn set_string(k: &str, v: String) {
- STRINGS.with(|m| m.borrow_mut().insert(k.to_string(), v));
- }
- pub fn get_float(k: &str) -> Option<f32> {
- if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
- return Some(l.to_px());
- }
- FLOATS.with(|m| m.borrow().get(k).copied())
- }
- pub fn get_len(k: &str) -> Option<crate::units::Len> {
- if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
- return Some(l);
- }
- FLOATS.with(|m| m.borrow().get(k).copied()).map(crate::units::Len::px)
- }
- pub fn get_string(k: &str) -> Option<String> {
- STRINGS.with(|m| m.borrow().get(k).cloned())
- }
-}
-
-#[derive(Debug)]
-pub struct StyleRegistry {
- pub floats: HashMap<String, f32>,
- pub strings: HashMap<String, String>,
- /// Slots whose config value carried a unit (`width=(mm)2.0`). Read
- /// through `get_float` like any other number, resolved against the
- /// process metric (`crate::units::metric`) at EVERY read, so a metric
- /// that arrives after config load — outputs come in after the first
- /// style read — or changes with the display is honoured live.
- pub lens: HashMap<String, crate::units::Len>,
-}
-
-impl StyleRegistry {
- pub fn new() -> Self {
- Self {
- floats: HashMap::new(),
- strings: HashMap::new(),
- lens: HashMap::new(),
- }
- }
-
- pub fn get_float(&self, key: &str) -> Option<f32> {
- #[cfg(test)]
- if let Some(v) = test_overlay::get_float(key) {
- return Some(v);
- }
- if let Some(len) = self.lens.get(key) {
- return Some(len.to_px());
- }
- self.floats.get(key).copied()
- }
-
- /// The slot as a length with its unit: the configured `Len` when one was
- /// given, else the plain number as logical px. For editors that show the
- /// unit the user chose rather than the resolved pixel count.
- pub fn get_len(&self, key: &str) -> Option<crate::units::Len> {
- #[cfg(test)]
- if let Some(v) = test_overlay::get_len(key) {
- return Some(v);
- }
- if let Some(len) = self.lens.get(key) {
- return Some(*len);
- }
- self.floats.get(key).map(|v| crate::units::Len::px(*v))
- }
-
- pub fn get_string(&self, key: &str) -> Option<String> {
- #[cfg(test)]
- if let Some(v) = test_overlay::get_string(key) {
- return Some(v);
- }
- self.strings.get(key).cloned()
- }
-
- /// A plain number wins over any earlier unit value for the slot — a
- /// runtime `set_float` is the newest opinion.
- pub fn set_float(&mut self, key: &str, val: f32) {
- #[cfg(test)]
- {
- test_overlay::set_float(key, val);
- return;
- }
- #[cfg(not(test))]
- self.load_float(key, val);
- }
-
- pub fn set_len(&mut self, key: &str, len: crate::units::Len) {
- #[cfg(test)]
- {
- test_overlay::set_len(key, len);
- return;
- }
- #[cfg(not(test))]
- self.load_len(key, len);
- }
-
- pub fn set_string(&mut self, key: &str, val: String) {
- #[cfg(test)]
- {
- test_overlay::set_string(key, val);
- return;
- }
- #[cfg(not(test))]
- self.load_string(key, val);
- }
-
- /// The CONFIG-LOAD writes, as opposed to the runtime `set_*` ones above.
- /// Kept apart because under `cfg(test)` a runtime set goes to a per-thread
- /// overlay while the loaded config must stay the shared base every test
- /// reads — routing config through `set_*` would put one thread's config
- /// in its overlay and leave every other thread with an empty registry.
- pub fn load_float(&mut self, key: &str, val: f32) {
- self.lens.remove(key);
- self.floats.insert(key.to_string(), val);
- }
-
- pub fn load_len(&mut self, key: &str, len: crate::units::Len) {
- self.floats.remove(key);
- self.lens.insert(key.to_string(), len);
- }
-
- pub fn load_string(&mut self, key: &str, val: String) {
- self.strings.insert(key.to_string(), val);
- }
-}
-
-pub static STYLE_REGISTRY: OnceLock<RwLock<StyleRegistry>> = OnceLock::new();
-
-pub fn get_style_registry() -> &'static RwLock<StyleRegistry> {
- STYLE_REGISTRY.get_or_init(|| RwLock::new(StyleRegistry::new()))
-}
-
-pub fn lazy_init_style_registry() {
- use std::sync::Once;
- static INIT: Once = Once::new();
- INIT.call_once(|| {
- reload_config();
- });
-}
-
-fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props: &mut String) {
- match val {
- serde_json::Value::Object(map) => {
- for (k, v) in map {
- let next_prefix = if prefix.is_empty() {
- k.clone()
- } else {
- format!("{}.{}", prefix, k)
- };
- flatten_json_to_flat_props(v, &next_prefix, flat_props);
- }
- }
- _ => {
- // A retired `window_manager` spelling of a relief key emits NO
- // line: the fall-through below strips a path's first segment, so
- // `window_manager.bevel_depth` would otherwise land on the live
- // `bevel_depth` registry key by accident, alias or not. The
- // `style.surface.*` retirements need no guard — stripped, they
- // become `surface.relief.depth` and the like, which nothing reads.
- if matches!(prefix, "window_manager.bevel_depth" | "window_manager.bevel_width" | "window_manager.bevel_shader") {
- return;
- }
- let flat_key = match prefix {
- "style.list.font" | "style.data.list.font" => "list_font",
- "style.list.font_color" | "style.data.list.font_color" => "list_font_color",
- "style.control.breadcrumb.font" => "breadcrumb_font",
-
- // The control rung's default radius: every control-scale
- // `corner_radius` below falls back to it when its own key is unset.
- "style.control.corner_radius" => "control_corner_radius",
- "style.control.button.padding" => "button_padding",
- "style.control.button.height" => "button_height",
- "style.control.button.corner_radius" => "button_corner_radius",
- "style.control.button.font" => "button_font",
- "style.list.corner_radius" | "style.data.list.corner_radius" => "list_corner_radius",
- "style.control.textbox.corner_radius" | "style.textbox.corner_radius" | "style.data.textbox.corner_radius" => "textbox_corner_radius",
- "style.control.dropdown.color" => "dropdown_color",
- "style.control.font_selector.font" => "font_selector_font",
- "style.container.section.font" | "style.section.font" => "section_label_font",
- "style.container.section.padding" | "style.section.padding" => "section_padding",
- "style.control.button_strip.font" => "button_strip_font",
- "style.control.button_strip.spacing" => "button_strip_spacing",
- "style.control.dropdown.height" => "dropdown_height",
- "style.control.dropdown.corner_radius" => "dropdown_corner_radius",
- "style.control.font_selector.height" => "font_selector_height",
- "style.control.font_selector.corner_radius" => "font_selector_corner_radius",
- "style.control.label.font" => "control_label_font",
- "style.control.label.font_detached" => "control_label_font_detached",
- "style.control.label.margin" => "control_label_margin",
- "style.control.slider.height" => "slider_height",
- "style.control.slider.corner_radius" => "slider_corner_radius",
- "style.control.slider.band_thickness" => "slider_band_thickness",
- "style.control.slider.bulge_width" => "slider_bulge_width",
- "style.control.slider.bulge_height" => "slider_bulge_height",
- "style.control.progressbar.height" => "progressbar_height",
- "style.control.rangeslider.height" => "rangeslider_height",
- "style.control.scrollbar.width" => "scrollbar_width",
- "style.control.scrollbar.inset" => "scrollbar_inset",
- "style.control.spinbox.height" => "spinbox_height",
- "style.control.spinbox.button_padding" => "spinbox_button_padding",
- "style.control.spinbox.corner_radius" => "spinbox_corner_radius",
- "style.control.textbox.height" | "style.textbox.height" | "style.data.textbox.height" => "textbox_height",
- "style.control.textbox.placeholder_text_color" | "style.textbox.placeholder_text_color" | "style.data.textbox.placeholder_text_color" => "textbox_placeholder_text_color",
- "style.control.textbox.background_color" | "style.textbox.background_color" | "style.data.textbox.background_color" => "textbox_background_color",
- "style.control.textbox.background_edit_color" | "style.textbox.background_edit_color" | "style.data.textbox.background_edit_color" => "textbox_background_edit_color",
- "style.control.textbox.multiline.line_wrap" | "style.textbox.multiline.line_wrap" | "style.data.textbox.multiline.line_wrap" => "textbox_line_wrap",
- "style.control.textbox.multiline.border_width" | "style.textbox.multiline.border_width" | "style.data.textbox.multiline.border_width" => "textbox_multiline_border_width",
- "style.control.toggle.height" => "toggle_height",
- "style.control.toggle.border_width" => "toggle_border_width",
- "style.control.toggle.disabled_color" => "toggle_disabled_color",
- "style.control.toggle.border_color" => "toggle_border_color",
- "style.control.toggle.corner_radius" => "toggle_corner_radius",
- "window_manager.light_source_position" => "light_source_position",
- // The DE's relief material, home style.surface.relief: these
- // shade every bevel/boss/recess in the toolkit. `light` is
- // the spelling (since 2026-09-28): it is the light strength,
- // not a length. Two older spellings are RETIRED — not read,
- // reported by path (`color::retired_surface_keys`), removed
- // by cce-relief's Save: `relief.depth`, what every config
- // said until that day, and `window_manager.bevel_depth` /
- // `bevel_width`, the block these keys were born in before
- // they had a home of their own (the compositor never read
- // them; the spelling survived as a compat alias until the
- // evening of 2026-09-28).
- "style.surface.relief.light" => "bevel_depth",
- "style.surface.relief.width" => "bevel_width",
- // The two SHAPES of the relief, each a node under it
- // (2026-09-28): `wall` is a carve's wall — a recess, boss,
- // ridge or trough cut into a surface — and `edge` is the
- // plate's perimeter roll. Each carries `height` (a length:
- // the carve's drop, the roll's rise; unset = follow the
- // width) and `profile` (the ramp spec of its curve, written
- // by cce-relief, installed by reload_config). The registry
- // keys keep their old names. The pre-rename flat spellings
- // (`height` / `profile` were the wall's, `edge_height` /
- // `edge_profile` the edge's) were aliases for the rest of
- // that day and are RETIRED: not read, reported by path
- // (`color::retired_surface_keys`), removed by cce-relief's
- // Save, which also reads them once as a seed. cce-relief's
- // slider knobs are NOT a style key any more
- // (`relief.wall.knobs` / `edge.knobs`, before that
- // `profile_knobs` / `edge_knobs`): editor state, kept in
- // that app's own state.kdl, and read off a config only by
- // the editor itself, as a one-time seed.
- "style.surface.relief.wall.height" => "bevel_height",
- "style.surface.relief.edge.height" => "roll_height",
- "style.surface.relief.wall.profile" => "bevel_profile_spec",
- "style.surface.relief.edge.profile" => "roll_profile_spec",
- "style.container.section.depth" => "section_depth",
- "style.surface.param.backdrop_compression" => "param_compression",
- "style.surface.param.label_layout" => "param_label_layout",
- // The relief's shader toggle lives with the relief
- // (`relief shader=(bool)false` is the legacy banded look);
- // `window_manager.bevel_shader`, its old home, is retired
- // and reported like the block's other bevel keys.
- "style.surface.relief.shader" => "bevel_shader",
- "window_manager.control_relief" => "control_relief",
- "window_manager.corner_shape" => "corner_shape",
- "style.control.ramp.height" => "ramp_height",
- "style.layout.column.gap" => "column_gap",
- "style.control.control_panel.padding" => "control_panel_padding",
- "style.control.control_panel.gap" => "control_panel_gap",
- "style.status.normal_color" => "status_normal_color",
- "style.status.background_color" => "status_background_color",
- "style.status.background_blur" => "status_background_blur",
- "style.highlight.primary" => "primary_highlight_color",
- "style.window.page_opacity" => "page_opacity",
- "style.window.page_margin" => "page_margin",
- "style.window.plate_padding" => "plate_padding",
- "style.window.transition_duration" => "transition_duration",
- "style.overlay.behavior" => "overlay_behavior",
- "style.overlay.width" => "overlay_width",
- "style.overlay.position" => "overlay_position",
- "style.overlay.border_gap" => "overlay_border_gap",
- "style.editor.last_page" | "style.data.editor.last_page" => "last_page",
- "style.data.tree.corner_radius" => "tree_corner_radius",
- "style.data.tree.opacity" => "tree_opacity",
- "style.data.tree.blur" => "tree_blur",
- "style.data.tree.font" => "tree_font",
- "style.surface.desktop.gap_color" => "desktop_gap_color",
- "style.surface.desktop.cell_color" => "desktop_cell_color",
- "style.surface.desktop.gap_width" => "desktop_gap_width",
- "style.surface.desktop.cell_fade_inset" => "desktop_cell_fade_inset",
- "style.surface.desktop.mode" => "desktop_mode",
- "style.surface.desktop.solid_color" => "desktop_solid_color",
- "style.surface.desktop.grid_cell_size" => "desktop_grid_scale",
- "style.surface.desktop.grid_cell_width" => "grid_cell_width",
- "style.surface.desktop.grid_cell_height" => "grid_cell_height",
- "style.surface.plate.padding" => "plate_padding",
- "style.surface.plate.gap" => "plate_gap",
- "style.control.gap" => "control_gap",
- // The context menu's own radius (`menu_corner_radius`): a
- // popover's corner is control-scale, not pane-scale.
- "style.surface.menu.corner_radius" => "menu_corner_radius",
- // `style.surface.plate.root.*` is the one spelling of the
- // root-plate style (RFC Phase 7a). The slot names keep the
- // historical `root_plate_` prefix; the legacy `root plate.*`
- // config read-alias was removed 2026-09-06.
- "style.surface.plate.root.padding" => "root_plate_padding",
- "style.surface.plate.root.gap" => "root_plate_gap",
- "style.surface.plate.root.color" => "root_plate_color",
- "style.surface.plate.root.blur" => "root_plate_blur",
- "style.surface.plate.root.corner_radius" => "root_plate_corner_radius",
- "style.surface.plate.root.menubar.color" => "root_plate_menubar_color",
- "style.surface.plate.root.menubar.text_color" => "root_plate_menubar_text_color",
- "style.surface.plate.root.menubar.blur" => "root_plate_menubar_blur",
- "style.surface.plate.root.menubar.font" => "menubar_font",
- "style.surface.statusbar.color" => "root_plate_statusbar_color",
- "style.surface.statusbar.text_color" => "root_plate_statusbar_text_color",
- "style.surface.statusbar.blur" => "root_plate_statusbar_blur",
- "style.surface.statusbar.font" => "statusbar_font",
- "style.surface.page.opacity" => "page_opacity",
- "style.surface.page.margin" => "page_margin",
- "style.surface.graph.grid_color" => "graph_grid_color",
- "style.surface.graph.opacity" => "graph_opacity",
- "style.surface.graph.node.opacity" => "graph_node_opacity",
- "style.surface.graph.spacing_x" => "graph_spacing_x",
- "style.surface.graph.spacing_y" => "graph_spacing_y",
- "style.surface.graph.line_width" => "graph_line_width",
- "style.surface.graph.node.width" => "graph_node_width",
- "style.surface.graph.node.height" => "graph_node_height",
- "style.surface.graph.grid_snap" => "graph_grid_snap",
- "style.surface.graph.blur" => "graph_blur",
- "style.surface.graph.font" => "graph_font",
- "style.surface.graph.node.color" => "graph_node_color",
- "style.surface.graph.node.font" => "graph_node_font",
- "style.surface.graph.node.delete" => "graph_node_delete",
- "style.surface.graph.node.selected_color" => "graph_node_selected_color",
- "style.surface.graph.node.drag_color" => "graph_node_drag_color",
- "style.surface.graph.node.corner_radius" => "graph_node_corner_radius",
- "style.surface.graph.node.wire_color" => "graph_wire_color",
- "style.surface.graph.node.wire_highlight_color" => "graph_wire_highlight_color",
- "style.surface.graph.node.wire_size" => "graph_wire_size",
- "style.surface.graph.node.wire_style" => "graph_wire_style",
- "style.surface.graph.node.wire_activation_radius" => "graph_wire_activation_radius",
- "style.surface.graph.node.connector_color" => "graph_connector_color",
- "style.surface.graph.node.connector_highlight_color" => "graph_connector_highlight_color",
- "style.surface.graph.node.connector_size" => "graph_connector_size",
- "style.surface.graph.node.connector_activation_radius" => "graph_connector_activation_radius",
- "style.surface.plate.color" => "plate_color",
- "style.surface.plate.border_color" => "plate_border_color",
- "style.surface.plate.border_thickness" => "plate_border_thickness",
- "input.touchpad.natural_scroll" => "touchpad_natural_scroll",
-
- other => {
- if let Some(rest) = other.strip_prefix("layout.") {
- rest
- } else if let Some(rest) = other.strip_prefix("transparency.") {
- rest
- } else {
- if let Some(idx) = other.find('.') {
- &other[idx + 1..]
- } else {
- other
- }
- }
- }
- };
-
- if let Some(s) = val.as_str() {
- flat_props.push_str(&format!("{} = \"{}\"\n", flat_key, s));
- } else if let Some(b) = val.as_bool() {
- flat_props.push_str(&format!("{} = {}\n", flat_key, b));
- } else if let Some(n) = val.as_f64() {
- flat_props.push_str(&format!("{} = {}\n", flat_key, n));
- } else if let Some(n) = val.as_i64() {
- flat_props.push_str(&format!("{} = {}\n", flat_key, n));
- }
- }
- }
-}
-
-// There is no alias-precedence pass any more. For the rest of 2026-09-28
-// `prefer_relief_spellings` ran here, dropping a relief key's legacy
-// spelling whenever its current one was present (`height` under
-// `wall.height`, `depth` under `light`), because two spellings of one
-// registry key would otherwise be decided by the order the JSON handed
-// the lines out. Every legacy spelling of the relief is retired now — not
-// read at all, reported by `color::retired_surface_keys` — so there is
-// nothing left to prefer, and a config is what it says.
-
-fn read_config() -> Option<String> {
- let path = crate::config::get_config_path();
- if let Ok(content) = std::fs::read_to_string(&path) {
- let val = crate::config::parse_kdl_to_json(&content);
- let mut flat_props = String::new();
- flatten_json_to_flat_props(&val, "", &mut flat_props);
- return Some(flat_props);
- }
- None
-}
-
-pub fn read_config_value(target_key: &str) -> Option<String> {
- let path = crate::config::get_config_path();
- if let Ok(content) = std::fs::read_to_string(&path) {
- let val = crate::config::parse_kdl_to_json(&content);
- let mut flat_props = String::new();
- flatten_json_to_flat_props(&val, "", &mut flat_props);
- for line in flat_props.lines() {
- let trimmed = line.trim();
- if let Some(eq_idx) = trimmed.find('=') {
- let key = trimmed[..eq_idx].trim();
- if key == target_key {
- let val_str = trimmed[eq_idx + 1..].trim().trim_matches('"').trim();
- return Some(val_str.to_string());
- }
- }
- }
- }
- None
-}
-
-pub fn parse_font_string(s: &str) -> (String, Option<f32>) {
- let (family, size) = split_font_string(s);
- (family.to_string(), size)
-}
-
-/// [`parse_font_string`] borrowing the family from `s` instead of copying it.
-pub fn split_font_string(s: &str) -> (&str, Option<f32>) {
- let s = s.trim();
- if let Some(last_space_idx) = s.rfind(' ') {
- let (family, size_str) = s.split_at(last_space_idx);
- let size_str = size_str.trim();
- if let Ok(size) = size_str.parse::<f32>() {
- return (family.trim(), Some(size));
- }
- }
- (s, None)
-}
+mod registry;
+pub use registry::*;
+mod bridge;
+pub use bridge::*;
+mod legacy;
+pub use legacy::*;
static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
@@ -3984,2569 +3526,65 @@ pub fn set_rangeslider_height(height: f32) {
-/// One step carve a widget's `paint` draws, handed to a flat-path host through
-/// [`RenderTarget::relief_carve`] so it can re-emit it as a real prim.
+/// The DE's font families, from the shared config's `fonts { }` block:
+/// `(sans_serif, serif, monospace, terminal)`.
///
-/// Geometry always comes from the WIDGET (`TextBox::well`, `Toggle::well` /
-/// `Toggle::slide_plate`), never re-derived here — a second copy of that math
-/// in the bridge is exactly how the flat host's carve and the drawn one drift
-/// apart.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct ReliefCarve {
- pub kind: CarveKind,
- pub x: f32,
- pub y: f32,
- pub w: f32,
- pub h: f32,
- /// Per-corner radii, clockwise from top-left.
- pub radii: (f32, f32, f32, f32),
- /// Full width of the step's transition band.
- pub depth: f32,
- /// Which walls the carve has (top, right, bottom, left). A suppressed wall
- /// means the step runs flush to its neighbour there — a Spinbox's field
- /// running into its button column, where the two meet in ONE step rather
- /// than two facing walls.
- pub edges: (bool, bool, bool, bool),
-}
-
-/// Which way a [`ReliefCarve`] steps.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub enum CarveKind {
- /// Interior one step DOWN ([`crate::scene::paint::PaintCtx::recess_edges`]).
- /// `tint` lights the rim in the focus accent (`recess_tinted`).
- Recess { tint: Option<[f32; 3]> },
- /// Interior one step UP ([`crate::scene::paint::PaintCtx::boss_edges`]).
- Boss { tint: Option<[f32; 3]> },
- /// A FLUSH inset ([`crate::scene::paint::PaintCtx::trough_edges`]): the
- /// interior stays level with the surface and a valley seam runs the
- /// boundary — the closed dropdown's chrome, for a control that is part
- /// of the plate rather than a step up or down from it.
- Trough,
-}
-
-impl ReliefCarve {
- /// This carve shifted vertically — the page-scroll adjustment a host
- /// applies when it re-emits collected carves.
- pub fn shifted_y(self, dy: f32) -> Self {
- Self { y: self.y + dy, ..self }
- }
-}
-
-pub trait RenderTarget {
- fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32);
- fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, _radius: f32) {
- self.rect(color, x, y, w, h);
- }
- fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _corners: (bool, bool, bool, bool)) {
- self.rect_with_radius(color, x, y, w, h, radius);
- }
- fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]);
- fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _font: &str) {
- self.text(content, x, y, size, color);
- }
- fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _bounds: Option<[f32; 4]>) {
- self.text(content, x, y, size, color);
- }
- fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, _bounds: Option<[f32; 4]>) {
- self.text_with_font(content, x, y, size, color, font);
- }
- fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
- fn pop_clip_rect(&mut self) {}
- /// A bundled cce-icons glyph — see `PaintCtx::icon`. A target that
- /// cannot draw images (the legacy `PopoverCollector`) draws nothing.
- fn icon(&mut self, _name: &str, _rect: crate::scene::layout::Rect, _color: [f32; 4]) {}
- /// A straight stroke — see `PaintCtx::vector`. What a graph's wires are
- /// made of; a target that draws none (the default) shows no wires.
- fn line(&mut self, _x1: f32, _y1: f32, _x2: f32, _y2: f32, _thickness: f32, _color: [f32; 4], _cap: crate::scene::paint::Cap) {}
- /// A stroked arc, `radius` its OUTER edge — see `PaintCtx::arc`. A
- /// rounded wire's bends.
- fn arc(&mut self, _cx: f32, _cy: f32, _radius: f32, _thickness: f32, _start: f32, _end: f32, _color: [f32; 4]) {}
- /// A filled disc — see `PaintCtx::circle`. A graph's ports.
- fn circle(&mut self, _cx: f32, _cy: f32, _radius: f32, _color: [f32; 4]) {}
- /// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
- /// control surface (groove ring down, beveled lip back up). Lets a popover
- /// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
- /// Hosts without relief prims degrade to a flat rounded fill.
- fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
- self.rect_with_radius(color, x, y, w, h, radius);
- }
- /// [`inset_plate`](Self::inset_plate) with the rim lit — the focused
- /// control plate's ring (`ControlPlate::with_tint`). Hosts without relief
- /// prims draw the plain plate.
- fn inset_plate_tinted(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, _tint: [f32; 3]) {
- self.inset_plate(color, x, y, w, h, radius, depth);
- }
- /// One step carve from a widget's `paint` ([`ReliefCarve`]) — offered here
- /// for the same reason as `inset_plate`: the legacy `all_quads` stream
- /// carries no relief prims, so a flat-path host never sees them.
- ///
- /// The default is deliberately a NO-OP, not a fill. These controls have
- /// transparent faces by design (the host surface IS the well floor / the
- /// plate's face), so the carve is their entire decoration — a host that
- /// can't carve has nothing truthful to draw, and a solid box here would
- /// paint every text field a flat slab it never had.
- fn relief_carve(&mut self, _carve: &ReliefCarve) {}
- /// Whether this host renders sections as sunken wells (the designer idiom).
- /// `SectionContext` then lays the title out left-aligned over its tab box
- /// instead of centered on the top border.
- fn section_relief_style(&self) -> bool {
- false
- }
- /// The section frame hatch: `SectionContext::finish` offers the frame here
- /// before falling back to the legacy 1px outline. A relief-capable host
- /// returns true and carves the section into its plate instead (the
- /// designer sunken-well idiom); the tuple hosts keep the default.
- fn section_relief(&mut self, _frame: &SectionFrame) -> bool {
- false
- }
-}
-
-/// A section frame offered to [`RenderTarget::section_relief`]: the content
-/// body box plus, under [`RenderTarget::section_relief_style`], the title tab
-/// box the label was laid out in — the tab sits flush on the body's top edge
-/// (the designer union-carve shape).
-pub struct SectionFrame {
- pub x: f32,
- pub y: f32,
- pub w: f32,
- pub h: f32,
- pub tab: Option<(f32, f32, f32, f32)>,
- pub focused: bool,
- pub is_child: bool,
-}
-
-pub struct PopoverCollector {
- pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
- pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
-}
-
-impl PopoverCollector {
- pub fn new() -> Self {
- Self { rects: Vec::new(), texts: Vec::new() }
- }
-}
-
-impl RenderTarget for PopoverCollector {
- fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
- self.rects.push((color, x, y, w, h));
- }
-
- fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
- self.texts.push((content.to_string(), size, x, y, color, None, None));
- }
-
- fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
- self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
- }
-
- fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
- self.texts.push((content.to_string(), size, x, y, color, None, bounds));
- }
-
- fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
- self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), bounds));
- }
-}
-
-
-pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32, ctx: &mut UiContext) {
- ctx.register_host(w);
- // The flat-host contract, the same block `set_rect` takes: `(x, y)` is the top of
- // the detached label and `wh` the block height, label strip included. `layout`
- // takes the CONTENT origin and height, so step down by the strip.
- let strip = w.label_strip();
- let content_h = (wh - strip).max(0.0);
- w.layout(crate::widget::Point { x, y: y + strip }, crate::widget::LayoutConstraints::new(ww, ww, content_h, content_h), ctx);
-
- // Shape, which on this path nobody else does. A flat host consumes
- // `all_quads`, so `prepare_text` — where a TextBox records the per-glyph x
- // offsets its selection highlight, caret and click->index mapping all read
- // — was never called for the widgets it draws. Those three then fell back
- // to `measure_text_width("M")`, an SVG-rasterized INKED extent rather than
- // an advance, so the highlight under-ran the glyphs by a few px per
- // character (a full glyph by the end of "example.com"). Hosts that shape
- // for themselves (cce-files, the TreeList) just re-read the shared buffer
- // cache here.
- if let Ok(mut fs) = crate::geometry_font_system().lock() {
- w.prepare_text(&mut fs);
- }
- let (style_r, corners) = w.corner_style();
- let r = if corners != (false, false, false, false) { style_r } else { 0.0 };
- let (wx, mut wy, www, mut whh) = w.rect();
- let top_room = w.label_strip();
- wy += top_room;
- whh -= top_room;
-
- // ONE ordered replay of the paint walk — the same walk the live display-
- // list render runs (`scene::painter`), every prim in the order the widget
- // painted it, each mapped onto the flat host's RenderTarget surface.
- //
- // This used to be three passes over three typed views of the same paint:
- // the relief prims (downcast per widget type and re-derived from the
- // widget's accessors — the Dropdown's inset plate, the TextBox's well, the
- // Button's face, the Toggle's faces and steps), then every plain quad
- // (`all_quads`), then every rounded quad (`all_rounded_quads`). Splitting
- // one paint into typed streams loses the order between them, and the
- // order is the picture: a TextBox draws its rounded background, carves
- // its well, THEN lays the selection highlight and caret on top — the
- // three-pass replay put the well under the highlight and the background
- // over both. A Dropdown's hovered row is drawn after its menu plate; a
- // host that replays plates after rects buries the highlight. The prim
- // walk keeps the widget's order, covers every widget instead of the four
- // that had a special case, and carries the relief prims' own per-corner
- // radii and depth (the re-derivations rounded those off).
- //
- // Mapping onto the tuple surface: Quad and RoundedRect are the two
- // native fills (the root's plain background keeps its solid-border
- // expansion and window-corner resolution); a zero-stroke Border is an
- // inset plate's FACE and is held until the Trough that follows it, so the
- // pair reaches the host as ONE `inset_plate` call (a relief host carves
- // it for real; the default degrades to the flat fill); Recess/Boss go to
- // `relief_carve`; a Bevel degrades to its fill — what a flat host can
- // draw of a raised plate. Ridges, circles, arcs, vectors and images have
- // no flat-surface counterpart and are skipped, as they always were.
- let solid_border = w.solid_border();
- let mut text_scratch = crate::scene::paint::PaintCtx::new();
- crate::scene::painter::paint_root_into(&*ctx, &*w, &mut text_scratch);
-
- // A zero-stroke Border waiting for its Trough: (rect, radii, fill).
- let mut pending_face: Option<(crate::scene::layout::Rect, crate::scene::paint::Radii, [f32; 4])> = None;
- fn same_rect(a: crate::scene::layout::Rect, b: crate::scene::layout::Rect) -> bool {
- (a.x - b.x).abs() < 0.1 && (a.y - b.y).abs() < 0.1 && (a.width - b.width).abs() < 0.1 && (a.height - b.height).abs() < 0.1
- }
- fn emit_rounded(pc: &mut dyn RenderTarget, rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, color: [f32; 4]) {
- let (r1, r2, r3, r4) = radii;
- let radius = r1.max(r2).max(r3).max(r4);
- let mask = (r1 > 0.0, r2 > 0.0, r3 > 0.0, r4 > 0.0);
- pc.rect_with_radius_corners(color, rect.x, rect.y, rect.width, rect.height, radius, mask);
- }
-
- for item in text_scratch.finish().items {
- use crate::scene::paint::Prim;
- let trough_for_face = match (&item.prim, &pending_face) {
- (Prim::Trough { rect, .. }, Some((face_rect, _, _))) => same_rect(*rect, *face_rect),
- // `inset_plate`'s edge: a field that is all run.
- (Prim::Field { rect, split, end, .. }, Some((face_rect, _, _))) => {
- *split <= rect.x && *end >= rect.x + rect.width && same_rect(*rect, *face_rect)
- }
- _ => false,
- };
- if !trough_for_face {
- if let Some((frect, fradii, fill)) = pending_face.take() {
- emit_rounded(pc, frect, fradii, fill);
- }
- }
- match item.prim {
- Prim::Quad { rect, color: qc } => {
- let (qx, qy, qw, qh) = (rect.x, rect.y, rect.width, rect.height);
- let extra_corners = (
- corners.0 && qx <= wx + 1.5 && qy <= wy + 1.5,
- corners.1 && qx + qw >= wx + www - 1.5 && qy <= wy + 1.5,
- corners.2 && qx + qw >= wx + www - 1.5 && qy + qh >= wy + whh - 1.5,
- corners.3 && qx <= wx + 1.5 && qy + qh >= wy + whh - 1.5,
- );
-
- let (resolved_r, resolved_corners) = if r <= 0.1 || corners == (false, false, false, false) || extra_corners == (false, false, false, false) {
- (0.0, (false, false, false, false))
- } else {
- (r, extra_corners)
- };
-
- // The widget's own background quad with a solid border: full-size
- // border quad, then the inset background over it.
- let mut border_drawn = false;
- let is_bg_quad = (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - www).abs() < 0.1 && (qh - whh).abs() < 0.1;
- if is_bg_quad {
- if let Some((border_color, thickness)) = solid_border {
- if thickness > 0.0 {
- pc.rect_with_radius_corners(border_color, qx, qy, qw, qh, resolved_r, resolved_corners);
- pc.rect_with_radius_corners(
- qc,
- qx + thickness,
- qy + thickness,
- (qw - 2.0 * thickness).max(0.0),
- (qh - 2.0 * thickness).max(0.0),
- (resolved_r - thickness).max(0.0),
- resolved_corners,
- );
- border_drawn = true;
- }
- }
- }
-
- if !border_drawn {
- pc.rect_with_radius_corners(qc, qx, qy, qw, qh, resolved_r, resolved_corners);
- }
- }
- Prim::RoundedRect { rect, radius, corners: qcorners, color: qc } => {
- pc.rect_with_radius_corners(qc, rect.x, rect.y, rect.width, rect.height, radius, qcorners);
- }
- Prim::Border { rect, radii, fill, border, thickness } => {
- if thickness > 0.0 && border[3].abs() > 0.001 {
- emit_rounded(pc, rect, radii, border);
- if fill[3].abs() > 0.001 {
- let inner = crate::scene::layout::Rect {
- x: rect.x + thickness,
- y: rect.y + thickness,
- width: (rect.width - 2.0 * thickness).max(0.0),
- height: (rect.height - 2.0 * thickness).max(0.0),
- };
- let (r1, r2, r3, r4) = radii;
- let shrink = |v: f32| if v > 0.0 { (v - thickness).max(0.0) } else { 0.0 };
- emit_rounded(pc, inner, (shrink(r1), shrink(r2), shrink(r3), shrink(r4)), fill);
- }
- } else if fill[3].abs() > 0.001 {
- // abs(): a negative alpha is the frost sentinel, a real face.
- pending_face = Some((rect, radii, fill));
- }
- }
- // A flat host has no blended outline: the two-box form
- // `Prim::Field` replaced — the well to the seam, the run's flush
- // plate past it.
- Prim::Field { rect, radii, depth, split, end, tint } => {
- let (fl, fr) = (rect.x, rect.x + rect.width);
- let (well_l, well_r) = (split > fl, end < fr);
- // All run and no well (`PaintCtx::inset_plate`'s edge): the
- // plate alone, with the face that came before it.
- let face = if !well_l && !well_r { pending_face.take().map(|(_, _, fill)| fill) } else { None }.unwrap_or([0.0; 4]);
- let rx = split.max(fl);
- let rw = (end.min(fr) - rx).max(0.0);
- let mut well = |x: f32, w: f32, radii: (f32, f32, f32, f32)| {
- pc.relief_carve(&ReliefCarve {
- kind: CarveKind::Recess { tint },
- x,
- y: rect.y,
- w,
- h: rect.height,
- radii,
- depth,
- edges: (true, true, true, true),
- });
- };
- if well_l {
- well(fl, rx - fl, (radii.0, 0.0, 0.0, radii.3));
- }
- if well_r {
- well(rx + rw, fr - rx - rw, (0.0, radii.1, radii.2, 0.0));
- }
- let r = if well_r { radii.0.max(radii.3) } else { radii.1.max(radii.2) };
- match tint {
- Some(t) => pc.inset_plate_tinted(face, rx, rect.y, rw, rect.height, r, depth, t),
- None => pc.inset_plate(face, rx, rect.y, rw, rect.height, r, depth),
- }
- }
- Prim::Trough { rect, radii, depth, tint, .. } => {
- let face = pending_face.take().map(|(_, _, fill)| fill).unwrap_or([0.0; 4]);
- let (r1, r2, r3, r4) = radii;
- let r = r1.max(r2).max(r3).max(r4);
- match tint {
- Some(t) => pc.inset_plate_tinted(face, rect.x, rect.y, rect.width, rect.height, r, depth, t),
- None => pc.inset_plate(face, rect.x, rect.y, rect.width, rect.height, r, depth),
- }
- }
- Prim::Bevel { rect, radii, material, .. } => {
- let color = material.fill(crate::scene::material::PlateRole::Nested);
- if color[3].abs() > 0.001 {
- emit_rounded(pc, rect, radii, color);
- }
- }
- Prim::Recess { rect, radii, depth, edges, tint } => {
- pc.relief_carve(&ReliefCarve {
- kind: CarveKind::Recess { tint },
- x: rect.x,
- y: rect.y,
- w: rect.width,
- h: rect.height,
- radii,
- depth,
- edges,
- });
- }
- Prim::Boss { rect, radii, depth, edges, tint } => {
- pc.relief_carve(&ReliefCarve {
- kind: CarveKind::Boss { tint },
- x: rect.x,
- y: rect.y,
- w: rect.width,
- h: rect.height,
- radii,
- depth,
- edges,
- });
- }
- // Text via the paint walk: each widget's Text prims (content font + scroll-ancestor
- // clip) exactly as the live display-list render does; the prim already carries the
- // per-widget font + bounds.
- Prim::Text { text, x, y, font_size, color, font, bounds, .. } => {
- let color_f32 = [
- color[0] as f32 / 255.0,
- color[1] as f32 / 255.0,
- color[2] as f32 / 255.0,
- 1.0,
- ];
- // Compose the walk's container clip with the prim's own bounds (the engine's dl-text
- // merge), so a clipping ancestor still bounds the text.
- let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
- let merged = match (clip, bounds) {
- (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
- (Some(a), None) => Some(a),
- (None, b) => b,
- };
- match font {
- Some(ref f) => pc.text_with_font_and_bounds(&text, x, y, font_size, color_f32, f, merged),
- None => pc.text_with_bounds(&text, x, y, font_size, color_f32, merged),
- }
- }
- // Strokes, arcs and discs — a graph's wires and ports, chiefly.
- // Until 2026-10-06 these were dropped too, so a graph in a flat
- // host (cce-files' Graph page) drew its nodes and no wires.
- Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
- if let Some(c) = item.clip {
- pc.push_clip_rect(c.x, c.y, c.width, c.height);
- }
- pc.line(x1, y1, x2, y2, thickness, color, cap);
- if item.clip.is_some() {
- pc.pop_clip_rect();
- }
- }
- Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
- if let Some(c) = item.clip {
- pc.push_clip_rect(c.x, c.y, c.width, c.height);
- }
- pc.arc(cx, cy, radius, thickness, start, end, color);
- if item.clip.is_some() {
- pc.pop_clip_rect();
- }
- }
- Prim::Circle { cx, cy, radius, color } => {
- if let Some(c) = item.clip {
- pc.push_clip_rect(c.x, c.y, c.width, c.height);
- }
- pc.circle(cx, cy, radius, color);
- if item.clip.is_some() {
- pc.pop_clip_rect();
- }
- }
- // A widget's GLYPH (a dropdown's arrow, a spinbox's −/+, a menu
- // mark): drawn by name through the host's `RenderTarget::icon`,
- // since an image id means nothing to a flat host. Until this arm
- // every widget glyph vanished from a flat host — the toolkit drew
- // its symbols as text before 2026-10-05, and this replay dropped
- // images. An image that is not a bundled glyph is still dropped.
- Prim::Image { image, rect, alpha } => {
- if let Some((name, _, tint)) = crate::icon_source(image) {
- let [r, g, b] = tint.unwrap_or([255, 255, 255]);
- let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, alpha];
- if let Some(c) = item.clip {
- pc.push_clip_rect(c.x, c.y, c.width, c.height);
- }
- pc.icon(&name, rect, color);
- if item.clip.is_some() {
- pc.pop_clip_rect();
- }
- }
- }
- _ => {}
- }
- }
- if let Some((frect, fradii, fill)) = pending_face.take() {
- emit_rounded(pc, frect, fradii, fill);
- }
- if w.popover_rect().is_some() {
- ctx.register_popover(w);
- }
-}
-
-pub fn render_popovers(pc: &mut dyn RenderTarget, ctx: &UiContext) {
- for &pop_id in &ctx.active_popovers {
- if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
- unsafe {
- (*ptr).render_popover(pc);
- }
- }
- }
- // Registry sweep for open popovers the app never registered (popover
- // registration is optional and spotty) — the same fallback the coverage
- // check and the engine's outside-press close use.
- for (id, ptr) in ctx.tree.iter_registered() {
- if ctx.active_popovers.contains(&id) {
- continue;
- }
- unsafe {
- if let Some(w) = ptr.as_ref() {
- if w.visible() && w.popover_rect().is_some() {
- w.render_popover(pc);
- }
- }
- }
- }
-}
-
-pub fn partition_concentric_corners(
- x: f32, y: f32, w: f32, h: f32,
- _r_std: f32,
- r_adjust: [f32; 4],
- color: [f32; 4],
-) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
- let mut quads = Vec::new();
-
- let r0 = r_adjust[0];
- let r1 = r_adjust[1];
- let r2 = r_adjust[2];
- let r3 = r_adjust[3];
-
- let max_left = r0.max(r3);
- let max_right = r1.max(r2);
-
- let mut push_valid_quad = |qx: f32, qy: f32, qw: f32, qh: f32, qr: f32, qcorners: (bool, bool, bool, bool)| {
- if qw > 0.001 && qh > 0.001 {
- quads.push((qx, qy, qw, qh, qr, color, qcorners));
- }
+/// These used to live in `~/.config/fontconfig/fonts.conf`, read back out of
+/// fontconfig's XML by alias. Three of the seven aliases it carried
+/// (`window-borders`, `status-interface`, `fuzzel`) were cce inventions
+/// squatting in fontconfig's family namespace, and by the end none of them was
+/// read by anything: window borders lost their text when titlebars went away,
+/// the status bar moved to `module { font }` / `/style/status/font` in KDL and
+/// only ever consulted the alias as a last-resort fallback, and fuzzel was
+/// replaced by cce-cloud. The settings app's Fonts page — which edited that
+/// file, rewriting it wholesale and preserving only its `<dir>` lines — went
+/// with them.
+///
+/// fonts.conf is still fontconfig's file and still governs GTK/Electron apps;
+/// cce simply no longer reads or writes it. Per-app overrides work here because
+/// this reads the merged config, the same way the status bar's font does.
+pub fn read_preferred_fonts() -> (String, String, String, String) {
+ let get = |key: &str, fallback: &str| {
+ crate::config::get_string(&format!("/fonts/{key}"))
+ .filter(|s| !s.trim().is_empty())
+ .unwrap_or_else(|| fallback.to_string())
};
-
- // 1. Center vertical block
- push_valid_quad(x + max_left, y, w - max_left - max_right, h, 0.0, (false, false, false, false));
-
- // 2. Left block
- push_valid_quad(x, y + r0, max_left, h - r0 - r3, 0.0, (false, false, false, false));
-
- // 3. Top-left transition
- push_valid_quad(x + r0, y, max_left - r0, r0, 0.0, (false, false, false, false));
-
- // 4. Bottom-left transition
- push_valid_quad(x + r3, y + h - r3, max_left - r3, r3, 0.0, (false, false, false, false));
-
- // 5. Right block
- push_valid_quad(x + w - max_right, y + r1, max_right, h - r1 - r2, 0.0, (false, false, false, false));
-
- // 6. Top-right transition
- push_valid_quad(x + w - max_right, y, max_right - r1, r1, 0.0, (false, false, false, false));
-
- // 7. Bottom-right transition
- push_valid_quad(x + w - max_right, y + h - r2, max_right - r2, r2, 0.0, (false, false, false, false));
-
- // 8. Corner 0 (top-left)
- push_valid_quad(x, y, r0, r0, r0, (true, false, false, false));
-
- // 9. Corner 1 (top-right)
- push_valid_quad(x + w - r1, y, r1, r1, r1, (false, true, false, false));
-
- // 10. Corner 2 (bottom-right)
- push_valid_quad(x + w - r2, y + h - r2, r2, r2, r2, (false, false, true, false));
-
- // 11. Corner 3 (bottom-left)
- push_valid_quad(x, y + h - r3, r3, r3, r3, (false, false, false, true));
-
- quads
+ (
+ get("sans_serif", "Noto Sans"),
+ get("serif", "Noto Serif"),
+ get("monospace", "Noto Sans Mono"),
+ get("terminal", "Noto Sans Mono"),
+ )
}
-pub struct UiFrame;
-
-impl UiFrame {
- pub fn start(scroll_offset: f32) -> Self {
- crate::widget::hover_animation::reset_frame_registration();
- crate::widget::hover_animation::set_scroll_offset(scroll_offset);
- Self
- }
-
- pub fn finish(self, pc: &mut dyn RenderTarget) {
- crate::widget::hover_animation::post_render_check();
- if let Some((qx, qy, qw, qh, qc)) = crate::widget::hover_animation::get_quad() {
- pc.rect(qc, qx, qy, qw, qh);
- }
- // render_popovers(pc);
+impl crate::widget::ContainerLayout for FlexLayout {
+ fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
+ Box::new(self.clone())
}
}
-pub struct Column {
- ox: f32,
- oy: f32,
- cx: f32,
- pub y: f32,
- pub cw: f32,
-}
-
-impl Column {
- pub fn new(ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
- Self { ox, oy, cx, y: cy, cw }
- }
-
- pub fn ax(&self, x_off: f32) -> f32 {
- self.ox + self.cx + x_off
- }
-
- pub fn ay(&self) -> f32 {
- self.oy + self.y
- }
-
- pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- pc.rect(color, self.ax(x_off), self.ay(), w, h);
- self.y += h;
- }
-
- pub fn advance(&mut self, dy: f32) {
- self.y += dy;
- }
-
- pub fn spacing(&mut self, dy: f32) {
- self.y += dy;
- }
-
- pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
- let x = self.ax(8.0);
- let y = self.ay();
- 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, pc: &mut dyn RenderTarget, text: &str, x_off: f32) {
- let x = self.ax(x_off);
- let y = self.ay();
- pc.text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
- self.y += 22.0;
- }
-
- pub fn text(&mut self, pc: &mut dyn RenderTarget, 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;
- pc.text(text, x, y, font_size, color);
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let top_room = w.label_strip();
- let total_h = wh + top_room;
- let x = self.ax(x_off);
- let y = self.ay();
- w.set_row_rect(self.ox + self.cx + 8.0, self.cw - 16.0);
- let clamped_w = ww.min((self.cw - x_off).max(0.0));
- render_widget(pc, w, x, y, clamped_w, total_h, ctx);
- self.y += total_h;
- }
-
- pub fn row<F: FnOnce(&mut Row)>(&mut self, pc: &mut dyn RenderTarget, h: f32, f: F) {
- let row_y = self.ay();
- let mut row = Row {
- pc: &mut *pc,
- base_x: self.ox + self.cx,
- y: row_y,
- cursor_x: 0.0,
- spacing: CONTROL_GAP,
- };
- f(&mut row);
- self.y = self.y + h;
+impl crate::widget::ContainerLayout for ColumnLayout {
+ fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
+ Box::new(self.clone())
}
}
-pub struct Row<'a> {
- pc: &'a mut dyn RenderTarget,
- base_x: f32,
- y: f32,
- pub cursor_x: f32,
- pub spacing: f32,
-}
-
-impl<'a> Row<'a> {
- pub fn set_spacing(&mut self, spacing: f32) {
- self.spacing = spacing;
- }
-
- pub fn gap(&mut self, width: f32) {
- self.cursor_x += width;
- }
-
- pub fn text(&mut self, text: &str, y_off: f32, font_size: f32, color: [f32; 4], width: f32) {
- self.pc
- .text(text, self.base_x + self.cursor_x, self.y + y_off, font_size, color);
- self.cursor_x += width + self.spacing;
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
- self.cursor_x += ww + self.spacing;
+impl crate::widget::ContainerLayout for AdaptiveGrid {
+ fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
+ Box::new(self.clone())
}
}
-fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
- let fam_lower = font_fam.to_lowercase();
- let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
- if is_mono {
- label.chars().count() as f32 * font_size * 0.60
- } else {
- let mut width = 0.0;
- for c in label.chars() {
- let factor = match c {
- 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
- 'f' | 'j' | 't' => 0.35,
- 'r' | 's' | 'c' | 'z' => 0.50,
- 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
- 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
- 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
- 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
- '0'..='9' => 0.60,
- _ => 0.60,
- };
- width += factor * font_size;
- }
- width
+impl crate::widget::ContainerLayout for RadialLayout {
+ fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
+ Box::new(self.clone())
}
}
-pub struct Section {
- pub left: f32,
- pub top: f32,
- pub content_y: f32,
- pub cw: f32,
- pub label_width: f32,
- pub is_child: bool,
- pub grid: Grid,
- pub last_col: usize,
-}
-
-impl Section {
- pub const DEFAULT_MARGIN_X: f32 = 12.0;
- pub const DEFAULT_ROW_GAP: f32 = 8.0;
-
- fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
- estimate_label_width_helper(label, font_size, font_fam)
- }
+#[cfg(test)]
+mod tests {
+ use crate::widget::WidgetHost;
- pub fn padding(&self) -> f32 {
- section_padding()
- }
-
- pub fn new(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str) -> Self {
- Self::new_opt(pc, left, top, cw, label, false)
- }
-
- pub fn new_opt(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str, is_child: bool) -> Self {
- let font_setting = if is_child {
- nested_section_label_font()
- } else {
- section_label_font()
- };
- let (font_fam, font_size_opt) = parse_font_string(&font_setting);
- let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
- let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
- let label_width = Self::estimate_label_width(label, font_size, &font_fam);
- let label_x = if is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => left + 12.0,
- 1 => left + (cw - label_width) / 2.0,
- 2 => left + cw - 12.0 - label_width,
- _ => left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else {
- left + (cw - label_width) / 2.0
- };
- pc.text_with_font(label, label_x, top, font_size, font_color, &font_fam);
-
- let pad = section_padding();
- let margin_x = 2.0 * pad + 12.0;
- let usable_w = (cw - 2.0 * margin_x).max(1.0);
- let min_col_width = 130.0;
- let gap = 8.0;
- let max_cols = if is_child {
- 1
- } else {
- ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
- };
- let content_start_y = top + pad + 19.0;
- let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
-
- Self { left, top, content_y: content_start_y, cw, label_width, is_child, grid, last_col: usize::MAX }
- }
-
- /// Horizontal inset of content from this section's left edge — the same
- /// one `row_layout`, the column `Grid` and `widget` use, so everything in
- /// a section lines up. See `SectionContext::content_margin`.
- pub fn content_margin(&self) -> f32 {
- 2.0 * self.padding() + 12.0
- }
-
- pub fn content_left(&self) -> f32 {
- self.left + self.content_margin()
- }
-
- pub fn content_width(&self) -> f32 {
- (self.cw - 2.0 * self.content_margin()).max(0.0)
- }
-
- /// See `SectionContext::ax` — same mapping, same reason it is no longer
- /// stepped at `x_off == 12.0`.
- pub fn ax(&self, x_off: f32) -> f32 {
- self.left + 2.0 * self.padding() + x_off
- }
-
- pub fn ay(&self) -> f32 { self.content_y }
-
- pub fn spacing(&mut self, dy: f32) {
- if self.grid.col_heights.len() >= 2 {
- if self.last_col == usize::MAX {
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- } else if self.last_col < self.grid.col_heights.len() {
- self.grid.col_heights[self.last_col] += dy;
- }
- self.content_y = self.grid.max_height();
- } else {
- self.content_y += dy;
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- }
- }
-
- pub fn text(&mut self, pc: &mut dyn RenderTarget, 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;
- // Bounded to the content box, like `SectionContext::text` — text is
- // the only thing a section draws that is not already sized to fit it.
- let right = self.content_left() + self.content_width();
- let bounds = if right > x {
- Some([x, y - font_size, right, y + 2.0 * font_size])
- } else {
- None
- };
- pc.text_with_bounds(text, x, y, font_size, color, bounds);
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let pad = self.padding();
- let top_room = w.label_strip();
- let total_h = wh + top_room;
-
- let name = w.type_name();
- let span_full = name == "Trackpad"
- || name == "Canvas"
- || name == "UsageBar"
- || name == "ProgressBar"
- || name == "ButtonStrip"
- || name == "Spreadsheet"
- || name == "Graph";
-
- if span_full {
- let margin_x = 2.0 * pad + 12.0;
- let x = self.left + margin_x;
- let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
-
- w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
- render_widget(pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.content_y = new_bottom;
- for h in &mut self.grid.col_heights {
- *h = new_bottom;
- }
- } else {
- let max_h = self.grid.max_height();
- if self.content_y > max_h {
- for h in &mut self.grid.col_heights {
- *h = self.content_y;
- }
- }
-
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let y = self.grid.col_heights[col];
-
- w.set_row_rect(x, self.grid.col_width);
- render_widget(pc, w, x, y, self.grid.col_width, total_h, ctx);
- self.grid.col_heights[col] += total_h;
- self.content_y = self.grid.max_height();
- }
- }
-
- pub fn widget_full<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, wh: f32, ctx: &mut UiContext) {
- let x_off = 12.0;
- let ww = self.cw - 2.0 * (self.padding() + x_off);
- self.widget(pc, w, x_off, ww, wh, ctx);
- }
-
- pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
- let pad = self.padding();
- let x = self.ax(pad);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
- pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
- self.content_y = max_h + 8.0;
- for h in &mut self.grid.col_heights {
- *h = self.content_y;
- }
- }
-
- pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- let max_h = self.grid.max_height().max(self.content_y);
- pc.rect(color, self.ax(x_off), max_h, w, h);
- self.content_y = max_h + h;
- for col_h in &mut self.grid.col_heights {
- *col_h = self.content_y;
- }
- }
-
- pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
- let margin_x = self.content_margin();
- let usable_w = self.content_width();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let col_w = (usable_w - total_gap).max(0.0) / count as f32;
-
- let mut cols = Vec::with_capacity(count);
- for i in 0..count {
- let x = self.left + margin_x + i as f32 * (col_w + gap);
- cols.push((x, col_w));
- }
- cols
- }
-
- pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- let max_h = self.grid.max_height().max(self.content_y);
- for col_h in &mut self.grid.col_heights {
- *col_h = max_h;
- }
- self.content_y = max_h;
-
- let cols = self.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- f(i, x, w);
- }
- self.content_y += h;
-
- for col_h in &mut self.grid.col_heights {
- *col_h = self.content_y;
- }
- }
-
- pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
- self.finish_focused(pc, false)
- }
-
- pub fn finish_focused(&mut self, pc: &mut dyn RenderTarget, focused: bool) -> f32 {
- let border: [f32; 4] = if self.is_child {
- if focused {
- [0.22, 0.38, 0.24, 1.0]
- } else {
- [0.18, 0.18, 0.25, 1.0]
- }
- } else {
- if focused {
- [0.30, 0.50, 0.32, 1.0]
- } else {
- [0.25, 0.25, 0.35, 1.0]
- }
- };
- let pad = self.padding();
- let x = self.left + pad;
- let y = self.top + 7.0;
- let w = self.cw - 2.0 * pad;
- let h = self.content_y - y;
-
- let left_edge = x;
- let right_edge = x + w;
- if self.label_width > 0.0 {
- let label_x = if self.is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => self.left + 12.0,
- 1 => self.left + (self.cw - self.label_width) / 2.0,
- 2 => self.left + self.cw - 12.0 - self.label_width,
- _ => self.left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else {
- self.left + (self.cw - self.label_width) / 2.0
- };
- let gap_margin = 6.0;
- let gap_start = label_x - gap_margin;
- let gap_end = label_x + self.label_width + gap_margin;
- if gap_start > left_edge {
- pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
- }
- if right_edge > gap_end {
- pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
- }
- } else {
- pc.rect(border, left_edge, y, w, 1.0);
- }
-
- let extra_bottom = pad + 12.0;
- pc.rect(border, x, y + h + extra_bottom, w, 1.0);
- pc.rect(border, x, y, 1.0, h + extra_bottom);
- pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
- self.content_y + extra_bottom + 8.0
- }
-
- pub fn vstack<'a>(&'a mut self, pc: &'a mut dyn RenderTarget, spacing: f32) -> SectionVStack<'a> {
- SectionVStack {
- section: self,
- pc,
- spacing,
- }
- }
-}
-
-pub struct SectionVStack<'a> {
- section: &'a mut Section,
- pc: &'a mut dyn RenderTarget,
- spacing: f32,
-}
-
-impl<'a> SectionVStack<'a> {
- pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, wh: f32, ctx: &mut UiContext) {
- self.section.widget(self.pc, w, Section::DEFAULT_MARGIN_X, ww, wh, ctx);
- self.section.spacing(self.spacing);
- }
-
- pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- self.section.row(count, gap, h, f);
- self.section.spacing(self.spacing);
- }
-}
-
-
-pub struct SplitterLayout {
- pub splitter1_x: f32,
- pub splitter2_x: f32,
- pub splitter_width: f32,
- pub min_column_width: f32,
-}
-
-impl SplitterLayout {
- pub fn new(width: f32, splitter_width: f32, min_column_width: f32) -> Self {
- let s1 = (width - 2.0 * splitter_width) / 3.0;
- let s2 = s1 + splitter_width + (width - 2.0 * splitter_width) / 3.0;
- Self {
- splitter1_x: s1,
- splitter2_x: s2,
- splitter_width,
- min_column_width,
- }
- }
-
- pub fn clamp(&mut self, total_width: f32, detached_circular_network: bool) {
- if detached_circular_network {
- let min_s2 = self.min_column_width;
- let max_s2 = (total_width - self.min_column_width).max(min_s2);
- self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
- } else {
- let min_s1 = self.min_column_width;
- let max_s1 = (self.splitter2_x - self.splitter_width - self.min_column_width).max(min_s1);
- self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
- let min_s2 = self.splitter1_x + self.splitter_width + self.min_column_width;
- let max_s2 = (total_width - self.min_column_width).max(min_s2);
- self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
- }
- }
-
- pub fn scale(&mut self, factor: f32) {
- self.splitter1_x *= factor;
- self.splitter2_x *= factor;
- }
-
- pub fn left_col(&self) -> (f32, f32) { // (x, width)
- (0.0, self.splitter1_x)
- }
-
- pub fn center_col(&self) -> (f32, f32) { // (x, width)
- let x = self.splitter1_x + self.splitter_width;
- (x, self.splitter2_x - x)
- }
-
- pub fn right_col(&self, total_width: f32) -> (f32, f32) { // (x, width)
- let x = self.splitter2_x + self.splitter_width;
- (x, (total_width - x).max(0.0))
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct Grid {
- pub left: f32,
- pub top: f32,
- pub width: f32,
- pub col_width: f32,
- pub gap: f32,
- pub col_heights: Vec<f32>,
- pub col_lefts: Vec<f32>,
-}
-
-impl Grid {
- pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
- let total_gap = gap * (count - 1) as f32;
- let col_width = if count > 0 {
- (width - total_gap).max(0.0) / count as f32
- } else {
- min_col_width
- };
- let left_offset = 0.0;
-
- let mut col_lefts = Vec::with_capacity(count);
- let col_heights = vec![top; count];
- for i in 0..count {
- col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
- }
-
- Self {
- left,
- top,
- width,
- col_width,
- gap,
- col_heights,
- col_lefts,
- }
- }
-
- pub fn next_column(&self) -> usize {
- let mut min_idx = 0;
- let mut min_h = self.col_heights[0];
- for i in 1..self.col_heights.len() {
- if self.col_heights[i] < min_h {
- min_h = self.col_heights[i];
- min_idx = i;
- }
- }
- min_idx
- }
-
- pub fn max_height(&self) -> f32 {
- let mut max_h = self.col_heights[0];
- for i in 1..self.col_heights.len() {
- if self.col_heights[i] > max_h {
- max_h = self.col_heights[i];
- }
- }
- max_h
- }
-}
-
-pub struct CircularPaneLayout {
- pub x: f32,
- pub y: f32,
- pub r: f32,
-}
-
-impl CircularPaneLayout {
- pub fn new(x: f32, y: f32, r: f32) -> Self {
- Self { x, y, r }
- }
-
- pub fn hit_test_content(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist_sq = dx * dx + dy * dy;
- dist_sq <= self.r * self.r && cy >= self.y - self.r + 45.0 + menubar_h + breadcrumb_h
- }
-
- pub fn hit_test_menubar(&self, cx: f32, cy: f32, menubar_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist = (dx * dx + dy * dy).sqrt();
- dist >= self.r - menubar_h && dist <= self.r && cy < self.y
- }
-
- pub fn hit_test_breadcrumb(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist_sq = dx * dx + dy * dy;
- dist_sq <= self.r * self.r && cy >= self.y - self.r + menubar_h && cy < self.y - self.r + 45.0 + menubar_h + breadcrumb_h
- }
-
- pub fn hit_test_border(&self, cx: f32, cy: f32, border_thickness: f32) -> bool {
- let dx = cx - self.x;
- let dy = cy - self.y;
- let dist = (dx * dx + dy * dy).sqrt();
- dist >= self.r - border_thickness && dist <= self.r
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct Radial {
- pub center_x: f32,
- pub center_y: f32,
- pub aspect_ratio: f32,
- pub base_spacing: f32,
-}
-
-impl Radial {
- pub fn new(center_x: f32, center_y: f32, aspect_ratio: f32, base_spacing: f32) -> Self {
- Self { center_x, center_y, aspect_ratio, base_spacing }
- }
-
- pub fn widget_rect(&self, idx: usize, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if idx == 0 {
- (self.center_x - ww / 2.0, self.center_y - wh / 2.0, ww, wh)
- } else {
- let mut ring = 1;
- let mut ring_start = 1;
- loop {
- let ring_capacity = ring * 6;
- if idx < ring_start + ring_capacity {
- let pos_in_ring = idx - ring_start;
- let angle = (pos_in_ring as f32) * (2.0 * std::f32::consts::PI / ring_capacity as f32);
- let radius = (ring as f32) * self.base_spacing;
-
- let x_offset = radius * angle.cos() * self.aspect_ratio;
- let y_offset = radius * angle.sin();
-
- return (
- self.center_x + x_offset - ww / 2.0,
- self.center_y + y_offset - wh / 2.0,
- ww,
- wh,
- );
- }
- ring_start += ring_capacity;
- ring += 1;
- }
- }
- }
-
-}
-
-
-
-pub trait LayoutStrategy: std::fmt::Debug {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32);
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32);
- fn set_section_count(&mut self, _count: usize) {}
- fn get_column_width(&self) -> Option<f32> { None }
- fn get_gap(&self) -> f32 { 20.0 }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32;
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size;
- fn box_clone(&self) -> Box<dyn LayoutStrategy>;
-}
-
-impl Clone for Box<dyn LayoutStrategy> {
- fn clone(&self) -> Self {
- self.box_clone()
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum FlexDirection {
- Row,
- Column,
-}
-
-#[derive(Debug, Clone)]
-pub struct FlexLayout {
- left: f32,
- top: f32,
- width: f32,
- height: f32,
- direction: FlexDirection,
- spacing: f32,
- current_x: f32,
- current_y: f32,
-}
-
-impl FlexLayout {
- pub fn new(direction: FlexDirection, spacing: f32) -> Self {
- Self {
- left: 0.0,
- top: 0.0,
- width: 0.0,
- height: 0.0,
- direction,
- spacing,
- current_x: 0.0,
- current_y: 0.0,
- }
- }
-}
-
-impl LayoutStrategy for FlexLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.height = height;
- self.current_x = left;
- self.current_y = top;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- match self.direction {
- FlexDirection::Row => {
- let rx = self.current_x;
- let ry = self.current_y;
- self.current_x += ww + self.spacing;
- (rx, ry, ww, wh)
- }
- FlexDirection::Column => {
- let rx = self.current_x;
- let ry = self.current_y;
- self.current_y += wh + self.spacing;
- (rx, ry, ww, wh)
- }
- }
- }
-
- fn get_gap(&self) -> f32 {
- self.spacing
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
- let (cur_x, cur_y) = (x, y);
- // Blocks: the label row (`label_lead`) above every child's content, the
- // gap between blocks, so a row's controls are level and a carve-out tab
- // sits a full gap from its neighbour.
- let lead = crate::widget::container::container_layout::label_lead(children);
- match self.direction {
- FlexDirection::Row => {
- let mut cur_x = cur_x;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_w = child.rect().2;
- let child_h = crate::widget::container::container_layout::content_height(child);
- let use_h = if child_h > 0.0 { child_h } else { h };
- child.layout(
- crate::widget::Point { x: cur_x, y: cur_y + lead },
- crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
- ctx,
- );
- cur_x += child_w + self.spacing;
- }
- }
- (cur_x - x).max(0.0)
- }
- FlexDirection::Column => {
- let mut cur_y = cur_y;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_h = crate::widget::container::container_layout::content_height(child);
- let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- child.layout(
- crate::widget::Point { x, y: cur_y + lead },
- crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
- ctx,
- );
- cur_y += lead + use_h + self.spacing;
- }
- }
- (cur_y - y).max(0.0)
- }
- }
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- match self.direction {
- FlexDirection::Row => {
- let mut total_w = 0.0f32;
- let mut max_h = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_w += size.width;
- max_h = max_h.max(size.height);
- if i > 0 {
- total_w += self.spacing;
- }
- }
- }
- crate::widget::Size {
- width: total_w.clamp(constraints.min_width, constraints.max_width),
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
- FlexDirection::Column => {
- let mut total_h = 0.0f32;
- let mut max_w = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_h += size.height;
- max_w = max_w.max(size.width);
- if i > 0 {
- total_h += self.spacing;
- }
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: total_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct ColumnLayout {
- left: f32,
- top: f32,
- width: f32,
- current_y: f32,
- gap: f32,
-}
-
-impl ColumnLayout {
- pub fn new(gap: f32) -> Self {
- Self {
- left: 0.0,
- top: 0.0,
- width: 0.0,
- current_y: 0.0,
- gap,
- }
- }
-}
-
-impl LayoutStrategy for ColumnLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- self.left = left;
- self.top = top;
- self.width = width;
- self.current_y = top;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- let x = self.left;
- let y = self.current_y;
- self.current_y += wh + self.gap;
- (x, y, ww, wh)
- }
-
- fn get_column_width(&self) -> Option<f32> {
- Some(self.width)
- }
-
- fn get_gap(&self) -> f32 {
- self.gap
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let mut cur_y = y;
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let child_h = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- child.set_rect(x, cur_y, w, use_h);
- cur_y += use_h + self.gap;
- }
- }
- (cur_y - y).max(0.0)
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let mut total_h = 0.0f32;
- let mut max_w = 0.0f32;
- for (i, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- total_h += size.height;
- max_w = max_w.max(size.width);
- if i > 0 {
- total_h += self.gap;
- }
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: total_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct AdaptiveGrid {
- grid: Option<Grid>,
- #[allow(dead_code)]
- min_col_width: f32,
- #[allow(dead_code)]
- gap: f32,
- num_sections: Option<usize>,
-}
-
-impl AdaptiveGrid {
- pub fn new(min_col_width: f32, gap: f32) -> Self {
- Self {
- grid: None,
- min_col_width,
- gap,
- num_sections: None,
- }
- }
-}
-
-impl LayoutStrategy for AdaptiveGrid {
- fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(max_cols).max(1)
- } else {
- max_cols
- };
- self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref mut grid) = self.grid {
- let num_cols = grid.col_heights.len();
- let num_cols_spanned = (((ww + grid.gap) / (grid.col_width + grid.gap)).round() as usize)
- .min(num_cols)
- .max(1);
-
- if num_cols_spanned >= num_cols {
- let y = grid.max_height();
- let x = grid.left;
- let allocated_w = grid.width;
- for col_h in &mut grid.col_heights {
- *col_h = y + wh + grid.gap;
- }
- (x, y, allocated_w, wh)
- } else if num_cols_spanned == 1 {
- let col = grid.next_column();
- let x = grid.col_lefts[col];
- let y = grid.col_heights[col];
- grid.col_heights[col] += wh + grid.gap;
- (x, y, grid.col_width, wh)
- } else {
- let n = num_cols_spanned;
- let mut best_start_col = 0;
- let mut min_max_h = f32::MAX;
- for c in 0..=(num_cols - n) {
- let mut max_h = 0.0f32;
- for i in 0..n {
- if grid.col_heights[c + i] > max_h {
- max_h = grid.col_heights[c + i];
- }
- }
- if max_h < min_max_h {
- min_max_h = max_h;
- best_start_col = c;
- }
- }
- let x = grid.col_lefts[best_start_col];
- let y = min_max_h;
- let allocated_w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
- for i in 0..n {
- grid.col_heights[best_start_col + i] = y + wh + grid.gap;
- }
- (x, y, allocated_w, wh)
- }
- } else {
- (0.0, 0.0, 0.0, wh)
- }
- }
-
- fn set_section_count(&mut self, count: usize) {
- self.num_sections = Some(count);
- }
-
- fn get_column_width(&self) -> Option<f32> {
- self.grid.as_ref().map(|g| g.col_width)
- }
-
- fn get_gap(&self) -> f32 {
- crate::layout::grid_gap()
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let usable_w = w.max(1.0);
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(cols).max(1)
- } else {
- cols
- };
-
- let total_gap = gap * (count - 1) as f32;
- let available_w = (w - total_gap).max(1.0);
- let col_w = available_w / count as f32;
-
- let mut col_heights = vec![y; count];
-
- for &child_ptr in children {
- unsafe {
- let child = &mut *child_ptr;
- let ch = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if ch > 0.0 { ch } else { 44.0 };
-
- let mut min_col = 0;
- let mut min_h = col_heights[0];
- for i in 1..count {
- if col_heights[i] < min_h {
- min_h = col_heights[i];
- min_col = i;
- }
- }
-
- let cx = x + min_col as f32 * (col_w + gap);
- let cy = col_heights[min_col];
- child.set_rect(cx, cy, col_w, use_h);
- col_heights[min_col] += use_h + gap;
- }
- }
-
- let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
- (max_h - y).max(0.0)
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let usable_w = constraints.max_width.max(1.0);
- let min_col_width = crate::layout::grid_min_col_width();
- let gap = crate::layout::grid_gap();
- let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
- let count = if let Some(n) = self.num_sections {
- n.min(cols).max(1)
- } else {
- cols
- };
-
- let mut col_heights = vec![0.0f32; count];
- let total_gap = gap * (count - 1) as f32;
- let available_w = (constraints.max_width - total_gap).max(1.0);
- let col_w = available_w / count as f32;
-
- let child_constraints = crate::widget::LayoutConstraints::new(col_w, col_w, constraints.min_height, constraints.max_height);
-
- for &child_ptr in children {
- unsafe {
- let size = (*child_ptr).measure(child_constraints, ctx);
- let mut min_col = 0;
- let mut min_h = col_heights[0];
- for i in 1..count {
- if col_heights[i] < min_h {
- min_h = col_heights[i];
- min_col = i;
- }
- }
- col_heights[min_col] += size.height + gap;
- }
- }
-
- let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
- crate::widget::Size {
- width: constraints.max_width,
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct RadialLayout {
- radial: Option<Radial>,
- aspect_ratio: f32,
- base_spacing: f32,
- idx: usize,
-}
-
-impl RadialLayout {
- pub fn new(aspect_ratio: f32, base_spacing: f32) -> Self {
- Self {
- radial: None,
- aspect_ratio,
- base_spacing,
- idx: 0,
- }
- }
-}
-
-impl LayoutStrategy for RadialLayout {
- fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
- let cx = left + width / 2.0;
- let cy = top + height / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (width / height.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- self.radial = Some(Radial::new(cx, cy, aspect, self.base_spacing));
- self.idx = 0;
- }
-
- fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
- if let Some(ref radial) = self.radial {
- let rect = radial.widget_rect(self.idx, ww, wh);
- self.idx += 1;
- rect
- } else {
- (0.0, 0.0, ww, wh)
- }
- }
-
- fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
- let cx = x + w / 2.0;
- let cy = y + h / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (w / h.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- let radial = Radial::new(cx, cy, aspect, self.base_spacing);
- for (idx, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let child = &mut *child_ptr;
- let cw = child.rect().2;
- let ch = child.preferred_height().unwrap_or(child.rect().3);
- let use_h = if ch > 0.0 { ch } else { 44.0 };
- let (rx, ry, rw, rh) = radial.widget_rect(idx, cw, use_h);
- child.set_rect(rx, ry, rw, rh);
- }
- }
- h
- }
-
- fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
- let cx = constraints.max_width / 2.0;
- let cy = constraints.max_height / 2.0;
- let aspect = if self.aspect_ratio > 0.0 {
- self.aspect_ratio
- } else {
- let screen_aspect = (constraints.max_width / constraints.max_height.max(1.0)).max(0.1);
- 1.0 + (screen_aspect - 1.0) * 0.4
- };
- let radial = Radial::new(cx, cy, aspect, self.base_spacing);
- let mut max_w = 0.0f32;
- let mut max_h = 0.0f32;
- for (idx, &child_ptr) in children.iter().enumerate() {
- unsafe {
- let size = (*child_ptr).measure(constraints, ctx);
- let (rx, ry, rw, rh) = radial.widget_rect(idx, size.width, size.height);
- max_w = max_w.max(rx + rw);
- max_h = max_h.max(ry + rh);
- }
- }
- crate::widget::Size {
- width: max_w.clamp(constraints.min_width, constraints.max_width),
- height: max_h.clamp(constraints.min_height, constraints.max_height),
- }
- }
-
- fn box_clone(&self) -> Box<dyn LayoutStrategy> {
- Box::new(self.clone())
- }
-}
-
-/// Vertical slack for a section's content clip. The clip exists to stop
-/// content escaping its section SIDEWAYS, which is the axis a section's width
-/// actually fixes; a section's height is only known once its content has been
-/// placed, so bounding that axis too would risk cutting content off rather
-/// than keeping it in. Deliberately far larger than any section.
-const SECTION_CLIP_SLACK: f32 = 100_000.0;
-
-pub struct PageLayoutBuilder<'a, P> {
- pub strategy: &'a mut dyn LayoutStrategy,
- pub cx: f32,
- pub cy: f32,
- pub cw: f32,
- pub ch: f32,
- pub section_width: f32,
- pub idx: usize,
- _phantom: std::marker::PhantomData<P>,
-}
-
-impl<'a, P: RenderTarget + Default> PageLayoutBuilder<'a, P> {
- pub fn new(
- strategy: &'a mut dyn LayoutStrategy,
- cx: f32,
- cy: f32,
- cw: f32,
- ch: f32,
- section_width: f32,
- ) -> Self {
- strategy.init(cx, cy, cw, ch);
- Self {
- strategy,
- cx,
- cy,
- cw,
- ch,
- section_width,
- idx: 0,
- _phantom: std::marker::PhantomData,
- }
- }
-
- pub fn with_section_count(self, count: usize) -> Self {
- self.strategy.set_section_count(count);
- self.strategy.init(self.cx, self.cy, self.cw, self.ch);
- self
- }
-
- pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let width = self.strategy.get_column_width().unwrap_or(self.section_width);
- let mut dummy = P::default();
- let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
- render_fn(&mut dummy_ctx);
- let wh = dummy_ctx.finish();
- let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
- let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
- let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
- real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
- render_fn(&mut real_ctx);
- real_ctx.pc.pop_clip_rect();
- real_ctx.finish();
- self.idx += 1;
- }
-
- pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let mut dummy = P::default();
- let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
- render_fn(&mut dummy_ctx);
- let wh = dummy_ctx.finish();
- let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
- let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
- let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
- real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
- render_fn(&mut real_ctx);
- real_ctx.pc.pop_clip_rect();
- real_ctx.finish();
- self.idx += 1;
- }
-
- pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let col_width = self.strategy.get_column_width().unwrap_or(self.section_width);
- let gap = self.strategy.get_gap();
- let width = span as f32 * col_width + (span - 1) as f32 * gap;
- self.add_section_with_width(final_pc, width, label, focused, render_fn);
- }
-}
-
-pub struct SectionContext<'a, P> {
- pub pc: &'a mut P,
- pub left: f32,
- pub top: f32,
- pub content_y: f32,
- pub cw: f32,
- pub label_width: f32,
- pub label_x: f32,
- /// Whether the host renders sections as sunken wells (`section_relief_style`).
- pub relief_style: bool,
- /// The title tab box (x, y, w, h) when the host's relief styling laid the
- /// label out left-aligned — `finish` offers it with the section carve.
- /// None under relief styling means a label-less section: the well carves
- /// tabless, flush with the allocation top.
- pub relief_tab: Option<(f32, f32, f32, f32)>,
- pub focused: bool,
- pub is_child: bool,
- pub grid: Grid,
- pub last_col: usize,
- pub content_start_y: f32,
- pub row_gap: f32,
-}
-
-impl<'a, P: RenderTarget> SectionContext<'a, P> {
- pub const DEFAULT_MARGIN_X: f32 = 12.0;
- pub const DEFAULT_ROW_GAP: f32 = 8.0;
-
- fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
- estimate_label_width_helper(label, font_size, font_fam)
- }
-
- pub fn padding(&self) -> f32 {
- section_padding()
- }
-
- pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
- let font_setting = if is_child {
- nested_section_label_font()
- } else {
- section_label_font()
- };
- let (font_fam, font_size_opt) = parse_font_string(&font_setting);
- let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
- let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
- let label_width = Self::estimate_label_width(label, font_size, &font_fam);
- let relief_style = pc.section_relief_style();
- let label_x = if is_child {
- let base_x = match nested_section_label_alignment() {
- 0 => left + 12.0,
- 1 => left + (cw - label_width) / 2.0,
- 2 => left + cw - 12.0 - label_width,
- _ => left + 12.0,
- };
- base_x + nested_section_label_offset()
- } else if relief_style {
- // Sunken style: the title sits in a tab flush with the well's left
- // edge (the designer look), not centered on the border.
- left + 12.0
- } else {
- left + (cw - label_width) / 2.0
- };
- // Under relief styling the well fills the whole allocated rect (so the
- // page's gaps and margins are the visual gaps) — the tab tops the
- // allocation and the label centers inside it.
- let label_y = if relief_style { top + 4.0 } else { top };
- pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
-
- // The tab wraps the label, flush on the well's top-left corner; clamped
- // so off-default child alignments can't push it outside the well.
- let relief_tab = if relief_style && label_width > 0.0 {
- let tab_x = (label_x - 12.0).max(left);
- Some((tab_x, top, label_width + 24.0, font_size + 10.0))
- } else {
- None
- };
-
- let pad = section_padding();
- let margin_x = 2.0 * pad + 12.0;
- let usable_w = (cw - 2.0 * margin_x).max(1.0);
- let min_col_width = 130.0;
- let gap = 8.0;
- let max_cols = if is_child {
- 1
- } else {
- ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
- };
- // Under relief styling the content stands off the well's top wall by
- // the same inset it keeps from the side walls (`margin_x`, which is
- // also what `finish` leaves below it), so a well reads as one even
- // frame. The outline style's `pad + 19` predates the title tab: under
- // relief the tab alone is `font_size + 10` tall, which left the first
- // line touching the well's top wall at the default padding while the
- // sides kept 20px and more.
- let content_start_y = if relief_style {
- relief_tab.map(|t| t.1 + t.3).unwrap_or(top) + margin_x
- } else {
- top + pad + 19.0
- };
- let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
-
- Self {
- pc,
- left,
- top,
- content_y: content_start_y,
- cw,
- label_width,
- label_x,
- relief_style,
- relief_tab,
- focused,
- is_child,
- grid,
- last_col: usize::MAX,
- content_start_y,
- row_gap: Self::DEFAULT_ROW_GAP,
- }
- }
-
- pub fn with_row_gap(mut self, gap: f32) -> Self {
- self.row_gap = gap;
- self
- }
-
- /// The section's content-box top edge: the body well's top (the tab's
- /// bottom) under relief styling, the outline's border line otherwise. Lets
- /// a page place content at an exact inset from the well's walls.
- pub fn well_top(&self) -> f32 {
- if self.relief_style {
- self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
- } else {
- self.top + 7.0
- }
- }
-
- pub fn set_row_gap(&mut self, gap: f32) {
- self.row_gap = gap;
- }
-
- /// Horizontal inset of section CONTENT from the section's left edge.
- ///
- /// One number, used by every content placer in here — `row_layout`, the
- /// column `Grid`, `widget`, `VStack` and `ax` (so `text`) — because they
- /// share a section and have to line up inside it. The section's border is
- /// drawn at `left + padding()` (see `finish`), so content clears the
- /// border by `padding() + 12`.
- pub fn content_margin(&self) -> f32 {
- 2.0 * self.padding() + 12.0
- }
-
- /// Left edge of the content box: where a row, a widget or a `text(_, 12.0,
- /// ..)` starts.
- pub fn content_left(&self) -> f32 {
- self.left + self.content_margin()
- }
-
- /// Width of the content box — the section's width less the inset on both
- /// sides. Nothing a section draws should extend past `content_left() +
- /// content_width()`.
- pub fn content_width(&self) -> f32 {
- (self.cw - 2.0 * self.content_margin()).max(0.0)
- }
-
- /// `x_off` px into the content box's coordinate space, where 12.0 is the
- /// content's own left edge — the offset 46 of the ~55 call sites in the
- /// tree already pass, and the one that lines text up with the buttons and
- /// widgets beside it.
- ///
- /// This used to add `padding()` only when `x_off >= 12.0`, which made the
- /// mapping DISCONTINUOUS: asking for 11 instead of 12 moved the text 5px
- /// LEFT rather than 1px, and silently dropped it out of alignment with
- /// every row in the same section. `cce-mail` and two others sit on the
- /// wrong side of that cliff today.
- pub fn ax(&self, x_off: f32) -> f32 {
- self.left + 2.0 * self.padding() + x_off
- }
-
- pub fn ay(&self) -> f32 {
- self.content_y
- }
-
- pub fn spacing(&mut self, dy: f32) {
- if self.grid.col_heights.len() >= 2 {
- if self.last_col == usize::MAX {
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- } else if self.last_col < self.grid.col_heights.len() {
- self.grid.col_heights[self.last_col] += dy;
- }
- self.content_y = self.grid.max_height();
- } else {
- self.content_y += dy;
- for h in &mut self.grid.col_heights {
- *h += dy;
- }
- }
- }
-
- pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
- let mut y = self.content_y + y_off;
- if y > self.content_start_y {
- y += self.row_gap;
- }
- let x = self.ax(x_off);
- // Bound it to the content box. A section's text was drawn unbounded,
- // so a string wider than its section simply kept going — over the
- // border, over whatever sat to the right, and off the window (the
- // settings app's GPU names did all three). Rows and widgets have
- // always been sized to the section; text was the one thing that could
- // leave it. The vertical band is generous on purpose: it is the
- // horizontal overrun that has to be cut, and a tight band would
- // shave descenders.
- let right = self.content_left() + self.content_width();
- let bounds = if right > x {
- Some([x, y - font_size, right, y + 2.0 * font_size])
- } else {
- None
- };
- self.pc.text_with_bounds(text, x, y, font_size, color, bounds);
- let new_bottom = y + font_size + 4.0;
- self.content_y = new_bottom;
- for h in &mut self.grid.col_heights {
- *h = new_bottom;
- }
- }
-
- pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
- if let Some(pref) = w.preferred_height() {
- wh = pref;
- }
- let pad = self.padding();
- let top_room = w.label_strip();
- let total_h = wh + top_room;
-
- let name = w.type_name();
- let span_full = name == "Trackpad"
- || name == "Canvas"
- || name == "UsageBar"
- || name == "ProgressBar"
- || name == "ButtonStrip"
- || name == "Spreadsheet"
- || name == "Graph";
-
- if span_full {
- let margin_x = 2.0 * pad + 12.0;
- let x = self.left + margin_x;
- let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
- let mut max_h = self.grid.max_height().max(self.content_y);
- if max_h > self.content_start_y {
- max_h += self.row_gap;
- }
- let y = max_h;
-
- w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
- render_widget(self.pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.content_y = new_bottom;
- for h in &mut self.grid.col_heights {
- *h = new_bottom;
- }
- } else {
- let max_h = self.grid.max_height();
- if self.content_y > max_h {
- for h in &mut self.grid.col_heights {
- *h = self.content_y;
- }
- }
-
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let mut y = self.grid.col_heights[col];
- if y > self.content_start_y {
- y += self.row_gap;
- }
-
- let aligned_x = x;
- let aligned_w = self.grid.col_width;
-
- w.set_row_rect(aligned_x, aligned_w);
- render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
- self.grid.col_heights[col] = y + total_h;
- self.content_y = self.grid.max_height();
- }
- }
-
- pub fn widget_full<T: WidgetHost + 'static>(&mut self, w: &mut T, wh: f32, ctx: &mut UiContext) {
- let x_off = 12.0;
- let ww = self.cw - 2.0 * (self.padding() + x_off); // cw - 40.0
- self.widget(w, x_off, ww, wh, ctx);
- }
-
- pub fn separator(&mut self) {
- let pad = self.padding();
- let x = self.ax(pad);
- let max_h = self.grid.max_height().max(self.content_y);
- let y = max_h;
- self.pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
- self.content_y = max_h + 8.0;
- for h in &mut self.grid.col_heights {
- *h = self.content_y;
- }
- }
-
- pub fn rect(&mut self, color: [f32; 4], x_off: f32, w: f32, h: f32) {
- let max_h = self.grid.max_height().max(self.content_y);
- self.pc.rect(color, self.ax(x_off), max_h, w, h);
- self.content_y = max_h + h;
- for col_h in &mut self.grid.col_heights {
- *col_h = self.content_y;
- }
- }
-
- pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
- let margin_x = self.content_margin();
- let usable_w = self.content_width();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let col_w = (usable_w - total_gap).max(0.0) / count as f32;
-
- let mut cols = Vec::with_capacity(count);
- for i in 0..count {
- let x = self.left + margin_x + i as f32 * (col_w + gap);
- cols.push((x, col_w));
- }
- cols
- }
-
- /// A row whose columns are sized to what goes IN them: each gets the width
- /// it asked for in `needs`, and whatever is left over is shared equally.
- ///
- /// `row_layout` splits a row evenly and knows nothing about content, so it
- /// hands "Reboot" and "Hibernate" the same width — one floats in slack
- /// while the other is cut off, which is what a row of mismatched labels
- /// looks like. Sharing the SLACK equally rather than sizing proportionally
- /// is deliberate: proportional widths would make a two-character label a
- /// sliver, where what is wanted is "everyone fits, then everyone gets the
- /// same bonus".
- ///
- /// When the needs do not fit, every column is scaled by the same factor, so
- /// the row still cannot overflow its section and the shortfall is shared
- /// rather than landing entirely on the last column.
- pub fn row_layout_for(&self, needs: &[f32], gap: f32) -> Vec<(f32, f32)> {
- let count = needs.len();
- if count == 0 {
- return Vec::new();
- }
- let total_gap = gap * (count - 1) as f32;
- let room = (self.content_width() - total_gap).max(0.0);
- let total_need: f32 = needs.iter().map(|n| n.max(0.0)).sum();
-
- let widths: Vec<f32> = if total_need <= room {
- let extra = (room - total_need) / count as f32;
- needs.iter().map(|n| n.max(0.0) + extra).collect()
- } else if total_need > 0.0 {
- let scale = room / total_need;
- needs.iter().map(|n| n.max(0.0) * scale).collect()
- } else {
- vec![room / count as f32; count]
- };
-
- let mut cols = Vec::with_capacity(count);
- let mut x = self.content_left();
- for w in widths {
- cols.push((x, w));
- x += w + gap;
- }
- cols
- }
-
-
- pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(usize, f32, f32),
- {
- let max_h = self.grid.max_height().max(self.content_y);
- for col_h in &mut self.grid.col_heights {
- *col_h = max_h;
- }
- self.content_y = max_h;
-
- let cols = self.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- f(i, x, w);
- }
- self.content_y += h;
-
- for col_h in &mut self.grid.col_heights {
- *col_h = self.content_y;
- }
- }
-
- pub fn vstack(&mut self, spacing: f32) -> VStack<'_, 'a, P> {
- VStack {
- context: self,
- spacing,
- }
- }
-
- pub fn add_section<F>(&mut self, label: &str, focused: bool, mut render_fn: F)
- where
- F: FnMut(&mut SectionContext<'_, P>),
- {
- let pad = self.padding();
- let (left, top, cw, is_side_by_side) = if self.grid.col_heights.len() >= 2 {
- let col = self.grid.next_column();
- self.last_col = col;
- let x = self.grid.col_lefts[col];
- let mut y = self.grid.col_heights[col];
- if y > self.content_start_y {
- y += self.row_gap;
- }
- (x, y, self.grid.col_width, true)
- } else {
- let left = self.ax(0.0) + pad;
- let mut max_h = self.grid.max_height().max(self.content_y);
- if max_h > self.content_start_y {
- max_h += self.row_gap;
- }
- let top = max_h;
- let cw = self.cw - 2.0 * pad;
- (left, top, cw, false)
- };
-
- let mut sub_ctx = SectionContext::new(self.pc, left, top, cw, label, focused, true);
- render_fn(&mut sub_ctx);
- let new_bottom = sub_ctx.finish();
-
- if is_side_by_side {
- let col = self.last_col;
- self.grid.col_heights[col] = new_bottom;
- self.content_y = self.grid.max_height();
- } else {
- self.content_y = new_bottom;
- for h in &mut self.grid.col_heights {
- *h = new_bottom;
- }
- }
- }
-
- pub fn finish(self) -> f32 {
- let border: [f32; 4] = if self.is_child {
- if self.focused {
- [0.22, 0.38, 0.24, 1.0]
- } else {
- [0.18, 0.18, 0.25, 1.0]
- }
- } else {
- if self.focused {
- [0.30, 0.50, 0.32, 1.0] // Focused green
- } else {
- [0.25, 0.25, 0.35, 1.0] // Default gray
- }
- };
- let pad = self.padding();
- let x = self.left + pad;
- let y = self.top + 7.0;
- let w = self.cw - 2.0 * pad;
- let h = self.content_y - y;
- // Under relief the well's walls are the allocation's edges, so the
- // floor below the content matches the inset beside and above it (see
- // `new`). The outline frame sits `pad` in from its allocation and
- // keeps its own slack.
- let extra_bottom = if self.relief_style { self.content_margin() } else { pad + 12.0 };
- let bottom = y + h + extra_bottom;
-
- if self.relief_style {
- // Sunken style: the well spans the full allocated rect — body top
- // edge at the tab's bottom (the tab is flush ON the body, the
- // designer union shape; label-less sections carve tabless from the
- // allocation top), walls on the allocation's edges, and no
- // trailing slack so the layout gap IS the visual gap.
- let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
- let frame = SectionFrame {
- x: self.left,
- y: body_y,
- w: self.cw,
- h: bottom - body_y,
- tab: self.relief_tab,
- focused: self.focused,
- is_child: self.is_child,
- };
- if self.pc.section_relief(&frame) {
- return self.content_y + extra_bottom;
- }
- }
-
- let left_edge = x;
- let right_edge = x + w;
- if self.label_width > 0.0 {
- let label_x = self.label_x;
- let gap_margin = 6.0;
- let gap_start = label_x - gap_margin;
- let gap_end = label_x + self.label_width + gap_margin;
- if gap_start > left_edge {
- self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
- }
- if right_edge > gap_end {
- self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
- }
- } else {
- self.pc.rect(border, left_edge, y, w, 1.0);
- }
-
- self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
- self.pc.rect(border, x, y, 1.0, h + extra_bottom);
- self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
- self.content_y + extra_bottom + 8.0
- }
-}
-
-
-pub struct VStack<'b, 'a, P> {
- pub context: &'b mut SectionContext<'a, P>,
- pub spacing: f32,
-}
-
-impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
- pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
- let pad = self.context.padding();
- let margin_x = 2.0 * pad + 12.0;
- let x = self.context.left + margin_x;
- let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
-
- let mut max_h = self.context.grid.max_height().max(self.context.content_y);
- if max_h > self.context.content_start_y {
- max_h += self.context.row_gap;
- }
- let y = max_h;
-
- let pref_h = w.preferred_height().unwrap_or(wh);
- let top_room = w.label_strip();
- let total_h = pref_h + top_room;
-
- w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
- render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
-
- let new_bottom = y + total_h;
- self.context.content_y = new_bottom;
- for h in &mut self.context.grid.col_heights {
- *h = new_bottom;
- }
- self.context.spacing(self.spacing);
- }
-
- /// [`add_row`](Self::add_row) with per-column widths from `needs` — see
- /// [`SectionContext::row_layout_for`]. For a row of buttons, `needs` is
- /// each label's measured width plus the plate's own inset.
- pub fn add_row_for<F>(&mut self, needs: &[f32], gap: f32, h: f32, mut f: F)
- where
- F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
- {
- let max_h = self.context.grid.max_height().max(self.context.content_y);
- for col_h in &mut self.context.grid.col_heights {
- *col_h = max_h;
- }
- self.context.content_y = max_h;
-
- let cols = self.context.row_layout_for(needs, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- self.context.content_y = max_h;
- f(self.context, i, x, w);
- }
-
- let new_bottom = max_h + h;
- self.context.content_y = new_bottom;
- for col_h in &mut self.context.grid.col_heights {
- *col_h = new_bottom;
- }
- self.context.spacing(self.spacing);
- }
-
- pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
- where
- F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
- {
- let max_h = self.context.grid.max_height().max(self.context.content_y);
- for col_h in &mut self.context.grid.col_heights {
- *col_h = max_h;
- }
- self.context.content_y = max_h;
-
- let cols = self.context.row_layout(count, gap);
- for (i, &(x, w)) in cols.iter().enumerate() {
- self.context.content_y = max_h;
- f(self.context, i, x, w);
- }
-
- let new_bottom = max_h + h;
- self.context.content_y = new_bottom;
- for col_h in &mut self.context.grid.col_heights {
- *col_h = new_bottom;
- }
- self.context.spacing(self.spacing);
- }
-}
-
-pub fn get_system_monospace_font() -> &'static str {
- static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
- MONOSPACE_FONT.get_or_init(|| {
- if let Ok(output) = std::process::Command::new("fc-match")
- .args(["-f", "%{family}", "monospace"])
- .output()
- {
- let name = String::from_utf8_lossy(&output.stdout);
- let parsed = name.split(',').next().unwrap_or("monospace").trim();
- if !parsed.is_empty() {
- return parsed.to_string();
- }
- }
- "monospace".to_string()
- })
-}
-
-pub fn get_system_sans_serif_font() -> &'static str {
- static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
- SANS_SERIF_FONT.get_or_init(|| {
- if let Ok(output) = std::process::Command::new("fc-match")
- .args(["-f", "%{family}", "sans-serif"])
- .output()
- {
- let name = String::from_utf8_lossy(&output.stdout);
- let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
- if !parsed.is_empty() {
- return parsed.to_string();
- }
- }
- "sans-serif".to_string()
- })
-}
-
-/// The DE's font families, from the shared config's `fonts { }` block:
-/// `(sans_serif, serif, monospace, terminal)`.
-///
-/// These used to live in `~/.config/fontconfig/fonts.conf`, read back out of
-/// fontconfig's XML by alias. Three of the seven aliases it carried
-/// (`window-borders`, `status-interface`, `fuzzel`) were cce inventions
-/// squatting in fontconfig's family namespace, and by the end none of them was
-/// read by anything: window borders lost their text when titlebars went away,
-/// the status bar moved to `module { font }` / `/style/status/font` in KDL and
-/// only ever consulted the alias as a last-resort fallback, and fuzzel was
-/// replaced by cce-cloud. The settings app's Fonts page — which edited that
-/// file, rewriting it wholesale and preserving only its `<dir>` lines — went
-/// with them.
-///
-/// fonts.conf is still fontconfig's file and still governs GTK/Electron apps;
-/// cce simply no longer reads or writes it. Per-app overrides work here because
-/// this reads the merged config, the same way the status bar's font does.
-pub fn read_preferred_fonts() -> (String, String, String, String) {
- let get = |key: &str, fallback: &str| {
- crate::config::get_string(&format!("/fonts/{key}"))
- .filter(|s| !s.trim().is_empty())
- .unwrap_or_else(|| fallback.to_string())
- };
- (
- get("sans_serif", "Noto Sans"),
- get("serif", "Noto Serif"),
- get("monospace", "Noto Sans Mono"),
- get("terminal", "Noto Sans Mono"),
- )
-}
-
-impl crate::widget::ContainerLayout for FlexLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for ColumnLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for AdaptiveGrid {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-impl crate::widget::ContainerLayout for RadialLayout {
- fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
- Box::new(self.clone())
- }
-}
-
-#[cfg(test)]
-mod tests {
#[test]
fn unit_slots_resolve_through_the_metric_at_read_time() {
use crate::units::{Len, Metric, MetricSource};
diff --git a/src/layout/registry.rs b/src/layout/registry.rs
new file mode 100644
index 0000000..2533539
--- /dev/null
+++ b/src/layout/registry.rs
@@ -0,0 +1,469 @@
+//! The style registry: the config flattened into one process-wide map of style keys, the
+//! per-thread test overlay over it, and the font-string helpers its readers share. Every
+//! getter in `layout` and `color` reads through it.
+
+use super::*;
+use std::sync::RwLock;
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+/// Per-thread overrides for runtime style writes, under `cfg(test)` only.
+///
+/// Every `graph_*`, corner-radius and control-height slot in this file is
+/// backed by the one process-wide [`STYLE_REGISTRY`], so a test that pins any
+/// of them pins it for every test running beside it. That is the same defect
+/// as the font flake fixed in 1dc0ab1, and it was live:
+/// `test_graph_style_configuration` sets ~20 style values and restores none,
+/// while `dual_geometry_views_stay_consistent` bakes quads with
+/// `graph_node_corner_radius` and then re-reads that getter to compare — so a
+/// write landing between the two makes them disagree. Widening the write
+/// window to 300ms reproduced it on demand.
+///
+/// Fixing it at the registry rather than per accessor covers every slot it
+/// holds in one place, including ones no test pins yet.
+#[cfg(test)]
+mod test_overlay {
+ use std::cell::RefCell;
+ use std::collections::HashMap;
+ thread_local! {
+ static FLOATS: RefCell<HashMap<String, f32>> = RefCell::new(HashMap::new());
+ static LENS: RefCell<HashMap<String, crate::units::Len>> = RefCell::new(HashMap::new());
+ static STRINGS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
+ }
+ pub fn set_float(k: &str, v: f32) {
+ LENS.with(|m| m.borrow_mut().remove(k));
+ FLOATS.with(|m| m.borrow_mut().insert(k.to_string(), v));
+ }
+ pub fn set_len(k: &str, v: crate::units::Len) {
+ FLOATS.with(|m| m.borrow_mut().remove(k));
+ LENS.with(|m| m.borrow_mut().insert(k.to_string(), v));
+ }
+ pub fn set_string(k: &str, v: String) {
+ STRINGS.with(|m| m.borrow_mut().insert(k.to_string(), v));
+ }
+ pub fn get_float(k: &str) -> Option<f32> {
+ if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
+ return Some(l.to_px());
+ }
+ FLOATS.with(|m| m.borrow().get(k).copied())
+ }
+ pub fn get_len(k: &str) -> Option<crate::units::Len> {
+ if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
+ return Some(l);
+ }
+ FLOATS.with(|m| m.borrow().get(k).copied()).map(crate::units::Len::px)
+ }
+ pub fn get_string(k: &str) -> Option<String> {
+ STRINGS.with(|m| m.borrow().get(k).cloned())
+ }
+}
+
+#[derive(Debug)]
+pub struct StyleRegistry {
+ pub floats: HashMap<String, f32>,
+ pub strings: HashMap<String, String>,
+ /// Slots whose config value carried a unit (`width=(mm)2.0`). Read
+ /// through `get_float` like any other number, resolved against the
+ /// process metric (`crate::units::metric`) at EVERY read, so a metric
+ /// that arrives after config load — outputs come in after the first
+ /// style read — or changes with the display is honoured live.
+ pub lens: HashMap<String, crate::units::Len>,
+}
+
+impl StyleRegistry {
+ pub fn new() -> Self {
+ Self {
+ floats: HashMap::new(),
+ strings: HashMap::new(),
+ lens: HashMap::new(),
+ }
+ }
+
+ pub fn get_float(&self, key: &str) -> Option<f32> {
+ #[cfg(test)]
+ if let Some(v) = test_overlay::get_float(key) {
+ return Some(v);
+ }
+ if let Some(len) = self.lens.get(key) {
+ return Some(len.to_px());
+ }
+ self.floats.get(key).copied()
+ }
+
+ /// The slot as a length with its unit: the configured `Len` when one was
+ /// given, else the plain number as logical px. For editors that show the
+ /// unit the user chose rather than the resolved pixel count.
+ pub fn get_len(&self, key: &str) -> Option<crate::units::Len> {
+ #[cfg(test)]
+ if let Some(v) = test_overlay::get_len(key) {
+ return Some(v);
+ }
+ if let Some(len) = self.lens.get(key) {
+ return Some(*len);
+ }
+ self.floats.get(key).map(|v| crate::units::Len::px(*v))
+ }
+
+ pub fn get_string(&self, key: &str) -> Option<String> {
+ #[cfg(test)]
+ if let Some(v) = test_overlay::get_string(key) {
+ return Some(v);
+ }
+ self.strings.get(key).cloned()
+ }
+
+ /// A plain number wins over any earlier unit value for the slot — a
+ /// runtime `set_float` is the newest opinion.
+ pub fn set_float(&mut self, key: &str, val: f32) {
+ #[cfg(test)]
+ {
+ test_overlay::set_float(key, val);
+ return;
+ }
+ #[cfg(not(test))]
+ self.load_float(key, val);
+ }
+
+ pub fn set_len(&mut self, key: &str, len: crate::units::Len) {
+ #[cfg(test)]
+ {
+ test_overlay::set_len(key, len);
+ return;
+ }
+ #[cfg(not(test))]
+ self.load_len(key, len);
+ }
+
+ pub fn set_string(&mut self, key: &str, val: String) {
+ #[cfg(test)]
+ {
+ test_overlay::set_string(key, val);
+ return;
+ }
+ #[cfg(not(test))]
+ self.load_string(key, val);
+ }
+
+ /// The CONFIG-LOAD writes, as opposed to the runtime `set_*` ones above.
+ /// Kept apart because under `cfg(test)` a runtime set goes to a per-thread
+ /// overlay while the loaded config must stay the shared base every test
+ /// reads — routing config through `set_*` would put one thread's config
+ /// in its overlay and leave every other thread with an empty registry.
+ pub fn load_float(&mut self, key: &str, val: f32) {
+ self.lens.remove(key);
+ self.floats.insert(key.to_string(), val);
+ }
+
+ pub fn load_len(&mut self, key: &str, len: crate::units::Len) {
+ self.floats.remove(key);
+ self.lens.insert(key.to_string(), len);
+ }
+
+ pub fn load_string(&mut self, key: &str, val: String) {
+ self.strings.insert(key.to_string(), val);
+ }
+}
+
+pub static STYLE_REGISTRY: OnceLock<RwLock<StyleRegistry>> = OnceLock::new();
+
+pub fn get_style_registry() -> &'static RwLock<StyleRegistry> {
+ STYLE_REGISTRY.get_or_init(|| RwLock::new(StyleRegistry::new()))
+}
+
+pub fn lazy_init_style_registry() {
+ use std::sync::Once;
+ static INIT: Once = Once::new();
+ INIT.call_once(|| {
+ reload_config();
+ });
+}
+
+pub(super) fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props: &mut String) {
+ match val {
+ serde_json::Value::Object(map) => {
+ for (k, v) in map {
+ let next_prefix = if prefix.is_empty() {
+ k.clone()
+ } else {
+ format!("{}.{}", prefix, k)
+ };
+ flatten_json_to_flat_props(v, &next_prefix, flat_props);
+ }
+ }
+ _ => {
+ // A retired `window_manager` spelling of a relief key emits NO
+ // line: the fall-through below strips a path's first segment, so
+ // `window_manager.bevel_depth` would otherwise land on the live
+ // `bevel_depth` registry key by accident, alias or not. The
+ // `style.surface.*` retirements need no guard — stripped, they
+ // become `surface.relief.depth` and the like, which nothing reads.
+ if matches!(prefix, "window_manager.bevel_depth" | "window_manager.bevel_width" | "window_manager.bevel_shader") {
+ return;
+ }
+ let flat_key = match prefix {
+ "style.list.font" | "style.data.list.font" => "list_font",
+ "style.list.font_color" | "style.data.list.font_color" => "list_font_color",
+ "style.control.breadcrumb.font" => "breadcrumb_font",
+
+ // The control rung's default radius: every control-scale
+ // `corner_radius` below falls back to it when its own key is unset.
+ "style.control.corner_radius" => "control_corner_radius",
+ "style.control.button.padding" => "button_padding",
+ "style.control.button.height" => "button_height",
+ "style.control.button.corner_radius" => "button_corner_radius",
+ "style.control.button.font" => "button_font",
+ "style.list.corner_radius" | "style.data.list.corner_radius" => "list_corner_radius",
+ "style.control.textbox.corner_radius" | "style.textbox.corner_radius" | "style.data.textbox.corner_radius" => "textbox_corner_radius",
+ "style.control.dropdown.color" => "dropdown_color",
+ "style.control.font_selector.font" => "font_selector_font",
+ "style.container.section.font" | "style.section.font" => "section_label_font",
+ "style.container.section.padding" | "style.section.padding" => "section_padding",
+ "style.control.button_strip.font" => "button_strip_font",
+ "style.control.button_strip.spacing" => "button_strip_spacing",
+ "style.control.dropdown.height" => "dropdown_height",
+ "style.control.dropdown.corner_radius" => "dropdown_corner_radius",
+ "style.control.font_selector.height" => "font_selector_height",
+ "style.control.font_selector.corner_radius" => "font_selector_corner_radius",
+ "style.control.label.font" => "control_label_font",
+ "style.control.label.font_detached" => "control_label_font_detached",
+ "style.control.label.margin" => "control_label_margin",
+ "style.control.slider.height" => "slider_height",
+ "style.control.slider.corner_radius" => "slider_corner_radius",
+ "style.control.slider.band_thickness" => "slider_band_thickness",
+ "style.control.slider.bulge_width" => "slider_bulge_width",
+ "style.control.slider.bulge_height" => "slider_bulge_height",
+ "style.control.progressbar.height" => "progressbar_height",
+ "style.control.rangeslider.height" => "rangeslider_height",
+ "style.control.scrollbar.width" => "scrollbar_width",
+ "style.control.scrollbar.inset" => "scrollbar_inset",
+ "style.control.spinbox.height" => "spinbox_height",
+ "style.control.spinbox.button_padding" => "spinbox_button_padding",
+ "style.control.spinbox.corner_radius" => "spinbox_corner_radius",
+ "style.control.textbox.height" | "style.textbox.height" | "style.data.textbox.height" => "textbox_height",
+ "style.control.textbox.placeholder_text_color" | "style.textbox.placeholder_text_color" | "style.data.textbox.placeholder_text_color" => "textbox_placeholder_text_color",
+ "style.control.textbox.background_color" | "style.textbox.background_color" | "style.data.textbox.background_color" => "textbox_background_color",
+ "style.control.textbox.background_edit_color" | "style.textbox.background_edit_color" | "style.data.textbox.background_edit_color" => "textbox_background_edit_color",
+ "style.control.textbox.multiline.line_wrap" | "style.textbox.multiline.line_wrap" | "style.data.textbox.multiline.line_wrap" => "textbox_line_wrap",
+ "style.control.textbox.multiline.border_width" | "style.textbox.multiline.border_width" | "style.data.textbox.multiline.border_width" => "textbox_multiline_border_width",
+ "style.control.toggle.height" => "toggle_height",
+ "style.control.toggle.border_width" => "toggle_border_width",
+ "style.control.toggle.disabled_color" => "toggle_disabled_color",
+ "style.control.toggle.border_color" => "toggle_border_color",
+ "style.control.toggle.corner_radius" => "toggle_corner_radius",
+ "window_manager.light_source_position" => "light_source_position",
+ // The DE's relief material, home style.surface.relief: these
+ // shade every bevel/boss/recess in the toolkit. `light` is
+ // the spelling (since 2026-09-28): it is the light strength,
+ // not a length. Two older spellings are RETIRED — not read,
+ // reported by path (`color::retired_surface_keys`), removed
+ // by cce-relief's Save: `relief.depth`, what every config
+ // said until that day, and `window_manager.bevel_depth` /
+ // `bevel_width`, the block these keys were born in before
+ // they had a home of their own (the compositor never read
+ // them; the spelling survived as a compat alias until the
+ // evening of 2026-09-28).
+ "style.surface.relief.light" => "bevel_depth",
+ "style.surface.relief.width" => "bevel_width",
+ // The two SHAPES of the relief, each a node under it
+ // (2026-09-28): `wall` is a carve's wall — a recess, boss,
+ // ridge or trough cut into a surface — and `edge` is the
+ // plate's perimeter roll. Each carries `height` (a length:
+ // the carve's drop, the roll's rise; unset = follow the
+ // width) and `profile` (the ramp spec of its curve, written
+ // by cce-relief, installed by reload_config). The registry
+ // keys keep their old names. The pre-rename flat spellings
+ // (`height` / `profile` were the wall's, `edge_height` /
+ // `edge_profile` the edge's) were aliases for the rest of
+ // that day and are RETIRED: not read, reported by path
+ // (`color::retired_surface_keys`), removed by cce-relief's
+ // Save, which also reads them once as a seed. cce-relief's
+ // slider knobs are NOT a style key any more
+ // (`relief.wall.knobs` / `edge.knobs`, before that
+ // `profile_knobs` / `edge_knobs`): editor state, kept in
+ // that app's own state.kdl, and read off a config only by
+ // the editor itself, as a one-time seed.
+ "style.surface.relief.wall.height" => "bevel_height",
+ "style.surface.relief.edge.height" => "roll_height",
+ "style.surface.relief.wall.profile" => "bevel_profile_spec",
+ "style.surface.relief.edge.profile" => "roll_profile_spec",
+ "style.container.section.depth" => "section_depth",
+ "style.surface.param.backdrop_compression" => "param_compression",
+ "style.surface.param.label_layout" => "param_label_layout",
+ // The relief's shader toggle lives with the relief
+ // (`relief shader=(bool)false` is the legacy banded look);
+ // `window_manager.bevel_shader`, its old home, is retired
+ // and reported like the block's other bevel keys.
+ "style.surface.relief.shader" => "bevel_shader",
+ "window_manager.control_relief" => "control_relief",
+ "window_manager.corner_shape" => "corner_shape",
+ "style.control.ramp.height" => "ramp_height",
+ "style.layout.column.gap" => "column_gap",
+ "style.control.control_panel.padding" => "control_panel_padding",
+ "style.control.control_panel.gap" => "control_panel_gap",
+ "style.status.normal_color" => "status_normal_color",
+ "style.status.background_color" => "status_background_color",
+ "style.status.background_blur" => "status_background_blur",
+ "style.highlight.primary" => "primary_highlight_color",
+ "style.window.page_opacity" => "page_opacity",
+ "style.window.page_margin" => "page_margin",
+ "style.window.plate_padding" => "plate_padding",
+ "style.window.transition_duration" => "transition_duration",
+ "style.overlay.behavior" => "overlay_behavior",
+ "style.overlay.width" => "overlay_width",
+ "style.overlay.position" => "overlay_position",
+ "style.overlay.border_gap" => "overlay_border_gap",
+ "style.editor.last_page" | "style.data.editor.last_page" => "last_page",
+ "style.data.tree.corner_radius" => "tree_corner_radius",
+ "style.data.tree.opacity" => "tree_opacity",
+ "style.data.tree.blur" => "tree_blur",
+ "style.data.tree.font" => "tree_font",
+ "style.surface.desktop.gap_color" => "desktop_gap_color",
+ "style.surface.desktop.cell_color" => "desktop_cell_color",
+ "style.surface.desktop.gap_width" => "desktop_gap_width",
+ "style.surface.desktop.cell_fade_inset" => "desktop_cell_fade_inset",
+ "style.surface.desktop.mode" => "desktop_mode",
+ "style.surface.desktop.solid_color" => "desktop_solid_color",
+ "style.surface.desktop.grid_cell_size" => "desktop_grid_scale",
+ "style.surface.desktop.grid_cell_width" => "grid_cell_width",
+ "style.surface.desktop.grid_cell_height" => "grid_cell_height",
+ "style.surface.plate.padding" => "plate_padding",
+ "style.surface.plate.gap" => "plate_gap",
+ "style.control.gap" => "control_gap",
+ // The context menu's own radius (`menu_corner_radius`): a
+ // popover's corner is control-scale, not pane-scale.
+ "style.surface.menu.corner_radius" => "menu_corner_radius",
+ // `style.surface.plate.root.*` is the one spelling of the
+ // root-plate style (RFC Phase 7a). The slot names keep the
+ // historical `root_plate_` prefix; the legacy `root plate.*`
+ // config read-alias was removed 2026-09-06.
+ "style.surface.plate.root.padding" => "root_plate_padding",
+ "style.surface.plate.root.gap" => "root_plate_gap",
+ "style.surface.plate.root.color" => "root_plate_color",
+ "style.surface.plate.root.blur" => "root_plate_blur",
+ "style.surface.plate.root.corner_radius" => "root_plate_corner_radius",
+ "style.surface.plate.root.menubar.color" => "root_plate_menubar_color",
+ "style.surface.plate.root.menubar.text_color" => "root_plate_menubar_text_color",
+ "style.surface.plate.root.menubar.blur" => "root_plate_menubar_blur",
+ "style.surface.plate.root.menubar.font" => "menubar_font",
+ "style.surface.statusbar.color" => "root_plate_statusbar_color",
+ "style.surface.statusbar.text_color" => "root_plate_statusbar_text_color",
+ "style.surface.statusbar.blur" => "root_plate_statusbar_blur",
+ "style.surface.statusbar.font" => "statusbar_font",
+ "style.surface.page.opacity" => "page_opacity",
+ "style.surface.page.margin" => "page_margin",
+ "style.surface.graph.grid_color" => "graph_grid_color",
+ "style.surface.graph.opacity" => "graph_opacity",
+ "style.surface.graph.node.opacity" => "graph_node_opacity",
+ "style.surface.graph.spacing_x" => "graph_spacing_x",
+ "style.surface.graph.spacing_y" => "graph_spacing_y",
+ "style.surface.graph.line_width" => "graph_line_width",
+ "style.surface.graph.node.width" => "graph_node_width",
+ "style.surface.graph.node.height" => "graph_node_height",
+ "style.surface.graph.grid_snap" => "graph_grid_snap",
+ "style.surface.graph.blur" => "graph_blur",
+ "style.surface.graph.font" => "graph_font",
+ "style.surface.graph.node.color" => "graph_node_color",
+ "style.surface.graph.node.font" => "graph_node_font",
+ "style.surface.graph.node.delete" => "graph_node_delete",
+ "style.surface.graph.node.selected_color" => "graph_node_selected_color",
+ "style.surface.graph.node.drag_color" => "graph_node_drag_color",
+ "style.surface.graph.node.corner_radius" => "graph_node_corner_radius",
+ "style.surface.graph.node.wire_color" => "graph_wire_color",
+ "style.surface.graph.node.wire_highlight_color" => "graph_wire_highlight_color",
+ "style.surface.graph.node.wire_size" => "graph_wire_size",
+ "style.surface.graph.node.wire_style" => "graph_wire_style",
+ "style.surface.graph.node.wire_activation_radius" => "graph_wire_activation_radius",
+ "style.surface.graph.node.connector_color" => "graph_connector_color",
+ "style.surface.graph.node.connector_highlight_color" => "graph_connector_highlight_color",
+ "style.surface.graph.node.connector_size" => "graph_connector_size",
+ "style.surface.graph.node.connector_activation_radius" => "graph_connector_activation_radius",
+ "style.surface.plate.color" => "plate_color",
+ "style.surface.plate.border_color" => "plate_border_color",
+ "style.surface.plate.border_thickness" => "plate_border_thickness",
+ "input.touchpad.natural_scroll" => "touchpad_natural_scroll",
+
+ other => {
+ if let Some(rest) = other.strip_prefix("layout.") {
+ rest
+ } else if let Some(rest) = other.strip_prefix("transparency.") {
+ rest
+ } else {
+ if let Some(idx) = other.find('.') {
+ &other[idx + 1..]
+ } else {
+ other
+ }
+ }
+ }
+ };
+
+ if let Some(s) = val.as_str() {
+ flat_props.push_str(&format!("{} = \"{}\"\n", flat_key, s));
+ } else if let Some(b) = val.as_bool() {
+ flat_props.push_str(&format!("{} = {}\n", flat_key, b));
+ } else if let Some(n) = val.as_f64() {
+ flat_props.push_str(&format!("{} = {}\n", flat_key, n));
+ } else if let Some(n) = val.as_i64() {
+ flat_props.push_str(&format!("{} = {}\n", flat_key, n));
+ }
+ }
+ }
+}
+
+// There is no alias-precedence pass any more. For the rest of 2026-09-28
+// `prefer_relief_spellings` ran here, dropping a relief key's legacy
+// spelling whenever its current one was present (`height` under
+// `wall.height`, `depth` under `light`), because two spellings of one
+// registry key would otherwise be decided by the order the JSON handed
+// the lines out. Every legacy spelling of the relief is retired now — not
+// read at all, reported by `color::retired_surface_keys` — so there is
+// nothing left to prefer, and a config is what it says.
+
+pub(super) fn read_config() -> Option<String> {
+ let path = crate::config::get_config_path();
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ let val = crate::config::parse_kdl_to_json(&content);
+ let mut flat_props = String::new();
+ flatten_json_to_flat_props(&val, "", &mut flat_props);
+ return Some(flat_props);
+ }
+ None
+}
+
+pub fn read_config_value(target_key: &str) -> Option<String> {
+ let path = crate::config::get_config_path();
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ let val = crate::config::parse_kdl_to_json(&content);
+ let mut flat_props = String::new();
+ flatten_json_to_flat_props(&val, "", &mut flat_props);
+ for line in flat_props.lines() {
+ let trimmed = line.trim();
+ if let Some(eq_idx) = trimmed.find('=') {
+ let key = trimmed[..eq_idx].trim();
+ if key == target_key {
+ let val_str = trimmed[eq_idx + 1..].trim().trim_matches('"').trim();
+ return Some(val_str.to_string());
+ }
+ }
+ }
+ }
+ None
+}
+
+pub fn parse_font_string(s: &str) -> (String, Option<f32>) {
+ let (family, size) = split_font_string(s);
+ (family.to_string(), size)
+}
+
+/// [`parse_font_string`] borrowing the family from `s` instead of copying it.
+pub fn split_font_string(s: &str) -> (&str, Option<f32>) {
+ let s = s.trim();
+ if let Some(last_space_idx) = s.rfind(' ') {
+ let (family, size_str) = s.split_at(last_space_idx);
+ let size_str = size_str.trim();
+ if let Ok(size) = size_str.parse::<f32>() {
+ return (family.trim(), Some(size));
+ }
+ }
+ (s, None)
+}
diff --git a/src/lib.rs b/src/lib.rs
index 7d54195..b9146ed 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -55,8 +55,6 @@ pub mod colors {
pub use cosmic_text;
-pub static IS_VERTICAL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
-pub static BAR_THICKNESS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(24);
/// `CCE_SCROLL_DEBUG=1` traces the wheel pipeline to stderr: raw coalesced
/// axis input (runner), routing decisions (ParametersBg), slider gate/value
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index fca7442..2121aa4 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -164,13 +164,13 @@ impl StyledLabel {
pub fn new_with_family(fs: &mut cosmic_text::FontSystem, text: &str, size: f32, color: [f32; 4], family: &str) -> Self {
let scale = crate::scale::scale_factor();
let mut final_text = text.to_string();
- let is_vert = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
+ let is_vert = crate::backend::text::vertical_text().is_some();
if is_vert {
final_text = text.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("\n");
}
let mut buffer = crate::backend::text::get_text_buffer(fs, &final_text, size, Some(family));
if is_vert {
- let bar_thickness = crate::BAR_THICKNESS.load(std::sync::atomic::Ordering::Relaxed) as f32;
+ let bar_thickness = crate::backend::text::vertical_text().unwrap_or(24) as f32;
buffer.set_size(fs, Some(bar_thickness * scale as f32), None);
for line in &mut buffer.lines {
line.set_align(Some(cosmic_text::Align::Center));
@@ -190,7 +190,7 @@ impl StyledLabel {
// Vertical text is boxed: the per-char-newline `final_text` wrapped to the bar
// thickness and centered — the same set_size + center-align the buffer path applied.
let prim_layout = if is_vert {
- let bar_thickness = crate::BAR_THICKNESS.load(std::sync::atomic::Ordering::Relaxed) as f32;
+ let bar_thickness = crate::backend::text::vertical_text().unwrap_or(24) as f32;
Some(crate::scene::paint::TextLayout {
// Effectively unbounded height (the legacy vertical path used height None);
// align_v Top means no vertical offset, so only set_size's height sees this.
@@ -220,7 +220,7 @@ impl StyledLabel {
/// (Phase 6ak): the source text, size, family, and — for vertical bars — the box layout.
/// `x, y` are the draw position; vertical labels pin `y` to 0 (as `draw` did).
pub fn into_prim(self, x: f32, y: f32) -> LabelPrim {
- let is_vert = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
+ let is_vert = crate::backend::text::vertical_text().is_some();
LabelPrim {
text: self.src_text,
size: self.src_size,
@@ -248,7 +248,7 @@ impl StyledLabel {
pub fn draw(self, text_items: &mut Vec<TextItem>, x: f32, y: f32) -> f32 {
let w = self.w;
- let is_vert = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
+ let is_vert = crate::backend::text::vertical_text().is_some();
text_items.push(TextItem {
buffer: self.buffer,
x,
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 733ef61..c441965 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1753,7 +1753,7 @@ impl Paint for TextBox {
font: font_fam.clone(),
attrs: self.font_attrs,
scale_bits: scale.to_bits(),
- vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
+ vertical: crate::backend::text::vertical_text().is_some(),
wrap,
};
if self.prep_key.as_ref() != Some(&key) {