Skip to main content

egui_elements/widgets/
frame.rs

1use egui::layers::ShapeIdx;
2use egui::{
3   Color32, CornerRadius, InnerResponse, Margin, Rect, Response, Sense, Shadow, Shape, Stroke, Ui,
4   UiBuilder, UiKind, UiStackInfo, epaint::MarginF32,
5};
6
7use crate::visuals::FrameVisuals;
8
9/// A frame around some content: margin, fill ([`Fill`]), stroke, rounding, shadow.
10///
11/// Same geometry as [`egui::Frame`]:
12/// `content_size + inner_margin + 2 * stroke.width + outer_margin`.
13///
14/// The difference is the fill is a [`Fill`], so it can be a two-stop gradient,
15/// and state colors live on [`FrameVisuals`] like the other widgets.
16///
17/// Stroke width is taken from the idle border at `begin`. Changing stroke
18/// width on hover after `begin` does not relayout.
19#[must_use = "You should call .show()"]
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct Frame {
22   pub inner_margin: Margin,
23   pub outer_margin: Margin,
24   pub visuals: FrameVisuals,
25   pub sense: Sense,
26   /// When `false`, paint always uses idle fill / border, even if the
27   /// pointer is over the frame. Hover / click fills need [`Self::interactive`].
28   pub interactive: bool,
29   /// Paint [`FrameVisuals::bg_selected`] instead of idle / hover / click.
30   pub selected: bool,
31   /// Stretch the frame to the available width (useful for sidebar rows).
32   pub fill_width: bool,
33   /// Set by [`Self::corner_radius`]. Wins over a
34   /// later [`Self::visuals`], which would otherwise restore the theme radius.
35   corner_radius_override: Option<CornerRadius>,
36}
37
38impl Default for Frame {
39   fn default() -> Self {
40      Self::NONE
41   }
42}
43
44impl Frame {
45   pub const NONE: Self = Self {
46      inner_margin: Margin::ZERO,
47      outer_margin: Margin::ZERO,
48      visuals: FrameVisuals {
49         bg: Color32::TRANSPARENT,
50         bg_hover: Color32::TRANSPARENT,
51         bg_click: Color32::TRANSPARENT,
52         bg_selected: Color32::TRANSPARENT,
53         border: Stroke::NONE,
54         border_hover: Stroke::NONE,
55         border_click: Stroke::NONE,
56         corner_radius: CornerRadius::ZERO,
57         shadow: Shadow::NONE,
58      },
59      sense: Sense::HOVER,
60      interactive: false,
61      selected: false,
62      fill_width: false,
63      corner_radius_override: None,
64   };
65
66   pub const fn new() -> Self {
67      Self::NONE
68   }
69
70   /// Copy margins, idle fill, stroke, radius, and shadow from an [`egui::Frame`].
71   /// Hover / click start equal to idle.
72   pub fn from_egui(frame: egui::Frame) -> Self {
73      let fill = frame.fill;
74      Self {
75         inner_margin: frame.inner_margin,
76         outer_margin: frame.outer_margin,
77         visuals: FrameVisuals {
78            bg: fill,
79            bg_hover: fill,
80            bg_click: fill,
81            bg_selected: fill,
82            border: frame.stroke,
83            border_hover: frame.stroke,
84            border_click: frame.stroke,
85            corner_radius: frame.corner_radius,
86            shadow: frame.shadow,
87         },
88         sense: Sense::HOVER,
89         interactive: false,
90         selected: false,
91         fill_width: false,
92         corner_radius_override: None,
93      }
94   }
95
96   #[inline]
97   pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self {
98      self.inner_margin = inner_margin.into();
99      self
100   }
101
102   #[inline]
103   pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self {
104      self.outer_margin = outer_margin.into();
105      self
106   }
107
108   /// Idle fill. Does not change hover / click unless they were still the old idle fill.
109   #[inline]
110   pub fn fill(mut self, fill: impl Into<Color32>) -> Self {
111      let fill = fill.into();
112      let old = self.visuals.bg;
113      self.visuals.bg = fill;
114      if self.visuals.bg_hover == old {
115         self.visuals.bg_hover = fill;
116      }
117      if self.visuals.bg_click == old {
118         self.visuals.bg_click = fill;
119      }
120      if self.visuals.bg_selected == old {
121         self.visuals.bg_selected = fill;
122      }
123      self
124   }
125
126   #[inline]
127   pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
128      let stroke = stroke.into();
129      let old = self.visuals.border;
130      self.visuals.border = stroke;
131      if self.visuals.border_hover == old {
132         self.visuals.border_hover = stroke;
133      }
134      if self.visuals.border_click == old {
135         self.visuals.border_click = stroke;
136      }
137      self
138   }
139
140   #[inline]
141   pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
142      let corner_radius = corner_radius.into();
143      self.visuals.corner_radius = corner_radius;
144      self.corner_radius_override = Some(corner_radius);
145      self
146   }
147
148   #[inline]
149   pub fn shadow(mut self, shadow: Shadow) -> Self {
150      self.visuals.shadow = shadow;
151      self
152   }
153
154   /// Replace fill, stroke, radius, and shadow. Margins and sense are kept.
155   ///
156   /// [`Self::corner_radius`] win if already set
157   #[inline]
158   pub fn visuals(mut self, visuals: FrameVisuals) -> Self {
159      self.visuals = visuals;
160      if let Some(corner_radius) = self.corner_radius_override {
161         self.visuals.corner_radius = corner_radius;
162      }
163      self
164   }
165
166   #[inline]
167   pub fn sense(mut self, sense: Sense) -> Self {
168      self.sense = sense;
169      self
170   }
171
172   /// `true` → [`Sense::click`] and hover / click fills. `false` → idle chrome
173   /// only (pointer hover does not swap fill).
174   #[inline]
175   pub fn interactive(mut self, interactive: bool) -> Self {
176      self.interactive = interactive;
177      self.sense = if interactive {
178         Sense::click()
179      } else {
180         Sense::HOVER
181      };
182      self
183   }
184
185   /// Paint [`FrameVisuals::bg_selected`] even when not hovered.
186   #[inline]
187   pub fn selected(mut self, selected: bool) -> Self {
188      self.selected = selected;
189      self
190   }
191
192   /// Stretch the frame to the available width.
193   ///
194   /// The content `Ui` also gets that min width, so inner
195   /// right-to-left rows (unread counts, timestamps) land on the far edge.
196   #[inline]
197   pub fn fill_width(mut self, fill: bool) -> Self {
198      self.fill_width = fill;
199      self
200   }
201
202   #[inline]
203   pub fn multiply_with_opacity(mut self, opacity: f32) -> Self {
204      self.visuals.bg = self.visuals.bg.gamma_multiply(opacity);
205      self.visuals.bg_hover = self.visuals.bg_hover.gamma_multiply(opacity);
206      self.visuals.bg_click = self.visuals.bg_click.gamma_multiply(opacity);
207      self.visuals.bg_selected = self.visuals.bg_selected.gamma_multiply(opacity);
208      self.visuals.border.color = self.visuals.border.color.gamma_multiply(opacity);
209      self.visuals.border_hover.color = self.visuals.border_hover.color.gamma_multiply(opacity);
210      self.visuals.border_click.color = self.visuals.border_click.color.gamma_multiply(opacity);
211      self.visuals.shadow.color = self.visuals.shadow.color.gamma_multiply(opacity);
212      self
213   }
214
215   /// Idle stroke width is part of the margin (same as [`egui::Frame`]).
216   #[inline]
217   pub fn total_margin(&self) -> MarginF32 {
218      MarginF32::from(self.inner_margin)
219         + MarginF32::from(self.visuals.border.width)
220         + MarginF32::from(self.outer_margin)
221   }
222
223   pub fn fill_rect(&self, content_rect: Rect) -> Rect {
224      content_rect + self.inner_margin
225   }
226
227   pub fn widget_rect(&self, content_rect: Rect) -> Rect {
228      content_rect + self.inner_margin + MarginF32::from(self.visuals.border.width)
229   }
230
231   pub fn outer_rect(&self, content_rect: Rect) -> Rect {
232      content_rect
233         + self.inner_margin
234         + MarginF32::from(self.visuals.border.width)
235         + self.outer_margin
236   }
237
238   fn stack_frame(&self) -> egui::Frame {
239      egui::Frame {
240         inner_margin: self.inner_margin,
241         fill: Color32::TRANSPARENT,
242         stroke: Stroke::new(self.visuals.border.width, Color32::TRANSPARENT),
243         corner_radius: self.visuals.corner_radius,
244         outer_margin: self.outer_margin,
245         shadow: self.visuals.shadow,
246      }
247   }
248
249   fn paint_shape(&self, widget_rect: Rect, fill: Color32, stroke: Stroke) -> Shape {
250      crate::visuals::paint_shape(
251         widget_rect,
252         fill,
253         stroke,
254         self.visuals.corner_radius,
255         self.visuals.shadow,
256      )
257   }
258}
259
260pub struct Prepared {
261   pub frame: Frame,
262   where_to_put_background: ShapeIdx,
263   pub content_ui: Ui,
264   max_content_rect: Rect,
265}
266
267impl Frame {
268   pub fn begin(self, ui: &mut Ui) -> Prepared {
269      let where_to_put_background = ui.painter().add(Shape::Noop);
270      let outer_rect_bounds = ui.available_rect_before_wrap();
271
272      let mut max_content_rect = outer_rect_bounds - self.total_margin();
273      max_content_rect.max.x = max_content_rect.max.x.max(max_content_rect.min.x);
274      max_content_rect.max.y = max_content_rect.max.y.max(max_content_rect.min.y);
275
276      let mut content_ui = ui.new_child(
277         UiBuilder::new()
278            .ui_stack_info(UiStackInfo::new(UiKind::Frame).with_frame(self.stack_frame()))
279            .max_rect(max_content_rect)
280            .sense(Sense::empty()),
281      );
282      if self.fill_width {
283         content_ui.set_min_width(max_content_rect.width());
284      }
285
286      Prepared {
287         frame: self,
288         where_to_put_background,
289         content_ui,
290         max_content_rect,
291      }
292   }
293
294   pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
295      let mut prepared = self.begin(ui);
296      let ret = add_contents(&mut prepared.content_ui);
297      let response = prepared.end(ui);
298      InnerResponse::new(ret, response)
299   }
300}
301
302impl Prepared {
303   fn content_rect(&self) -> Rect {
304      let mut content_rect = self.content_ui.min_rect();
305      if self.frame.fill_width {
306         content_rect.min.x = content_rect.min.x.min(self.max_content_rect.min.x);
307         content_rect.max.x = content_rect.max.x.max(self.max_content_rect.max.x);
308      }
309      content_rect
310   }
311
312   fn allocated_outer_rect(&self) -> Rect {
313      self.frame.outer_rect(self.content_rect())
314   }
315
316   pub fn allocate_space(&self, ui: &mut Ui) -> Response {
317      ui.allocate_rect(self.allocated_outer_rect(), self.frame.sense)
318   }
319
320   pub fn paint(&self, ui: &Ui, response: &Response) {
321      let content_rect = self.content_rect();
322      let widget_rect = self.frame.widget_rect(content_rect);
323      if !ui.is_rect_visible(widget_rect) {
324         return;
325      }
326      let (fill, stroke) = if self.frame.selected {
327         (
328            self.frame.visuals.bg_selected,
329            self.frame.visuals.border,
330         )
331      } else if self.frame.interactive {
332         (
333            self.frame.visuals.bg_from_res(response),
334            self.frame.visuals.border_from_res(response),
335         )
336      } else {
337         (self.frame.visuals.bg, self.frame.visuals.border)
338      };
339      let shape = self.frame.paint_shape(widget_rect, fill, stroke);
340      ui.painter().set(self.where_to_put_background, shape);
341   }
342
343   pub fn end(self, ui: &mut Ui) -> Response {
344      let response = self.allocate_space(ui);
345      self.paint(ui, &response);
346      response
347   }
348}
349
350#[cfg(test)]
351mod tests {
352   use super::*;
353
354   #[test]
355   fn total_margin_sums_parts() {
356      let frame = Frame::new()
357         .inner_margin(Margin::same(4))
358         .outer_margin(Margin::same(3))
359         .stroke(Stroke::new(2.0, Color32::WHITE));
360      let m = frame.total_margin();
361      assert_eq!(m.left, 9.0);
362      assert_eq!(m.right, 9.0);
363      assert_eq!(m.top, 9.0);
364      assert_eq!(m.bottom, 9.0);
365   }
366
367   #[test]
368   fn fill_propagates_to_matching_states() {
369      let frame = Frame::new().fill(Color32::RED);
370      assert_eq!(frame.visuals.bg, Color32::RED);
371      assert_eq!(frame.visuals.bg_hover, Color32::RED);
372      assert_eq!(frame.visuals.bg_click, Color32::RED);
373      assert_eq!(frame.visuals.bg_selected, Color32::RED);
374   }
375
376   #[test]
377   fn interactive_false_is_default() {
378      let frame = Frame::new().fill(Color32::RED);
379      assert!(!frame.interactive);
380      assert!(!frame.selected);
381      assert!(!frame.fill_width);
382      assert!(frame.interactive(true).interactive);
383      assert!(!frame.interactive(true).interactive(false).interactive);
384      assert!(frame.selected(true).selected);
385      assert!(frame.fill_width(true).fill_width);
386   }
387
388   fn theme_visuals() -> FrameVisuals {
389      FrameVisuals {
390         bg: Color32::RED,
391         bg_hover: Color32::BLUE,
392         bg_click: Color32::RED,
393         bg_selected: Color32::GREEN,
394         border: Stroke::NONE,
395         border_hover: Stroke::NONE,
396         border_click: Stroke::NONE,
397         corner_radius: CornerRadius::same(6),
398         shadow: Shadow::NONE,
399      }
400   }
401
402   #[test]
403   fn zero_radius_survives_later_visuals() {
404      let frame = Frame::new().corner_radius(0).visuals(theme_visuals());
405      assert_eq!(frame.visuals.corner_radius, CornerRadius::ZERO);
406   }
407
408   #[test]
409   fn corner_radius_zero_survives_later_visuals() {
410      let frame = Frame::new().corner_radius(0).visuals(theme_visuals());
411      assert_eq!(frame.visuals.corner_radius, CornerRadius::ZERO);
412   }
413
414   #[test]
415   fn visuals_then_zero_corner_radius() {
416      let frame = Frame::new().visuals(theme_visuals()).corner_radius(0);
417      assert_eq!(frame.visuals.corner_radius, CornerRadius::ZERO);
418   }
419
420   #[test]
421   fn visuals_without_override_keeps_theme_radius() {
422      let frame = Frame::new().visuals(theme_visuals());
423      assert_eq!(frame.visuals.corner_radius, CornerRadius::same(6));
424   }
425}