Skip to main content

azul_layout/managers/
selection.rs

1//! Clipboard content types for copy/paste operations
2//!
3//! Contains `ClipboardContent` and `StyledTextRun`, used by clipboard and
4//! changeset modules.
5//!
6//! **Rich-text status:** `StyledTextRun`, `StyledTextRunVec` and the
7//! `ClipboardContent.styled_runs` field are FFI-exported (api.json), but the
8//! rich path is only half-wired: the live clipboard producers build
9//! `styled_runs` empty (`window.rs::get_selected_content_for_clipboard`,
10//! paste in `common/event.rs`) and the platform clipboard backends write only
11//! `plain_text`. Fully wiring it means (a) extracting per-run style from the
12//! styled DOM when copying and (b) adding an HTML/RTF format to each platform's
13//! clipboard write (and reading it back on paste). `to_html()` below is the
14//! retained consumer for that future format. Until then the FFI surface is
15//! kept (it is public API) but `styled_runs` stays empty.
16
17use azul_css::{impl_option, impl_option_inner, AzString, OptionString};
18
19// Clipboard Content Extraction
20
21/// Styled text run for rich clipboard content
22#[derive(Debug, Clone, PartialEq)]
23#[repr(C)]
24pub struct StyledTextRun {
25    /// The actual text content
26    pub text: AzString,
27    /// Font family name
28    pub font_family: OptionString,
29    /// Font size in pixels
30    pub font_size_px: f32,
31    /// Text color
32    pub color: azul_css::props::basic::ColorU,
33    /// Whether text is bold
34    pub is_bold: bool,
35    /// Whether text is italic
36    pub is_italic: bool,
37}
38
39azul_css::impl_option!(StyledTextRun, OptionStyledTextRun, copy = false, [Debug, Clone, PartialEq]);
40azul_css::impl_vec!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor, StyledTextRunVecDestructorType, StyledTextRunVecSlice, OptionStyledTextRun);
41azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
42azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
43azul_css::impl_vec_partialeq!(StyledTextRun, StyledTextRunVec);
44
45/// Clipboard content with both plain text and styled (HTML) representation
46#[derive(Debug, Clone, PartialEq)]
47#[repr(C)]
48pub struct ClipboardContent {
49    /// Plain text representation (UTF-8)
50    pub plain_text: AzString,
51    /// Rich text runs with styling information
52    pub styled_runs: StyledTextRunVec,
53}
54
55impl_option!(
56    ClipboardContent,
57    OptionClipboardContent,
58    copy = false,
59    [Debug, Clone, PartialEq]
60);
61
62impl ClipboardContent {
63    /// Convert styled runs to HTML for rich clipboard formats.
64    ///
65    /// Retained consumer of the FFI-exported `styled_runs`: returns an empty
66    /// `<div></div>` until `styled_runs` is populated and the platform clipboard
67    /// backends gain an HTML format (see module docs). Kept as public API.
68    #[must_use] pub fn to_html(&self) -> String {
69        use core::fmt::Write as _;
70        let mut html = String::from("<div>");
71
72        for run in self.styled_runs.as_slice() {
73            html.push_str("<span style=\"");
74
75            if let Some(font_family) = run.font_family.as_ref() {
76                let _ = write!(html, "font-family: {}; ", font_family.as_str());
77            }
78            let _ = write!(html, "font-size: {}px; ", run.font_size_px);
79            let _ = write!(
80                html,
81                "color: rgba({}, {}, {}, {}); ",
82                run.color.r,
83                run.color.g,
84                run.color.b,
85                f32::from(run.color.a) / 255.0
86            );
87            if run.is_bold {
88                html.push_str("font-weight: bold; ");
89            }
90            if run.is_italic {
91                html.push_str("font-style: italic; ");
92            }
93
94            html.push_str("\">");
95            // Escape HTML entities
96            let escaped = run
97                .text
98                .as_str()
99                .replace('&', "&amp;")
100                .replace('<', "&lt;")
101                .replace('>', "&gt;");
102            html.push_str(&escaped);
103            html.push_str("</span>");
104        }
105
106        html.push_str("</div>");
107        html
108    }
109}
110
111#[cfg(test)]
112mod autotest_generated {
113    use azul_css::{props::basic::ColorU, AzString, OptionString};
114
115    use super::*;
116
117    // =========================================================================
118    // Fixtures
119    //
120    // `ClipboardContent::to_html` is a pure string builder, so the adversarial
121    // surface is (a) the escaping pass over `text` (ordering, double-escaping,
122    // characters it does *not* cover), (b) `f32` Display of `font_size_px`
123    // (NaN / inf / -0.0 / MAX), (c) the u8 -> f32 alpha division at the
124    // channel boundaries, and (d) structural invariants that must hold for
125    // every input (balanced tags, one span per run, determinism).
126    // =========================================================================
127
128    const OPAQUE_BLACK: ColorU = ColorU {
129        r: 0,
130        g: 0,
131        b: 0,
132        a: 255,
133    };
134
135    /// A styled run, parameterized on every field the formatter reads.
136    fn run(text: &str, font_size_px: f32, family: Option<&str>) -> StyledTextRun {
137        StyledTextRun {
138            text: AzString::from(text),
139            font_family: family.map_or(OptionString::None, |f| {
140                OptionString::Some(AzString::from(f))
141            }),
142            font_size_px,
143            color: OPAQUE_BLACK,
144            is_bold: false,
145            is_italic: false,
146        }
147    }
148
149    fn content(runs: Vec<StyledTextRun>) -> ClipboardContent {
150        ClipboardContent {
151            plain_text: AzString::from(""),
152            styled_runs: runs.into(),
153        }
154    }
155
156    /// Inverse of the escaping pass in `to_html` (entities undone in reverse
157    /// order, so `&amp;` is restored last).
158    fn unescape(s: &str) -> String {
159        s.replace("&lt;", "<")
160            .replace("&gt;", ">")
161            .replace("&amp;", "&")
162    }
163
164    // ---------------------------------------------------------------------
165    // basic_access: expected value after a known construction
166    // ---------------------------------------------------------------------
167
168    #[test]
169    fn to_html_single_run_produces_exact_markup() {
170        let c = content(vec![run("hi", 12.0, Some("Arial"))]);
171        assert_eq!(
172            c.to_html(),
173            "<div><span style=\"font-family: Arial; font-size: 12px; color: rgba(0, 0, 0, 1); \
174             \">hi</span></div>"
175        );
176    }
177
178    #[test]
179    fn to_html_omits_font_family_when_none() {
180        let html = content(vec![run("x", 1.0, None)]).to_html();
181        assert!(!html.contains("font-family"), "{html}");
182        assert!(html.contains("font-size: 1px; "), "{html}");
183    }
184
185    #[test]
186    fn to_html_emits_bold_and_italic_only_when_set() {
187        let mut r = run("x", 10.0, None);
188        assert!(!content(vec![r.clone()]).to_html().contains("font-weight"));
189        assert!(!content(vec![r.clone()]).to_html().contains("font-style"));
190
191        r.is_bold = true;
192        r.is_italic = true;
193        let html = content(vec![r]).to_html();
194        assert!(html.contains("font-weight: bold; "), "{html}");
195        assert!(html.contains("font-style: italic; "), "{html}");
196    }
197
198    #[test]
199    fn to_html_concatenates_runs_in_order() {
200        let html = content(vec![
201            run("a", 1.0, None),
202            run("b", 2.0, None),
203            run("c", 3.0, None),
204        ])
205        .to_html();
206        let a = html.find(">a<").expect("run a missing");
207        let b = html.find(">b<").expect("run b missing");
208        let c = html.find(">c<").expect("run c missing");
209        assert!(a < b && b < c, "runs reordered: {html}");
210    }
211
212    // ---------------------------------------------------------------------
213    // edge_access: default / empty / extreme instances must not panic
214    // ---------------------------------------------------------------------
215
216    #[test]
217    fn to_html_empty_runs_is_empty_div() {
218        assert_eq!(content(Vec::new()).to_html(), "<div></div>");
219    }
220
221    #[test]
222    fn to_html_empty_run_text_yields_empty_span_body() {
223        let html = content(vec![run("", 0.0, Some(""))]).to_html();
224        assert!(html.ends_with("\"></span></div>"), "{html}");
225        assert!(html.contains("font-family: ; "), "{html}");
226    }
227
228    #[test]
229    fn to_html_ignores_plain_text_field() {
230        // `to_html` only reads `styled_runs`; a populated `plain_text` (the
231        // only field the live producers fill) must not leak into the markup.
232        let c = ClipboardContent {
233            plain_text: AzString::from("SHOULD-NOT-APPEAR"),
234            styled_runs: Vec::<StyledTextRun>::new().into(),
235        };
236        assert_eq!(c.to_html(), "<div></div>");
237    }
238
239    // ---------------------------------------------------------------------
240    // escaping / round-trip: escape(text) must be losslessly reversible
241    // ---------------------------------------------------------------------
242
243    #[test]
244    fn to_html_escapes_angle_brackets_and_ampersand() {
245        let html = content(vec![run("<script>a && b</script>", 10.0, None)]).to_html();
246        assert!(
247            html.contains("&lt;script&gt;a &amp;&amp; b&lt;/script&gt;"),
248            "{html}"
249        );
250        assert!(!html.contains("<script>"), "raw tag survived: {html}");
251    }
252
253    #[test]
254    fn to_html_does_not_double_escape_existing_entities() {
255        // `&` is replaced first, so an input entity is escaped exactly once.
256        let html = content(vec![run("&lt;&amp;", 10.0, None)]).to_html();
257        assert!(html.contains(">&amp;lt;&amp;amp;<"), "{html}");
258    }
259
260    #[test]
261    fn to_html_text_round_trips_through_unescape() {
262        for text in [
263            "",
264            "plain",
265            "&",
266            "<",
267            ">",
268            "&amp;",
269            "&lt;<>&gt;",
270            "a<b>c&d",
271            "&&&&<<<<>>>>",
272        ] {
273            let html = content(vec![run(text, 10.0, None)]).to_html();
274            let body = html
275                .rsplit_once("</span>")
276                .and_then(|(head, _)| head.rsplit_once("\">").map(|(_, b)| b.to_string()))
277                .expect("span body not found");
278            assert_eq!(unescape(&body), text, "round-trip failed for {text:?}");
279        }
280    }
281
282    #[test]
283    fn to_html_escaped_text_introduces_no_raw_markup_chars() {
284        // With no font-family, every `<`/`>` in the output must come from the
285        // four structural tags: <div>, <span ...>, </span>, </div>.
286        let html = content(vec![run("<<<>>>&&&", 10.0, None)]).to_html();
287        assert_eq!(html.matches('<').count(), 4, "{html}");
288        assert_eq!(html.matches('>').count(), 4, "{html}");
289    }
290
291    #[test]
292    fn to_html_font_family_is_interpolated_raw_into_the_style_attribute() {
293        // Characterization test (NOT an endorsement): unlike `text`, the font
294        // family is written into the `style="..."` attribute with no escaping
295        // or quoting, so a quote in the family name terminates the attribute.
296        // Live producers never populate `styled_runs`, so this is currently
297        // unreachable โ€” but any future producer must sanitize the family name.
298        let html = content(vec![run("t", 10.0, Some("\"><img onerror=x>"))]).to_html();
299        assert!(
300            html.contains("font-family: \"><img onerror=x>; "),
301            "escaping behaviour changed, re-check the injection note: {html}"
302        );
303    }
304
305    // ---------------------------------------------------------------------
306    // numeric: font_size_px is a raw f32 Display
307    // ---------------------------------------------------------------------
308
309    #[test]
310    fn to_html_non_finite_font_size_does_not_panic() {
311        for (size, rendered) in [
312            (f32::NAN, "font-size: NaNpx; "),
313            (f32::INFINITY, "font-size: infpx; "),
314            (f32::NEG_INFINITY, "font-size: -infpx; "),
315        ] {
316            let html = content(vec![run("x", size, None)]).to_html();
317            assert!(html.contains(rendered), "{size} -> {html}");
318            assert!(html.ends_with("</div>"), "{html}");
319        }
320    }
321
322    #[test]
323    fn to_html_extreme_finite_font_sizes_do_not_panic() {
324        for size in [
325            0.0,
326            -0.0,
327            -1.0,
328            f32::MIN,
329            f32::MAX,
330            f32::MIN_POSITIVE,
331            f32::EPSILON,
332        ] {
333            let html = content(vec![run("x", size, None)]).to_html();
334            assert!(html.starts_with("<div><span"), "{size} -> {html}");
335            assert!(html.ends_with("x</span></div>"), "{size} -> {html}");
336            assert!(html.contains("font-size: "), "{size} -> {html}");
337        }
338    }
339
340    #[test]
341    fn to_html_alpha_is_normalized_to_0_1_at_channel_boundaries() {
342        let alpha_of = |a: u8| {
343            let mut r = run("x", 10.0, None);
344            r.color = ColorU { r: 0, g: 0, b: 0, a };
345            content(vec![r]).to_html()
346        };
347        assert!(alpha_of(255).contains("rgba(0, 0, 0, 1); "));
348        assert!(alpha_of(0).contains("rgba(0, 0, 0, 0); "));
349        // 1/255 keeps full f32 precision โ€” it is not truncated to 0.
350        let expected = format!("rgba(0, 0, 0, {}); ", 1.0_f32 / 255.0);
351        assert!(alpha_of(1).contains(&expected), "{}", alpha_of(1));
352    }
353
354    #[test]
355    fn to_html_color_channels_are_verbatim_u8() {
356        let mut r = run("x", 10.0, None);
357        r.color = ColorU {
358            r: 255,
359            g: 0,
360            b: 128,
361            a: 255,
362        };
363        assert!(content(vec![r])
364            .to_html()
365            .contains("color: rgba(255, 0, 128, 1); "));
366    }
367
368    // ---------------------------------------------------------------------
369    // unicode / hostile payloads
370    // ---------------------------------------------------------------------
371
372    #[test]
373    fn to_html_preserves_unicode_and_control_characters() {
374        for text in [
375            "๐Ÿ˜€๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ",         // emoji + ZWJ sequence
376            "ู…ุฑุญุจุง ุจุงู„ุนุงู„ู…",  // RTL
377            "e\u{0301}\u{0327}", // combining marks
378            "a\u{0}b",        // interior NUL
379            "line\nbreak\ttab",
380            "\u{200B}\u{FEFF}", // zero-width space + BOM
381            "\u{202E}reversed", // RTL override
382        ] {
383            let html = content(vec![run(text, 10.0, None)]).to_html();
384            assert!(html.contains(text), "lost {text:?} in {html:?}");
385            assert!(html.ends_with("</span></div>"), "{html:?}");
386        }
387    }
388
389    #[test]
390    fn to_html_large_text_does_not_panic() {
391        let text = "&".repeat(100_000);
392        let html = content(vec![run(&text, 10.0, None)]).to_html();
393        // every `&` expands to the 5-byte `&amp;`
394        assert_eq!(html.matches("&amp;").count(), 100_000);
395        assert!(html.ends_with("</span></div>"));
396    }
397
398    #[test]
399    fn to_html_many_runs_emits_one_span_each() {
400        let runs: Vec<_> = (0..2_000u16)
401            .map(|i| run("t", f32::from(i), Some("Arial")))
402            .collect();
403        let html = content(runs).to_html();
404        assert_eq!(html.matches("<span style=\"").count(), 2_000);
405        assert_eq!(html.matches("</span>").count(), 2_000);
406    }
407
408    // ---------------------------------------------------------------------
409    // invariants: structure, purity, clone-equivalence
410    // ---------------------------------------------------------------------
411
412    #[test]
413    fn to_html_always_wraps_output_in_a_single_div() {
414        let cases = vec![
415            content(Vec::new()),
416            content(vec![run("", f32::NAN, None)]),
417            content(vec![run("<>&", -0.0, Some("a\"b"))]),
418            content(vec![run("๐Ÿ˜€", f32::MAX, Some(""))]),
419        ];
420        for c in cases {
421            let html = c.to_html();
422            assert!(html.starts_with("<div>"), "{html}");
423            assert!(html.ends_with("</div>"), "{html}");
424            assert_eq!(html.matches("<div>").count(), 1, "{html}");
425            assert_eq!(html.matches("</div>").count(), 1, "{html}");
426            assert_eq!(
427                html.matches("<span style=\"").count(),
428                html.matches("</span>").count(),
429                "unbalanced spans: {html}"
430            );
431        }
432    }
433
434    #[test]
435    fn to_html_is_pure_and_deterministic() {
436        let c = content(vec![run("<a>", 12.5, Some("Arial")), run("&", 0.0, None)]);
437        let before = c.clone();
438        let first = c.to_html();
439        let second = c.to_html();
440        assert_eq!(first, second, "to_html is not deterministic");
441        assert_eq!(c, before, "to_html mutated the receiver");
442        assert_eq!(c.clone().to_html(), first, "clone renders differently");
443    }
444}
445