1use egui::{
28 emath::RectAlign, Color32, CornerRadius, Frame, Margin, Pos2, Rect, Response, Sense, Shape,
29 Stroke, Ui, Vec2, WidgetText,
30};
31
32use crate::theme::Theme;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum TooltipSide {
40 Top,
42 Bottom,
44 Left,
46 Right,
48}
49
50impl TooltipSide {
51 fn to_rect_align(self) -> RectAlign {
52 match self {
53 TooltipSide::Top => RectAlign::TOP,
54 TooltipSide::Bottom => RectAlign::BOTTOM,
55 TooltipSide::Left => RectAlign::LEFT,
56 TooltipSide::Right => RectAlign::RIGHT,
57 }
58 }
59}
60
61#[derive(Clone, Debug)]
67#[must_use = "Call `.show(&trigger)` to render the tooltip."]
68pub struct Tooltip {
69 body: WidgetText,
70 heading: Option<WidgetText>,
71 shortcut: Option<String>,
72 shortcut_label: String,
73 side: TooltipSide,
74 width: Option<f32>,
75 arrow: bool,
76 gap: f32,
77}
78
79impl Tooltip {
80 pub fn new(body: impl Into<WidgetText>) -> Self {
85 Self {
86 body: body.into(),
87 heading: None,
88 shortcut: None,
89 shortcut_label: "Shortcut".into(),
90 side: TooltipSide::Top,
91 width: None,
92 arrow: true,
93 gap: 8.0,
94 }
95 }
96
97 #[inline]
99 pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
100 self.heading = Some(heading.into());
101 self
102 }
103
104 #[inline]
109 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
110 self.shortcut = Some(shortcut.into());
111 self
112 }
113
114 #[inline]
116 pub fn shortcut_label(mut self, label: impl Into<String>) -> Self {
117 self.shortcut_label = label.into();
118 self
119 }
120
121 #[inline]
123 pub fn side(mut self, side: TooltipSide) -> Self {
124 self.side = side;
125 self
126 }
127
128 #[inline]
131 pub fn width(mut self, w: f32) -> Self {
132 self.width = Some(w);
133 self
134 }
135
136 #[inline]
138 pub fn arrow(mut self, arrow: bool) -> Self {
139 self.arrow = arrow;
140 self
141 }
142
143 #[inline]
145 pub fn gap(mut self, gap: f32) -> Self {
146 self.gap = gap;
147 self
148 }
149
150 pub fn show(self, trigger: &Response) -> Option<Response> {
153 let theme = Theme::current(&trigger.ctx);
154 let p = &theme.palette;
155
156 let frame = Frame::new()
157 .fill(p.card)
158 .stroke(Stroke::new(1.0, p.border))
159 .corner_radius(CornerRadius::same(theme.control_radius as u8))
160 .inner_margin(Margin::symmetric(10, 8));
161
162 let mut tip = egui::Tooltip::for_enabled(trigger);
163 tip.popup = tip
164 .popup
165 .frame(frame)
166 .align(self.side.to_rect_align())
167 .align_alternatives(&[])
168 .gap(self.gap);
169 tip.popup = tip.popup.width(self.width.unwrap_or(260.0));
170
171 let trigger_rect = trigger.rect;
172 let trigger_ctx = trigger.ctx.clone();
173 let arrow = self.arrow;
174 let side = self.side;
175 let theme_for_paint = theme.clone();
176 let heading = self.heading;
177 let body = self.body;
178 let shortcut = self.shortcut;
179 let shortcut_label = self.shortcut_label;
180
181 let inner = tip.show(move |ui| {
182 paint_contents(
183 ui,
184 &theme_for_paint,
185 heading.as_ref(),
186 &body,
187 shortcut.as_deref(),
188 &shortcut_label,
189 );
190 })?;
191
192 if arrow {
193 let actual_side = detect_side(trigger_rect, inner.response.rect, side);
194 paint_arrow(
195 &trigger_ctx,
196 inner.response.layer_id,
197 inner.response.rect,
198 trigger_rect,
199 actual_side,
200 theme.palette.card,
201 theme.palette.border,
202 );
203 }
204
205 Some(inner.response)
206 }
207}
208
209fn paint_contents(
210 ui: &mut Ui,
211 theme: &Theme,
212 heading: Option<&WidgetText>,
213 body: &WidgetText,
214 shortcut: Option<&str>,
215 shortcut_label: &str,
216) {
217 let p = &theme.palette;
218 let t = &theme.typography;
219
220 if let Some(h) = heading {
221 ui.add(
222 egui::Label::new(
223 egui::RichText::new(h.text())
224 .color(p.text)
225 .size(t.body)
226 .strong(),
227 )
228 .wrap_mode(egui::TextWrapMode::Wrap),
229 );
230 ui.add_space(2.0);
231 }
232
233 ui.add(
234 egui::Label::new(
235 egui::RichText::new(body.text())
236 .color(p.text_muted)
237 .size(t.small),
238 )
239 .wrap_mode(egui::TextWrapMode::Wrap),
240 );
241
242 if let Some(sc) = shortcut {
243 ui.add_space(6.0);
244 let avail = ui.available_width();
245 let sep_y = ui.cursor().min.y;
246 ui.painter().line_segment(
247 [
248 Pos2::new(ui.cursor().min.x, sep_y),
249 Pos2::new(ui.cursor().min.x + avail, sep_y),
250 ],
251 Stroke::new(1.0, p.border),
252 );
253 ui.add_space(6.0);
254
255 ui.horizontal(|ui| {
256 ui.spacing_mut().item_spacing.x = 6.0;
257 ui.add(egui::Label::new(
258 egui::RichText::new(shortcut_label)
259 .color(p.text_faint)
260 .size(t.small),
261 ));
262 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
263 ui.spacing_mut().item_spacing.x = 2.0;
264 let tokens: Vec<&str> = sc.split_whitespace().collect();
267 for token in tokens.iter().rev() {
268 add_kbd(ui, token, theme);
269 }
270 });
271 });
272 }
273}
274
275fn add_kbd(ui: &mut Ui, text: &str, theme: &Theme) -> Response {
276 let p = &theme.palette;
277 let font_id = egui::FontId::monospace(11.0);
278 let galley = ui
279 .painter()
280 .layout_no_wrap(text.to_string(), font_id, p.text);
281 let pad_x = 5.0;
282 let width = (galley.size().x + pad_x * 2.0).max(16.0);
283 let height = 18.0;
284 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
285 if ui.is_rect_visible(rect) {
286 ui.painter().rect(
287 rect,
288 CornerRadius::same(3),
289 p.input_bg,
290 Stroke::new(1.0, p.border),
291 egui::StrokeKind::Inside,
292 );
293 let pos = Pos2::new(
294 rect.center().x - galley.size().x * 0.5,
295 rect.center().y - galley.size().y * 0.5,
296 );
297 ui.painter().galley(pos, galley, p.text);
298 }
299 response
300}
301
302fn detect_side(trigger: Rect, popup: Rect, requested: TooltipSide) -> TooltipSide {
303 match requested {
304 TooltipSide::Top | TooltipSide::Bottom => {
305 if popup.center().y < trigger.center().y {
306 TooltipSide::Top
307 } else {
308 TooltipSide::Bottom
309 }
310 }
311 TooltipSide::Left | TooltipSide::Right => {
312 if popup.center().x < trigger.center().x {
313 TooltipSide::Left
314 } else {
315 TooltipSide::Right
316 }
317 }
318 }
319}
320
321fn paint_arrow(
322 ctx: &egui::Context,
323 layer: egui::LayerId,
324 popup: Rect,
325 trigger: Rect,
326 side: TooltipSide,
327 fill: Color32,
328 border: Color32,
329) {
330 let painter = ctx.layer_painter(layer);
331 let half_base = 6.0;
332 let depth = 6.0;
333 let inset = 10.0;
334
335 let (base_center, perp, base_axis) = match side {
336 TooltipSide::Bottom => {
337 let cx = trigger
338 .center()
339 .x
340 .clamp(popup.min.x + inset, popup.max.x - inset);
341 (
342 Pos2::new(cx, popup.min.y),
343 Vec2::new(0.0, -1.0),
344 Vec2::new(1.0, 0.0),
345 )
346 }
347 TooltipSide::Top => {
348 let cx = trigger
349 .center()
350 .x
351 .clamp(popup.min.x + inset, popup.max.x - inset);
352 (
353 Pos2::new(cx, popup.max.y),
354 Vec2::new(0.0, 1.0),
355 Vec2::new(1.0, 0.0),
356 )
357 }
358 TooltipSide::Right => {
359 let cy = trigger
360 .center()
361 .y
362 .clamp(popup.min.y + inset, popup.max.y - inset);
363 (
364 Pos2::new(popup.min.x, cy),
365 Vec2::new(-1.0, 0.0),
366 Vec2::new(0.0, 1.0),
367 )
368 }
369 TooltipSide::Left => {
370 let cy = trigger
371 .center()
372 .y
373 .clamp(popup.min.y + inset, popup.max.y - inset);
374 (
375 Pos2::new(popup.max.x, cy),
376 Vec2::new(1.0, 0.0),
377 Vec2::new(0.0, 1.0),
378 )
379 }
380 };
381
382 let base_a = base_center + base_axis * half_base;
383 let base_b = base_center - base_axis * half_base;
384 let tip = base_center + perp * depth;
385
386 painter.add(Shape::convex_polygon(
387 vec![base_a, tip, base_b],
388 fill,
389 Stroke::NONE,
390 ));
391 painter.line_segment([base_a, base_b], Stroke::new(1.5, fill));
392 let stroke = Stroke::new(1.0, border);
393 painter.line_segment([base_a, tip], stroke);
394 painter.line_segment([base_b, tip], stroke);
395}