Skip to main content

frus_widgets/
licenses.rs

1//! **An application's licences**: the registry, and the three widgets that show them —
2//! [`LicensePage`], [`AboutDialog`] and [`AboutListTile`].
3//!
4//! Every application distributed anywhere, and every application depending on anything
5//! under Apache-2.0 or MIT, has to show the licences of what it links. Until now this
6//! framework gave it nothing to show them with, so an application wrote its own screen or
7//! — more likely — shipped without one. The obligation is silent: nothing warns anybody
8//! that it is missing.
9//!
10//! ## Where the text comes from, which is the whole question
11//!
12//! The reference keeps a **registry** that packages add themselves to at startup. That
13//! shape does not survive the crossing: a Rust crate cannot run code before `main`, so
14//! nothing a dependency ships would ever register itself, and the list would be
15//! hand-maintained — which is to say wrong the first time somebody adds a dependency.
16//!
17//! What Rust has instead is better: **cargo knows the answer**. `scripts/gen_licenses.py`
18//! reads the actual dependency graph of an application (`cargo tree -e no-dev --target
19//! all`), finds the licence files each package ships, groups the ones that are
20//! byte-identical, and writes a plain-text file the application embeds:
21//!
22//! ```ignore
23//! frus::licenses::add_all(include_str!("../assets/licenses.txt"));
24//! ```
25//!
26//! One call, at startup, and the list cannot drift from what is linked without the file
27//! changing — which is the one failure mode that matters here.
28//!
29//! **Nothing is registered by default**, and that is deliberate. A framework can only know
30//! its own subtree, and an application's list has to cover the application; a token entry
31//! for frus would be a list that looks complete and is not. An empty registry says so on
32//! the page instead, in words, which turns a silent obligation into a visible one.
33
34use std::rc::Rc;
35use std::sync::Mutex;
36
37use frus_core::TextStyle;
38use frus_layout::{Align, Dimension};
39
40use crate::theme::Theme;
41use crate::widget::Widget;
42
43/// A package, as cargo names it.
44#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
45pub struct Package {
46    /// Its crate name.
47    pub name: String,
48    /// Its version.
49    pub version: String,
50}
51
52impl Package {
53    /// A package from its name and version.
54    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
55        Self {
56            name: name.into(),
57            version: version.into(),
58        }
59    }
60
61    /// `name version`, the way cargo prints it.
62    pub fn label(&self) -> String {
63        format!("{} {}", self.name, self.version)
64    }
65}
66
67/// One licence text, and every package that ships exactly it.
68///
69/// The grouping is the generator's: a hundred packages under the same Apache-2.0 file are
70/// one notice covering a hundred packages, which is what keeps a page of four hundred
71/// dependencies readable.
72#[derive(Clone, Debug, Default, PartialEq, Eq)]
73pub struct LicenseNotice {
74    /// The packages this text covers.
75    pub packages: Vec<Package>,
76    /// The licence, as the packages ship it.
77    pub text: String,
78}
79
80/// The notices an application has registered.
81///
82/// A `Mutex` and not a thread-local: registering happens once, at startup, from wherever
83/// the application's `main` runs, and the page is built on the UI thread. The same shape
84/// as [`frus_core::set_image_fetcher`], for the same reason.
85static REGISTRY: Mutex<Vec<LicenseNotice>> = Mutex::new(Vec::new());
86
87/// Registers one notice.
88pub fn add(notice: LicenseNotice) {
89    if let Ok(mut guard) = REGISTRY.lock() {
90        guard.push(notice);
91    }
92}
93
94/// Registers everything in a generated file, and answers how many notices that was.
95///
96/// ```ignore
97/// frus::licenses::add_all(include_str!("../assets/licenses.txt"));
98/// ```
99pub fn add_all(generated: &str) -> usize {
100    let notices = parse(generated);
101    let count = notices.len();
102    if let Ok(mut guard) = REGISTRY.lock() {
103        guard.extend(notices);
104    }
105    count
106}
107
108/// Everything registered, in the order it was registered.
109pub fn all() -> Vec<LicenseNotice> {
110    REGISTRY
111        .lock()
112        .map(|guard| guard.clone())
113        .unwrap_or_default()
114}
115
116/// Forgets everything registered. For a test, and for an application that rebuilds its
117/// list — registering twice would otherwise show every licence twice.
118pub fn clear() {
119    if let Ok(mut guard) = REGISTRY.lock() {
120        guard.clear();
121    }
122}
123
124/// Reads the generator's format: a header, then one entry per notice.
125///
126/// ```text
127/// frus-licenses 1
128/// @ <byte length of the text>
129/// - <package> <version>
130/// .
131/// <exactly that many bytes>
132/// ```
133///
134/// The length prefix is what makes it safe: a licence text can contain any line at all,
135/// including one that looks like a separator, and several of them do.
136pub fn parse(generated: &str) -> Vec<LicenseNotice> {
137    // A file whose line endings were rewritten on the way out of version control — which
138    // is what a Windows checkout does by default — carries two bytes where the generator
139    // wrote one, so every length in it falls short of its own text and the file reads as
140    // **no notices at all**: not a wrong page, an empty one. `.gitattributes` is what
141    // stops that here; this is what happens when it does anyway, in a repository where
142    // that file does not reach.
143    //
144    // The **header line is the witness**, rather than a carriage return anywhere: the
145    // generator writes `frus-licenses 1` and one on the end of that can only have been
146    // added on the way here, while one inside a licence text is the licence's own and
147    // counts towards its length. Undoing those would break exactly what this repairs.
148    let repaired;
149    let generated = if generated
150        .split('\n')
151        .next()
152        .is_some_and(|h| h.ends_with('\r'))
153    {
154        repaired = generated.replace("\r\n", "\n");
155        repaired.as_str()
156    } else {
157        generated
158    };
159
160    /// The next line, and what follows it.
161    fn line<'a>(rest: &mut &'a str) -> Option<&'a str> {
162        if rest.is_empty() {
163            return None;
164        }
165        let (head, tail) = match rest.find('\n') {
166            Some(at) => (&rest[..at], &rest[at + 1..]),
167            None => (*rest, ""),
168        };
169        *rest = tail;
170        Some(head)
171    }
172
173    let mut rest = generated;
174    let mut notices = Vec::new();
175    while let Some(head) = line(&mut rest) {
176        // The header, comments and blank lines are skipped: an entry starts at `@`.
177        let Some(length) = head
178            .strip_prefix("@ ")
179            .and_then(|n| n.trim().parse::<usize>().ok())
180        else {
181            continue;
182        };
183        let mut packages = Vec::new();
184        while let Some(head) = line(&mut rest) {
185            if head == "." {
186                break;
187            }
188            if let Some(entry) = head.strip_prefix("- ") {
189                let mut parts = entry.split_whitespace();
190                if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
191                    packages.push(Package::new(name, version));
192                }
193            }
194        }
195        // A truncated file stops the walk rather than panicking: what was read is still
196        // worth showing, and a page missing its last entry is better than no page.
197        if rest.len() < length || !rest.is_char_boundary(length) {
198            break;
199        }
200        let (text, tail) = rest.split_at(length);
201        rest = tail.strip_prefix('\n').unwrap_or(tail);
202        notices.push(LicenseNotice {
203            packages,
204            text: text.to_string(),
205        });
206    }
207    notices
208}
209
210/// The packages of a set of notices, each with the notices covering it: the list the page
211/// shows, which is the transpose of what the generator writes.
212///
213/// The generator groups by text because that is what deduplicates; a reader looks for a
214/// package. Sorted by name, case-insensitively, because that is the order somebody
215/// looking for `wgpu` expects to find it in.
216pub fn by_package(notices: &[LicenseNotice]) -> Vec<(Package, Vec<usize>)> {
217    let mut out: Vec<(Package, Vec<usize>)> = Vec::new();
218    for (index, notice) in notices.iter().enumerate() {
219        for package in &notice.packages {
220            match out.iter_mut().find(|(p, _)| p == package) {
221                Some((_, list)) => list.push(index),
222                None => out.push((package.clone(), vec![index])),
223            }
224        }
225    }
226    out.sort_by(|(a, _), (b, _)| {
227        a.name
228            .to_lowercase()
229            .cmp(&b.name.to_lowercase())
230            .then_with(|| a.version.cmp(&b.version))
231    });
232    out
233}
234
235/// The gap between two paragraphs of a licence.
236const PARAGRAPH_GAP: f32 = 12.0;
237/// The size licence text is set at: smaller than body text, because there is a great deal
238/// of it and nobody reads it as prose.
239const LICENCE_SIZE: f32 = 13.0;
240
241/// A licence's paragraphs, re-flowed.
242///
243/// Licence files are hard-wrapped at seventy-odd columns for a terminal, which on a phone
244/// is a paragraph with a ragged edge or one that runs off the side. The lines of a
245/// paragraph are joined and left to wrap at the width they are given — the same thing the
246/// reference does with a `LicenseParagraph`, and for the same reason.
247fn paragraphs(text: &str) -> Vec<String> {
248    text.split("\n\n")
249        .map(|block| {
250            block
251                .split('\n')
252                .map(str::trim_end)
253                .collect::<Vec<_>>()
254                .join(" ")
255                .trim()
256                .to_string()
257        })
258        .filter(|block| !block.is_empty())
259        .collect()
260}
261
262/// **The licences of everything an application links**, as a page.
263///
264/// A list of packages, each with the licences covering it; selecting one shows the text.
265/// The selection is the **application's**, like every other piece of state here, so the
266/// page fits a route as easily as a panel: `on_select(Some(i))` on a row, and
267/// `on_select(None)` from the way back.
268///
269/// It does not scroll itself, and it does not guess how tall it is: put it in a
270/// [`crate::SingleChildScrollView`], or in a scaffold's scrolling body, which is where a page
271/// belongs anyway.
272///
273/// ```ignore
274/// LicensePage::new(app.licence_open, Msg::OpenLicence)
275///     .application("Tasks")
276///     .version("1.4.0")
277///     .legalese("© 2026 Someone")
278///     .build()
279/// ```
280pub struct LicensePage<Msg = crate::callback::Callback> {
281    notices: Vec<LicenseNotice>,
282    selected: Option<usize>,
283    on_select: Option<Rc<dyn Fn(Option<usize>) -> Msg>>,
284    application: Option<String>,
285    version: Option<String>,
286    legalese: Option<String>,
287    empty: Option<String>,
288    back_label: Option<String>,
289    text_style: Option<TextStyle>,
290    width: Dimension,
291    flex_grow: f32,
292}
293
294impl<Msg: Clone + 'static> LicensePage<Msg> {
295    /// A page over everything [`add_all`] has registered. `selected` is which package is
296    /// open — an index into the page's own list — and `on_select` carries the row that was
297    /// touched, or `None` for the way back.
298    pub fn new(
299        selected: Option<usize>,
300        on_select: impl Fn(Option<usize>) -> Msg + 'static,
301    ) -> Self {
302        Self {
303            notices: all(),
304            selected,
305            on_select: Some(Rc::new(on_select)),
306            application: None,
307            version: None,
308            legalese: None,
309            empty: None,
310            back_label: None,
311            text_style: None,
312            width: Dimension::Auto,
313            flex_grow: 0.0,
314        }
315    }
316
317    /// A page over notices of the caller's own, instead of the registry's.
318    #[must_use]
319    pub fn notices(mut self, notices: Vec<LicenseNotice>) -> Self {
320        self.notices = notices;
321        self
322    }
323
324    /// The application's name, at the head of the page.
325    #[must_use]
326    pub fn application(mut self, name: impl Into<String>) -> Self {
327        self.application = Some(name.into());
328        self
329    }
330
331    /// Its version, under the name.
332    #[must_use]
333    pub fn version(mut self, version: impl Into<String>) -> Self {
334        self.version = Some(version.into());
335        self
336    }
337
338    /// The application's own legal line — a copyright, usually.
339    #[must_use]
340    pub fn legalese(mut self, legalese: impl Into<String>) -> Self {
341        self.legalese = Some(legalese.into());
342        self
343    }
344
345    /// What the page says when **nothing** is registered. There is a default, and it says
346    /// what to do about it.
347    #[must_use]
348    pub fn empty_message(mut self, message: impl Into<String>) -> Self {
349        self.empty = Some(message.into());
350        self
351    }
352
353    /// The label on the way back from a licence, for an application in another language.
354    #[must_use]
355    pub fn back_label(mut self, label: impl Into<String>) -> Self {
356        self.back_label = Some(label.into());
357        self
358    }
359
360    /// The type the licence text is set in.
361    #[must_use]
362    pub fn text_style(mut self, style: TextStyle) -> Self {
363        self.text_style = Some(style);
364        self
365    }
366
367    /// An explicit width.
368    #[must_use]
369    pub fn width(mut self, width: f32) -> Self {
370        self.width = Dimension::Length(width);
371        self
372    }
373
374    /// The share of a parent flex's spare room the page takes.
375    #[must_use]
376    pub fn flex(mut self, grow: f32) -> Self {
377        self.flex_grow = grow;
378        self
379    }
380
381    /// Composes the page. Deferred until the theme is known, because everything on it is
382    /// text and every piece of that text takes its size from the theme.
383    #[must_use]
384    pub fn build(self) -> Box<dyn Widget<Msg>> {
385        let LicensePage {
386            notices,
387            selected,
388            on_select,
389            application,
390            version,
391            legalese,
392            empty,
393            back_label,
394            text_style,
395            width,
396            flex_grow,
397        } = self;
398        Box::new(crate::ThemeBuilder::boxed(move |theme: &Theme| {
399            let scale = crate::theme::type_scale(Some(theme));
400            let packages = by_package(&notices);
401            let mut column = crate::Flex::column()
402                .align(Align::Stretch)
403                .gap(8.0)
404                .flex(flex_grow);
405            if let Dimension::Length(w) = width {
406                column = column.width(w);
407            }
408
409            // The head of the page: who this is, which the licences are *of*.
410            if let Some(name) = &application {
411                column = column.child(crate::Text::styled(name.clone(), scale.title_large));
412            }
413            if let Some(version) = &version {
414                column = column.child(
415                    crate::Text::styled(version.clone(), scale.body_medium).color(theme.muted),
416                );
417            }
418            if let Some(legalese) = &legalese {
419                column = column.child(
420                    crate::Text::styled(legalese.clone(), scale.body_small)
421                        .color(theme.muted)
422                        .wrap(),
423                );
424            }
425            if application.is_some() || version.is_some() || legalese.is_some() {
426                column = column.child(crate::Divider::new());
427            }
428
429            // Nothing registered: the page says so, and says what to do. An application
430            // that has forgotten this obligation is exactly the one that will not notice
431            // an empty list.
432            if packages.is_empty() {
433                let message = empty.clone().unwrap_or_else(|| {
434                    "No licences are registered. Generate them from the dependency graph \
435                     and register them at startup: see scripts/gen_licenses.py and \
436                     licenses::add_all."
437                        .to_string()
438                });
439                return Box::new(
440                    crate::Flex::column()
441                        .align(Align::Stretch)
442                        .gap(8.0)
443                        .child(column)
444                        .child(
445                            crate::Text::styled(message, scale.body_medium)
446                                .color(theme.error)
447                                .wrap(),
448                        ),
449                );
450            }
451
452            match selected.and_then(|index| packages.get(index)) {
453                // One package: its name, and the whole of every licence covering it.
454                Some((package, covering)) => {
455                    let back = back_label.clone().unwrap_or_else(|| "Back".to_string());
456                    if let Some(on_select) = &on_select {
457                        let on_select = Rc::clone(on_select);
458                        column = column.child(
459                            crate::Flex::row().child(
460                                crate::Button::new(back)
461                                    .variant(crate::Variant::Text)
462                                    .on_press(on_select(None)),
463                            ),
464                        );
465                    }
466                    column = column.child(crate::Text::styled(package.label(), scale.title_medium));
467                    let body = text_style.unwrap_or(TextStyle {
468                        size: Some(LICENCE_SIZE),
469                        ..scale.body_small
470                    });
471                    for index in covering {
472                        let Some(notice) = notices.get(*index) else {
473                            continue;
474                        };
475                        let mut block = crate::Flex::column()
476                            .align(Align::Stretch)
477                            .gap(PARAGRAPH_GAP);
478                        for paragraph in paragraphs(&notice.text) {
479                            block = block.child(crate::Text::styled(paragraph, body).wrap());
480                        }
481                        column = column.child(crate::Divider::new()).child(block);
482                    }
483                }
484                // The list: every package, and how many licences cover it.
485                None => {
486                    for (index, (package, covering)) in packages.iter().enumerate() {
487                        let mut tile = crate::ListTile::new()
488                            .title(package.label())
489                            .subtitle(if covering.len() == 1 {
490                                "1 licence".to_string()
491                            } else {
492                                format!("{} licences", covering.len())
493                            })
494                            .dense();
495                        if let Some(on_select) = &on_select {
496                            tile = tile.on_tap(on_select(Some(index)));
497                        }
498                        column = column.child(tile);
499                    }
500                }
501            }
502            Box::new(column)
503        }))
504    }
505}
506
507/// **The box that says what this application is**, with a way through to the licences.
508///
509/// A name, a version, an icon, a legal line, and two buttons: one to the licences, one to
510/// close. It is an [`crate::AlertDialog`] underneath, so everything an alert dialog can be
511/// told, this can be told too.
512pub struct AboutDialog<Msg = crate::callback::Callback> {
513    open: bool,
514    application: Option<String>,
515    version: Option<String>,
516    legalese: Option<String>,
517    icon: Option<Box<dyn Widget<Msg>>>,
518    licences_label: Option<String>,
519    close_label: Option<String>,
520    on_licences: Option<Msg>,
521    on_close: Option<Msg>,
522}
523
524impl<Msg: Clone + 'static> AboutDialog<Msg> {
525    /// A dialog, shown when `open`.
526    pub fn new(open: bool) -> Self {
527        Self {
528            open,
529            application: None,
530            version: None,
531            legalese: None,
532            icon: None,
533            licences_label: None,
534            close_label: None,
535            on_licences: None,
536            on_close: None,
537        }
538    }
539
540    /// The application's name.
541    #[must_use]
542    pub fn application(mut self, name: impl Into<String>) -> Self {
543        self.application = Some(name.into());
544        self
545    }
546
547    /// Its version.
548    #[must_use]
549    pub fn version(mut self, version: impl Into<String>) -> Self {
550        self.version = Some(version.into());
551        self
552    }
553
554    /// The legal line — a copyright, a notice of the application's own.
555    #[must_use]
556    pub fn legalese(mut self, legalese: impl Into<String>) -> Self {
557        self.legalese = Some(legalese.into());
558        self
559    }
560
561    /// Its mark, beside the name.
562    #[must_use]
563    pub fn icon(mut self, icon: impl Widget<Msg> + 'static) -> Self {
564        self.icon = Some(Box::new(icon));
565        self
566    }
567
568    /// The button through to the licences, and what it says.
569    #[must_use]
570    pub fn on_licences(mut self, message: impl Into<Msg>) -> Self {
571        self.on_licences = Some(message.into());
572        self
573    }
574
575    /// Its label, for an application in another language.
576    #[must_use]
577    pub fn licences_label(mut self, label: impl Into<String>) -> Self {
578        self.licences_label = Some(label.into());
579        self
580    }
581
582    /// The button that closes it — which is also what a tap outside sends.
583    #[must_use]
584    pub fn on_close(mut self, message: impl Into<Msg>) -> Self {
585        self.on_close = Some(message.into());
586        self
587    }
588
589    /// Its label.
590    #[must_use]
591    pub fn close_label(mut self, label: impl Into<String>) -> Self {
592        self.close_label = Some(label.into());
593        self
594    }
595
596    /// Sets the screen behind it and finalises the dialog.
597    #[must_use]
598    pub fn body(self, body: impl Widget<Msg> + 'static) -> crate::Dialog<Msg> {
599        let AboutDialog {
600            open,
601            application,
602            version,
603            legalese,
604            icon,
605            licences_label,
606            close_label,
607            on_licences,
608            on_close,
609        } = self;
610        let mut dialog = crate::AlertDialog::new(open);
611        if let Some(icon) = icon {
612            dialog = dialog.icon(icon);
613        }
614        if let Some(name) = application {
615            dialog = dialog.title(name);
616        }
617        // The version and the legal line are one block of content: two texts in a column,
618        // rather than a title's subtitle, because the dialog has one title and it is the
619        // application's name.
620        let content: Vec<String> = [version, legalese].into_iter().flatten().collect();
621        if !content.is_empty() {
622            dialog = dialog.content(content.join("\n"));
623        }
624        if let Some(message) = on_licences {
625            dialog = dialog.action(
626                crate::Button::new(licences_label.unwrap_or_else(|| "Licences".to_string()))
627                    .variant(crate::Variant::Text)
628                    .on_press(message),
629            );
630        }
631        if let Some(message) = on_close {
632            dialog = dialog.on_dismiss(message.clone()).action(
633                crate::Button::new(close_label.unwrap_or_else(|| "Close".to_string()))
634                    .variant(crate::Variant::Text)
635                    .on_press(message),
636            );
637        }
638        dialog.body(body)
639    }
640}
641
642/// **The row that opens the about box**, for a settings screen or a drawer.
643///
644/// One line over [`crate::ListTile`], which is the point: the reference has this widget
645/// because every application writes the same row, and every application gets it slightly
646/// differently.
647pub struct AboutListTile<Msg = crate::callback::Callback> {
648    application: Option<String>,
649    icon: Option<crate::IconData>,
650    title: Option<String>,
651    subtitle: Option<String>,
652    on_tap: Option<Msg>,
653}
654
655impl<Msg: Clone + 'static> AboutListTile<Msg> {
656    /// A row that emits `on_tap` — which an application turns into an open about box.
657    pub fn new(on_tap: Msg) -> Self {
658        Self {
659            application: None,
660            icon: Some(crate::Icons::INFO),
661            title: None,
662            subtitle: None,
663            on_tap: Some(on_tap),
664        }
665    }
666
667    /// The application's name, which the row's default title is *about*.
668    #[must_use]
669    pub fn application(mut self, name: impl Into<String>) -> Self {
670        self.application = Some(name.into());
671        self
672    }
673
674    /// A title of the caller's own, over the default `About <name>`.
675    #[must_use]
676    pub fn title(mut self, title: impl Into<String>) -> Self {
677        self.title = Some(title.into());
678        self
679    }
680
681    /// A second line under it — a version, usually.
682    #[must_use]
683    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
684        self.subtitle = Some(subtitle.into());
685        self
686    }
687
688    /// The glyph at its leading edge, or `None` for a row with no icon at all.
689    #[must_use]
690    pub fn icon(mut self, icon: Option<crate::IconData>) -> Self {
691        self.icon = icon;
692        self
693    }
694
695    /// Composes the row.
696    #[must_use]
697    pub fn build(self) -> crate::ListTile<Msg> {
698        let title = self.title.unwrap_or_else(|| match &self.application {
699            Some(name) => format!("About {name}"),
700            None => "About".to_string(),
701        });
702        let mut tile = crate::ListTile::new().title(title);
703        if let Some(icon) = self.icon {
704            tile = tile.leading(crate::Icon::new(icon));
705        }
706        if let Some(subtitle) = self.subtitle {
707            tile = tile.subtitle(subtitle);
708        }
709        if let Some(message) = self.on_tap {
710            tile = tile.on_tap(message);
711        }
712        tile
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use crate::{build_ui, Runtime, Size};
720
721    #[derive(Clone, Debug, PartialEq)]
722    enum Msg {
723        Open(Option<usize>),
724    }
725
726    /// A file in the generator's format, with **a licence text that contains the format's
727    /// own punctuation** — a line starting with `@`, a lone full stop, a line that looks
728    /// like a package. Several real licences have lines like these, which is why the
729    /// entries are length-prefixed rather than delimited.
730    const AWKWARD: &str = "frus-licenses 1\n# a comment\n\n@ 52\n- one 1.0.0\n- two 0.2.1\n.\n@ 9\nnot a header\n.\n- not a package\nstill the licence\n@ 4\n- three 0.1.0\n.\nlast\n";
731
732    #[test]
733    fn the_format_survives_a_licence_that_looks_like_the_format() {
734        let notices = parse(AWKWARD);
735        assert_eq!(notices.len(), 2, "two entries: {notices:#?}");
736        assert_eq!(
737            notices[0].packages,
738            vec![Package::new("one", "1.0.0"), Package::new("two", "0.2.1")]
739        );
740        assert!(
741            notices[0].text.starts_with("@ 9\nnot a header"),
742            "the text is taken by length, whatever it contains: {:?}",
743            notices[0].text
744        );
745        assert_eq!(notices[1].text, "last");
746    }
747
748    /// **The same file with its line endings rewritten reads the same.** A length in
749    /// bytes cannot survive a checkout that adds a byte to every line: each entry's text
750    /// then ends before its length says it does, the walk loses its place at the first
751    /// one, and the page shows nothing at all. That is what happened on Windows, and no
752    /// test running on Linux could have seen it. `.gitattributes` keeps the bytes; this
753    /// keeps the page in a repository where nobody thought to.
754    #[test]
755    fn a_checkout_that_rewrote_the_line_endings_still_reads() {
756        let notices = parse(&AWKWARD.replace('\n', "\r\n"));
757        assert_eq!(notices, parse(AWKWARD), "the same file, read the same");
758        assert_eq!(notices.len(), 2);
759    }
760
761    /// A file cut off mid-entry gives back what was read rather than panicking: a page
762    /// missing its last notice is better than no page, and better than a crash on a
763    /// screen somebody opened to comply with a licence.
764    #[test]
765    fn a_truncated_file_stops_where_it_stops() {
766        let cut = "frus-licenses 1\n@ 3\n- one 1.0.0\n.\nMIT\n@ 4000\n- two 1.0.0\n.\nshort";
767        let notices = parse(cut);
768        assert_eq!(notices.len(), 1);
769        assert_eq!(notices[0].text, "MIT");
770    }
771
772    /// The generator groups by text; a reader looks for a package. The page transposes,
773    /// and sorts the way somebody looking for `wgpu` expects.
774    #[test]
775    fn the_page_lists_packages_and_not_texts() {
776        let notices = vec![
777            LicenseNotice {
778                packages: vec![
779                    Package::new("Zebra", "1.0.0"),
780                    Package::new("apple", "2.0.0"),
781                ],
782                text: "MIT".to_string(),
783            },
784            LicenseNotice {
785                packages: vec![Package::new("apple", "2.0.0")],
786                text: "Apache".to_string(),
787            },
788        ];
789        let listed = by_package(&notices);
790        let names: Vec<&str> = listed.iter().map(|(p, _)| p.name.as_str()).collect();
791        assert_eq!(
792            names,
793            ["apple", "Zebra"],
794            "sorted, and case is not an order"
795        );
796        // A package under two licences is one row that says two.
797        assert_eq!(listed[0].1.len(), 2);
798        assert_eq!(listed[1].1.len(), 1);
799    }
800
801    /// Licence files are hard-wrapped for a terminal. A paragraph is re-flowed so that it
802    /// wraps to the width it is given instead of to somebody else's eighty columns.
803    #[test]
804    fn paragraphs_are_reflowed_and_blank_blocks_dropped() {
805        let text =
806            "Permission is hereby\ngranted, free of charge\n\n\nTHE SOFTWARE IS\nPROVIDED AS IS";
807        assert_eq!(
808            paragraphs(text),
809            [
810                "Permission is hereby granted, free of charge",
811                "THE SOFTWARE IS PROVIDED AS IS"
812            ]
813        );
814    }
815
816    /// The registry: what an application registers is what the page shows. One test
817    /// touches it, because it is process-wide and tests share a process.
818    #[test]
819    fn the_registry_holds_what_was_registered() {
820        clear();
821        assert!(all().is_empty());
822        let added = add_all("frus-licenses 1\n@ 3\n- one 1.0.0\n.\nMIT\n");
823        assert_eq!(added, 1);
824        add(LicenseNotice {
825            packages: vec![Package::new("two", "1.0.0")],
826            text: "BSD".to_string(),
827        });
828        assert_eq!(all().len(), 2);
829        clear();
830        assert!(all().is_empty(), "and an application can start again");
831    }
832
833    /// **The empty page says so.** The obligation this widget exists for is a silent one,
834    /// and a page that says nothing at all where the licences should be is exactly how it
835    /// stays silent.
836    #[test]
837    fn a_page_with_nothing_registered_says_what_to_do() {
838        let page: Box<dyn Widget<Msg>> = LicensePage::new(None, Msg::Open)
839            .notices(Vec::new())
840            .application("Something")
841            .build();
842        let words = text_of(page.as_ref());
843        assert!(
844            words.iter().any(|line| line.contains("No licences")),
845            "the page says the list is empty: {words:#?}"
846        );
847        assert!(
848            words.iter().any(|line| line.contains("gen_licenses")),
849            "and what to do about it: {words:#?}"
850        );
851    }
852
853    /// The list shows a row per package, with how many licences cover it; opening one
854    /// shows the text itself.
855    #[test]
856    fn the_list_opens_onto_the_licence_itself() {
857        let notices = vec![LicenseNotice {
858            packages: vec![Package::new("wgpu", "22.1.0")],
859            text: "Apache License\n\nSome terms.".to_string(),
860        }];
861        let list = LicensePage::new(None, Msg::Open)
862            .notices(notices.clone())
863            .build();
864        let words = text_of(list.as_ref());
865        assert!(words.iter().any(|w| w == "wgpu 22.1.0"), "{words:#?}");
866        assert!(words.iter().any(|w| w == "1 licence"), "{words:#?}");
867        assert!(
868            !words.iter().any(|w| w.contains("Some terms")),
869            "the list is a list, not four hundred licences at once: {words:#?}"
870        );
871
872        let open = LicensePage::new(Some(0), Msg::Open)
873            .notices(notices)
874            .build();
875        let words = text_of(open.as_ref());
876        assert!(words.iter().any(|w| w == "Some terms."), "{words:#?}");
877        assert!(
878            words.iter().any(|w| w == "Back"),
879            "and a way back: {words:#?}"
880        );
881    }
882
883    /// The row that opens the box, and the box itself, say what they are about.
884    #[test]
885    fn the_about_row_and_box_name_the_application() {
886        let tile = AboutListTile::new(Msg::Open(None))
887            .application("Tasks")
888            .subtitle("Version 1.0")
889            .build();
890        let words = text_of(&tile);
891        assert!(words.iter().any(|w| w == "About Tasks"), "{words:#?}");
892        assert!(words.iter().any(|w| w == "Version 1.0"), "{words:#?}");
893
894        let dialog = AboutDialog::new(true)
895            .application("Tasks")
896            .version("Version 1.0")
897            .legalese("© somebody")
898            .on_licences(Msg::Open(Some(0)))
899            .on_close(Msg::Open(None))
900            .body(crate::Container::new().width(400.0).height(400.0));
901        let words = text_of(&dialog);
902        for expected in ["Tasks", "Licences", "Close"] {
903            assert!(
904                words.iter().any(|w| w.contains(expected)),
905                "the box says {expected}: {words:#?}"
906            );
907        }
908    }
909
910    /// Every string a widget paints, in paint order.
911    fn text_of(root: &dyn Widget<Msg>) -> Vec<String> {
912        let size = Size::new(420.0, 900.0);
913        let ui = build_ui(root, size, &Runtime::default(), &Theme::dark());
914        ui.scene()
915            .primitives()
916            .iter()
917            .filter_map(|p| match p {
918                frus_core::Primitive::Text { text, .. } => Some(text.clone()),
919                _ => None,
920            })
921            .collect()
922    }
923}