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
106            + (details.len() as f64 - 1.0) * style.line_gap
107    };
108    Size::new(
109        w + style.pad_x * 2.0,
110        style.title_size + style.line_gap + detail_block_h + style.pad_y * 2.0,
111    )
112}
113
114/// Place a card of `size` near `anchor` inside `container`, avoiding the
115/// frame's [`crate::overlay_insets`]. See [`anchored_rect_with_insets`].
116pub fn anchored_rect(container: Size, anchor: Point, size: Size, style: &CardStyle) -> Rect {
117    anchored_rect_with_insets(container, anchor, size, style, crate::overlay_insets::current())
118}
119
120/// Pure placement: prefer sitting fully below the anchor (Y-up: spanning
121/// downward from `anchor.y - clearance`), flip fully above when the
122/// bottom lacks room, and otherwise clamp into the safe area on the side
123/// with more space. Horizontally the card centres on the anchor and
124/// clamps; if it is wider than the safe area it centres so any overflow
125/// is symmetric. The result never leaves `container − insets − margin`
126/// on any side the card fits.
127pub fn anchored_rect_with_insets(
128    container: Size,
129    anchor: Point,
130    size: Size,
131    style: &CardStyle,
132    insets: Insets,
133) -> Rect {
134    let m = style.margin;
135    let safe_l = insets.left + m;
136    let safe_r = container.width - insets.right - m;
137    let safe_b = insets.bottom + m;
138    let safe_t = container.height - insets.top - m;
139
140    let x = if size.width >= safe_r - safe_l {
141        safe_l + (safe_r - safe_l - size.width) / 2.0
142    } else {
143        (anchor.x - size.width / 2.0).clamp(safe_l, safe_r - size.width)
144    };
145
146    let clearance = style.anchor_clearance;
147    let below_y = anchor.y - clearance - size.height;
148    let above_y = anchor.y + clearance;
149    let y = if size.height >= safe_t - safe_b {
150        safe_b + (safe_t - safe_b - size.height) / 2.0
151    } else if below_y >= safe_b {
152        below_y
153    } else if above_y + size.height <= safe_t {
154        above_y
155    } else {
156        // Neither side holds the whole card: pin it inside the safe area
157        // on the roomier side of the anchor.
158        let below_room = anchor.y - safe_b;
159        let above_room = safe_t - anchor.y;
160        if below_room >= above_room {
161            safe_b
162        } else {
163            safe_t - size.height
164        }
165    };
166
167    Rect::new(x, y, size.width, size.height)
168}
169
170/// Paint the card chrome + text into `rect` (as produced by
171/// [`anchored_rect`]).
172pub fn paint(
173    ctx: &mut dyn DrawCtx,
174    font: Arc<Font>,
175    style: &CardStyle,
176    rect: Rect,
177    title: &str,
178    details: &[String],
179) {
180    ctx.set_fill_color(style.bg);
181    ctx.begin_path();
182    ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
183    ctx.fill();
184    if style.border_width > 0.0 {
185        ctx.set_stroke_color(style.border_color);
186        ctx.set_line_width(style.border_width);
187        ctx.begin_path();
188        ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
189        ctx.stroke();
190    }
191
192    // Y-up: baselines measured down from the card's top edge so the
193    // title reads first.
194    ctx.set_font(font);
195    ctx.set_fill_color(style.title_color);
196    ctx.set_font_size(style.title_size);
197    let title_baseline = rect.y + rect.height - style.pad_y - style.title_size * 0.75;
198    ctx.fill_text(title, rect.x + style.pad_x, title_baseline);
199
200    ctx.set_fill_color(style.detail_color);
201    ctx.set_font_size(style.detail_size);
202    for (i, line) in details.iter().enumerate() {
203        let dy = (i as f64) * (style.detail_size + style.line_gap);
204        let baseline = title_baseline - style.title_size - style.line_gap - dy
205            + (style.title_size - style.detail_size) * 0.25;
206        ctx.fill_text(line, rect.x + style.pad_x, baseline);
207    }
208}
209
210/// Greedy word-wrap of one line to `max_w`, using `text_width` to
211/// measure candidates. A single word wider than `max_w` stays on its own
212/// (overflowing) line — never split mid-word. Pure so the policy is unit
213/// testable without a draw context.
214fn wrap_with(text_width: &dyn Fn(&str) -> f64, line: &str, max_w: f64) -> Vec<String> {
215    if text_width(line) <= max_w {
216        return vec![line.to_string()];
217    }
218    let mut out: Vec<String> = Vec::new();
219    let mut current = String::new();
220    for word in line.split_whitespace() {
221        let candidate = if current.is_empty() {
222            word.to_string()
223        } else {
224            format!("{current} {word}")
225        };
226        if !current.is_empty() && text_width(&candidate) > max_w {
227            out.push(std::mem::take(&mut current));
228            current = word.to_string();
229        } else {
230            current = candidate;
231        }
232    }
233    if !current.is_empty() {
234        out.push(current);
235    }
236    if out.is_empty() {
237        out.push(line.to_string());
238    }
239    out
240}
241
242/// Wrap every detail line so the card fits within `max_card_w` (card
243/// width including horizontal padding).
244fn wrap_details(
245    ctx: &mut dyn DrawCtx,
246    font: Arc<Font>,
247    style: &CardStyle,
248    details: &[String],
249    max_card_w: f64,
250) -> Vec<String> {
251    ctx.set_font(font);
252    ctx.set_font_size(style.detail_size);
253    let max_text_w = (max_card_w - style.pad_x * 2.0).max(1.0);
254    let size = style.detail_size;
255    let width = |s: &str| -> f64 {
256        ctx.measure_text(s)
257            .map(|m| m.width)
258            .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
259    };
260    let mut wrapped = Vec::new();
261    for line in details {
262        wrapped.extend(wrap_with(&width, line, max_text_w));
263    }
264    wrapped
265}
266
267/// Measure → wrap → place → paint in one call; returns the rect painted
268/// so the caller can hit-test or decorate it. This is the intended entry
269/// point: detail lines wrap to the safe width, so even a narrow phone
270/// viewport with a wide reserved rail produces a taller card instead of
271/// one that pokes underneath the chrome. A clipped or edge-hugging card
272/// now requires deliberate effort rather than being the accidental
273/// default.
274///
275/// The frame's [`crate::overlay_insets`] are converted into the painting
276/// widget's local space through the context transform
277/// ([`crate::overlay_insets::for_paint_ctx`]), so this is correct whether
278/// or not the widget fills the viewport. `extra_insets` is merged in
279/// per-edge max — pass the widget's own reserved strips (e.g. an
280/// in-widget ruler) or `Insets::default()`.
281pub fn paint_anchored(
282    ctx: &mut dyn DrawCtx,
283    font: Arc<Font>,
284    style: &CardStyle,
285    container: Size,
286    anchor: Point,
287    extra_insets: Insets,
288    title: &str,
289    details: &[String],
290) -> Rect {
291    let frame = crate::overlay_insets::for_paint_ctx(ctx, container);
292    let insets = Insets {
293        left: frame.left.max(extra_insets.left),
294        right: frame.right.max(extra_insets.right),
295        top: frame.top.max(extra_insets.top),
296        bottom: frame.bottom.max(extra_insets.bottom),
297    };
298    let safe_w = container.width - insets.left - insets.right - style.margin * 2.0;
299    let details = wrap_details(ctx, Arc::clone(&font), style, details, safe_w);
300    let size = measure(ctx, Arc::clone(&font), style, title, &details);
301    let rect = anchored_rect_with_insets(container, anchor, size, style, insets);
302    paint(ctx, font, style, rect, title, &details);
303    rect
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn style() -> CardStyle {
311        CardStyle {
312            margin: 8.0,
313            anchor_clearance: 6.0,
314            ..CardStyle::default()
315        }
316    }
317
318    const CONTAINER: Size = Size {
319        width: 240.0,
320        height: 500.0,
321    };
322    const CARD: Size = Size {
323        width: 180.0,
324        height: 60.0,
325    };
326
327    #[test]
328    fn sits_below_anchor_with_room() {
329        let r = anchored_rect_with_insets(
330            CONTAINER,
331            Point { x: 120.0, y: 300.0 },
332            CARD,
333            &style(),
334            Insets::default(),
335        );
336        assert_eq!(r.y, 300.0 - 6.0 - 60.0);
337        assert_eq!(r.x, 120.0 - 90.0, "centred on the anchor");
338    }
339
340    #[test]
341    fn flips_above_when_bottom_is_reserved() {
342        // A keyboard-sized bottom reservation eats the space below.
343        let ins = Insets {
344            bottom: 260.0,
345            ..Insets::default()
346        };
347        let r = anchored_rect_with_insets(
348            CONTAINER,
349            Point { x: 120.0, y: 300.0 },
350            CARD,
351            &style(),
352            ins,
353        );
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!(
417            wrap_with(&w, "Circumpolar", 30.0),
418            vec!["Circumpolar"]
419        );
420    }
421
422    #[test]
423    fn no_room_either_side_pins_inside_safe_area() {
424        // Anchor near the bottom with a top reservation squeezing space.
425        let ins = Insets {
426            top: 100.0,
427            bottom: 40.0,
428            ..Insets::default()
429        };
430        let r = anchored_rect_with_insets(
431            CONTAINER,
432            Point { x: 120.0, y: 70.0 },
433            Size {
434                width: 180.0,
435                height: 330.0,
436            },
437            &style(),
438            ins,
439        );
440        assert!(r.y >= 40.0 + 8.0 - 1e-9, "stays above the bottom strip");
441        assert!(
442            r.y + 330.0 <= 500.0 - 100.0 - 8.0 + 1e-9,
443            "stays below the top strip"
444        );
445    }
446}