Share native theme components and fix dialog backdrop blur

Centralize native palettes, presets, typography, controls, menus, calendar rendering, and icon assets in ferese-theme. Reuse the shared components across the shell, settings, lock screen, authentication prompt, and screen-sharing chooser.

Attach themed dialogs to compositor-owned materials, correct backdrop capture and sampling across output transforms, smooth patterned backgrounds, and prevent clipped picker buttons with the configured shell font.
This commit is contained in:
Samuel Onoja committed 2026-09-29 21:19:19 +01:00
1 parent 362bf79012
commit b2363a18b0
85 files changed
+2163 -1591

No files matched your search

+4
View File
@@ -6,4 +6,8 @@ license.workspace = true
rust-version = "1.95"
[dependencies]
jiff.workspace = true
ferese-config = { path = "../ferese-config" }
libcosmic.workspace = true
ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] }
wayland-client.workspace = true
+32
View File
@@ -0,0 +1,32 @@
# Ferese theme
The shared native UI theme for the shell, settings, lock screen, authentication
prompt and screen-sharing/recording chooser.
- `Palette` resolves KDL colors and corners and builds the native toolkit theme.
- `PRESETS` defines the six built-in color schemes.
- `font` and `text` share bounded font-name storage and explicit font selection.
- `controls` supplies surfaces, navigation, buttons and input styles.
- `menus` supplies headings, rows, sections, separators, switches and slider styles.
- `calendar` supplies date/time labels, stacked clock digits and calendar grids.
Callers supply the date and navigation controls.
- `icons` owns the shared SVG assets, outline icons, sizing and semantic tinting.
- Contrast helpers keep filled controls readable in every interaction state.
- `material::ModalMaterial` attaches a dialog to the compositor's shell material.
Keep the attachment alive with its window and make the client background
transparent after attachment succeeds. Other compositors retain the client fill.
Applications own layout, data, actions and animation timing. Pass their current
palette and animation progress into the shared components; do not build another
native theme or copy a common control style into an application.
```rust
let palette = ferese_theme::Palette::from_document(Some(&document));
let native_theme = palette.native_theme();
let input_style = ferese_theme::controls::settings_input(palette);
let font = ferese_theme::font(Some("Inter"));
let label = ferese_theme::text("Appearance", font);
```
The compositor owns backdrop blur and material rendering. This crate does not
provide a client-side imitation of those effects.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 12h14m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 200 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="12" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="m8 12 3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/><path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 271 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Ferese symbol: tiled F">
<rect x="20" y="20" width="50" height="160" rx="8" fill="currentColor"/>
<rect x="80" y="20" width="100" height="55" rx="8" fill="currentColor"/>
<rect x="80" y="95" width="70" height="45" rx="8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.8"/>
<rect x="14" y="3" width="7" height="7" rx="1.8"/>
<rect x="3" y="14" width="7" height="7" rx="1.8"/>
<rect x="14" y="14" width="7" height="7" rx="1.8"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3c-.9 0-1.5.7-1.5 1.7V9L3 13v2l7.5-2v4.5L8 19v1.5l4-1 4 1V19l-2.5-1.5V13l7.5 2v-2l-7.5-4V4.7C13.5 3.7 12.9 3 12 3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 25">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
<rect x="5" y="9" width="4.75" height="6" rx="1.2" fill="#e0654f"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 50">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
<rect x="5" y="9" width="9.5" height="6" rx="1.2" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 75">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
<rect x="5" y="9" width="14.25" height="6" rx="1.2" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery charging">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
<rect x="5" y="9" width="19" height="6" rx="1.2" fill="#3d7be6" opacity="0.35"/>
<path d="M16,7 L12,13 H15 L14,17 L19,11 H16 Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery empty">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery full">
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
<rect x="5" y="9" width="19" height="6" rx="1.2" fill="#63c168"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth">
<g transform="translate(-11 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></g>
<g transform="translate(-11 -4)"><circle cx="49" cy="34" r="4" fill="#3d7be6"/></g>
</svg>

After

Width:  |  Height:  |  Size: 382 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth off">
<g transform="translate(-6 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/></g>
<path d="M8,8 L52,52" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth">
<g transform="translate(-6 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></g>
</svg>

After

Width:  |  Height:  |  Size: 297 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M19 5l-1.4 1.4M6.4 17.6 5 19"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/><path d="M8 14h2m4 0h2m-8 4h2m4 0h2"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 9 7 7 7-7"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Control center">
<g transform="translate(0 -4)">
<rect x="10" y="14" width="40" height="16" rx="8" fill="none" stroke="currentColor" stroke-width="3"/>
<circle cx="38" cy="22" r="6" fill="#3d7be6"/>
<rect x="10" y="38" width="40" height="16" rx="8" fill="none" stroke="currentColor" stroke-width="3"/>
<circle cx="22" cy="46" r="6" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 449 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 3h14v12h-4v3H9v-3H5ZM8 3v4m4-4v4m4-4v4M12 18v3M8 21h8"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="10" width="14" height="11" rx="2.8"/>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14.5v2"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6a3 3 0 0 1 6 0v5M5.5 11.5a6.5 6.5 0 0 0 10.6 5M18.5 11.5a6.5 6.5 0 0 1-.4 2.3M12 18v3m-3 0h6"/>
<path d="M4 4l16 16"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3"/>
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3m-3 0h6"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Notification off">
<g transform="translate(0 -2)">
<path d="M30,10 A16,16 0 0,1 46,26 L46,38 L14,38 L14,26 A16,16 0 0,1 30,10 Z" fill="currentColor" opacity="0.5"/>
<rect x="10" y="38" width="40" height="5" rx="2" fill="currentColor" opacity="0.5"/>
<circle cx="30" cy="50" r="4" fill="currentColor" opacity="0.5"/>
<path d="M8,8 L52,56" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 509 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Notification">
<g transform="translate(0 -2)">
<path d="M30,10 A16,16 0 0,1 46,26 L46,38 L14,38 L14,26 A16,16 0 0,1 30,10 Z" fill="currentColor"/>
<rect x="10" y="38" width="40" height="5" rx="2" fill="currentColor"/>
<circle cx="30" cy="50" r="4" fill="#3d7be6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 360 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M7 12h10"/></svg>

