Skip to main content

armas_basic/components/
popover.rs

1//! Popover Component
2//!
3//! Floating panels anchored to elements.
4
5use crate::{Card, CardVariant, Theme};
6use egui::{pos2, vec2, Color32, Id, Pos2, Rect, Ui, Vec2};
7
8// ============================================================================
9// Constants
10// ============================================================================
11
12const MIN_SPACE_FOR_POSITION: f32 = 50.0;
13
14// ============================================================================
15// Enums
16// ============================================================================
17
18/// Popover position relative to anchor
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum PopoverPosition {
21    /// Above the anchor
22    Top,
23    /// Below the anchor
24    #[default]
25    Bottom,
26    /// To the left of the anchor
27    Left,
28    /// To the right of the anchor
29    Right,
30    /// Automatically choose based on available space
31    Auto,
32}
33
34/// Popover visual style
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum PopoverStyle {
37    /// Default style with soft border
38    #[default]
39    Default,
40    /// Elevated style with stronger shadow
41    Elevated,
42    /// Bordered style with stronger border
43    Bordered,
44    /// Flat style with no shadow or border
45    Flat,
46}
47
48/// Popover color themes
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum PopoverColor {
51    /// Default surface color
52    #[default]
53    Surface,
54    /// Primary theme color
55    Primary,
56    /// Success/positive (green)
57    Success,
58    /// Warning/caution (yellow)
59    Warning,
60    /// Error/danger (red)
61    Error,
62    /// Informational (blue)
63    Info,
64}
65
66// ============================================================================
67// Response
68// ============================================================================
69
70/// Response from showing a popover
71pub struct PopoverResponse {
72    /// The UI response
73    pub response: egui::Response,
74    /// Whether the user clicked outside the popover
75    pub clicked_outside: bool,
76    /// Whether the popover should be closed (for external state management)
77    pub should_close: bool,
78}
79
80// ============================================================================
81// Popover Component
82// ============================================================================
83
84/// Popover component for floating panels anchored to elements
85///
86/// # Example
87///
88/// ```rust,no_run
89/// # use egui::{Ui, Rect};
90/// # use armas_basic::Theme;
91/// # fn example(ctx: &egui::Context, theme: &Theme, anchor: Rect) {
92/// use armas_basic::components::Popover;
93///
94/// let mut popover = Popover::new("settings").open(true);
95/// popover.show(ctx, theme, anchor, |ui| {
96///     ui.label("Popover content");
97/// });
98/// # }
99/// ```
100#[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
113/// Style parameters for popover rendering
114struct PopoverRenderStyle {
115    bg_color: Color32,
116    border_color: Color32,
117    rounding: f32,
118    padding: f32,
119    card_variant: CardVariant,
120}
121
122impl Popover {
123    /// Create a new popover with the given ID
124    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    /// Set the popover to be open (for external control)
139    #[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    /// Set the open state (mutable version)
146    pub const fn set_open(&mut self, is_open: bool) {
147        self.external_is_open = Some(is_open);
148    }
149
150    /// Set the popover position relative to anchor
151    #[must_use]
152    pub const fn position(mut self, position: PopoverPosition) -> Self {
153        self.position = position;
154        self
155    }
156
157    /// Set the popover visual style
158    #[must_use]
159    pub const fn style(mut self, style: PopoverStyle) -> Self {
160        self.style = style;
161        self
162    }
163
164    /// Set the popover color theme
165    #[must_use]
166    pub const fn color(mut self, color: PopoverColor) -> Self {
167        self.color = color;
168        self
169    }
170
171    /// Set the offset from the anchor
172    #[must_use]
173    pub const fn offset(mut self, offset: Vec2) -> Self {
174        self.offset = offset;
175        self
176    }
177
178    /// Set a fixed width
179    #[must_use]
180    pub const fn width(mut self, width: f32) -> Self {
181        self.width = Some(width);
182        self
183    }
184
185    /// Set maximum width
186    #[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    /// Set custom inner padding (overrides style default)
193    #[must_use]
194    pub const fn padding(mut self, padding: f32) -> Self {
195        self.padding = Some(padding);
196        self
197    }
198
199    /// Show the popover anchored to the given rect
200    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        // Check if should be open
208        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        // Calculate position
223        let position = self.determine_position(ctx, anchor_rect);
224        let popover_pos = self.calculate_popover_position(anchor_rect, position);
225
226        // Get styling
227        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        // Render the popover
240        let area_response = self.render_popover(ctx, theme, popover_pos, &style, content);
241
242        // Handle click outside
243        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    // ========================================================================
254    // Position Calculation
255    // ========================================================================
256
257    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        // Prefer bottom, then top, then sides
269        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    // ========================================================================
305    // Styling
306    // ========================================================================
307
308    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    // ========================================================================
362    // Rendering
363    // ========================================================================
364
365    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
418// ============================================================================
419// Helper Functions
420// ============================================================================
421
422fn 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}