feat(theme): themes as files (theme crate: toml subset + ints, contrast check, wire); whimsy dark + light; display reads themes/, serves THEME/THEME_NAME, init SET_THEME -> THEME_CHANGED to all windows, bar themed; k-ui follows; settings appearance; init keeps choice in sealed users/<id>/settings/, reapplies at sign-in + display restart
This commit is contained in:
9
crates/theme/Cargo.toml
Normal file
9
crates/theme/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "theme"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
types = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
libm = "0.2"
|
||||
50
crates/theme/src/contrast.rs
Normal file
50
crates/theme/src/contrast.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Contrast as WCAG 2 measures it: the ratio of two colours' relative
|
||||
//! luminance, from 1 (the same) to 21 (black on white). Text needs 4.5,
|
||||
//! other things the eye must find (the focus ring, a toggle's track) 3.
|
||||
|
||||
use crate::parse::Error;
|
||||
use crate::{Rgba, Theme};
|
||||
|
||||
fn channel(c: u8) -> f32 {
|
||||
let c = c as f32 / 255.0;
|
||||
if c <= 0.040_45 { c / 12.92 } else { libm::powf((c + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
fn luminance(c: Rgba) -> f32 {
|
||||
0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2)
|
||||
}
|
||||
|
||||
pub fn ratio(a: Rgba, b: Rgba) -> f32 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
const TEXT: f32 = 4.5;
|
||||
const SHAPE: f32 = 3.0;
|
||||
|
||||
/// Every pairing K-UI draws, each with what it must reach.
|
||||
fn pairings(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 10] {
|
||||
[
|
||||
("text_primary on background", t.text_primary, t.background, TEXT),
|
||||
("text_primary on surface", t.text_primary, t.surface, TEXT),
|
||||
("text_primary on surface_raised", t.text_primary, t.surface_raised, TEXT),
|
||||
("text_secondary on background", t.text_secondary, t.background, TEXT),
|
||||
("text_secondary on surface", t.text_secondary, t.surface, TEXT),
|
||||
("accent on background", t.accent, t.background, TEXT),
|
||||
("on_accent on accent", t.on_accent, t.accent, TEXT),
|
||||
("on_destructive on destructive", t.on_destructive, t.destructive, TEXT),
|
||||
("accent on surface", t.accent, t.surface, SHAPE),
|
||||
("focus on background", t.focus, t.background, SHAPE),
|
||||
]
|
||||
}
|
||||
|
||||
/// The theme, if everything drawn with it can be read.
|
||||
pub fn check(t: Theme) -> Result<Theme, Error> {
|
||||
for (pair, fg, bg, min) in pairings(&t) {
|
||||
let r = ratio(fg, bg);
|
||||
if r < min {
|
||||
return Err(Error::Contrast { pair, ratio_x100: (r * 100.0) as u32 });
|
||||
}
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
202
crates/theme/src/lib.rs
Normal file
202
crates/theme/src/lib.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! A theme's tokens and the words apps describe their UI in (see the
|
||||
//! vision's K-UI note): apps say what a thing is (a primary button, a
|
||||
//! heading), never its colour or size. Themes are files (`themes/*.toml`
|
||||
//! on the system image), read strictly (`parse`) and refused unless every
|
||||
//! pairing text is drawn in has enough contrast (`check`). The display
|
||||
//! holds the one in use and hands it to every window (`wire`). Pure.
|
||||
|
||||
#![no_std]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod contrast;
|
||||
mod parse;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use contrast::{check, ratio};
|
||||
pub use parse::{Error, parse};
|
||||
|
||||
/// A colour as the theme names it: straight (not premultiplied) RGBA.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgba(pub u8, pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgba {
|
||||
pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
|
||||
Self(r, g, b, 255)
|
||||
}
|
||||
|
||||
/// From `0xrrggbb`.
|
||||
pub const fn rgb(v: u32) -> Self {
|
||||
Self::opaque((v >> 16) as u8, (v >> 8) as u8, v as u8)
|
||||
}
|
||||
|
||||
pub const fn to_rgb(self) -> u32 {
|
||||
(self.0 as u32) << 16 | (self.1 as u32) << 8 | self.2 as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps of the spacing scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Spacing {
|
||||
None,
|
||||
Xs,
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
Xl,
|
||||
}
|
||||
|
||||
/// Steps of the type scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TextStyle {
|
||||
Heading,
|
||||
Subheading,
|
||||
Body,
|
||||
Caption,
|
||||
Label,
|
||||
}
|
||||
|
||||
/// What a text is, for its colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TextColour {
|
||||
Primary,
|
||||
Secondary,
|
||||
Accent,
|
||||
OnAccent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
Primary,
|
||||
Secondary,
|
||||
Ghost,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weight {
|
||||
Regular,
|
||||
SemiBold,
|
||||
}
|
||||
|
||||
/// A theme's name: what Settings lists and a user's choice records.
|
||||
pub const NAME_BYTES: usize = types::name::BYTES;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Theme {
|
||||
pub name: [u8; NAME_BYTES],
|
||||
pub name_len: usize,
|
||||
pub background: Rgba,
|
||||
pub surface: Rgba,
|
||||
pub surface_raised: Rgba,
|
||||
pub accent: Rgba,
|
||||
pub on_accent: Rgba,
|
||||
pub text_primary: Rgba,
|
||||
pub text_secondary: Rgba,
|
||||
pub destructive: Rgba,
|
||||
pub on_destructive: Rgba,
|
||||
pub focus: Rgba,
|
||||
/// Soft colours the wallpaper glows with, behind the glass.
|
||||
pub orbs: [Rgba; 3],
|
||||
pub corner_radius: f32,
|
||||
/// The spacing scale's unit, in pixels.
|
||||
pub spacing_base: f32,
|
||||
/// Milliseconds; zero turns motion off.
|
||||
pub duration_fast: u16,
|
||||
pub duration_normal: u16,
|
||||
pub spring_stiffness: u16,
|
||||
pub spring_damping: u16,
|
||||
/// Pixels.
|
||||
pub blur_radius: u8,
|
||||
/// Percent.
|
||||
pub panel_opacity: u8,
|
||||
pub highlight: u8,
|
||||
}
|
||||
|
||||
pub(crate) const fn name_bytes(s: &[u8]) -> ([u8; NAME_BYTES], usize) {
|
||||
let mut out = [0u8; NAME_BYTES];
|
||||
let mut i = 0;
|
||||
while i < s.len() && i < NAME_BYTES {
|
||||
out[i] = s[i];
|
||||
i += 1;
|
||||
}
|
||||
(out, i)
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// What a window shows before the display has said, and what the
|
||||
/// display uses when no theme file loads; `disk/themes/whimsy-dark.toml`
|
||||
/// says the same (a test keeps them so).
|
||||
pub const WHIMSY_DARK: Theme = Theme {
|
||||
name: name_bytes(b"Whimsy Dark").0,
|
||||
name_len: name_bytes(b"Whimsy Dark").1,
|
||||
background: Rgba::rgb(0x0f0e17),
|
||||
surface: Rgba::rgb(0x1a1a2e),
|
||||
surface_raised: Rgba::rgb(0x2a2a44),
|
||||
accent: Rgba::rgb(0x4fc3f7),
|
||||
on_accent: Rgba::rgb(0x0f0e17),
|
||||
text_primary: Rgba::rgb(0xe0e0e0),
|
||||
text_secondary: Rgba::rgb(0x9e9eb8),
|
||||
destructive: Rgba::rgb(0xef5350),
|
||||
on_destructive: Rgba::rgb(0x1a0a0a),
|
||||
focus: Rgba::rgb(0xffffff),
|
||||
orbs: [Rgba::rgb(0x3a1c71), Rgba::rgb(0x0f4c5c), Rgba::rgb(0x5b1a4a)],
|
||||
corner_radius: 16.0,
|
||||
spacing_base: 4.0,
|
||||
duration_fast: 150,
|
||||
duration_normal: 280,
|
||||
spring_stiffness: 300,
|
||||
spring_damping: 30,
|
||||
blur_radius: 12,
|
||||
panel_opacity: 72,
|
||||
highlight: 18,
|
||||
};
|
||||
|
||||
pub fn name(&self) -> &[u8] {
|
||||
&self.name[..self.name_len]
|
||||
}
|
||||
|
||||
pub fn space(&self, s: Spacing) -> f32 {
|
||||
self.spacing_base
|
||||
* match s {
|
||||
Spacing::None => 0.0,
|
||||
Spacing::Xs => 1.0,
|
||||
Spacing::Sm => 2.0,
|
||||
Spacing::Md => 4.0,
|
||||
Spacing::Lg => 6.0,
|
||||
Spacing::Xl => 8.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text style's size in pixels and its weight.
|
||||
pub fn text(&self, s: TextStyle) -> (f32, Weight) {
|
||||
match s {
|
||||
TextStyle::Heading => (32.0, Weight::SemiBold),
|
||||
TextStyle::Subheading => (22.0, Weight::SemiBold),
|
||||
TextStyle::Body => (17.0, Weight::Regular),
|
||||
TextStyle::Caption => (13.0, Weight::Regular),
|
||||
TextStyle::Label => (17.0, Weight::SemiBold),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_colour(&self, c: TextColour) -> Rgba {
|
||||
match c {
|
||||
TextColour::Primary => self.text_primary,
|
||||
TextColour::Secondary => self.text_secondary,
|
||||
TextColour::Accent => self.accent,
|
||||
TextColour::OnAccent => self.on_accent,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button's fill (none for a ghost) and its label's colour.
|
||||
pub fn button(&self, v: ButtonVariant) -> (Option<Rgba>, Rgba) {
|
||||
match v {
|
||||
ButtonVariant::Primary => (Some(self.accent), self.on_accent),
|
||||
ButtonVariant::Secondary => (Some(self.surface_raised), self.text_primary),
|
||||
ButtonVariant::Ghost => (None, self.accent),
|
||||
ButtonVariant::Destructive => (Some(self.destructive), self.on_destructive),
|
||||
}
|
||||
}
|
||||
}
|
||||
126
crates/theme/src/parse.rs
Normal file
126
crates/theme/src/parse.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! A theme file, read strictly: every token present once, nothing unknown,
|
||||
//! every value in range. Contrast is `check`'s.
|
||||
|
||||
use manifest::Error as Toml;
|
||||
use manifest::toml::{self, Value};
|
||||
|
||||
use crate::{NAME_BYTES, Rgba, Theme};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not the TOML subset, a token given twice or unknown, or out of range.
|
||||
Toml(Toml),
|
||||
/// Two colours drawn on one another are too close: the pairing, and
|
||||
/// the ratio they have, times 100.
|
||||
Contrast { pair: &'static str, ratio_x100: u32 },
|
||||
}
|
||||
|
||||
const COLOURS: [&str; 10] = [
|
||||
"background",
|
||||
"surface",
|
||||
"surface_raised",
|
||||
"accent",
|
||||
"on_accent",
|
||||
"text_primary",
|
||||
"text_secondary",
|
||||
"destructive",
|
||||
"on_destructive",
|
||||
"focus",
|
||||
];
|
||||
|
||||
/// The whole numbers: table, key, and the range allowed.
|
||||
const NUMBERS: [(&str, &str, u32, u32); 9] = [
|
||||
("shape", "corner_radius", 0, 32),
|
||||
("spacing", "base", 2, 8),
|
||||
("motion", "duration_fast", 0, 2000),
|
||||
("motion", "duration_normal", 0, 2000),
|
||||
("motion", "spring_stiffness", 1, 2000),
|
||||
("motion", "spring_damping", 1, 200),
|
||||
("glass", "blur_radius", 0, 32),
|
||||
("glass", "panel_opacity", 0, 100),
|
||||
("glass", "highlight", 0, 100),
|
||||
];
|
||||
|
||||
fn colour(s: &str) -> Option<Rgba> {
|
||||
manifest::Accent::parse(s).map(|a| Rgba::rgb(a.0))
|
||||
}
|
||||
|
||||
fn once<T>(slot: &mut Option<T>, value: Option<T>, line: usize) -> Result<(), Toml> {
|
||||
if slot.is_some() {
|
||||
return Err(Toml::Duplicate { line });
|
||||
}
|
||||
*slot = Some(value.ok_or(Toml::Invalid { line })?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a theme file. It is not checked for contrast here.
|
||||
pub fn parse(text: &str) -> Result<Theme, Error> {
|
||||
let mut name: Option<([u8; NAME_BYTES], usize)> = None;
|
||||
let mut colours: [Option<Rgba>; 10] = [None; 10];
|
||||
let mut orbs: Option<[Rgba; 3]> = None;
|
||||
let mut numbers: [Option<u32>; 9] = [None; 9];
|
||||
toml::parse(text, |e| {
|
||||
let line = e.line;
|
||||
match (e.table, e.key, e.value) {
|
||||
("meta", "name", Value::Str(s)) => {
|
||||
let fits = (1..=NAME_BYTES).contains(&s.len());
|
||||
once(&mut name, fits.then(|| crate::name_bytes(s.as_bytes())), line)
|
||||
}
|
||||
("colour", key, Value::Str(s)) => {
|
||||
let i = COLOURS.iter().position(|k| *k == key).ok_or(Toml::Unknown { line })?;
|
||||
once(&mut colours[i], colour(s), line)
|
||||
}
|
||||
("wallpaper", "orbs", Value::List(list)) => {
|
||||
let mut out = [Rgba::rgb(0); 3];
|
||||
let mut n = 0;
|
||||
for s in list.iter() {
|
||||
*out.get_mut(n).ok_or(Toml::Invalid { line })? = colour(s).ok_or(Toml::Invalid { line })?;
|
||||
n += 1;
|
||||
}
|
||||
once(&mut orbs, (n == 3).then_some(out), line)
|
||||
}
|
||||
(table, key, Value::Int(v)) => {
|
||||
let i = NUMBERS.iter().position(|(t, k, ..)| *t == table && *k == key).ok_or(Toml::Unknown { line })?;
|
||||
let (_, _, lo, hi) = NUMBERS[i];
|
||||
once(&mut numbers[i], (lo..=hi).contains(&v).then_some(v), line)
|
||||
}
|
||||
("meta" | "colour" | "wallpaper" | "shape" | "spacing" | "motion" | "glass", _, _) => Err(Toml::Invalid { line }),
|
||||
_ => Err(Toml::Unknown { line }),
|
||||
}
|
||||
})
|
||||
.map_err(Error::Toml)?;
|
||||
let (name, name_len) = name.ok_or(Error::Toml(Toml::Missing("name")))?;
|
||||
let mut c = [Rgba::rgb(0); 10];
|
||||
for (i, slot) in colours.iter().enumerate() {
|
||||
c[i] = slot.ok_or(Error::Toml(Toml::Missing(COLOURS[i])))?;
|
||||
}
|
||||
let mut n = [0u32; 9];
|
||||
for (i, slot) in numbers.iter().enumerate() {
|
||||
n[i] = slot.ok_or(Error::Toml(Toml::Missing(NUMBERS[i].1)))?;
|
||||
}
|
||||
Ok(Theme {
|
||||
name,
|
||||
name_len,
|
||||
background: c[0],
|
||||
surface: c[1],
|
||||
surface_raised: c[2],
|
||||
accent: c[3],
|
||||
on_accent: c[4],
|
||||
text_primary: c[5],
|
||||
text_secondary: c[6],
|
||||
destructive: c[7],
|
||||
on_destructive: c[8],
|
||||
focus: c[9],
|
||||
orbs: orbs.ok_or(Error::Toml(Toml::Missing("orbs")))?,
|
||||
corner_radius: n[0] as f32,
|
||||
spacing_base: n[1] as f32,
|
||||
// In range, so these fit.
|
||||
duration_fast: n[2] as u16,
|
||||
duration_normal: n[3] as u16,
|
||||
spring_stiffness: n[4] as u16,
|
||||
spring_damping: n[5] as u16,
|
||||
blur_radius: n[6] as u8,
|
||||
panel_opacity: n[7] as u8,
|
||||
highlight: n[8] as u8,
|
||||
})
|
||||
}
|
||||
53
crates/theme/src/tests.rs
Normal file
53
crates/theme/src/tests.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
extern crate std;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const DARK: &str = include_str!("../../../disk/themes/whimsy-dark.toml");
|
||||
const LIGHT: &str = include_str!("../../../disk/themes/whimsy-light.toml");
|
||||
|
||||
#[test]
|
||||
fn the_shipped_themes_load_and_pass_contrast() {
|
||||
for text in [DARK, LIGHT] {
|
||||
let t = parse(text).unwrap();
|
||||
check(t).unwrap_or_else(|e| panic!("{}: {e:?}", core::str::from_utf8(t.name()).unwrap()));
|
||||
}
|
||||
assert_eq!(parse(LIGHT).unwrap().name(), b"Whimsy Light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_built_in_theme_is_the_dark_file() {
|
||||
assert_eq!(parse(DARK).unwrap(), Theme::WHIMSY_DARK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_survives_the_wire_but_its_name() {
|
||||
for text in [DARK, LIGHT] {
|
||||
let t = parse(text).unwrap();
|
||||
let back = wire::decode(wire::encode(&t));
|
||||
assert_eq!(back, Theme { name: [0; NAME_BYTES], name_len: 0, ..t });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_pairings_are_refused() {
|
||||
let grey = DARK.replace("text_secondary = \"#9e9eb8\"", "text_secondary = \"#3a3a4a\"");
|
||||
assert!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on background", .. })));
|
||||
assert!((ratio(Rgba::rgb(0), Rgba::rgb(0xffffff)) - 21.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token_is_needed_and_checked() {
|
||||
use manifest::Error as T;
|
||||
let missing = DARK.replace("focus = \"#ffffff\"\n", "");
|
||||
assert_eq!(parse(&missing), Err(Error::Toml(T::Missing("focus"))));
|
||||
for (from, to) in [
|
||||
("corner_radius = 16", "corner_radius = 99"),
|
||||
("base = 4", "base = \"4\""),
|
||||
("\"#ffffff\"", "\"white\""),
|
||||
("orbs = [\"#3a1c71\", \"#0f4c5c\", \"#5b1a4a\"]", "orbs = [\"#3a1c71\"]"),
|
||||
] {
|
||||
assert!(matches!(parse(&DARK.replace(from, to)), Err(Error::Toml(T::Invalid { .. }))), "{to}");
|
||||
}
|
||||
assert!(matches!(parse(&DARK.replace("highlight = 18", "glow = 18")), Err(Error::Toml(T::Unknown { .. }))));
|
||||
assert!(matches!(parse(&std::format!("{DARK}\n[glass]\n")), Err(Error::Toml(T::Duplicate { .. }))));
|
||||
}
|
||||
73
crates/theme/src/wire.rs
Normal file
73
crates/theme/src/wire.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! A theme in message words, as the display hands it to a window
|
||||
//! (`types::display::window::THEME`). Its name stays behind.
|
||||
|
||||
use crate::{Rgba, Theme};
|
||||
|
||||
pub const WORDS: usize = types::display::window::THEME_WORDS;
|
||||
|
||||
fn colours(t: &Theme) -> [Rgba; 12] {
|
||||
[
|
||||
t.background,
|
||||
t.surface,
|
||||
t.surface_raised,
|
||||
t.accent,
|
||||
t.on_accent,
|
||||
t.text_primary,
|
||||
t.text_secondary,
|
||||
t.destructive,
|
||||
t.on_destructive,
|
||||
t.focus,
|
||||
t.orbs[0],
|
||||
t.orbs[1],
|
||||
]
|
||||
}
|
||||
|
||||
pub fn encode(t: &Theme) -> [usize; WORDS] {
|
||||
let c = colours(t);
|
||||
let mut w = [0usize; WORDS];
|
||||
for i in 0..6 {
|
||||
w[i] = c[2 * i].to_rgb() as usize | (c[2 * i + 1].to_rgb() as usize) << 32;
|
||||
}
|
||||
w[6] = t.corner_radius as usize
|
||||
| (t.spacing_base as usize) << 8
|
||||
| (t.blur_radius as usize) << 16
|
||||
| (t.panel_opacity as usize) << 24
|
||||
| (t.highlight as usize) << 32
|
||||
| (t.duration_fast as usize) << 40;
|
||||
w[7] = t.duration_normal as usize
|
||||
| (t.spring_stiffness as usize) << 16
|
||||
| (t.spring_damping as usize & 0xff) << 32
|
||||
| (t.orbs[2].to_rgb() as usize & 0xff_ffff) << 40;
|
||||
w
|
||||
}
|
||||
|
||||
pub fn decode(w: [usize; WORDS]) -> Theme {
|
||||
let c = |i: usize| Rgba::rgb((w[i / 2] >> (32 * (i % 2))) as u32 & 0xff_ffff);
|
||||
let byte = |word: usize, at: u32| (w[word] >> at) as u8;
|
||||
let half = |word: usize, at: u32| (w[word] >> at) as u16;
|
||||
Theme {
|
||||
name: [0; crate::NAME_BYTES],
|
||||
name_len: 0,
|
||||
background: c(0),
|
||||
surface: c(1),
|
||||
surface_raised: c(2),
|
||||
accent: c(3),
|
||||
on_accent: c(4),
|
||||
text_primary: c(5),
|
||||
text_secondary: c(6),
|
||||
destructive: c(7),
|
||||
on_destructive: c(8),
|
||||
focus: c(9),
|
||||
orbs: [c(10), c(11), Rgba::rgb((w[7] >> 40) as u32 & 0xff_ffff)],
|
||||
corner_radius: byte(6, 0) as f32,
|
||||
spacing_base: byte(6, 8) as f32,
|
||||
blur_radius: byte(6, 16),
|
||||
panel_opacity: byte(6, 24),
|
||||
highlight: byte(6, 32),
|
||||
duration_fast: half(6, 40),
|
||||
duration_normal: half(7, 0),
|
||||
spring_stiffness: half(7, 16),
|
||||
// At most 200 (see `parse`): a byte.
|
||||
spring_damping: byte(7, 32) as u16,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user