Skip to main content

gitkraft_gui/widgets/
bounded_popup.rs

1//! Reusable bounded popup — positions content near a cursor point while
2//! keeping it fully within the window bounds.
3//!
4//! Works correctly for any window size (maximized, windowed, small).
5
6use iced::widget::{column, container, row, scrollable, Space};
7use iced::{Element, Length};
8
9use crate::message::Message;
10use crate::view_utils;
11
12/// Minimum distance from window edges (pixels).
13const EDGE_MARGIN: f32 = 2.0;
14
15/// Compute the (x, y) position for a popup, clamped within window bounds.
16///
17/// This is a pure function so it can be tested independently of rendering.
18pub fn compute_popup_position(
19    cursor_x: f32,
20    cursor_y: f32,
21    popup_w: f32,
22    popup_max_h: f32,
23    window_w: f32,
24    window_h: f32,
25) -> (f32, f32) {
26    // Horizontal: prefer right of cursor; flip left if it would overflow.
27    let px = if cursor_x + popup_w + EDGE_MARGIN > window_w {
28        (cursor_x - popup_w - EDGE_MARGIN).max(EDGE_MARGIN)
29    } else {
30        (cursor_x + EDGE_MARGIN).max(EDGE_MARGIN)
31    };
32
33    // Vertical: prefer below cursor; push up if it would overflow bottom.
34    let py = if cursor_y + popup_max_h + EDGE_MARGIN > window_h {
35        let overflow = cursor_y + popup_max_h + EDGE_MARGIN - window_h;
36        (cursor_y - overflow).max(EDGE_MARGIN)
37    } else {
38        (cursor_y + EDGE_MARGIN).max(EDGE_MARGIN)
39    };
40
41    (px, py)
42}
43
44/// A builder for a bounds-aware popup.
45pub struct BoundedPopup<'a> {
46    content: Element<'a, Message>,
47    cursor_x: f32,
48    cursor_y: f32,
49    window_w: f32,
50    window_h: f32,
51    popup_width: f32,
52    popup_max_height: f32,
53    style_fn: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
54}
55
56/// Create a bounded popup that stays within the window.
57pub fn bounded_popup<'a>(
58    content: impl Into<Element<'a, Message>>,
59    cursor_x: f32,
60    cursor_y: f32,
61    window_w: f32,
62    window_h: f32,
63) -> BoundedPopup<'a> {
64    BoundedPopup {
65        content: content.into(),
66        cursor_x,
67        cursor_y,
68        window_w,
69        window_h,
70        popup_width: 280.0,
71        popup_max_height: 500.0,
72        style_fn: None,
73    }
74}
75
76impl<'a> BoundedPopup<'a> {
77    /// Set the popup width (default 280).
78    pub fn width(mut self, w: f32) -> Self {
79        self.popup_width = w;
80        self
81    }
82
83    /// Set the maximum popup height before scrolling (default 500).
84    pub fn max_height(mut self, h: f32) -> Self {
85        self.popup_max_height = h;
86        self
87    }
88
89    /// Set the container style for the popup panel.
90    pub fn style(mut self, s: fn(&iced::Theme) -> iced::widget::container::Style) -> Self {
91        self.style_fn = Some(s);
92        self
93    }
94
95    /// Build the positioned popup element.
96    pub fn build(self) -> Element<'a, Message> {
97        let (px, py) = compute_popup_position(
98            self.cursor_x,
99            self.cursor_y,
100            self.popup_width,
101            self.popup_max_height,
102            self.window_w,
103            self.window_h,
104        );
105
106        // Cap max_height to the AVAILABLE space below the computed position.
107        // This ensures the popup never extends past the window bottom,
108        // regardless of window size (maximized, windowed, small).
109        let available_h = (self.window_h - py - EDGE_MARGIN).max(100.0);
110        let effective_max_h = self.popup_max_height.min(available_h);
111
112        let scrollable_content = scrollable(self.content)
113            .direction(view_utils::thin_scrollbar())
114            .style(crate::theme::overlay_scrollbar);
115
116        let mut panel = container(scrollable_content)
117            .width(self.popup_width)
118            .max_height(effective_max_h);
119
120        if let Some(s) = self.style_fn {
121            panel = panel.style(s);
122        }
123
124        let panel_element: Element<'a, Message> = panel.into();
125
126        column![
127            Space::new().height(py),
128            row![Space::new().width(px), panel_element],
129        ]
130        .width(Length::Fill)
131        .height(Length::Fill)
132        .into()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn assert_within_bounds(px: f32, py: f32, popup_w: f32, win_w: f32) {
141        assert!(px >= EDGE_MARGIN, "px={px} below margin");
142        assert!(py >= EDGE_MARGIN, "py={py} below margin");
143        assert!(
144            px + popup_w <= win_w + 0.01,
145            "right edge overflow: px={px} + w={popup_w} > win_w={win_w}"
146        );
147    }
148
149    /// Simulate the effective_max_h calculation from build().
150    fn effective_max_h(py: f32, popup_max_h: f32, win_h: f32) -> f32 {
151        let available = (win_h - py - EDGE_MARGIN).max(100.0);
152        popup_max_h.min(available)
153    }
154
155    /// Assert the popup bottom never exceeds the window bottom.
156    fn assert_no_bottom_overflow(py: f32, popup_max_h: f32, win_h: f32) {
157        let h = effective_max_h(py, popup_max_h, win_h);
158        assert!(
159            py + h <= win_h + 0.01,
160            "bottom overflow: py={py} + h={h} = {} > win_h={win_h}",
161            py + h
162        );
163    }
164
165    #[test]
166    fn fits_normally_at_cursor() {
167        let (px, py) = compute_popup_position(100.0, 200.0, 280.0, 500.0, 1400.0, 800.0);
168        assert!((px - 102.0).abs() < 0.1);
169        assert!((py - 202.0).abs() < 0.1);
170        assert_within_bounds(px, py, 280.0, 1400.0);
171        assert_no_bottom_overflow(py, 500.0, 800.0);
172    }
173
174    #[test]
175    fn flips_left_when_right_overflow() {
176        let (px, _) = compute_popup_position(1300.0, 100.0, 280.0, 500.0, 1400.0, 800.0);
177        assert!(px < 1300.0, "should flip left");
178        assert!(px + 280.0 <= 1400.0);
179    }
180
181    #[test]
182    fn pushes_up_when_bottom_overflow() {
183        let (_, py) = compute_popup_position(100.0, 700.0, 280.0, 500.0, 1400.0, 800.0);
184        assert!(py < 700.0, "should push up");
185        assert!(py >= EDGE_MARGIN);
186        assert_no_bottom_overflow(py, 500.0, 800.0);
187    }
188
189    #[test]
190    fn bottom_right_corner() {
191        let (px, py) = compute_popup_position(1350.0, 780.0, 280.0, 500.0, 1400.0, 800.0);
192        assert!(px < 1350.0);
193        assert!(py < 780.0);
194        assert!(px >= EDGE_MARGIN);
195        assert!(py >= EDGE_MARGIN);
196    }
197
198    #[test]
199    fn top_left_corner() {
200        let (px, py) = compute_popup_position(0.0, 0.0, 280.0, 500.0, 1400.0, 800.0);
201        assert!((px - EDGE_MARGIN).abs() < 0.1);
202        assert!((py - EDGE_MARGIN).abs() < 0.1);
203    }
204
205    #[test]
206    fn small_windowed_mode_400x300() {
207        let (px, py) = compute_popup_position(200.0, 150.0, 280.0, 500.0, 400.0, 300.0);
208        assert!(px >= EDGE_MARGIN);
209        assert!(py >= EDGE_MARGIN);
210        assert_within_bounds(px, py, 280.0, 400.0);
211        assert_no_bottom_overflow(py, 500.0, 300.0);
212    }
213
214    #[test]
215    fn window_smaller_than_popup() {
216        let (px, py) = compute_popup_position(50.0, 50.0, 280.0, 500.0, 200.0, 200.0);
217        assert!(px >= EDGE_MARGIN);
218        assert!(py >= EDGE_MARGIN);
219    }
220
221    #[test]
222    fn negative_cursor_clamped() {
223        let (px, py) = compute_popup_position(-10.0, -20.0, 280.0, 500.0, 1400.0, 800.0);
224        assert!(px >= EDGE_MARGIN);
225        assert!(py >= EDGE_MARGIN);
226    }
227
228    #[test]
229    fn windowed_800x600() {
230        let (px, py) = compute_popup_position(500.0, 400.0, 280.0, 350.0, 800.0, 600.0);
231        assert!(px + 280.0 <= 800.0, "overflows right in 800px window");
232        assert!(py >= EDGE_MARGIN);
233    }
234
235    #[test]
236    fn windowed_right_click_near_edge() {
237        let (px, py) = compute_popup_position(700.0, 500.0, 280.0, 350.0, 800.0, 600.0);
238        assert!(px < 700.0, "should flip left");
239        assert!(py < 500.0, "should push up");
240        assert!(px >= EDGE_MARGIN);
241        assert!(py >= EDGE_MARGIN);
242        assert_no_bottom_overflow(py, 350.0, 600.0);
243    }
244
245    #[test]
246    fn free_floating_window_900px() {
247        // Simulates the user's screenshot: ~900px window, cursor at y=570
248        let (_, py) = compute_popup_position(200.0, 570.0, 280.0, 500.0, 1000.0, 900.0);
249        assert!(py >= EDGE_MARGIN);
250        assert_no_bottom_overflow(py, 500.0, 900.0);
251    }
252
253    #[test]
254    fn very_tall_menu_in_short_window() {
255        // 600px popup in 400px window — must scroll, never overflow
256        let (_, py) = compute_popup_position(100.0, 200.0, 280.0, 600.0, 800.0, 400.0);
257        assert!(py >= EDGE_MARGIN);
258        assert_no_bottom_overflow(py, 600.0, 400.0);
259    }
260
261    #[test]
262    fn exact_fit_no_adjustment() {
263        let win_w = 100.0 + 280.0 + EDGE_MARGIN;
264        let win_h = 100.0 + 500.0 + EDGE_MARGIN;
265        let (px, py) = compute_popup_position(100.0, 100.0, 280.0, 500.0, win_w, win_h);
266        assert!((px - 102.0).abs() < 0.1);
267        assert!((py - 102.0).abs() < 0.1);
268    }
269}