at main 333 lines 12 kB view raw
1//! Colour generation for notebook themes. 2//! 3//! Generates a full 16-colour palette from 4 user-specified colours 4//! (background, text, primary, link) using perceptual colour operations. 5 6use palette::{Clamp, FromColor, IntoColor, LinSrgb, Oklch, Srgb}; 7use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; 8 9use crate::themes::BUILTIN_COLOUR_SCHEMES; 10 11/// Parse any CSS colour string to Oklch. 12/// 13/// Supports: hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), hsla(), 14/// hwb(), lab(), lch(), oklab(), oklch(), and named colours. 15pub fn css_to_oklch(css: &str) -> Result<Oklch, ColourGenError> { 16 let css = css.trim(); 17 let css = if !css.starts_with('#') 18 && !css.contains('(') 19 && css.chars().all(|c| c.is_ascii_hexdigit()) 20 { 21 format!("#{}", css) 22 } else { 23 css.to_string() 24 }; 25 26 let color = csscolorparser::parse(&css) 27 .map_err(|e| ColourGenError::InvalidColour(format!("{}: {}", css, e)))?; 28 29 let [r, g, b, _a] = color.to_array(); 30 let srgb = Srgb::new(r as f32, g as f32, b as f32); 31 Ok(Oklch::from_color(srgb.into_linear())) 32} 33 34/// Convert Oklch to hex string (without #, uppercase). 35pub fn oklch_to_hex(oklch: Oklch) -> String { 36 let linear: LinSrgb = oklch.into_color(); 37 let clamped = linear.clamp(); 38 let rgb: Srgb<u8> = Srgb::from_linear(clamped); 39 format!("{:02X}{:02X}{:02X}", rgb.red, rgb.green, rgb.blue) 40} 41 42/// Determine if a colour is "dark" based on Oklch lightness. 43pub fn is_dark(oklch: &Oklch) -> bool { 44 oklch.l < 0.5 45} 46 47/// Apply the hue and chroma from `source` to the lightness of `target`. 48/// 49/// The chroma is scaled proportionally to maintain the relative colourfulness 50/// while respecting the target lightness. 51pub fn transfer_hue_chroma(source: Oklch, target: Oklch) -> Oklch { 52 Oklch::new(target.l, source.chroma, source.hue) 53} 54 55/// Apply hue/chroma with lightness clamping for dark themes. 56/// Ensures text is lightest, base is darkest. 57pub fn transfer_for_dark_theme( 58 source: Oklch, 59 target: Oklch, 60 is_text: bool, 61 is_base: bool, 62) -> Oklch { 63 let mut result = transfer_hue_chroma(source, target); 64 65 if is_base { 66 result.l = result.l.min(0.25); 67 } else if is_text { 68 result.l = result.l.max(0.85); 69 } 70 71 result 72} 73 74/// Apply hue/chroma with lightness clamping for light themes. 75/// Ensures text is darkest, base is lightest. 76pub fn transfer_for_light_theme( 77 source: Oklch, 78 target: Oklch, 79 is_text: bool, 80 is_base: bool, 81) -> Oklch { 82 let mut result = transfer_hue_chroma(source, target); 83 84 if is_base { 85 result.l = result.l.max(0.92); 86 } else if is_text { 87 result.l = result.l.min(0.25); 88 } 89 90 result 91} 92 93/// Semantic colour configuration. 94mod semantic { 95 /// Hues in Oklch degrees. 96 pub mod hue { 97 pub const ERROR: f32 = 30.0; // Red 98 pub const WARNING: f32 = 80.0; // Gold-amber with high saturation 99 pub const SUCCESS: f32 = 165.0; // Teal-green (shifted from pure green to allow more cyan) 100 } 101 102 /// Minimum chroma values to keep semantic colours recognizable. 103 pub mod min_chroma { 104 pub const ERROR: f32 = 0.16; // Must stay clearly red 105 pub const WARNING: f32 = 0.19; // Needs high saturation to avoid looking muddy 106 pub const SUCCESS: f32 = 0.08; // Can be quite muted, teal reads well 107 } 108 109 /// Lightness values per variant. Warning needs to be brighter to avoid going brown. 110 pub mod lightness { 111 pub const ERROR_DARK: f32 = 0.70; 112 pub const ERROR_LIGHT: f32 = 0.55; 113 pub const WARNING_DARK: f32 = 0.78; // Brighter to stay amber 114 pub const WARNING_LIGHT: f32 = 0.78; // Brighter to avoid brown 115 pub const SUCCESS_DARK: f32 = 0.70; 116 pub const SUCCESS_LIGHT: f32 = 0.50; // Can be slightly darker, teal handles it 117 } 118} 119 120/// Generate a semantic colour derived from user's primary. 121/// 122/// Takes the user's chroma (clamped to minimum) and applies a fixed semantic hue. 123fn generate_semantic_colour( 124 user_primary: Oklch, 125 hue: f32, 126 min_chroma: f32, 127 lightness: f32, 128) -> Oklch { 129 let chroma = user_primary.chroma.max(min_chroma); 130 Oklch::new(lightness, chroma, hue) 131} 132 133#[derive(Debug, Clone, thiserror::Error)] 134pub enum ColourGenError { 135 #[error("invalid colour: {0}")] 136 InvalidColour(String), 137} 138 139/// The 4 user-provided colours for theme generation. 140#[derive(Debug, Clone)] 141pub struct ThemeInputs { 142 pub background: String, 143 pub text: String, 144 pub primary: String, 145 pub link: String, 146} 147 148/// Which variant to generate. 149#[derive(Debug, Clone, Copy, PartialEq, Eq)] 150pub enum ThemeVariant { 151 Light, 152 Dark, 153} 154 155/// Find the most neutral base theme for a variant. 156fn get_neutral_base(variant: ThemeVariant) -> &'static ColourSchemeColours<'static> { 157 let target_variant = match variant { 158 ThemeVariant::Light => "light", 159 ThemeVariant::Dark => "dark", 160 }; 161 162 BUILTIN_COLOUR_SCHEMES 163 .iter() 164 .find(|s| s.variant == target_variant) 165 .map(|s| &s.colours) 166 .expect("should have at least one builtin theme per variant") 167} 168 169/// Determine theme variant from background colour luminance. 170pub fn detect_variant(background: &str) -> Result<ThemeVariant, ColourGenError> { 171 let oklch = css_to_oklch(background)?; 172 Ok(if is_dark(&oklch) { 173 ThemeVariant::Dark 174 } else { 175 ThemeVariant::Light 176 }) 177} 178 179/// Generate a full 16-colour palette from 4 user inputs. 180pub fn generate_palette( 181 inputs: &ThemeInputs, 182 variant: ThemeVariant, 183) -> Result<ColourSchemeColours<'static>, ColourGenError> { 184 let user_base = css_to_oklch(&inputs.background)?; 185 let user_text = css_to_oklch(&inputs.text)?; 186 let user_primary = css_to_oklch(&inputs.primary)?; 187 let user_link = css_to_oklch(&inputs.link)?; 188 189 let base_theme = get_neutral_base(variant); 190 191 let base_base = css_to_oklch(base_theme.base.as_ref())?; 192 let base_surface = css_to_oklch(base_theme.surface.as_ref())?; 193 let base_overlay = css_to_oklch(base_theme.overlay.as_ref())?; 194 let base_text = css_to_oklch(base_theme.text.as_ref())?; 195 let base_muted = css_to_oklch(base_theme.muted.as_ref())?; 196 let base_subtle = css_to_oklch(base_theme.subtle.as_ref())?; 197 let base_emphasis = css_to_oklch(base_theme.emphasis.as_ref())?; 198 let base_primary = css_to_oklch(base_theme.primary.as_ref())?; 199 let base_secondary = css_to_oklch(base_theme.secondary.as_ref())?; 200 let base_tertiary = css_to_oklch(base_theme.tertiary.as_ref())?; 201 let base_border = css_to_oklch(base_theme.border.as_ref())?; 202 let base_link = css_to_oklch(base_theme.link.as_ref())?; 203 let base_highlight = css_to_oklch(base_theme.highlight.as_ref())?; 204 205 let transfer: fn(Oklch, Oklch, bool, bool) -> Oklch = match variant { 206 ThemeVariant::Dark => transfer_for_dark_theme, 207 ThemeVariant::Light => transfer_for_light_theme, 208 }; 209 210 // Generate semantic colours derived from user's primary with fixed hues. 211 let error = generate_semantic_colour( 212 user_primary, 213 semantic::hue::ERROR, 214 semantic::min_chroma::ERROR, 215 match variant { 216 ThemeVariant::Dark => semantic::lightness::ERROR_DARK, 217 ThemeVariant::Light => semantic::lightness::ERROR_LIGHT, 218 }, 219 ); 220 let warning = generate_semantic_colour( 221 user_primary, 222 semantic::hue::WARNING, 223 semantic::min_chroma::WARNING, 224 match variant { 225 ThemeVariant::Dark => semantic::lightness::WARNING_DARK, 226 ThemeVariant::Light => semantic::lightness::WARNING_LIGHT, 227 }, 228 ); 229 let success = generate_semantic_colour( 230 user_primary, 231 semantic::hue::SUCCESS, 232 semantic::min_chroma::SUCCESS, 233 match variant { 234 ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK, 235 ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT, 236 }, 237 ); 238 239 Ok(ColourSchemeColours { 240 base: oklch_to_hex(transfer(user_base, base_base, false, true)).into(), 241 surface: oklch_to_hex(transfer(user_base, base_surface, false, false)).into(), 242 overlay: oklch_to_hex(transfer(user_base, base_overlay, false, false)).into(), 243 text: oklch_to_hex(transfer(user_text, base_text, true, false)).into(), 244 muted: oklch_to_hex(transfer(user_text, base_muted, false, false)).into(), 245 subtle: oklch_to_hex(transfer(user_text, base_subtle, false, false)).into(), 246 emphasis: oklch_to_hex(transfer(user_text, base_emphasis, false, false)).into(), 247 primary: oklch_to_hex(transfer(user_primary, base_primary, false, false)).into(), 248 secondary: oklch_to_hex(transfer(user_primary, base_secondary, false, false)).into(), 249 tertiary: oklch_to_hex(transfer(user_primary, base_tertiary, false, false)).into(), 250 error: oklch_to_hex(error).into(), 251 warning: oklch_to_hex(warning).into(), 252 success: oklch_to_hex(success).into(), 253 border: oklch_to_hex(transfer(user_base, base_border, false, false)).into(), 254 link: oklch_to_hex(transfer(user_link, base_link, false, false)).into(), 255 highlight: oklch_to_hex(transfer(user_primary, base_highlight, false, false)).into(), 256 extra_data: None, 257 }) 258} 259 260/// Generate the counterpart palette (light for dark, dark for light). 261/// Uses neutral base for the counterpart and applies user hue/chroma. 262pub fn generate_counterpart_palette( 263 inputs: &ThemeInputs, 264 primary_variant: ThemeVariant, 265) -> Result<ColourSchemeColours<'static>, ColourGenError> { 266 let counterpart_variant = match primary_variant { 267 ThemeVariant::Light => ThemeVariant::Dark, 268 ThemeVariant::Dark => ThemeVariant::Light, 269 }; 270 271 let neutral_base = get_neutral_base(counterpart_variant); 272 273 let user_primary = css_to_oklch(&inputs.primary)?; 274 let user_link = css_to_oklch(&inputs.link)?; 275 276 let base_primary = css_to_oklch(neutral_base.primary.as_ref())?; 277 let base_secondary = css_to_oklch(neutral_base.secondary.as_ref())?; 278 let base_tertiary = css_to_oklch(neutral_base.tertiary.as_ref())?; 279 let base_link = css_to_oklch(neutral_base.link.as_ref())?; 280 let base_highlight = css_to_oklch(neutral_base.highlight.as_ref())?; 281 282 // Generate semantic colours for the counterpart variant. 283 let error = generate_semantic_colour( 284 user_primary, 285 semantic::hue::ERROR, 286 semantic::min_chroma::ERROR, 287 match counterpart_variant { 288 ThemeVariant::Dark => semantic::lightness::ERROR_DARK, 289 ThemeVariant::Light => semantic::lightness::ERROR_LIGHT, 290 }, 291 ); 292 let warning = generate_semantic_colour( 293 user_primary, 294 semantic::hue::WARNING, 295 semantic::min_chroma::WARNING, 296 match counterpart_variant { 297 ThemeVariant::Dark => semantic::lightness::WARNING_DARK, 298 ThemeVariant::Light => semantic::lightness::WARNING_LIGHT, 299 }, 300 ); 301 let success = generate_semantic_colour( 302 user_primary, 303 semantic::hue::SUCCESS, 304 semantic::min_chroma::SUCCESS, 305 match counterpart_variant { 306 ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK, 307 ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT, 308 }, 309 ); 310 311 Ok(ColourSchemeColours { 312 base: neutral_base.base.clone(), 313 surface: neutral_base.surface.clone(), 314 overlay: neutral_base.overlay.clone(), 315 text: neutral_base.text.clone(), 316 muted: neutral_base.muted.clone(), 317 subtle: neutral_base.subtle.clone(), 318 emphasis: neutral_base.emphasis.clone(), 319 border: neutral_base.border.clone(), 320 error: oklch_to_hex(error).into(), 321 warning: oklch_to_hex(warning).into(), 322 success: oklch_to_hex(success).into(), 323 primary: oklch_to_hex(transfer_hue_chroma(user_primary, base_primary)).into(), 324 secondary: oklch_to_hex(transfer_hue_chroma(user_primary, base_secondary)).into(), 325 tertiary: oklch_to_hex(transfer_hue_chroma(user_primary, base_tertiary)).into(), 326 link: oklch_to_hex(transfer_hue_chroma(user_link, base_link)).into(), 327 highlight: oklch_to_hex(transfer_hue_chroma(user_primary, base_highlight)).into(), 328 extra_data: None, 329 }) 330} 331 332#[cfg(test)] 333mod tests;