After

Width:  |  Height:  |  Size: 226 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v9"/><path d="M7 5.5a9 9 0 1 0 10 0"/></svg>

After

Width:  |  Height:  |  Size: 219 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 12 18 6"/><circle cx="12" cy="12" r="1"/></svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 4c-8 0-14 3-14 10a6 6 0 0 0 6 6c7 0 10-6 8-16Z"/><path d="M4 21c2-5 6-8 12-11"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.8v8M7.4 5.9a8 8 0 1 0 9.2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Cancel display selection"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><path d="m23 23 14 14m0-14L23 37" fill="none" stroke="#3d7be6" stroke-width="3" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Saving recording"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><g fill="#3d7be6"><circle cx="20" cy="30" r="3"/><circle cx="30" cy="30" r="3"/><circle cx="40" cy="30" r="3"/></g></svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
<circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/>
<rect x="20" y="20" width="20" height="20" rx="4" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Record screen">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<circle cx="30" cy="26" r="9" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Settings">
<g fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M25 7h10l2 7 5 3 7-2 5 9-5 5v6l5 5-5 9-7-2-5 3-2 7H25l-2-7-5-3-7 2-5-9 5-5v-6l-5-5 5-9 7 2 5-3z" transform="translate(0 -2)"/>
<circle cx="30" cy="30" r="8" stroke="#3d7be6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
<g transform="translate(3 0)">
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
<path d="M40,15 Q50,30 40,45" fill="none" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
<g transform="translate(3 0)">
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
<path d="M40,15 Q50,30 40,45" fill="none" opacity="0.18" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 446 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
<g transform="translate(3 0)">
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
<path d="M40,15 Q50,30 40,45" fill="none" opacity="0.35" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 446 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio off">
<g transform="translate(3 0)">
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor" opacity="0.5"/>
<path d="M6,6 L54,54" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
<g transform="translate(3 -1.5) scale(0.9)">
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 510 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
<g transform="translate(3 -1.5) scale(0.9)">
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path opacity="0.25" d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path opacity="0.25" d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 540 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
<g transform="translate(3 -1.5) scale(0.9)">
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<path opacity="0.25" d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 525 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi off">
<g transform="translate(3 -1.5) scale(0.9)">
<circle cx="30" cy="53" r="3" fill="currentColor" opacity="0.5"/>
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
<path d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
<path d="M6,6 L54,54" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M15.2 8.5a5 5 0 0 1 0 7"/>
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0"/>
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0"/>
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

