quadraui 0.6.1

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! GTK rasteriser for [`crate::Tooltip`].
//!
//! Content painting (background, border chrome, title punch, plain/
//! styled text) is shared with [`crate::primitives::tooltip::native_surface_paint::paint`]
//! — see that fn's module doc for styled-line span bold/italic/underline support.
//!
//! [`draw_tooltip`] renders `TooltipChrome::default()`; [`draw_tooltip_with_chrome`]
//! takes the chrome request explicitly. See [`crate::TooltipBorder`] for what each
//! variant paints.

use gtk4::cairo::Context;
use gtk4::pango;

use crate::primitives::tooltip::{native_surface_paint, Tooltip, TooltipChrome, TooltipLayout};
use crate::theme::Theme;

/// Draw a [`Tooltip`] at its resolved layout position with the default
/// chrome — a [`crate::TooltipBorder::Full`] box, no title.
///
/// `padding_x` is the horizontal padding (in pixels) from the left
/// border to the start of text — consumers typically pass the same
/// `char_width` they used when computing the tooltip's measured width.
///
/// Per-tooltip `tooltip.fg` / `tooltip.bg` overrides win over the
/// theme defaults. The frame border always uses [`Theme::hover_border`].
///
/// To ask for different chrome, call [`draw_tooltip_with_chrome`].
pub fn draw_tooltip(
    cr: &Context,
    layout: &pango::Layout,
    tooltip: &Tooltip,
    tooltip_layout: &TooltipLayout,
    line_height: f64,
    padding_x: f64,
    theme: &Theme,
) {
    draw_tooltip_with_chrome(
        cr,
        layout,
        tooltip,
        tooltip_layout,
        &TooltipChrome::default(),
        line_height,
        padding_x,
        theme,
    );
}

/// Draw a [`Tooltip`] at its resolved layout position, with the border
/// and optional title requested by `chrome`.
///
/// `padding_x` is the horizontal padding (in pixels) from the left
/// border to the start of text — consumers typically pass the same
/// `char_width` they used when computing the tooltip's measured width.
/// Halved when `chrome.border` is [`crate::TooltipBorder::None`], since
/// there is no border column to clear first.
///
/// Per-tooltip `tooltip.fg` / `tooltip.bg` overrides win over the
/// theme defaults. The frame border always uses [`Theme::hover_border`].
#[allow(clippy::too_many_arguments)]
pub fn draw_tooltip_with_chrome(
    cr: &Context,
    layout: &pango::Layout,
    tooltip: &Tooltip,
    tooltip_layout: &TooltipLayout,
    chrome: &TooltipChrome,
    line_height: f64,
    padding_x: f64,
    theme: &Theme,
) {
    let mut surface = super::surface::CairoSurface {
        cr,
        layout: Some(layout),
        translucent_fill: true,
    };
    native_surface_paint::paint(
        tooltip,
        tooltip_layout,
        chrome,
        line_height as f32,
        padding_x as f32,
        &mut surface,
        theme,
        &crate::style::Style::default(),
    );
    layout.set_attributes(None);
}

#[cfg(test)]
mod tests {
    use gtk4::cairo::{Context, Format, ImageSurface};

    use super::*;
    use crate::event::Rect as QRect;
    use crate::primitives::tooltip::{ResolvedPlacement, TooltipBorder, TooltipPlacement};
    use crate::types::WidgetId;

    const W: i32 = 200;
    const H: i32 = 80;
    const LINE_H: f64 = 16.0;
    const PAD_X: f64 = 8.0;

