Skip to main content

agg_gui/
card.rs

1//! Anchored info cards — measured, placed, and clamped by the library.
2//!
3//! A recurring overlay shape: a small rounded card with a title and a few
4//! detail lines, floated next to an anchor point (a tapped body, a
5//! hovered element, a reticle). Every app that hand-rolls one repeats
6//! the same three mistakes:
7//!
8//! 1. **Guessed text width** (`chars × size × k`) — wrong for anything
9//!    non-monospace or non-ASCII, so the card is too tight or too wide.
10//! 2. **Clamping against its own widget bounds only** — the card lands
11//!    underneath sibling edge chrome (button rails, trays, the on-screen
12//!    keyboard) and looks cut off.
13//! 3. **No side-flipping** — when the anchor nears an edge the card gets
14//!    pushed over the anchor or off-screen instead of flipping sides.
15//!
16//! This module owns all three: [`measure`] uses the draw context's real
17//! text metrics, and [`anchored_rect`] places the card against the
18//! viewport-wide safe area ([`crate::overlay_insets`]) with symmetric
19//! margins, flipping below/above the anchor as space demands. The
20//! convenience wrapper [`paint_anchored`] does measure → place → paint in
21//! one call — the pit of success for tap-info overlays.
22//!
23//! All coordinates are logical, Y-up, relative to the widget doing the
24//! painting — pass that widget's size as `container`. When the widget
25//! fills the viewport (the common overlay-host case) the safe-area
26//! insets line up 1:1; a non-fullscreen host can still use
27//! [`anchored_rect_with_insets`] with its own insets.
28
29use std::sync::Arc;
30
31use crate::color::Color;
32use crate::draw_ctx::DrawCtx;
33use crate::geometry::{Point, Rect, Size};
34use crate::layout_props::Insets;
35use crate::text::Font;
36
37/// Visual + spacing parameters for an anchored card. The defaults give a
38/// dark tooltip-style card; override the colors to match app chrome.
39#[derive(Clone)]
40pub struct CardStyle {
41    pub title_size: f64,
42    pub detail_size: f64,
43    pub pad_x: f64,
44    pub pad_y: f64,
45    pub line_gap: f64,
46    pub corner_radius: f64,
47    /// Symmetric breathing room kept between the card and the safe-area
48    /// boundary on every side.
49    pub margin: f64,
50    /// Distance kept between the anchor point and the card's near edge.
51    pub anchor_clearance: f64,
52    pub bg: Color,
53    pub border_color: Color,
54    pub border_width: f64,
55    pub title_color: Color,
56    pub detail_color: Color,
57}
58
59impl Default for CardStyle {
60    fn default() -> Self {
61        Self {
62            title_size: 14.0,
63            detail_size: 11.0,
64            pad_x: 12.0,
65            pad_y: 9.0,
66            line_gap: 4.0,
67            corner_radius: 7.0,
68            margin: 8.0,
69            anchor_clearance: 6.0,
70            bg: Color::from_rgba8(15, 20, 38, 230),
71            border_color: Color::from_rgba8(120, 140, 180, 180),
72            border_width: 1.0,
73            title_color: Color::from_rgb8(235, 238, 250),
74            detail_color: Color::from_rgb8(200, 205, 225),
75        }
76    }
77}
78
79/// Measure the card needed for `title` + `details` using the context's
80/// real text metrics (falls back to a monospace estimate only if the
81/// backend can't measure).
82pub fn measure(
83    ctx: &mut dyn DrawCtx,
84    font: Arc<Font>,
85    style: &CardStyle,
86    title: &str,
87    details: &[String],
88) -> Size {
89    ctx.set_font(font);
90    let text_w = |ctx: &mut dyn DrawCtx, s: &str, size: f64| -> f64 {
91        ctx.set_font_size(size);
92        ctx.measure_text(s)
93            .map(|m| m.width)
94            .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
95    };
96
97    let mut w = text_w(ctx, title, style.title_size);
98    for d in details {
99        w = w.max(text_w(ctx, d, style.detail_size));
100    }
101
102    let detail_block_h = if details.is_empty() {
103        0.0
104    } else {
105        details.len() as f64 * style.detail_size + (details.len() as f64 - 1.0) * style.line_gap
106    };
107    Size::new(
108        w + style.pad_x * 2.0,
109        style.title_size + style.line_gap + detail_block_h + style.pad_y * 2.0,
110    )
111}
112
113/// Place a card of `size` near `anchor` inside `container`, avoiding the
114/// frame's [`crate::overlay_insets`]. See [`anchored_rect_with_insets`].
115pub fn anchored_rect(container: Size, anchor: Point, size: Size, style: &CardStyle) -> Rect {
116    anchored_rect_with_insets(
117        container,
118        anchor,
119        size,
120        style,
121        crate::overlay_insets::current(),
122    )
123}
124
125/// Pure placement: prefer sitting fully below the anchor (Y-up: spanning
126/// downward from `anchor.y - clearance`), flip fully above when the
127/// bottom lacks room, and otherwise clamp into the safe area on the side
128/// with more space. Horizontally the card centres on the anchor and
129/// clamps; if it is wider than the safe area it centres so any overflow
130/// is symmetric. The result never leaves `container − insets − margin`
131/// on any side the card fits.
132pub fn anchored_rect_with_insets(
133    container: Size,
134    anchor: Point,
135    size: Size,
136    style: &CardStyle,
137    insets: Insets,
138) -> Rect {
139    let m = style.margin;
140    let safe_l = insets.left + m;
141    let safe_r = container.width - insets.right - m;
142    let safe_b = insets.bottom + m;
143    let safe_t = container.height - insets.top - m;
144
145    let x = if size.width >= safe_r - safe_l {
146        safe_l + (safe_r - safe_l - size.width) / 2.0
147    } else {
148        (anchor.x - size.width / 2.0).clamp(safe_l, safe_r - size.width)
149    };
150
151    let clearance = style.anchor_clearance;
152    let below_y = anchor.y - clearance - size.height;
153    let above_y = anchor.y + clearance;
154    let y = if size.height >= safe_t - safe_b {
155        safe_b + (safe_t - safe_b - size.height) / 2.0
156    } else if below_y >= safe_b {
157        below_y
158    } else if above_y + size.height <= safe_t {
159        above_y
160    } else {
161        // Neither side holds the whole card: pin it inside the safe area
162        // on the roomier side of the anchor.
163        let below_room = anchor.y - safe_b;
164        let above_room = safe_t - anchor.y;
165        if below_room >= above_room {
166            safe_b
167        } else {
168            safe_t - size.height
169        }
170    };
171
172    Rect::new(x, y, size.width, size.height)
173}
174
175/// Paint the card chrome + text into `rect` (as produced by
176/// [`anchored_rect`]).
177pub fn paint(
178    ctx: &mut dyn DrawCtx,
179    font: Arc<Font>,
180    style: &CardStyle,
181    rect: Rect,
182    title: &str,
183    details: &[String],
184) {
185    ctx.set_fill_color(style.bg);
186    ctx.begin_path();
187    ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
188    ctx.fill();
189    if style.border_width > 0.0 {
190        ctx.set_stroke_color(style.border_color);
191        ctx.set_line_width(style.border_width);
192        ctx.begin_path();
193        ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
194        ctx.stroke();
195    }
196
197    // Y-up: baselines measured down from the card's top edge so the
198    // title reads first.
199    ctx.set_font(font);
200    ctx.set_fill_color(style.title_color);
201    ctx.set_font_size(style.title_size);
202    let title_baseline = rect.y + rect.height - style.pad_y - style.title_size * 0.75;
203    ctx.fill_text(title, rect.x + style.pad_x, title_baseline);
204
205    ctx.set_fill_color(style.detail_color);
206    ctx.set_font_size(style.detail_size);
207    for (i, line) in details.iter().enumerate() {
208        let dy = (i as f64) * (style.detail_size + style.line_gap);
209        let baseline = title_baseline - style.title_size - style.line_gap - dy
210            + (style.title_size - style.detail_size) * 0.25;
211        ctx.fill_text(line, rect.x + style.pad_x, baseline);
212    }
213}
214
215/// Greedy word-wrap of one line to `max_w`, using `text_width` to
216/// measure candidates. A single word wider than `max_w` stays on its own
217/// (overflowing) line — never split mid-word. Pure so the policy is unit
218/// testable without a draw context.
219fn wrap_with(text_width: &dyn Fn(&str) -> f64, line: &str, max_w: f64) -> Vec<String> {
220    if text_width(line) <= max_w {
221        return vec![line.to_string()];
222    }
223    let mut out: Vec<String> = Vec::new();
224    let mut current = String::new();
225    for word in line.split_whitespace() {
226        let candidate = if current.is_empty() {
227            word.to_string()
228        } else {
229            format!("{current} {word}")
230        };
231        if !current.is_empty() && text_width(&candidate) > max_w {
232            out.push(std::mem::take(&mut current));
233            current = word.to_string();
234        } else {
235            current = candidate;
236        }
237    }
238    if !current.is_empty() {
239        out.push(current);
240    }
241    if out.is_empty() {
242        out.push(line.to_string());
243    }
244    out
245}
246
247/// Wrap every detail line so the card fits within `max_card_w` (card
248/// width including horizontal padding).
249fn wrap_details(
250    ctx: &mut dyn DrawCtx,
251    font: Arc<Font>,
252    style: &CardStyle,
253    details: &[String],
254    max_card_w: f64,
255) -> Vec<String> {
256    ctx.set_font(font);
257    ctx.set_font_size(style.detail_size);
258    let max_text_w = (max_card_w - style.pad_x * 2.0).max(1.0);
259    let size = style.detail_size;
260    let width = |s: &str| -> f64 {
261        ctx.measure_text(s)
262            .map(|m| m.width)
263            .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
264    };
265    let mut wrapped = Vec::new();
266    for line in details {
267        wrapped.extend(wrap_with(&width, line, max_text_w));
268    }
269    wrapped
270}
271
272/// Measure → wrap → place → paint in one call; returns the rect painted
273/// so the caller can hit-test or decorate it. This is the intended entry
274/// point: detail lines wrap to the safe width, so even a narrow phone
275/// viewport with a wide reserved rail produces a taller card instead of
276/// one that pokes underneath the chrome. A clipped or edge-hugging card
277/// now requires deliberate effort rather than being the accidental
278/// default.
279///
280/// The frame's [`crate::overlay_insets`] are converted into the painting
281/// widget's local space through the context transform
282/// ([`crate::overlay_insets::for_paint_ctx`]), so this is correct whether
283/// or not the widget fills the viewport. `extra_insets` is merged in
284/// per-edge max — pass the widget's own reserved strips (e.g. an
285/// in-widget ruler) or `Insets::default()`.
286pub fn paint_anchored(
287    ctx: &mut dyn DrawCtx,
288    font: Arc<Font>,
289    style: &CardStyle,
290    container: Size,
291    anchor: Point,
292    extra_insets: Insets,
293    title: &str,
294    details: &[String],
295) -> Rect {
296    let frame = crate::overlay_insets::for_paint_ctx(ctx, container);
297    let insets = Insets {
298        left: frame.left.max(extra_insets.left),
299        right: frame.right.max(extra_insets.right),
300        top: frame.top.max(extra_insets.top),
301        bottom: frame.bottom.max(extra_insets.bottom),
302    };
303    let safe_w = container.width - insets.left - insets.right - style.margin * 2.0;
304    let details = wrap_details(ctx, Arc::clone(&font), style, details, safe_w);
305    let size = measure(ctx, Arc::clone(&font), style, title, &details);
306    let rect = anchored_rect_with_insets(container, anchor, size, style, insets);
307    paint(ctx, font, style, rect, title, &details);
308    rect
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn style() -> CardStyle {
316        CardStyle {
317            margin: 8.0,
318            anchor_clearance: 6.0,
319            ..CardStyle::default()
320        }
321    }
322
323    const CONTAINER: Size = Size {
324        width: 240.0,
325        height: 500.0,
326    };
327    const CARD: Size = Size {
328        width: 180.0,
329        height: 60.0,
330    };
331
332    #[test]
333    fn sits_below_anchor_with_room() {
334        let r = anchored_rect_with_insets(
335            CONTAINER,
336            Point { x: 120.0, y: 300.0 },
337            CARD,
338            &style(),
339            Insets::default(),
340        );
341        assert_eq!(r.y, 300.0 - 6.0 - 60.0);
342        assert_eq!(r.x, 120.0 - 90.0, "centred on the anchor");
343    }
344
345    #[test]
346    fn flips_above_when_bottom_is_reserved() {
347        // A keyboard-sized bottom reservation eats the space below.
348        let ins = Insets {
349            bottom: 260.0,
350            ..Insets::default()
351        };
352        let r =
353            anchored_rect_with_insets(CONTAINER, Point { x: 120.0, y: 300.0 }, CARD, &style(), ins);
354        assert_eq!(r.y, 300.0 + 6.0, "flipped fully above the anchor");
355        assert!(r.y >= 260.0 + 8.0, "clear of the reserved strip");
356    }
357
358    #[test]
359    fn left_rail_reservation_pushes_card_right() {
360        let ins = Insets {
361            left: 56.0,
362            ..Insets::default()
363        };
364        let r = anchored_rect_with_insets(
365            CONTAINER,
366            Point { x: 10.0, y: 300.0 },
367            Size {
368                width: 120.0,
369                height: 60.0,
370            },
371            &style(),
372            ins,
373        );
374        assert_eq!(r.x, 56.0 + 8.0, "left edge clears rail + margin");
375    }
376
377    #[test]
378    fn wider_than_safe_area_overflows_symmetrically() {
379        let r = anchored_rect_with_insets(
380            Size {
381                width: 150.0,
382                height: 500.0,
383            },
384            Point { x: 20.0, y: 300.0 },
385            CARD, // 180 wide > 150 - 16 safe
386            &style(),
387            Insets::default(),
388        );
389        let overflow_left = 8.0 - r.x;
390        let overflow_right = (r.x + 180.0) - 142.0;
391        assert!(
392            (overflow_left - overflow_right).abs() < 1e-9,
393            "overflow must be symmetric: {overflow_left} vs {overflow_right}"
394        );
395    }
396
397    #[test]
398    fn wrap_splits_long_lines_on_word_boundaries() {
399        // Fake measure: 6 px per char, like a monospace face.
400        let w = |s: &str| s.chars().count() as f64 * 6.0;
401
402        assert_eq!(
403            wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 200.0),
404            vec!["Rises 11:34pm · Sets 1:04pm"],
405            "no wrapping when the line already fits"
406        );
407
408        // 27 chars × 6 = 162 px; a 100 px budget forces a split.
409        let wrapped = wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 100.0);
410        assert_eq!(wrapped, vec!["Rises 11:34pm ·", "Sets 1:04pm"]);
411        for line in &wrapped {
412            assert!(w(line) <= 100.0, "every wrapped line fits: {line}");
413        }
414
415        // A single over-long word stays whole rather than splitting.
416        assert_eq!(wrap_with(&w, "Circumpolar", 30.0), vec!["Circumpolar"]);
417    }
418
419    #[test]
420    fn no_room_either_side_pins_inside_safe_area() {
421        // Anchor near the bottom with a top reservation squeezing space.
422        let ins = Insets {
423            top: 100.0,
424            bottom: 40.0,
425            ..Insets::default()
426        };
427        let r = anchored_rect_with_insets(
428            CONTAINER,
429            Point { x: 120.0, y: 70.0 },
430            Size {
431                width: 180.0,
432                height: 330.0,
433            },
434            &style(),
435            ins,
436        );
437        assert!(r.y >= 40.0 + 8.0 - 1e-9, "stays above the bottom strip");
438        assert!(
439            r.y + 330.0 <= 500.0 - 100.0 - 8.0 + 1e-9,
440            "stays below the top strip"
441        );
442    }
443}