Skip to main content

egui_elements/
utils.rs

1//! Helpers: theme switcher, HSLA colors, and interactive frames.
2
3use crate::theme::*;
4use crate::visuals::FrameVisuals;
5use crate::widgets::{ComboBox, Label};
6use egui::{Color32, Frame, Response, RichText, Sense, Stroke, Ui};
7
8/// Should work for most images that are shown on a very dark background
9pub const TINT_1: Color32 = Color32::from_rgba_premultiplied(216, 216, 216, 255);
10
11/// HSLA color used by the theme editor (hue 0..=360, s/l 0..=100, a 0..=1).
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[derive(Debug, Clone, Copy)]
14pub struct Hsla {
15   /// Hue 0.0..=360.0
16   pub h: f32,
17   /// Saturation 0.0..=100.0
18   pub s: f32,
19   /// Lightness 0.0..=100.0
20   pub l: f32,
21   /// Alpha 0.0..=1.0
22   pub a: f32,
23}
24
25impl Hsla {
26   /// Convert a premultiplied [`Color32`] to unpremultiplied HSLA.
27   pub fn from_color32(c: Color32) -> Self {
28      let (r, g, b) = unpremultiply_srgb(c);
29      let (h, s, l) = srgb_to_hsl(r, g, b);
30      Hsla {
31         h,
32         s: s * 100.0,
33         l: l * 100.0,
34         a: c.a() as f32 / 255.0,
35      }
36   }
37
38   /// Parse `#RGB`, `#RRGGBB`, or `#RRGGBBAA`.
39   pub fn from_hex(hex: &str) -> Option<Self> {
40      match Color32::from_hex(hex) {
41         Ok(c) => Some(Self::from_color32(c)),
42         Err(_) => None,
43      }
44   }
45
46   /// Convert back to a premultiplied [`Color32`].
47   pub fn to_color32(&self) -> Color32 {
48      let (r, g, b, a) = self.to_rgba_components();
49      Color32::from_rgba_unmultiplied(r, g, b, a)
50   }
51
52   /// Unpremultiplied `(r, g, b, a)` in 0..=255.
53   pub fn to_rgba_components(&self) -> (u8, u8, u8, u8) {
54      let (r, g, b) = hsl_to_srgb(self.h, self.s / 100.0, self.l / 100.0);
55      (
56         channel_to_u8(r),
57         channel_to_u8(g),
58         channel_to_u8(b),
59         channel_to_u8(self.a),
60      )
61   }
62
63   /// Walk lightness in 5% steps, including `self` as the first entry.
64   pub fn shades(&self, num_shades: usize, direction: ShadeDirection) -> Vec<Color32> {
65      let mut shades = Vec::new();
66      let step = if direction == ShadeDirection::Lighter {
67         5.0
68      } else {
69         -5.0
70      };
71      let mut current = *self;
72      for _ in 0..num_shades {
73         shades.push(current.to_color32());
74         current.l = (current.l as f32 + step).clamp(0.0, 100.0);
75      }
76      shades
77   }
78}
79
80/// Direction for [`Hsla::shades`].
81#[derive(Debug, PartialEq, Eq, Clone, Copy)]
82pub enum ShadeDirection {
83   /// Increase lightness.
84   Lighter,
85   /// Decrease lightness.
86   Darker,
87}
88
89/// `Color32` stores premultiplied sRGB. HSL is computed in unpremultiplied space.
90fn unpremultiply_srgb(c: Color32) -> (f32, f32, f32) {
91   let a = c.a();
92   if a == 0 {
93      (0.0, 0.0, 0.0)
94   } else if a == 255 {
95      (
96         c.r() as f32 / 255.0,
97         c.g() as f32 / 255.0,
98         c.b() as f32 / 255.0,
99      )
100   } else {
101      let a = a as f32;
102      (
103         c.r() as f32 / a,
104         c.g() as f32 / a,
105         c.b() as f32 / a,
106      )
107   }
108}
109
110/// Gamma-encoded sRGB → HSL (CSS / Wikipedia). `s` and `l` in 0..=1, `h` in 0..360.
111fn srgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
112   let max = r.max(g).max(b);
113   let min = r.min(g).min(b);
114   let l = (max + min) * 0.5;
115   let d = max - min;
116
117   if d == 0.0 {
118      return (0.0, 0.0, l);
119   }
120
121   let s = d / (1.0 - (2.0 * l - 1.0).abs());
122   let h = if max == r {
123      (g - b) / d
124   } else if max == g {
125      (b - r) / d + 2.0
126   } else {
127      (r - g) / d + 4.0
128   };
129   (h.rem_euclid(6.0) * 60.0, s, l)
130}
131
132/// HSL → gamma-encoded sRGB. `h` in degrees, `s` and `l` in 0..=1.
133fn hsl_to_srgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
134   let s = s.clamp(0.0, 1.0);
135   let l = l.clamp(0.0, 1.0);
136   let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
137   let hp = h.rem_euclid(360.0) / 60.0;
138   let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
139   let m = l - c * 0.5;
140   let (r, g, b) = match hp as u8 {
141      0 => (c, x, 0.0),
142      1 => (x, c, 0.0),
143      2 => (0.0, c, x),
144      3 => (0.0, x, c),
145      4 => (x, 0.0, c),
146      _ => (c, 0.0, x),
147   };
148   (r + m, g + m, b + m)
149}
150
151fn channel_to_u8(channel: f32) -> u8 {
152   (channel.clamp(0.0, 1.0) * 255.0) as u8
153}
154
155/// `true` if any frame-related palette slot changed.
156pub fn frame_palette_changed(old: &ThemeColors, new: &ThemeColors) -> bool {
157   old.title_bar != new.title_bar
158      || old.bg != new.bg
159      || old.widget_bg != new.widget_bg
160      || old.hover != new.hover
161      || old.highlight != new.highlight
162      || old.border != new.border
163}
164
165/// Write `new` into `slot` only if it still equals `old`.
166pub fn remap_if_eq(slot: &mut Color32, old: Color32, new: Color32) {
167   if *slot == old {
168      *slot = new;
169   }
170}
171
172/// Remap fill, stroke, and shadow colors on an [`egui::Frame`] when they
173/// still match the previous palette.
174pub fn remap_frame(
175   frame: &mut Frame,
176   old_fill: Color32,
177   new_fill: Color32,
178   old_border: Color32,
179   new_border: Color32,
180) {
181   remap_if_eq(&mut frame.fill, old_fill, new_fill);
182   remap_if_eq(&mut frame.stroke.color, old_border, new_border);
183   remap_if_eq(&mut frame.shadow.color, old_border, new_border);
184}
185
186/// Remap hover/click fills on [`FrameVisuals`] when they still match the
187/// previous palette.
188pub fn remap_frame_visuals(
189   visuals: &mut FrameVisuals,
190   old_hover: Color32,
191   new_hover: Color32,
192   old_click: Color32,
193   new_click: Color32,
194   old_highlight: Color32,
195   new_highlight: Color32,
196) {
197   remap_if_eq(&mut visuals.bg_on_hover, old_hover, new_hover);
198   remap_if_eq(&mut visuals.bg_on_click, old_click, new_click);
199   remap_if_eq(
200      &mut visuals.border_on_hover.1,
201      old_highlight,
202      new_highlight,
203   );
204   remap_if_eq(
205      &mut visuals.border_on_click.1,
206      old_highlight,
207      new_highlight,
208   );
209}
210
211/// Show a ComboBox to change the theme
212///
213/// Returns the new theme if we select one, the new theme is installed to the [egui::Context]
214pub fn theme_switcher(current_theme: &Theme, ui: &mut Ui) -> Option<Theme> {
215   let mut new_theme_opt = None;
216
217   let text = RichText::new(current_theme.kind.to_str()).size(current_theme.typography.normal);
218   let current = Label::new(text, None);
219
220   ComboBox::new("Theme switcher", current).width(200.0).show_ui(ui, |ui| {
221      ui.spacing_mut().item_spacing.y = 12.0;
222
223      for kind in ThemeKind::to_vec() {
224         let label = RichText::new(kind.to_str()).size(current_theme.typography.normal);
225         let label = Label::new(label, None)
226            .interactive(true)
227            .fill_width(true)
228            .sense(Sense::click())
229            .expand(Some(4.0));
230
231         if ui.add(label).clicked() {
232            let mut new_theme = Theme::new(kind);
233            new_theme_opt = Some(new_theme.clone());
234            new_theme.install(ui.ctx());
235         }
236      }
237   });
238   new_theme_opt
239}
240
241/// Paint `frame` with hover/click fills from `visuals`.
242///
243/// The closure runs inside the frame. Returns the inner content response.
244pub fn frame(
245   frame: &mut Frame,
246   visuals: FrameVisuals,
247   ui: &mut Ui,
248   add_contents: impl FnOnce(&mut Ui),
249) -> Response {
250   let mut frame = frame.begin(ui);
251   let res = frame.content_ui.scope(|ui| add_contents(ui));
252
253   if res.response.interact(Sense::click()).clicked() {
254      frame.frame = frame.frame.fill(visuals.bg_on_click);
255      frame.frame = frame.frame.stroke(Stroke::new(
256         visuals.border_on_click.0,
257         visuals.border_on_click.1,
258      ));
259   } else if res.response.hovered() {
260      frame.frame = frame.frame.fill(visuals.bg_on_hover);
261      frame.frame = frame.frame.stroke(Stroke::new(
262         visuals.border_on_hover.0,
263         visuals.border_on_hover.1,
264      ));
265   }
266
267   frame.end(ui);
268   res.response
269}
270
271#[cfg(test)]
272mod tests {
273   use super::*;
274
275   fn assert_near_color(a: Color32, b: Color32) {
276      let dr = (a.r() as i16 - b.r() as i16).abs();
277      let dg = (a.g() as i16 - b.g() as i16).abs();
278      let db = (a.b() as i16 - b.b() as i16).abs();
279      assert!(
280         dr <= 1 && dg <= 1 && db <= 1 && a.a() == b.a(),
281         "roundtrip {a:?} -> {b:?}"
282      );
283   }
284
285   #[test]
286   fn color32_hsla_roundtrip() {
287      let samples = [
288         Color32::from_rgba_unmultiplied(0, 0, 0, 255),
289         Color32::from_rgba_unmultiplied(255, 255, 255, 255),
290         Color32::from_rgba_unmultiplied(255, 0, 0, 255),
291         Color32::from_rgba_unmultiplied(0, 255, 0, 255),
292         Color32::from_rgba_unmultiplied(0, 0, 255, 255),
293         Color32::from_rgba_unmultiplied(128, 64, 192, 128),
294         Color32::from_rgba_unmultiplied(26, 27, 38, 255),
295         Color32::from_rgba_unmultiplied(122, 162, 247, 200),
296      ];
297      for c in samples {
298         assert_near_color(c, Hsla::from_color32(c).to_color32());
299      }
300   }
301
302   #[test]
303   fn from_hex_parses_and_preserves_alpha() {
304      let hsla = Hsla::from_hex("#7aa2f7").unwrap();
305      let (r, g, b, a) = hsla.to_rgba_components();
306      assert_eq!((r, g, b, a), (122, 162, 247, 255));
307
308      let hsla = Hsla::from_hex("#7aa2f780").unwrap();
309      assert!((hsla.a - 128.0 / 255.0).abs() < 1e-5);
310      assert_eq!(hsla.to_color32().a(), 128);
311   }
312
313   #[test]
314   fn hsl_pure_red() {
315      let c = Hsla {
316         h: 0.0,
317         s: 100.0,
318         l: 50.0,
319         a: 1.0,
320      }
321      .to_color32();
322      assert_eq!(c, Color32::from_rgb(255, 0, 0));
323   }
324}