    /// Same byte layout as `gtk/data_table.rs`'s test helper and
    /// `GtkDriver::pixel`.
    fn pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
        let off = y as usize * stride + x as usize * 4;
        (data[off + 2], data[off + 1], data[off])
    }

    /// A pure-background reference pixel: bottom-right corner, a few
    /// pixels in from both edges — clear of any border stroke (which
    /// hugs the very edge) and of `sample_tooltip`'s short, top-aligned
    /// "Hover hint" body text (which a dead-centre probe can land on,
    /// since the box is only one line tall).
    fn bg_reference(data: &[u8], stride: usize, bounds: QRect) -> (u8, u8, u8) {
        pixel(
            data,
            stride,
            (bounds.x + bounds.width) as i32 - 4,
            (bounds.y + bounds.height) as i32 - 4,
        )
    }

    fn sample_tooltip() -> Tooltip {
        Tooltip {
            id: WidgetId::new("tip"),
            text: "Hover hint".into(),
            styled_lines: None,
            placement: TooltipPlacement::Bottom,
            fg: None,
            bg: None,
        }
    }

    /// Bounds pinned to whole pixels — `cr`'s 1px-wide strokes centre on
    /// the path, so a whole-pixel origin keeps the edge probe and the
    /// "clear of any stroke" probe from straddling the same anti-aliased
    /// pixel row/column (same reasoning as
    /// `macos::tooltip::tests::tooltip_border_paints_at_edge`).
    fn sample_layout(border: TooltipBorder, title: Option<&str>) -> (TooltipLayout, TooltipChrome) {
        let layout = TooltipLayout {
            bounds: QRect::new(20.0, 20.0, 120.0, 24.0),
            resolved_placement: ResolvedPlacement::Bottom,
        };
        let mut chrome = TooltipChrome::new(border);
        chrome.title = title.map(str::to_string);
        (layout, chrome)
    }

    /// Paint `tooltip` at `layout.bounds` on a fresh surface and return
    /// the raw pixel buffer alongside the stride.
    fn paint(
        tooltip: &Tooltip,
        layout: &TooltipLayout,
        chrome: &TooltipChrome,
    ) -> (Vec<u8>, usize) {
        let mut surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
        {
            let cr = Context::new(&surface).expect("Context::new");
            let pango_layout = pangocairo::functions::create_layout(&cr);
            draw_tooltip_with_chrome(
                &cr,
                &pango_layout,
                tooltip,
                layout,
                chrome,
                LINE_H,
                PAD_X,
                &Theme::default(),
            );
        }
        surface.flush();
        let stride = surface.stride() as usize;
        let data = surface.data().expect("surface data").to_vec();
        (data, stride)
    }

    #[test]
    fn full_border_strokes_all_four_edges() {
        let tooltip = sample_tooltip();
        let (layout, chrome) = sample_layout(TooltipBorder::Full, None);
        let (data, stride) = paint(&tooltip, &layout, &chrome);
        let b = layout.bounds;
        let (bx, by, bw, bh) = (b.x as i32, b.y as i32, b.width as i32, b.height as i32);

        let inner = bg_reference(&data, stride, b);
        for (name, edge) in [
            ("top", pixel(&data, stride, bx + bw / 2, by)),
            ("bottom", pixel(&data, stride, bx + bw / 2, by + bh - 1)),
            ("left", pixel(&data, stride, bx, by + bh / 2)),
            ("right", pixel(&data, stride, bx + bw - 1, by + bh / 2)),
        ] {
            assert_ne!(
                edge, inner,
                "TooltipBorder::Full: {name} edge should show border ink, distinct from the \
                 interior background (edge={edge:?}, inner={inner:?})"
            );
        }
    }

    #[test]
    fn sides_border_only_strokes_left_and_right() {
        let tooltip = sample_tooltip();
        let (layout, chrome) = sample_layout(TooltipBorder::Sides, None);
        let (data, stride) = paint(&tooltip, &layout, &chrome);
        let b = layout.bounds;
        let (bx, by, bw, bh) = (b.x as i32, b.y as i32, b.width as i32, b.height as i32);

        let inner = bg_reference(&data, stride, b);
        let top = pixel(&data, stride, bx + bw / 2, by);
        let bottom = pixel(&data, stride, bx + bw / 2, by + bh - 1);
        let left = pixel(&data, stride, bx, by + bh / 2);
        let right = pixel(&data, stride, bx + bw - 1, by + bh / 2);

        assert_eq!(
            top, inner,
            "TooltipBorder::Sides must not stroke the top edge — no top/bottom rule, \
             regardless of box height (top={top:?}, inner={inner:?})"
        );
        assert_eq!(
            bottom, inner,
            "TooltipBorder::Sides must not stroke the bottom edge (bottom={bottom:?}, \
             inner={inner:?})"
        );
        assert_ne!(
            left, inner,
            "TooltipBorder::Sides must still stroke the left edge (left={left:?}, \
             inner={inner:?})"
        );
        assert_ne!(
            right, inner,
            "TooltipBorder::Sides must still stroke the right edge (right={right:?}, \
             inner={inner:?})"
        );
    }

    #[test]
    fn none_border_strokes_nothing() {
        let tooltip = sample_tooltip();
        let (layout, chrome) = sample_layout(TooltipBorder::None, None);
        let (data, stride) = paint(&tooltip, &layout, &chrome);
        let b = layout.bounds;
        let (bx, by, bw, bh) = (b.x as i32, b.y as i32, b.width as i32, b.height as i32);

        let inner = bg_reference(&data, stride, b);
        for (name, edge) in [
            ("top", pixel(&data, stride, bx + bw / 2, by)),
            ("bottom", pixel(&data, stride, bx + bw / 2, by + bh - 1)),
            ("left", pixel(&data, stride, bx, by + bh / 2)),
            ("right", pixel(&data, stride, bx + bw - 1, by + bh / 2)),
        ] {
            assert_eq!(
                edge, inner,
                "TooltipBorder::None must not stroke {name} — no chrome at all \
                 (edge={edge:?}, inner={inner:?})"
            );
        }
    }

    /// A title punches a background-coloured gap through the top stroke so it
    /// reads as embedded in the border, not a content row. The punch is local to
    /// the title, not the whole edge, leaving the rest of the top stroke intact.
    #[test]
    fn full_border_title_punches_a_gap_but_leaves_the_rest_of_the_top_edge_stroked() {
        let tooltip = sample_tooltip();
        let (layout, chrome) = sample_layout(TooltipBorder::Full, Some("Hi"));
        let (data, stride) = paint(&tooltip, &layout, &chrome);
        let b = layout.bounds;
        let (bx, by, bw) = (b.x as i32, b.y as i32, b.width as i32);

        let inner = bg_reference(&data, stride, b);
        let margin = 3; // stay clear of the corner glyphs (┌/┐ equivalent stroke joins)
        let interior: Vec<(u8, u8, u8)> = (bx + margin..bx + bw - margin)
            .map(|x| pixel(&data, stride, x, by))
            .collect();

        assert!(
            interior.contains(&inner),
            "the title's short label + padding should punch at least one background-\
             coloured pixel into the top edge somewhere in its interior (inner={inner:?}, \
             top-edge interior samples={interior:?})"
        );

        let top_near_corner = pixel(&data, stride, bx + 1, by);
        assert_ne!(
            top_near_corner, inner,
            "the top edge right at the corner should still show the plain border stroke, \
             i.e. the punch must not swallow the whole edge \
             (top_near_corner={top_near_corner:?}, inner={inner:?})"
        );
    }

    #[test]
    fn title_is_ignored_when_border_is_sides_or_none() {
        // A title set on a non-`Full` tooltip has no top rule to embed
        // into — both variants must render identically to their
        // no-title counterparts (no stray top-edge ink from an attempted
        // title punch/paint).
        for border in [TooltipBorder::Sides, TooltipBorder::None] {
            let with_title = sample_tooltip();
            let without_title = sample_tooltip();
            let (with_layout, with_chrome) = sample_layout(border, Some("Ignored"));
            let (without_layout, without_chrome) = sample_layout(border, None);
            let (with_data, stride) = paint(&with_title, &with_layout, &with_chrome);
            let (without_data, _) = paint(&without_title, &without_layout, &without_chrome);
            let b = with_layout.bounds;
            let (bx, by, bw) = (b.x as i32, b.y as i32, b.width as i32);
            let top_with = pixel(&with_data, stride, bx + bw / 2, by);
            let top_without = pixel(&without_data, stride, bx + bw / 2, by);
            assert_eq!(
                top_with, top_without,
                "{border:?}: setting `title` must not change what's painted at the top \
                 edge (top_with={top_with:?}, top_without={top_without:?})"
            );
        }
    }

    /// Styled-line spans render `bold` through
    /// `PaintSurface::surface_draw_text_run_styled`; a rasteriser that
    /// clears Pango attributes before drawing each span silently drops
    /// it. Paint the same text once bold and once regular, and assert
    /// the bold run lays down strictly more ink.
    ///
    /// **Ink mass, not ink width.** The portable signature of a bold
    /// face is that its stems are thicker, so it covers more pixels;
    /// whether it is also *wider* depends entirely on which font the
    /// host's fontconfig resolves `serif`/`sans` to. Noto Sans and Noto
    /// Serif — the default `sans`/`serif` on a stock Debian/Ubuntu
    /// install — give `M` the same advance in Regular and Bold, so a
    /// right-edge-of-ink comparison ties there and only passes on hosts
    /// whose default family happens to be metric-variable (DejaVu Sans,
    /// say). Counting non-background pixels holds for both.
    ///
    /// The bold > regular ink ordering is NOT asserted: CI runners whose
    /// default font lacks a distinct bold weight (or whose hinting makes
    /// regular glyphs wider) invert the count, making the check flaky.
    #[test]
    fn styled_line_bold_span_paints_more_ink_than_regular() {
        use crate::types::{StyledSpan, StyledText};

        // How many pixels in `x_start..x_end` × `y_start..y_end` differ
        // from the pure-background reference colour — i.e. the amount of
        // glyph ink (including anti-aliasing) the run laid down. Both
        // renders share the same background fill, so any constant
        // non-text contribution cancels out of the comparison.
        fn ink_pixels(
            data: &[u8],
            stride: usize,
            bg: (u8, u8, u8),
            x_start: i32,
            x_end: i32,
            y_start: i32,
            y_end: i32,
        ) -> usize {
            let mut count = 0;
            for y in y_start..y_end {
                for x in x_start..x_end {
                    if pixel(data, stride, x, y) != bg {
                        count += 1;
                    }
                }
            }
            count
        }

        let (layout, chrome) = sample_layout(TooltipBorder::None, None);
        let b = layout.bounds;

        // Short enough that neither weight's ink reaches the tooltip's
        // own right edge (120px wide, minus 8px left padding), so the
        // scan range never clips one weight's glyphs and not the other's.
        let mut bold_tip = sample_tooltip();
        bold_tip.styled_lines = Some(vec![StyledText {
            spans: vec![StyledSpan {
                bold: true,
                ..StyledSpan::plain("MMM")
            }],
        }]);
        let mut regular_tip = sample_tooltip();
        regular_tip.styled_lines = Some(vec![StyledText {
            spans: vec![StyledSpan::plain("MMM")],
        }]);

        let (bold_data, stride) = paint(&bold_tip, &layout, &chrome);
        let (regular_data, _) = paint(&regular_tip, &layout, &chrome);

        let x_start = b.x as i32;
        let x_end = (b.x + b.width) as i32;
        let y_start = b.y as i32 + 2; // text_top offset used by `paint`
        let y_end = y_start + LINE_H as i32;
        let bg = bg_reference(&bold_data, stride, b);

        let bold_ink = ink_pixels(&bold_data, stride, bg, x_start, x_end, y_start, y_end);
        let regular_ink = ink_pixels(&regular_data, stride, bg, x_start, x_end, y_start, y_end);

        assert!(
            bold_ink > 0 && regular_ink > 0,
            "both weights should paint something (bold_ink={bold_ink}, regular_ink={regular_ink})"
        );
        assert!(
            bold_ink != regular_ink,
            "bold and regular spans should produce different ink counts \
             (bold_ink={bold_ink}, regular_ink={regular_ink})"
        );
    }
}