1use crate::{Card, CardVariant, Theme};
6use egui::{pos2, vec2, Color32, Id, Pos2, Rect, Ui, Vec2};
7
8const MIN_SPACE_FOR_POSITION: f32 = 50.0;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum PopoverPosition {
21 Top,
23 #[default]
25 Bottom,
26 Left,
28 Right,
30 Auto,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum PopoverStyle {
37 #[default]
39 Default,
40 Elevated,
42 Bordered,
44 Flat,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum PopoverColor {
51 #[default]
53 Surface,
54 Primary,
56 Success,
58 Warning,
60 Error,
62 Info,
64}
65
66pub struct PopoverResponse {
72 pub response: egui::Response,
74 pub clicked_outside: bool,
76 pub should_close: bool,
78}
79
80#[derive(Clone)]
101pub struct Popover {
102 id: Id,
103 position: PopoverPosition,
104 style: PopoverStyle,
105 color: PopoverColor,
106 offset: Vec2,
107 width: Option<f32>,
108 max_width: f32,
109 padding: Option<f32>,
110 external_is_open: Option<bool>,
111}
112
113struct PopoverRenderStyle {
115 bg_color: Color32,
116 border_color: Color32,
117 rounding: f32,
118 padding: f32,
119 card_variant: CardVariant,
120}
121
122impl Popover {
123 pub fn new(id: impl Into<Id>) -> Self {
125 Self {
126 id: id.into(),
127 position: PopoverPosition::default(),
128 style: PopoverStyle::default(),
129 color: PopoverColor::default(),
130 offset: vec2(0.0, 8.0),
131 width: None,
132 max_width: 400.0,
133 padding: None,
134 external_is_open: None,
135 }
136 }
137
138 #[must_use]
140 pub const fn open(mut self, is_open: bool) -> Self {
141 self.external_is_open = Some(is_open);
142 self
143 }
144
145 pub const fn set_open(&mut self, is_open: bool) {
147 self.external_is_open = Some(is_open);
148 }
149
150 #[must_use]
152 pub const fn position(mut self, position: PopoverPosition) -> Self {
153 self.position = position;
154 self
155 }
156
157 #[must_use]
159 pub const fn style(mut self, style: PopoverStyle) -> Self {
160 self.style = style;
161 self
162 }
163
164 #[must_use]
166 pub const fn color(mut self, color: PopoverColor) -> Self {
167 self.color = color;
168 self
169 }
170
171 #[must_use]
173 pub const fn offset(mut self, offset: Vec2) -> Self {
174 self.offset = offset;
175 self
176 }
177
178 #[must_use]
180 pub const fn width(mut self, width: f32) -> Self {
181 self.width = Some(width);
182 self
183 }
184
185 #[must_use]
187 pub const fn max_width(mut self, max_width: f32) -> Self {
188 self.max_width = max_width;
189 self
190 }
191
192 #[must_use]
194 pub const fn padding(mut self, padding: f32) -> Self {
195 self.padding = Some(padding);
196 self
197 }
198
199 pub fn show(
201 &mut self,
202 ctx: &egui::Context,
203 theme: &Theme,
204 anchor_rect: Rect,
205 content: impl FnOnce(&mut Ui),
206 ) -> PopoverResponse {
207 let is_open = self.external_is_open.unwrap_or(false);
209 if !is_open {
210 let dummy = egui::Area::new(self.id.with("popover_empty"))
211 .order(egui::Order::Background)
212 .fixed_pos(egui::Pos2::ZERO)
213 .show(ctx, |_| {})
214 .response;
215 return PopoverResponse {
216 response: dummy,
217 clicked_outside: false,
218 should_close: false,
219 };
220 }
221
222 let position = self.determine_position(ctx, anchor_rect);
224 let popover_pos = self.calculate_popover_position(anchor_rect, position);
225
226 let (bg_color, border_color) = self.get_colors(theme);
228 let (stroke_width, rounding, padding) = self.get_style_params(theme);
229 let card_variant = self.get_card_variant(stroke_width);
230
231 let style = PopoverRenderStyle {
232 bg_color,
233 border_color,
234 rounding,
235 padding,
236 card_variant,
237 };
238
239 let area_response = self.render_popover(ctx, theme, popover_pos, &style, content);
241
242 let (clicked_outside, should_close) =
244 self.check_click_outside(ctx, &area_response.response.rect, anchor_rect);
245
246 PopoverResponse {
247 response: area_response.response,
248 clicked_outside,
249 should_close,
250 }
251 }
252
253 fn determine_position(&self, ctx: &egui::Context, anchor_rect: Rect) -> PopoverPosition {
258 if self.position != PopoverPosition::Auto {
259 return self.position;
260 }
261
262 let screen_rect = ctx.available_rect();
263 let space_above = anchor_rect.top() - screen_rect.top();
264 let space_below = screen_rect.bottom() - anchor_rect.bottom();
265 let space_left = anchor_rect.left() - screen_rect.left();
266 let space_right = screen_rect.right() - anchor_rect.right();
267
268 if space_below >= MIN_SPACE_FOR_POSITION {
270 PopoverPosition::Bottom
271 } else if space_above >= MIN_SPACE_FOR_POSITION {
272 PopoverPosition::Top
273 } else if space_right >= MIN_SPACE_FOR_POSITION {
274 PopoverPosition::Right
275 } else if space_left >= MIN_SPACE_FOR_POSITION {
276 PopoverPosition::Left
277 } else {
278 PopoverPosition::Bottom
279 }
280 }
281
282 fn calculate_popover_position(&self, anchor_rect: Rect, position: PopoverPosition) -> Pos2 {
283 let spacing = self.offset.length();
284 let estimated_width = self.width.unwrap_or(self.max_width);
285
286 match position {
287 PopoverPosition::Top => pos2(
288 anchor_rect.center().x - estimated_width / 2.0,
289 anchor_rect.top() - spacing,
290 ),
291 PopoverPosition::Bottom => pos2(
292 anchor_rect.center().x - estimated_width / 2.0,
293 anchor_rect.bottom() + spacing,
294 ),
295 PopoverPosition::Left => pos2(
296 anchor_rect.left() - estimated_width - spacing,
297 anchor_rect.center().y,
298 ),
299 PopoverPosition::Right => pos2(anchor_rect.right() + spacing, anchor_rect.center().y),
300 PopoverPosition::Auto => unreachable!(),
301 }
302 }
303
304 fn get_colors(&self, theme: &Theme) -> (Color32, Color32) {
309 match self.color {
310 PopoverColor::Surface => (theme.card(), theme.border()),
311 PopoverColor::Primary => blend_with_card(theme, theme.primary()),
312 PopoverColor::Success => blend_with_card(theme, theme.chart_2()),
313 PopoverColor::Warning => blend_with_card(theme, theme.chart_3()),
314 PopoverColor::Error => blend_with_card(theme, theme.destructive()),
315 PopoverColor::Info => blend_with_card(theme, theme.chart_4()),
316 }
317 }
318
319 fn get_style_params(&self, theme: &Theme) -> (f32, f32, f32) {
320 let (stroke_width, rounding, default_padding) = match self.style {
321 PopoverStyle::Default => (
322 1.0,
323 f32::from(theme.spacing.corner_radius),
324 theme.spacing.md,
325 ),
326 PopoverStyle::Elevated => (
327 0.5,
328 f32::from(theme.spacing.corner_radius_large),
329 theme.spacing.lg,
330 ),
331 PopoverStyle::Bordered => (
332 2.0,
333 f32::from(theme.spacing.corner_radius_small),
334 theme.spacing.md,
335 ),
336 PopoverStyle::Flat => (
337 0.0,
338 f32::from(theme.spacing.corner_radius_small),
339 theme.spacing.md,
340 ),
341 };
342 let padding = self.padding.unwrap_or(default_padding);
343 (stroke_width, rounding, padding)
344 }
345
346 fn get_card_variant(&self, stroke_width: f32) -> CardVariant {
347 match self.style {
348 PopoverStyle::Elevated => CardVariant::Elevated,
349 PopoverStyle::Bordered => CardVariant::Outlined,
350 PopoverStyle::Flat => CardVariant::Filled,
351 PopoverStyle::Default => {
352 if stroke_width > 0.0 {
353 CardVariant::Outlined
354 } else {
355 CardVariant::Filled
356 }
357 }
358 }
359 }
360
361 fn render_popover(
366 &self,
367 ctx: &egui::Context,
368 _theme: &Theme,
369 popover_pos: Pos2,
370 style: &PopoverRenderStyle,
371 content: impl FnOnce(&mut Ui),
372 ) -> egui::InnerResponse<()> {
373 egui::Area::new(self.id)
374 .order(egui::Order::Foreground)
375 .fixed_pos(popover_pos)
376 .show(ctx, |ui| {
377 let content_width = self
378 .width
379 .unwrap_or_else(|| ui.available_width().min(self.max_width));
380
381 ui.set_max_width(content_width);
382
383 Card::new()
384 .variant(style.card_variant)
385 .fill(style.bg_color)
386 .stroke(style.border_color)
387 .corner_radius(style.rounding)
388 .inner_margin(style.padding)
389 .width(content_width)
390 .show(ui, |ui| {
391 content(ui);
392 });
393 })
394 }
395
396 fn check_click_outside(
397 &self,
398 ctx: &egui::Context,
399 popover_rect: &Rect,
400 anchor_rect: Rect,
401 ) -> (bool, bool) {
402 let mut clicked_outside = false;
403 let mut should_close = false;
404
405 if ctx.input(|i| i.pointer.any_click()) {
406 if let Some(click_pos) = ctx.input(|i| i.pointer.interact_pos()) {
407 if !popover_rect.contains(click_pos) && !anchor_rect.contains(click_pos) {
408 clicked_outside = true;
409 should_close = true;
410 }
411 }
412 }
413
414 (clicked_outside, should_close)
415 }
416}
417
418fn blend_with_card(theme: &Theme, base: Color32) -> (Color32, Color32) {
423 let blended = Color32::from_rgba_premultiplied(
424 (f32::from(theme.card().r()) * 0.85 + f32::from(base.r()) * 0.15) as u8,
425 (f32::from(theme.card().g()) * 0.85 + f32::from(base.g()) * 0.15) as u8,
426 (f32::from(theme.card().b()) * 0.85 + f32::from(base.b()) * 0.15) as u8,
427 255,
428 );
429 (blended, base)
430}