1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
45pub struct Package {
46 pub name: String,
48 pub version: String,
50}
51
52impl Package {
53 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 pub fn label(&self) -> String {
63 format!("{} {}", self.name, self.version)
64 }
65}
66
67#[derive(Clone, Debug, Default, PartialEq, Eq)]
73pub struct LicenseNotice {
74 pub packages: Vec<Package>,
76 pub text: String,
78}
79
80static REGISTRY: Mutex<Vec<LicenseNotice>> = Mutex::new(Vec::new());
86
87pub fn add(notice: LicenseNotice) {
89 if let Ok(mut guard) = REGISTRY.lock() {
90 guard.push(notice);
91 }
92}
93
94pub 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
108pub fn all() -> Vec<LicenseNotice> {
110 REGISTRY
111 .lock()
112 .map(|guard| guard.clone())
113 .unwrap_or_default()
114}
115
116pub fn clear() {
119 if let Ok(mut guard) = REGISTRY.lock() {
120 guard.clear();
121 }
122}
123
124pub fn parse(generated: &str) -> Vec<LicenseNotice> {
137 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 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 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 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
210pub 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 ¬ice.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
235const PARAGRAPH_GAP: f32 = 12.0;
237const LICENCE_SIZE: f32 = 13.0;
240
241fn 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
262pub 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 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 #[must_use]
319 pub fn notices(mut self, notices: Vec<LicenseNotice>) -> Self {
320 self.notices = notices;
321 self
322 }
323
324 #[must_use]
326 pub fn application(mut self, name: impl Into<String>) -> Self {
327 self.application = Some(name.into());
328 self
329 }
330
331 #[must_use]
333 pub fn version(mut self, version: impl Into<String>) -> Self {
334 self.version = Some(version.into());
335 self
336 }
337
338 #[must_use]
340 pub fn legalese(mut self, legalese: impl Into<String>) -> Self {
341 self.legalese = Some(legalese.into());
342 self
343 }
344
345 #[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 #[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 #[must_use]
362 pub fn text_style(mut self, style: TextStyle) -> Self {
363 self.text_style = Some(style);
364 self
365 }
366
367 #[must_use]
369 pub fn width(mut self, width: f32) -> Self {
370 self.width = Dimension::Length(width);
371 self
372 }
373
374 #[must_use]
376 pub fn flex(mut self, grow: f32) -> Self {
377 self.flex_grow = grow;
378 self
379 }
380
381 #[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(¬ices);
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 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 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 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(¬ice.text) {
479 block = block.child(crate::Text::styled(paragraph, body).wrap());
480 }
481 column = column.child(crate::Divider::new()).child(block);
482 }
483 }
484 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
507pub 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 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 #[must_use]
542 pub fn application(mut self, name: impl Into<String>) -> Self {
543 self.application = Some(name.into());
544 self
545 }
546
547 #[must_use]
549 pub fn version(mut self, version: impl Into<String>) -> Self {
550 self.version = Some(version.into());
551 self
552 }
553
554 #[must_use]
556 pub fn legalese(mut self, legalese: impl Into<String>) -> Self {
557 self.legalese = Some(legalese.into());
558 self
559 }
560
561 #[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 #[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 #[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 #[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 #[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 #[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 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
642pub 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 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 #[must_use]
669 pub fn application(mut self, name: impl Into<String>) -> Self {
670 self.application = Some(name.into());
671 self
672 }
673
674 #[must_use]
676 pub fn title(mut self, title: impl Into<String>) -> Self {
677 self.title = Some(title.into());
678 self
679 }
680
681 #[must_use]
683 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
684 self.subtitle = Some(subtitle.into());
685 self
686 }
687
688 #[must_use]
690 pub fn icon(mut self, icon: Option<crate::IconData>) -> Self {
691 self.icon = icon;
692 self
693 }
694
695 #[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 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 #[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 #[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 #[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(¬ices);
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 assert_eq!(listed[0].1.len(), 2);
798 assert_eq!(listed[1].1.len(), 1);
799 }
800
801 #[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 #[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 #[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 #[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 #[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 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}