Version 1

This commit is contained in:
2026-09-09 21:19:44 +02:00
commit f35e5c82e5
16 changed files with 11148 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
//! Colours: the six `canvasColor` presets plus the light and dark palettes the
//! editor paints with.
use eframe::egui::Color32;
use crate::model::Color;
/// The six preset colours, in spec order (`"1"` … `"6"`).
pub const PRESETS: [Color32; 6] = [
Color32::from_rgb(0xfb, 0x46, 0x4c), // 1 red
Color32::from_rgb(0xe9, 0x97, 0x3f), // 2 orange
Color32::from_rgb(0xe0, 0xde, 0x71), // 3 yellow
Color32::from_rgb(0x44, 0xcf, 0x6e), // 4 green
Color32::from_rgb(0x53, 0xdf, 0xdd), // 5 cyan
Color32::from_rgb(0xa8, 0x82, 0xff), // 6 purple
];
/// Every colour the canvas is painted with, for one of the two themes.
#[derive(Clone, Copy)]
pub struct Palette {
pub dark: bool,
pub canvas_bg: Color32,
pub grid: Color32,
pub grid_strong: Color32,
pub surface: Color32,
pub surface_muted: Color32,
pub border: Color32,
pub text: Color32,
pub text_muted: Color32,
pub accent: Color32,
pub edge: Color32,
pub danger: Color32,
}
impl Palette {
pub fn dark() -> Self {
Self {
dark: true,
canvas_bg: Color32::from_rgb(0x1a, 0x1a, 0x1e),
grid: Color32::from_rgb(0x26, 0x26, 0x2c),
grid_strong: Color32::from_rgb(0x30, 0x30, 0x38),
surface: Color32::from_rgb(0x24, 0x24, 0x2a),
surface_muted: Color32::from_rgb(0x2c, 0x2c, 0x33),
border: Color32::from_rgb(0x44, 0x44, 0x4e),
text: Color32::from_rgb(0xe4, 0xe4, 0xe8),
text_muted: Color32::from_rgb(0x95, 0x95, 0xa0),
accent: Color32::from_rgb(0x70, 0x9c, 0xff),
edge: Color32::from_rgb(0x8a, 0x8a, 0x98),
danger: Color32::from_rgb(0xe5, 0x53, 0x53),
}
}
pub fn light() -> Self {
Self {
dark: false,
canvas_bg: Color32::from_rgb(0xf7, 0xf7, 0xf9),
grid: Color32::from_rgb(0xe6, 0xe6, 0xea),
grid_strong: Color32::from_rgb(0xd6, 0xd6, 0xdc),
surface: Color32::from_rgb(0xff, 0xff, 0xff),
surface_muted: Color32::from_rgb(0xf0, 0xf0, 0xf4),
border: Color32::from_rgb(0xcd, 0xcd, 0xd6),
text: Color32::from_rgb(0x1e, 0x1e, 0x24),
text_muted: Color32::from_rgb(0x6b, 0x6b, 0x78),
accent: Color32::from_rgb(0x2f, 0x6f, 0xed),
edge: Color32::from_rgb(0x76, 0x76, 0x84),
danger: Color32::from_rgb(0xc4, 0x2b, 0x2b),
}
}
/// Turns a `canvasColor` into a paintable colour.
pub fn resolve(&self, color: &Color) -> Color32 {
match color {
Color::Preset(n) => PRESETS[(*n as usize - 1).min(5)],
Color::Hex(hex) => parse_hex(hex).unwrap_or(self.accent),
}
}
/// Border and fill for a node with the given `canvasColor`.
pub fn node_colors(&self, color: Option<&Color>) -> (Color32, Color32) {
match color {
None => (self.surface, self.border),
Some(color) => {
let accent = self.resolve(color);
let strength = if self.dark { 0.16 } else { 0.10 };
(blend(accent, self.surface, strength), accent)
}
}
}
/// Fill and border for a group box.
pub fn group_colors(&self, color: Option<&Color>) -> (Color32, Color32) {
let border = color.map_or(self.border, |c| self.resolve(c));
let strength = if self.dark { 0.10 } else { 0.07 };
(blend(border, self.canvas_bg, strength), border)
}
/// Colour of an edge, honouring its optional `canvasColor`.
pub fn edge_color(&self, color: Option<&Color>) -> Color32 {
color.map_or(self.edge, |c| self.resolve(c))
}
}
/// Parses `#rgb` / `#rrggbb`.
pub fn parse_hex(hex: &str) -> Option<Color32> {
let hex = hex.trim().strip_prefix('#')?;
let expand = |c: u8| c * 17;
match hex.len() {
3 => {
let mut it = hex.chars().map(|c| c.to_digit(16).map(|d| expand(d as u8)));
Some(Color32::from_rgb(it.next()??, it.next()??, it.next()??))
}
6 => {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
_ => None,
}
}
/// Mixes `fg` into `bg` with the given strength in `0..=1`.
pub fn blend(fg: Color32, bg: Color32, strength: f32) -> Color32 {
let mix = |a: u8, b: u8| (a as f32 * strength + b as f32 * (1.0 - strength)).round() as u8;
Color32::from_rgb(
mix(fg.r(), bg.r()),
mix(fg.g(), bg.g()),
mix(fg.b(), bg.b()),
)
}
/// Black or white, whichever stays readable on `background`.
pub fn contrasting_text(background: Color32) -> Color32 {
let luma = 0.299 * background.r() as f32 + 0.587 * background.g() as f32
+ 0.114 * background.b() as f32;
if luma > 150.0 {
Color32::from_rgb(0x1e, 0x1e, 0x24)
} else {
Color32::from_rgb(0xf2, 0xf2, 0xf5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_both_hex_lengths() {
assert_eq!(parse_hex("#fff"), Some(Color32::WHITE));
assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0)));
assert_eq!(parse_hex("nope"), None);
assert_eq!(parse_hex("#12345"), None);
}
#[test]
fn presets_resolve_by_number() {
let palette = Palette::dark();
assert_eq!(palette.resolve(&Color::Preset(1)), PRESETS[0]);
assert_eq!(palette.resolve(&Color::Preset(6)), PRESETS[5]);
assert_eq!(
palette.resolve(&Color::Hex("#ff00ff".into())),
Color32::from_rgb(255, 0, 255)
);
}
#[test]
fn a_coloured_node_keeps_a_readable_fill() {
let palette = Palette::dark();
let (fill, border) = palette.node_colors(Some(&Color::Preset(1)));
assert_eq!(border, PRESETS[0]);
// The fill is only tinted, it stays close to the surface colour.
assert!(fill.r() > palette.surface.r() && fill.r() < PRESETS[0].r());
}
#[test]
fn text_contrast_flips_with_the_background() {
assert_eq!(contrasting_text(Color32::WHITE).r(), 0x1e);
assert_eq!(contrasting_text(Color32::BLACK).r(), 0xf2);
}
}