1use std::hash::Hash;
29
30use egui::{
31 emath::RectAlign, Color32, CornerRadius, Frame, Id, InnerResponse, Margin, Pos2, Rect,
32 Response, Shape, Stroke, Ui, Vec2, WidgetText,
33};
34
35use crate::theme::Theme;
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum PopoverSide {
43 Top,
45 Bottom,
47 Left,
49 Right,
51}
52
53impl PopoverSide {
54 fn to_rect_align(self) -> RectAlign {
55 match self {
56 PopoverSide::Top => RectAlign::TOP,
57 PopoverSide::Bottom => RectAlign::BOTTOM,
58 PopoverSide::Left => RectAlign::LEFT,
59 PopoverSide::Right => RectAlign::RIGHT,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
70#[must_use = "Call `.show(&trigger, |ui| ...)` to render the popover."]
71pub struct Popover {
72 id_salt: Id,
73 side: PopoverSide,
74 title: Option<WidgetText>,
75 width: Option<f32>,
76 min_width: f32,
77 gap: f32,
78 arrow: bool,
79}
80
81impl Popover {
82 pub fn new(id_salt: impl Hash) -> Self {
86 Self {
87 id_salt: Self::popup_id(id_salt),
88 side: PopoverSide::Bottom,
89 title: None,
90 width: None,
91 min_width: 200.0,
92 gap: 8.0,
93 arrow: true,
94 }
95 }
96
97 pub fn popup_id(id_salt: impl Hash) -> Id {
103 Id::new(("elegance::popover", Id::new(id_salt)))
104 }
105
106 #[inline]
108 pub fn side(mut self, side: PopoverSide) -> Self {
109 self.side = side;
110 self
111 }
112
113 #[inline]
115 pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
116 self.title = Some(title.into());
117 self
118 }
119
120 #[inline]
123 pub fn width(mut self, width: f32) -> Self {
124 self.width = Some(width);
125 self
126 }
127
128 #[inline]
130 pub fn min_width(mut self, min_width: f32) -> Self {
131 self.min_width = min_width;
132 self
133 }
134
135 #[inline]
137 pub fn gap(mut self, gap: f32) -> Self {
138 self.gap = gap;
139 self
140 }
141
142 #[inline]
144 pub fn arrow(mut self, arrow: bool) -> Self {
145 self.arrow = arrow;
146 self
147 }
148
149 pub fn show<R>(
153 self,
154 trigger: &Response,
155 add_contents: impl FnOnce(&mut Ui) -> R,
156 ) -> Option<InnerResponse<R>> {
157 let theme = Theme::current(&trigger.ctx);
158 let p = &theme.palette;
159
160 let popup_id = self.id_salt;
161 let side = self.side;
162 let title = self.title;
163 let arrow = self.arrow;
164 let width = self.width;
165 let min_width = self.min_width;
166
167 let frame = Frame::new()
168 .fill(p.card)
169 .stroke(Stroke::new(1.0, p.border))
170 .corner_radius(CornerRadius::same(theme.card_radius as u8))
171 .inner_margin(Margin::same(12));
172
173 let mut popup = egui::Popup::from_toggle_button_response(trigger)
174 .id(popup_id)
175 .align(side.to_rect_align())
176 .align_alternatives(&[])
177 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
178 .gap(self.gap)
179 .frame(frame);
180 if let Some(w) = width {
181 popup = popup.width(w);
182 }
183
184 let trigger_rect = trigger.rect;
185 let trigger_ctx = trigger.ctx.clone();
186
187 let response = popup.show(move |ui| {
188 ui.set_min_width(min_width);
189 if let Some(h) = &title {
190 let t = Theme::current(ui.ctx());
191 let rt = egui::RichText::new(h.text())
192 .color(t.palette.text)
193 .size(t.typography.body)
194 .strong();
195 ui.add(egui::Label::new(rt).wrap_mode(egui::TextWrapMode::Extend));
196 ui.add_space(4.0);
197 }
198 add_contents(ui)
199 });
200
201 let inner = response?;
202
203 if arrow {
204 let actual_side = detect_side(trigger_rect, inner.response.rect, side);
208 paint_arrow(
209 &trigger_ctx,
210 inner.response.layer_id,
211 inner.response.rect,
212 trigger_rect,
213 actual_side,
214 p.card,
215 p.border,
216 );
217 }
218
219 Some(inner)
220 }
221}
222
223fn detect_side(trigger: Rect, popup: Rect, requested: PopoverSide) -> PopoverSide {
224 match requested {
225 PopoverSide::Top | PopoverSide::Bottom => {
226 if popup.center().y < trigger.center().y {
227 PopoverSide::Top
228 } else {
229 PopoverSide::Bottom
230 }
231 }
232 PopoverSide::Left | PopoverSide::Right => {
233 if popup.center().x < trigger.center().x {
234 PopoverSide::Left
235 } else {
236 PopoverSide::Right
237 }
238 }
239 }
240}
241
242fn paint_arrow(
243 ctx: &egui::Context,
244 layer: egui::LayerId,
245 popup: Rect,
246 trigger: Rect,
247 side: PopoverSide,
248 fill: Color32,
249 border: Color32,
250) {
251 let painter = ctx.layer_painter(layer);
252
253 let half_base = 6.0;
255 let depth = 6.0;
256 let inset = 10.0; let (base_center, perp, base_axis) = match side {
263 PopoverSide::Bottom => {
264 let cx = trigger
265 .center()
266 .x
267 .clamp(popup.min.x + inset, popup.max.x - inset);
268 (
269 Pos2::new(cx, popup.min.y),
270 Vec2::new(0.0, -1.0),
271 Vec2::new(1.0, 0.0),
272 )
273 }
274 PopoverSide::Top => {
275 let cx = trigger
276 .center()
277 .x
278 .clamp(popup.min.x + inset, popup.max.x - inset);
279 (
280 Pos2::new(cx, popup.max.y),
281 Vec2::new(0.0, 1.0),
282 Vec2::new(1.0, 0.0),
283 )
284 }
285 PopoverSide::Right => {
286 let cy = trigger
287 .center()
288 .y
289 .clamp(popup.min.y + inset, popup.max.y - inset);
290 (
291 Pos2::new(popup.min.x, cy),
292 Vec2::new(-1.0, 0.0),
293 Vec2::new(0.0, 1.0),
294 )
295 }
296 PopoverSide::Left => {
297 let cy = trigger
298 .center()
299 .y
300 .clamp(popup.min.y + inset, popup.max.y - inset);
301 (
302 Pos2::new(popup.max.x, cy),
303 Vec2::new(1.0, 0.0),
304 Vec2::new(0.0, 1.0),
305 )
306 }
307 };
308
309 let base_a = base_center + base_axis * half_base;
310 let base_b = base_center - base_axis * half_base;
311 let tip = base_center + perp * depth;
312
313 painter.add(Shape::convex_polygon(
316 vec![base_a, tip, base_b],
317 fill,
318 Stroke::NONE,
319 ));
320
321 painter.line_segment([base_a, base_b], Stroke::new(1.5, fill));
324
325 let stroke = Stroke::new(1.0, border);
327 painter.line_segment([base_a, tip], stroke);
328 painter.line_segment([base_b, tip], stroke);
329}