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.
No files matched your search
@@ -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
|
||||
@@ -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 |
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.)),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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)
|
||||
}
|
||||