+53
View File
@@ -0,0 +1,53 @@
use crate::{composite, foreground};
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
#[derive(Clone, Copy)]
pub(crate) enum State {
Active,
Hovered,
Pressed,
Disabled,
}
pub(crate) fn accent_button_style(
theme: &cosmic::Theme,
state: State,
focused: bool,
) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
State::Active => component.base.into(),
State::Hovered => component.hover.into(),
State::Pressed => component.pressed.into(),
State::Disabled => Color {
a: 0.5,
..component.base.into()
},
};
let background = composite(fill, native.primary(false).base.into());
let foreground = foreground(background, component.on.into());
button::Style {
background: Some(Background::Color(background)),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: native.corner_radii.radius_xl.into(),
outline_width: if focused { 1. } else { 0. },
outline_color: foreground,
..Default::default()
}
}
/// Filled accent buttons and selected rows share this per-state contrast policy.
pub fn accent_button() -> theme::Button {
theme::Button::Custom {
active: Box::new(|focused, theme| accent_button_style(theme, State::Active, focused)),
hovered: Box::new(|focused, theme| accent_button_style(theme, State::Hovered, focused)),
pressed: Box::new(|focused, theme| accent_button_style(theme, State::Pressed, focused)),
disabled: Box::new(|theme| accent_button_style(theme, State::Disabled, false)),
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Date/time labels and a calendar grid; callers own navigation and popup behavior.
use cosmic::{
Element,
iced::{Alignment, Background, Border, Length},
theme,
widget::{column, container, row},
};
use jiff::Zoned;
pub fn format_bar_time(now: &Zoned) -> String {
let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
];
let hour = now.hour();
let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
format!(
"{} {}, {}:{:02} {}",
now.day(),
months[(now.month() - 1) as usize],
hour12,
now.minute(),
if hour < 12 { "am" } else { "pm" }
)
}
pub fn stacked_digits(label: &str) -> Option<[String; 2]> {
let mut parts = label.split_whitespace();
let time = parts.next()?;
if let Some(period) = parts.next()
&& !period.eq_ignore_ascii_case("am")
&& !period.eq_ignore_ascii_case("pm")
{
return None;
}
if parts.next().is_some() {
return None;
}
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
|| !hour
.bytes()
.chain(minute.bytes())
.all(|digit| digit.is_ascii_digit())
{
return None;
}
Some([format!("{hour:0>2}"), minute.to_owned()])
}
pub fn month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) {
let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
let year = month_index.div_euclid(12) as i16;
let month = (month_index.rem_euclid(12) + 1) as i8;
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0);
let days = usize::try_from(first.days_in_month()).unwrap_or(31);
(first, start, days)
}
pub fn grid<'a, M: Clone + 'a>(
today: jiff::civil::Date,
offset: i32,
palette: crate::Palette,
font: cosmic::font::Font,
opacity: f32,
navigation: [Element<'a, M>; 3],
) -> Element<'a, M> {
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let (first, start, days) = month(today, offset);
let year = first.year();
let month = first.month();
let primary = palette.text.scale_alpha(opacity);
let muted = palette.muted.scale_alpha(opacity);
let [previous, today_button, next] = navigation;
let header = row![
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
.size(15)
.width(Length::Fill),
previous,
today_button,
next,
]
.align_y(Alignment::Center)
.spacing(2);
let mut grid = column::with_capacity(8).spacing(3).push(header);
let mut weekdays = row::with_capacity(7).spacing(3);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(
crate::text(label, font)
.size(11)
.class(theme::Text::Color(muted)),
)
.width(30)
.center_x(30),
);
}
grid = grid.push(weekdays);
for week in 0..(start + days).div_ceil(7) {
let mut dates = row::with_capacity(7).spacing(3);
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(
crate::text(label, font)
.size(12)
.class(theme::Text::Color(if is_today { primary } else { muted })),
)
.width(30)
.height(25)
.center_x(30)
.center_y(25)
.class(theme::Container::custom(move |_| container::Style {
background: is_today
.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
border: Border {
radius: palette.radius.min(12.).into(),
..Default::default()
},
..Default::default()
})),
);
}
grid = grid.push(dates);
}
grid.into()
}
#[cfg(test)]
mod tests {
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert!(super::stacked_digits("14:45:30").is_none());
assert!(super::stacked_digits("Today 14:45").is_none());
assert!(super::stacked_digits("14:45 pm extra").is_none());
}
#[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [
(
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
"28 sept, 9:32 pm",
),
(
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
"28 sept, 12:05 am",
),
(
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
"28 sept, 12:00 pm",
),
] {
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
}
}
#[test]
fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = super::month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = super::month(february, -2);
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = super::month(june, 0);
assert_eq!((start, days), (6, 30));
}
}
+250
View File
@@ -0,0 +1,250 @@
use cosmic::iced::Color;
pub fn luminance(color: Color) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
pub fn contrast(a: Color, b: Color) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Resolve a control fill against its surface before selecting its foreground.
/// The returned fill is opaque, so wallpaper and translucent panels cannot change
/// the contrast of a small, filled control.
pub fn composite(fill: Color, surface: Color) -> Color {
let a = fill.a.clamp(0., 1.);
Color::from_rgb(
fill.r * a + surface.r * (1. - a),
fill.g * a + surface.g * (1. - a),
fill.b * a + surface.b * (1. - a),
)
}
/// Preserve the user's preferred color when it passes normal-text contrast.
/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill.
pub fn foreground(background: Color, preferred: Color) -> Color {
let preferred = composite(preferred, background);
if contrast(background, preferred) >= 4.5 {
return preferred;
}
if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) {
Color::BLACK
} else {
Color::WHITE
}
}
/// Prefer the theme's foreground for filled controls by adjusting the accent's
/// brightness until the text has enough contrast.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=65 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
}
/// Supply the same semantic on-accent colors to native toolkit controls.
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
}
// Native controls reuse one foreground for normal/hover/pressed. Keep their
// hover feedback, but pull an unreadable fill toward the readable normal fill.
fn readable_fill(candidate: Color, base: Color, on: Color) -> Color {
for step in 0..=20 {
let t = step as f32 / 20.;
let fill = Color::from_rgb(
candidate.r + (base.r - candidate.r) * t,
candidate.g + (base.g - candidate.g) * t,
candidate.b + (base.b - candidate.b) * t,
);
if contrast(fill, on) >= 4.5 {
return fill;
}
}
base
}
fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::button::{State, accent_button_style};
use cosmic::iced::Background;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001);
}
#[test]
fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
fn all_accent_hues_and_opacities_have_readable_text() {
for r in (0..=255).step_by(17) {
for g in (0..=255).step_by(17) {
for b in (0..=255).step_by(17) {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
}
}
}
}
#[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn gruvbox_orange_button_keeps_its_light_theme_text_when_enabled() {
let preferred = Color::from_rgb8(235, 219, 178);
let accent = Color::from_rgb8(254, 128, 25);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
let Some(Background::Color(fill)) = actual.background else {
panic!("No button fill")
};
assert!(contrast(fill, preferred) >= 4.5);
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
Color::WHITE,
Color::from_rgb8(61, 123, 230),
Color::from_rgb8(255, 220, 0),
] {
for mut native in [
cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build(),
cosmic::cosmic_theme::ThemeBuilder::light()
.accent(rgba(accent).color)
.build(),
] {
apply(&mut native, Color::from_rgb8(244, 247, 251));
for component in [&native.accent, &native.accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = accent_button_style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
};
assert_eq!(style.text_color, style.icon_color);
assert!(contrast(fill, style.text_color.unwrap()) >= 4.5);
}
}
}
}
}
+229
View File
@@ -0,0 +1,229 @@
use crate::{Palette, mix};
use cosmic::{
iced::{Background, Border, Color, Vector},
theme,
widget::{self, button, container},
};
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| surface_appearance(background, radius))
}
pub fn button_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, false)
}
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, true)
}
pub fn settings_input(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(),
border_radius: p.radius.min(7.).into(),
border_width: if focused { 1. } else { 0. },
border_offset: None,
border_color: p.accent,
icon_color: Some(p.muted),
text_color: Some(p.text),
placeholder_color: p.muted,
selected_text_color: p.sidebar,
selected_fill: p.accent,
label_color: p.muted,
};
theme::TextInput::Custom {
active: Box::new(move |_| appearance(false, false)),
hovered: Box::new(move |_| appearance(false, true)),
focused: Box::new(move |_| appearance(true, true)),
error: Box::new(move |_| appearance(true, false)),
disabled: Box::new(move |_| appearance(false, false)),
}
}
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
let style = move |hover: bool| {
let background = if selected {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover {
mix(p.card, crate::surface_shade(p.sidebar), 0.06)
} else if navigation {
p.sidebar
} else {
p.card
};
let (background, on) = if selected {
crate::accent_pair(background, p.text)
} else {
(background, p.text)
};
button::Style {
background: Some(Background::Color(background)),
text_color: Some(on),
icon_color: Some(on),
border_radius: p.radius.min(9.).into(),
border_width: if selected { 1. } else { 0. },
border_color: if selected {
p.accent
} else {
Color::TRANSPARENT
},
outline_width: 0.,
outline_color: Color::TRANSPARENT,
overlay: None,
shadow_offset: Vector::ZERO,
}
};
theme::Button::Custom {
active: Box::new(move |_, _| style(false)),
hovered: Box::new(move |_, _| style(true)),
pressed: Box::new(move |_, _| style(true)),
disabled: Box::new(move |_| style(false)),
}
}
pub fn authentication_input(palette: Palette) -> theme::TextInput {
let text = palette.text;
let muted = palette.muted;
let accent = palette.accent;
let radius = palette.radius.min(10.);
let appearance = move |focused: bool| widget::text_input::Appearance {
background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(),
border_radius: radius.into(),
border_width: 1.,
border_offset: None,
border_color: if focused {
accent.scale_alpha(0.82)
} else {
muted.scale_alpha(0.26)
},
icon_color: Some(muted),
text_color: Some(text),
placeholder_color: muted,
selected_text_color: text,
selected_fill: accent.scale_alpha(0.35),
label_color: text,
};
theme::TextInput::Custom {
active: Box::new(move |_| appearance(false)),
hovered: Box::new(move |_| appearance(true)),
focused: Box::new(move |_| appearance(true)),
error: Box::new(move |_| appearance(false)),
disabled: Box::new(move |_| appearance(false)),
}
}
pub fn lock_input(
radius: f32,
accent: cosmic::iced::Color,
surface: cosmic::iced::Color,
) -> theme::TextInput {
let appearance = move |focused: bool| widget::text_input::Appearance {
background: cosmic::iced::Color::from_rgba(
(surface.r + 1.) * 0.5,
(surface.g + 1.) * 0.5,
(surface.b + 1.) * 0.5,
0.24,
)
.into(),
border_radius: (radius * 2.).min(26.).into(),
border_width: 1.,
border_offset: None,
border_color: if focused {
accent.scale_alpha(0.7)
} else {
cosmic::iced::Color::WHITE.scale_alpha(0.15)
},
icon_color: Some(cosmic::iced::Color::WHITE.scale_alpha(0.8)),
text_color: Some(cosmic::iced::Color::WHITE),
placeholder_color: cosmic::iced::Color::WHITE.scale_alpha(0.55),
selected_text_color: cosmic::iced::Color::WHITE,
selected_fill: accent.scale_alpha(0.5),
label_color: cosmic::iced::Color::WHITE,
};
theme::TextInput::Custom {
active: Box::new(move |_| appearance(false)),
hovered: Box::new(move |_| appearance(true)),
focused: Box::new(move |_| appearance(true)),
error: Box::new(move |_| appearance(false)),
disabled: Box::new(move |_| appearance(false)),
}
}
pub fn shell_button(
foreground: Color,
selected: bool,
opacity: f32,
radius: f32,
progress: f32,
pressed: bool,
) -> button::Style {
button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: radius.into(),
background: Some(Background::Color(Color {
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
..Default::default()
}
}
pub fn surface_appearance(background: Color, radius: f32) -> container::Style {
container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: radius.into(),
..Default::default()
},
..Default::default()
}
}
pub fn notification_button(
foreground: Color,
hover: Color,
radius: f32,
filled: bool,
) -> theme::Button {
let style = move |active: bool| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
background: (active || filled).then_some(Background::Color(hover)),
border_radius: radius.into(),
..Default::default()
};
theme::Button::Custom {
active: Box::new(move |_, _| style(false)),
hovered: Box::new(move |_, _| style(true)),
pressed: Box::new(move |_, _| style(true)),
disabled: Box::new(move |_| style(false)),
}
}
pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32) -> theme::Button {
let paint = move |outline| button::Style {
background: Some(Color { a: opacity, ..fill }.into()),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: radius.into(),
outline_width: outline,
outline_color: foreground,
..Default::default()
};
theme::Button::Custom {
active: Box::new(move |focused, _| paint(if focused { 1. } else { 0. })),
hovered: Box::new(move |_, _| paint(1.)),
pressed: Box::new(move |_, _| paint(2.)),
disabled: Box::new(move |_| paint(0.)),
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Canonical SVG assets, sizing and semantic tint rules for native Ferese UIs.
use cosmic::{
iced::{Color, ContentFit, Length},
theme,
widget::icon,
};
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg");
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg");
pub const FERESE: &[u8] = include_bytes!("../assets/icons/ferese.svg");
pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg");
pub const AIRPLANE: &[u8] = include_bytes!("../assets/icons/status/airplane.svg");
pub const BATTERY_25: &[u8] = include_bytes!("../assets/icons/status/battery-25.svg");
pub const BATTERY_50: &[u8] = include_bytes!("../assets/icons/status/battery-50.svg");
pub const BATTERY_75: &[u8] = include_bytes!("../assets/icons/status/battery-75.svg");
pub const BATTERY_CHARGING: &[u8] = include_bytes!("../assets/icons/status/battery-charging.svg");
pub const BATTERY_EMPTY: &[u8] = include_bytes!("../assets/icons/status/battery-empty.svg");
pub const BATTERY_FULL: &[u8] = include_bytes!("../assets/icons/status/battery-full.svg");
pub const BLUETOOTH_CONNECTED: &[u8] =
include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_OFF: &[u8] = include_bytes!("../assets/icons/status/bluetooth-off.svg");
pub const BLUETOOTH_ON: &[u8] = include_bytes!("../assets/icons/status/bluetooth-on.svg");
pub const BRIGHTNESS: &[u8] = include_bytes!("../assets/icons/status/brightness.svg");
pub const CALENDAR: &[u8] = include_bytes!("../assets/icons/status/calendar.svg");
pub const CHEVRON_DOWN: &[u8] = include_bytes!("../assets/icons/status/chevron-down.svg");
pub const CHEVRON_UP: &[u8] = include_bytes!("../assets/icons/status/chevron-up.svg");
pub const CLOSE: &[u8] = include_bytes!("../assets/icons/status/close.svg");
pub const CONTROL_CENTER: &[u8] = include_bytes!("../assets/icons/status/control-center.svg");
pub const DND: &[u8] = include_bytes!("../assets/icons/status/dnd.svg");
pub const ETHERNET: &[u8] = include_bytes!("../assets/icons/status/ethernet.svg");
pub const LOCK: &[u8] = include_bytes!("../assets/icons/status/lock.svg");
pub const MIC_MUTE: &[u8] = include_bytes!("../assets/icons/status/mic-mute.svg");
pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg");
pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg");
pub const NOTIFICATIONS_OFF: &[u8] = include_bytes!("../assets/icons/status/notifications-off.svg");
pub const NOTIFICATIONS: &[u8] = include_bytes!("../assets/icons/status/notifications.svg");
pub const POWER_BALANCED: &[u8] = include_bytes!("../assets/icons/status/power-balanced.svg");
pub const POWER_OFF: &[u8] = include_bytes!("../assets/icons/status/power-off.svg");
pub const POWER_PERFORMANCE: &[u8] = include_bytes!("../assets/icons/status/power-performance.svg");
pub const POWER_SAVER: &[u8] = include_bytes!("../assets/icons/status/power-saver.svg");
pub const POWER: &[u8] = include_bytes!("../assets/icons/status/power.svg");
pub const RECORD_CANCEL: &[u8] = include_bytes!("../assets/icons/status/record-cancel.svg");
pub const RECORD_SAVING: &[u8] = include_bytes!("../assets/icons/status/record-saving.svg");
pub const RECORD_STOP: &[u8] = include_bytes!("../assets/icons/status/record-stop.svg");
pub const RECORD: &[u8] = include_bytes!("../assets/icons/status/record.svg");
pub const RESTART: &[u8] = include_bytes!("../assets/icons/status/restart.svg");
pub const SETTINGS: &[u8] = include_bytes!("../assets/icons/status/settings.svg");
pub const SUSPEND: &[u8] = include_bytes!("../assets/icons/status/suspend.svg");
pub const TRASH: &[u8] = include_bytes!("../assets/icons/status/trash.svg");
pub const VOLUME_HIGH: &[u8] = include_bytes!("../assets/icons/status/volume-high.svg");
pub const VOLUME_LOW: &[u8] = include_bytes!("../assets/icons/status/volume-low.svg");
pub const VOLUME_MEDIUM: &[u8] = include_bytes!("../assets/icons/status/volume-medium.svg");
pub const VOLUME_MUTE: &[u8] = include_bytes!("../assets/icons/status/volume-mute.svg");
pub const WIFI_FULL: &[u8] = include_bytes!("../assets/icons/status/wifi-full.svg");
pub const WIFI_LOW: &[u8] = include_bytes!("../assets/icons/status/wifi-low.svg");
pub const WIFI_MEDIUM: &[u8] = include_bytes!("../assets/icons/status/wifi-medium.svg");
pub const WIFI_OFF: &[u8] = include_bytes!("../assets/icons/status/wifi-off.svg");
pub const VOLUME: &[u8] = include_bytes!("../assets/icons/volume.svg");
pub const WIFI: &[u8] = include_bytes!("../assets/icons/wifi.svg");
pub const APPLICATION: &[u8] = include_bytes!("../../../packaging/icons/ferese.svg");
pub fn tinted(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
accented(source, size, foreground, foreground)
}
pub fn symbolic(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon {
let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8");
// Battery canvases are wider; reserve that space instead of stretching
// or clipping them into the square slot used by the other status icons.
let aspect = if svg.contains("viewBox=\"0 0 32 24\"") {
4.0 / 3.0
} else {
1.0
};
icon::from_svg_bytes(tint_svg(source, foreground, accent))
.symbolic(false)
.icon()
.size(size)
.width(Length::Fixed(f32::from(size) * aspect))
.content_fit(ContentFit::Contain)
.class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style { color: None }
}))
}
pub fn tint_svg(source: &[u8], foreground: Color, accent: Color) -> Vec<u8> {
let rgba = |foreground: Color| {
format!(
"rgba({},{},{},{})",
(foreground.r * 255.0).round() as u8,
(foreground.g * 255.0).round() as u8,
(foreground.b * 255.0).round() as u8,
foreground.a,
)
};
std::str::from_utf8(source)
.expect("embedded icons must be UTF-8 SVG")
.replace("currentColor", &rgba(foreground))
// The source artwork's blue swatch is the semantic theme accent.
// Battery warning/success swatches remain status colors.
.replace("#3d7be6", &rgba(accent))
.into_bytes()
}
pub fn outline(path: &str, tint: Color, size: u16) -> icon::Icon {
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="{path}" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>"##,
);
icon::from_svg_bytes(tint_svg(svg.as_bytes(), tint, tint))
.symbolic(false)
.icon()
.size(size)
}
#[cfg(test)]
mod tests {
use cosmic::iced::Color;
#[test]
fn icons_use_theme_accent_and_preserve_battery_status_colors() {
for (source, accent) in [
(super::BATTERY_FULL, "#63c168"),
(super::BATTERY_50, "#3d7be6"),
(super::BATTERY_25, "#e0654f"),
(super::WIFI_FULL, "#3d7be6"),
(super::CONTROL_CENTER, "#3d7be6"),
] {
let svg = String::from_utf8(super::tint_svg(
source,
Color::from_rgb8(205, 214, 244),
Color::from_rgb8(203, 166, 247),
))
.unwrap();
assert!(!svg.contains("currentColor"));
assert!(svg.contains("rgba(205,214,244,1)"));
assert!(!svg.contains("#3d7be6"));
assert!(svg.contains(if accent == "#3d7be6" {
"rgba(203,166,247,1)"
} else {
accent
}));
}
}
}
+16 -291
View File
@@ -1,292 +1,17 @@
//! Ferese owns foreground contrast; toolkit defaults must not choose it separately.
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
//! Shared colors, typography, controls and compositor materials for Ferese applications.
mod button;
pub mod calendar;
mod contrast;
pub mod controls;
pub mod icons;
pub mod material;
pub mod menus;
mod palette;
mod presets;
mod typography;
pub fn luminance(color: Color) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
pub fn contrast(a: Color, b: Color) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Resolve a control fill against its surface before selecting its foreground.
/// The returned fill is opaque, so wallpaper and translucent panels cannot change
/// the contrast of a small, filled control.
pub fn composite(fill: Color, surface: Color) -> Color {
let a = fill.a.clamp(0., 1.);
Color::from_rgb(
fill.r * a + surface.r * (1. - a),
fill.g * a + surface.g * (1. - a),
fill.b * a + surface.b * (1. - a),
)
}
/// Preserve the user's preferred color when it passes normal-text contrast.
/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill.
pub fn foreground(background: Color, preferred: Color) -> Color {
let preferred = composite(preferred, background);
if contrast(background, preferred) >= 4.5 {
return preferred;
}
if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) {
Color::BLACK
} else {
Color::WHITE
}
}
/// Prefer the theme's foreground for filled controls by adjusting the accent's
/// brightness until the text has enough contrast.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=65 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
}
/// Supply the same semantic on-accent colors to native toolkit controls.
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
}
// Native controls reuse one foreground for normal/hover/pressed. Keep their
// hover feedback, but pull an unreadable fill toward the readable normal fill.
fn readable_fill(candidate: Color, base: Color, on: Color) -> Color {
for step in 0..=20 {
let t = step as f32 / 20.;
let fill = Color::from_rgb(
candidate.r + (base.r - candidate.r) * t,
candidate.g + (base.g - candidate.g) * t,
candidate.b + (base.b - candidate.b) * t,
);
if contrast(fill, on) >= 4.5 {
return fill;
}
}
base
}
fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a)
}
#[derive(Clone, Copy)]
enum State {
Active,
Hovered,
Pressed,
Disabled,
}
fn style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
State::Active => component.base.into(),
State::Hovered => component.hover.into(),
State::Pressed => component.pressed.into(),
State::Disabled => Color {
a: 0.5,
..component.base.into()
},
};
let background = composite(fill, native.primary(false).base.into());
let foreground = foreground(background, component.on.into());
button::Style {
background: Some(Background::Color(background)),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: native.corner_radii.radius_xl.into(),
outline_width: if focused { 1. } else { 0. },
outline_color: foreground,
..Default::default()
}
}
/// Filled accent buttons and selected rows share this per-state contrast policy.
pub fn accent_button() -> theme::Button {
theme::Button::Custom {
active: Box::new(|focused, theme| style(theme, State::Active, focused)),
hovered: Box::new(|focused, theme| style(theme, State::Hovered, focused)),
pressed: Box::new(|focused, theme| style(theme, State::Pressed, focused)),
disabled: Box::new(|theme| style(theme, State::Disabled, false)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001);
}
#[test]
fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
fn all_accent_hues_and_opacities_have_readable_text() {
for r in (0..=255).step_by(17) {
for g in (0..=255).step_by(17) {
for b in (0..=255).step_by(17) {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
}
}
}
}
#[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn gruvbox_orange_button_keeps_its_light_theme_text_when_enabled() {
let preferred = Color::from_rgb8(235, 219, 178);
let accent = Color::from_rgb8(254, 128, 25);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
let Some(Background::Color(fill)) = actual.background else {
panic!("No button fill")
};
assert!(contrast(fill, preferred) >= 4.5);
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
Color::WHITE,
Color::from_rgb8(61, 123, 230),
Color::from_rgb8(255, 220, 0),
] {
for mut native in [
cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build(),
cosmic::cosmic_theme::ThemeBuilder::light()
.accent(rgba(accent).color)
.build(),
] {
apply(&mut native, Color::from_rgb8(244, 247, 251));
for component in [&native.accent, &native.accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
};
assert_eq!(style.text_color, style.icon_color);
assert!(contrast(fill, style.text_color.unwrap()) >= 4.5);
}
}
}
}
}
pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset};
pub use typography::{font, text};
+121
View File
@@ -0,0 +1,121 @@
use std::sync::Arc;
use ferese_protocols::material::v1::client::{
ferese_material_manager_v1::FereseMaterialManagerV1,
ferese_surface_material_v1::FereseSurfaceMaterialV1,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface},
};
/// Keeps a compositor-owned dialog material attached for the window lifetime.
#[derive(Clone, Debug)]
pub struct ModalMaterial {
_binding: Arc<Binding>,
}
#[derive(Debug)]
struct Binding {
connection: Connection,
manager: FereseMaterialManagerV1,
material: FereseSurfaceMaterialV1,
}
impl ModalMaterial {
/// Attach while Iced owns the native window; keep its background transparent on success.
/// Other compositors return an error so the caller can retain its own surface fill.
pub fn attach(window: &dyn cosmic::iced::window::Window) -> Result<Self, String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let display = window.display_handle().map_err(|error| error.to_string())?;
let handle = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
(display.as_raw(), handle.as_raw())
else {
return Err("Dialog materials require Wayland".into());
};
// Iced lends live native handles; this backend borrows its connection.
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
wl_surface::WlSurface::interface(),
handle.surface.as_ptr().cast(),
)
}
.map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<MaterialState>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
let manager = globals
.bind::<FereseMaterialManagerV1, _, _>(&qh, 1..=1, ())
.map_err(|error| error.to_string())?;
let material = manager.get_surface_material(&surface, &qh, ());
let binding = Binding {
connection,
manager,
material,
};
let mut state = MaterialState::default();
queue
.roundtrip(&mut state)
.map_err(|error| error.to_string())?;
if !state.ready {
return Err("Dialog material attachment was not acknowledged".into());
}
Ok(Self {
_binding: Arc::new(binding),
})
}
}
impl Drop for Binding {
fn drop(&mut self) {
self.material.destroy();
self.manager.destroy();
let _ = self.connection.flush();
}
}
#[derive(Default)]
struct MaterialState {
ready: bool,
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for MaterialState {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
impl Dispatch<FereseSurfaceMaterialV1, ()> for MaterialState {
fn event(
state: &mut Self,
_material: &FereseSurfaceMaterialV1,
event: ferese_protocols::material::v1::client::ferese_surface_material_v1::Event,
_data: &(),
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
if matches!(
event,
ferese_protocols::material::v1::client::ferese_surface_material_v1::Event::Ready
) {
state.ready = true;
}
}
}
delegate_noop!(MaterialState: ignore FereseMaterialManagerV1);
+176
View File
@@ -0,0 +1,176 @@
//! Shared menu layouts and paint; callers own actions and animation timing.
use cosmic::{
Element,
iced::{Alignment, Color, Length},
widget::{button, container, row as iced_row},
};
pub fn button<'a, M: Clone + 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
message: M,
font: cosmic::font::Font,
) -> button::Button<'a, M> {
button::custom(crate::text(label, font).size(13))
.padding([6, 8])
.on_press(message)
}
pub fn row<'a, M: 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
trailing: Element<'a, M>,
font: cosmic::font::Font,
) -> Element<'a, M> {
iced_row![
crate::text(label, font).width(Length::Fill).size(14),
trailing
]
.align_y(Alignment::Center)
.into()
}
pub fn heading<'a, M: 'a>(
badge: Element<'a, M>,
title: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
) -> cosmic::widget::Row<'a, M, cosmic::Theme, cosmic::Renderer> {
iced_row![badge, crate::text(title, font).size(18).width(Length::Fill)]
.spacing(10)
.align_y(Alignment::Center)
}
pub fn badge<'a, M: 'a>(
content: Element<'a, M>,
accent: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.width(36)
.height(36)
.center_x(36)
.center_y(36)
.class(crate::controls::surface(
accent.scale_alpha(0.12 * opacity),
radius.min(20.),
))
.into()
}
pub fn section<'a, M: 'a>(
content: Element<'a, M>,
foreground: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.padding(10)
.width(Length::Fill)
.class(crate::controls::surface(
Color {
a: 0.045 * opacity,
..foreground
},
radius.min(22.),
))
.into()
}
pub fn section_label<'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
muted: Color,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
crate::text(label, font)
.size(13)
.class(cosmic::theme::Text::Color(muted))
}
pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
container(cosmic::widget::Space::new().width(Length::Fill).height(1))
.class(crate::controls::surface(
foreground.scale_alpha(0.12 * opacity),
0.,
))
.into()
}
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
let track = if enabled {
palette.accent
} else {
Color {
a: 0.22,
..palette.text
}
};
let thumb = if enabled {
crate::foreground(track, palette.text)
} else {
palette.text
};
let knob = container(cosmic::widget::Space::new().width(16).height(16))
.width(16)
.height(16)
.class(crate::controls::surface(
Color {
a: opacity,
..thumb
},
palette.radius.min(8.),
));
container(knob)
.width(36)
.height(20)
.padding(2)
.align_x(if enabled {
cosmic::iced::alignment::Horizontal::Right
} else {
cosmic::iced::alignment::Horizontal::Left
})
.class(crate::controls::surface(
track.scale_alpha(opacity),
palette.radius.min(10.),
))
.into()
}
pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::iced::Slider {
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
Style {
rail: Rail {
backgrounds: (
foreground.into(),
Color {
a: 0.16 * opacity,
..foreground
}
.into(),
),
width: 4.,
border: cosmic::iced::Border {
radius: radius.min(2.).into(),
..Default::default()
},
},
handle: Handle {
shape: HandleShape::Rectangle {
width: 10,
height: 10,
border_radius: radius.min(5.).into(),
},
background: foreground.into(),
border_width: 0.,
border_color: Color::TRANSPARENT,
},
breakpoint: Breakpoint {
color: Color::TRANSPARENT,
},
}
});
cosmic::theme::iced::Slider::Custom {
active: style.clone(),
hovered: style.clone(),
dragging: style,
}
}
+189
View File
@@ -0,0 +1,189 @@
use cosmic::iced::Color;
use ferese_config::Document;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
pub background: Color,
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub text: Color,
pub muted: Color,
pub error: Color,
pub radius: f32,
}
impl Palette {
pub fn from_document(document: Option<&Document>) -> Self {
let color = |key, fallback| {
document
.and_then(|document| document.get(key))
.and_then(|value| value.as_str())
.and_then(parse_color)
.unwrap_or_else(|| parse_color(fallback).expect("valid default theme color"))
};
let base = color("theme.colors.surface_base", "#111821");
Self {
background: mix(base, surface_shade(base), 0.025),
sidebar: base,
card: mix(base, surface_shade(base), 0.055),
accent: color("theme.colors.accent", "#3D7BE6"),
text: color("theme.colors.text_primary", "#F4F7FB"),
muted: color("theme.colors.text_muted", "#8793A2"),
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
radius: document
.and_then(|document| {
document
.get("theme.geometry.shell_radius")
.or_else(|| document.get("appearance.corner_radius"))
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(14.)
.clamp(0., 64.) as f32,
}
}
pub fn flat(mut self) -> Self {
self.background = self.sidebar;
self.card = self.sidebar;
self
}
pub fn native_theme(self) -> cosmic::Theme {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
let builder = if crate::luminance(self.sidebar) > 0.5 {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
};
let corners = cosmic::cosmic_theme::CornerRadii {
radius_xs: [self.radius.min(4.); 4],
radius_s: [self.radius.min(8.); 4],
radius_m: [self.radius; 4],
radius_l: [self.radius; 4],
radius_xl: [self.radius; 4],
radius_0: Default::default(),
};
let mut native = builder
.corner_radii(corners)
.bg_color(rgba(self.background))
.primary_container_bg(rgba(self.card))
.text_tint(rgba(self.text).color)
.accent(crate::accent_color(self.accent, self.card))
.build();
crate::apply(&mut native, self.text);
cosmic::Theme::custom(std::sync::Arc::new(native))
}
pub fn application_style(self, background: Color) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: background,
text_color: self.text,
icon_color: self.text,
}
}
}
pub fn parse_color(value: &str) -> Option<Color> {
let hex = value.strip_prefix('#')?;
let packed = u32::from_str_radix(hex, 16).ok()?;
let rgba = match hex.len() {
6 => (packed << 8) | 255,
8 => packed,
_ => return None,
};
Some(Color::from_rgba8(
(rgba >> 24) as u8,
(rgba >> 16) as u8,
(rgba >> 8) as u8,
(rgba & 255) as f32 / 255.,
))
}
pub fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
)
}
pub fn surface_shade(base: Color) -> Color {
if crate::luminance(base) > 0.5 {
Color::BLACK
} else {
Color::WHITE
}
}
pub fn material_opacity(document: Option<&Document>) -> f32 {
let style = document
.and_then(|document| document.get("theme.material.style"))
.and_then(|value| value.as_str())
.unwrap_or("solid");
if style != "translucent" {
return 1.;
}
document
.and_then(|document| document.get("theme.material.opacity"))
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.) as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS {
let document = Document::parse(&format!(
r##"theme {{
colors {{
surface-base "{}"
accent "{}"
text-primary "{}"
text-muted "{}"
}}
material {{ style translucent; opacity 0.25; }}
geometry {{ shell-radius 0; }}
}}"##,
preset.base, preset.accent, preset.text, preset.muted,
))
.unwrap();
let palette = Palette::from_document(Some(&document));
let native = palette.native_theme();
assert_eq!(material_opacity(Some(&document)), 0.25);
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(
crate::contrast(fill.into(), component.on.into()) >= 4.5,
"{}",
preset.name
);
}
}
}
}
#[test]
fn solid_materials_ignore_opacity_and_translucent_materials_bound_it() {
for (style, opacity, expected) in [
("solid", 0.4, 1.),
("translucent", 0.4, 0.4),
("translucent", 2., 1.),
("translucent", -1., 0.),
] {
let document = Document::parse(&format!(
"theme {{ material {{ style {style}; opacity {opacity}; }} }}"
))
.unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
pub struct Preset {
pub name: &'static str,
pub description: &'static str,
pub accent: &'static str,
pub base: &'static str,
pub text: &'static str,
pub muted: &'static str,
pub border: &'static str,
pub gradient_end: &'static str,
}
pub const PRESETS: [Preset; 6] = [
Preset {
name: "Ferese Blue",
description: "Default · logo blue",
accent: "#3D7BE6",
base: "#111821",
text: "#F4F7FB",
muted: "#8793A2",
border: "#FFFFFF18",
gradient_end: "#3D7BE6",
},
Preset {
name: "Monochrome",
description: "Dark · grayscale",
accent: "#E5E5E5",
base: "#101012",
text: "#EDEDF0",
muted: "#97979F",
border: "#FFFFFF18",
gradient_end: "#696973",
},
Preset {
name: "Gruvbox",
description: "Dark · warm orange",
accent: "#FE8019",
base: "#282828",
text: "#EBDBB2",
muted: "#BDAE93",
border: "#504945",
gradient_end: "#FABD2F",
},
Preset {
name: "Dracula",
description: "Dark · purple",
accent: "#BD93F9",
base: "#282A36",
text: "#F8F8F2",
muted: "#A4ADCD",
border: "#44475A",
gradient_end: "#FF79C6",
},
Preset {
name: "Ayu Light",
description: "Light · paper",
accent: "#8F5300",
base: "#F8F9FA",
text: "#5C6166",
muted: "#596574",
border: "#C8CDD3",
gradient_end: "#996000",
},
Preset {
name: "Monokai",
description: "Dark · warm green",
accent: "#A6E22E",
base: "#272822",
text: "#F8F8F2",
muted: "#B2B29F",
border: "#414339",
gradient_end: "#E6DB74",
},
];
+30
View File
@@ -0,0 +1,30 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
pub fn font(family: Option<&str>) -> cosmic::font::Font {
let Some(family) = family else {
return cosmic::font::default();
};
static FONTS: OnceLock<Mutex<HashMap<String, cosmic::font::Font>>> = OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let name = Box::leak(family.to_owned().into_boxed_str());
let font = cosmic::font::Font::with_name(name);
fonts.insert(family.to_owned(), font);
font
}
/// COSMIC's text helper overrides Settings::default_font; set the Ferese font explicitly.
pub fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
cosmic::widget::text(content).font(font)
}