1use iced::widget::{column, container, row, scrollable, Space};
7use iced::{Element, Length};
8
9use crate::message::Message;
10use crate::view_utils;
11
12const EDGE_MARGIN: f32 = 2.0;
14
15pub 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 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 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
44pub 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
56pub 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 pub fn width(mut self, w: f32) -> Self {
79 self.popup_width = w;
80 self
81 }
82
83 pub fn max_height(mut self, h: f32) -> Self {
85 self.popup_max_height = h;
86 self
87 }
88
89 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 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 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 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 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 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 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}