| 41 |
41 |
|
//! write it back, at which point it is a form model.
|
| 42 |
42 |
|
|
| 43 |
43 |
|
use crate::{Emit, class, push_class};
|
| 44 |
|
- |
use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, Tone};
|
|
44 |
+ |
use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone};
|
| 45 |
45 |
|
use std::fmt::Write as _;
|
| 46 |
46 |
|
|
| 47 |
47 |
|
/// Every class this module can put in markup.
|
| 739 |
739 |
|
}
|
| 740 |
740 |
|
}
|
| 741 |
741 |
|
|
|
742 |
+ |
/// The themes, as one `<optgroup>` per variant with a contrast mark per row.
|
|
743 |
+ |
///
|
|
744 |
+ |
/// # The grouping comes out of the order, not out of a group list
|
|
745 |
+ |
///
|
|
746 |
+ |
/// [`makeover_layout::Field::themes`] arrives sorted by variant and then by
|
|
747 |
+ |
/// measured contrast, and the run of one variant is the group. So this walks
|
|
748 |
+ |
/// the list once and opens a new `<optgroup>` whenever the variant changes,
|
|
749 |
+ |
/// which is the whole of the grouping logic and cannot disagree with the order
|
|
750 |
+ |
/// the way a separately-carried group list could.
|
|
751 |
+ |
///
|
|
752 |
+ |
/// A theme whose variant equals its predecessor's never opens a group, so a
|
|
753 |
+ |
/// list that arrived unsorted would emit repeated groups rather than silently
|
|
754 |
+ |
/// merging distant rows. That is the honest report on a description that broke
|
|
755 |
+ |
/// its own contract, and it is visible on screen rather than in a log.
|
|
756 |
+ |
///
|
|
757 |
+ |
/// # The follow row is not in a group
|
|
758 |
+ |
///
|
|
759 |
+ |
/// It names no theme and sits in no variant, so it is emitted first and bare.
|
|
760 |
+ |
/// Grouping it under a heading would be inventing a fourth variant for one row.
|
|
761 |
+ |
///
|
|
762 |
+ |
/// # The badge is text, because a `<select>` has nowhere else to put it
|
|
763 |
+ |
///
|
|
764 |
+ |
/// A `<select>`'s options take no elements, no second line and no title the
|
|
765 |
+ |
/// keyboard reaches, which is [`push_options`]' finding about
|
|
766 |
+ |
/// [`Choice::unavailable`] met a second time. So the tier rides in the option's
|
|
767 |
+ |
/// own text, in brackets after the name, and it is
|
|
768 |
+ |
/// [`makeover_layout::Contrast::badge`]'s spelling rather than one invented
|
|
769 |
+ |
/// here — three renderers picking their own is one picker reading three ways.
|
|
770 |
+ |
fn push_theme_options(out: &mut String, field: &Field<'_>, value: &str) {
|
|
771 |
+ |
if let Some(follow) = field.follows {
|
|
772 |
+ |
out.push_str("<option value=\"");
|
|
773 |
+ |
escape_into(follow.value, out);
|
|
774 |
+ |
out.push('"');
|
|
775 |
+ |
if follow.value == value {
|
|
776 |
+ |
out.push_str(" selected");
|
|
777 |
+ |
}
|
|
778 |
+ |
out.push('>');
|
|
779 |
+ |
escape_into(follow.label, out);
|
|
780 |
+ |
out.push_str("</option>");
|
|
781 |
+ |
}
|
|
782 |
+ |
|
|
783 |
+ |
// A stored id naming a theme that is no longer installed. `push_options`'
|
|
784 |
+ |
// reasoning applies unchanged: a value no row carries is a wrong answer
|
|
785 |
+ |
// rather than an absent one, and dropping it would silently show the user
|
|
786 |
+ |
// a different theme than the one their config names.
|
|
787 |
+ |
let known = field.themes.iter().any(|theme| theme.id == value)
|
|
788 |
+ |
|| field.follows.is_some_and(|follow| follow.value == value);
|
|
789 |
+ |
if !value.is_empty() && !known {
|
|
790 |
+ |
let escaped = escape(value);
|
|
791 |
+ |
let _ = write!(
|
|
792 |
+ |
out,
|
|
793 |
+ |
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
|
|
794 |
+ |
);
|
|
795 |
+ |
}
|
|
796 |
+ |
|
|
797 |
+ |
let mut open: Option<ThemeVariant> = None;
|
|
798 |
+ |
for theme in field.themes {
|
|
799 |
+ |
if open != Some(theme.variant) {
|
|
800 |
+ |
if open.is_some() {
|
|
801 |
+ |
out.push_str("</optgroup>");
|
|
802 |
+ |
}
|
|
803 |
+ |
out.push_str("<optgroup label=\"");
|
|
804 |
+ |
escape_into(theme.variant.heading(), out);
|
|
805 |
+ |
out.push_str("\" data-variant=\"");
|
|
806 |
+ |
out.push_str(theme.variant.as_str());
|
|
807 |
+ |
out.push_str("\">");
|
|
808 |
+ |
open = Some(theme.variant);
|
|
809 |
+ |
}
|
|
810 |
+ |
|
|
811 |
+ |
out.push_str("<option value=\"");
|
|
812 |
+ |
escape_into(theme.id, out);
|
|
813 |
+ |
out.push_str("\" data-contrast=\"");
|
|
814 |
+ |
out.push_str(theme.contrast.as_str());
|
|
815 |
+ |
out.push('"');
|
|
816 |
+ |
if theme.id == value {
|
|
817 |
+ |
out.push_str(" selected");
|
|
818 |
+ |
}
|
|
819 |
+ |
out.push('>');
|
|
820 |
+ |
escape_into(theme.name, out);
|
|
821 |
+ |
out.push_str(" (");
|
|
822 |
+ |
out.push_str(theme.contrast.badge());
|
|
823 |
+ |
out.push(')');
|
|
824 |
+ |
out.push_str("</option>");
|
|
825 |
+ |
}
|
|
826 |
+ |
if open.is_some() {
|
|
827 |
+ |
out.push_str("</optgroup>");
|
|
828 |
+ |
}
|
|
829 |
+ |
}
|
|
830 |
+ |
|
| 742 |
831 |
|
/// The control itself, without its label, hint or error.
|
| 743 |
832 |
|
fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
|
| 744 |
833 |
|
// Emitted before anything else is computed: a radio group carries its
|
| 809 |
898 |
|
push_options(out, field, field.options, filling.value.as_text());
|
| 810 |
899 |
|
out.push_str("</select>");
|
| 811 |
900 |
|
}
|
|
901 |
+ |
// The one place this renderer emits `<optgroup>`, and it emits it
|
|
902 |
+ |
// because the description finally says there is a group. The measured
|
|
903 |
+ |
// history is the argument: `optgroup` appears at one live site in the
|
|
904 |
+ |
// whole tree, and the two apps that had grouped theme pickers lost the
|
|
905 |
+ |
// grouping the moment they were described, because `Choice` is a value
|
|
906 |
+ |
// and a label and a group is neither.
|
|
907 |
+ |
FieldKind::Theme => {
|
|
908 |
+ |
out.push_str("<select class=\"");
|
|
909 |
+ |
push_class(out, "field", opts);
|
|
910 |
+ |
out.push('"');
|
|
911 |
+ |
push_control_attributes(out, field, filling, &id, field.name);
|
|
912 |
+ |
out.push('>');
|
|
913 |
+ |
push_theme_options(out, field, filling.value.as_text());
|
|
914 |
+ |
out.push_str("</select>");
|
|
915 |
+ |
}
|
| 812 |
916 |
|
FieldKind::Checkbox => {
|
| 813 |
917 |
|
out.push_str("<label class=\"");
|
| 814 |
918 |
|
push_class(out, "form-checkbox-label", opts);
|
| 2367 |
2471 |
|
);
|
| 2368 |
2472 |
|
assert!(html.contains(r#"id="title" name="title""#), "{html}");
|
| 2369 |
2473 |
|
}
|
|
2474 |
+ |
|
|
2475 |
+ |
/// Two variants and two tiers, which is the smallest list that can show
|
|
2476 |
+ |
/// where a group opens and that two badges differ.
|
|
2477 |
+ |
const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
|
|
2478 |
+ |
makeover_layout::ThemeChoice::new(
|
|
2479 |
+ |
"goingson",
|
|
2480 |
+ |
"GoingsOn",
|
|
2481 |
+ |
ThemeVariant::Light,
|
|
2482 |
+ |
makeover_layout::Contrast::High,
|
|
2483 |
+ |
),
|
|
2484 |
+ |
makeover_layout::ThemeChoice::new(
|
|
2485 |
+ |
"ayu-light",
|
|
2486 |
+ |
"Ayu Light",
|
|
2487 |
+ |
ThemeVariant::Light,
|
|
2488 |
+ |
makeover_layout::Contrast::Low,
|
|
2489 |
+ |
),
|
|
2490 |
+ |
makeover_layout::ThemeChoice::new(
|
|
2491 |
+ |
"carbonfox",
|
|
2492 |
+ |
"Carbonfox",
|
|
2493 |
+ |
ThemeVariant::Dark,
|
|
2494 |
+ |
makeover_layout::Contrast::High,
|
|
2495 |
+ |
),
|
|
2496 |
+ |
];
|
|
2497 |
+ |
|
|
2498 |
+ |
#[test]
|
|
2499 |
+ |
fn a_theme_picker_opens_one_optgroup_per_variant() {
|
|
2500 |
+ |
let f = Field::theme("theme", "Theme", THEMES);
|
|
2501 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
2502 |
+ |
|
|
2503 |
+ |
assert_eq!(html.matches("<optgroup").count(), 2, "{html}");
|
|
2504 |
+ |
assert_eq!(html.matches("</optgroup>").count(), 2, "{html}");
|
|
2505 |
+ |
assert!(
|
|
2506 |
+ |
html.contains(r#"<optgroup label="Light" data-variant="light">"#),
|
|
2507 |
+ |
"{html}"
|
|
2508 |
+ |
);
|
|
2509 |
+ |
assert!(
|
|
2510 |
+ |
html.contains(r#"<optgroup label="Dark" data-variant="dark">"#),
|
|
2511 |
+ |
"{html}"
|
|
2512 |
+ |
);
|
|
2513 |
+ |
// The two light themes share one group: a new group opens on a change
|
|
2514 |
+ |
// of variant and on nothing else.
|
|
2515 |
+ |
assert!(
|
|
2516 |
+ |
html.find("Ayu Light") < html.find("<optgroup label=\"Dark\""),
|
|
2517 |
+ |
"{html}"
|
|
2518 |
+ |
);
|
|
2519 |
+ |
}
|
|
2520 |
+ |
|
|
2521 |
+ |
#[test]
|
|
2522 |
+ |
fn every_theme_carries_its_measured_tier() {
|
|
2523 |
+ |
// The fact the three hand-written pickers lost. It rides in the text
|
|
2524 |
+ |
// because a `<select>`'s options take no elements, and in an attribute
|
|
2525 |
+ |
// because a stylesheet cannot read text.
|
|
2526 |
+ |
let f = Field::theme("theme", "Theme", THEMES);
|
|
2527 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
2528 |
+ |
|
|
2529 |
+ |
assert!(html.contains(r#"data-contrast="high""#), "{html}");
|
|
2530 |
+ |
assert!(html.contains(r#"data-contrast="low""#), "{html}");
|
|
2531 |
+ |
assert!(html.contains("GoingsOn (AA)"), "{html}");
|
|
2532 |
+ |
assert!(html.contains("Ayu Light (low)"), "{html}");
|
|
2533 |
+ |
}
|
|
2534 |
+ |
|
|
2535 |
+ |
#[test]
|
|
2536 |
+ |
fn the_follow_row_is_first_and_sits_in_no_group() {
|
|
2537 |
+ |
// It names no theme and belongs to no variant, so grouping it would be
|
|
2538 |
+ |
// inventing a fourth variant for one row.
|
|
2539 |
+ |
let f = Field::theme("theme", "Theme", THEMES)
|
|
2540 |
+ |
.following(Choice::new("system", "Follow System"));
|
|
2541 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
2542 |
+ |
|
|
2543 |
+ |
let follow = html.find("Follow System").expect("the row was offered");
|
|
2544 |
+ |
assert!(follow < html.find("<optgroup").expect("groups"), "{html}");
|
|
2545 |
+ |
}
|
|
2546 |
+ |
|
|
2547 |
+ |
#[test]
|
|
2548 |
+ |
fn the_stored_theme_is_the_selected_one() {
|
|
2549 |
+ |
let f = Field::theme("theme", "Theme", THEMES)
|
|
2550 |
+ |
.following(Choice::new("system", "Follow System"));
|
|
2551 |
+ |
|
|
2552 |
+ |
let named = field_html(&f, &Filling::of(Value::Text("carbonfox")), &Emit::default());
|
|
2553 |
+ |
assert!(
|
|
2554 |
+ |
named.contains(r#"value="carbonfox" data-contrast="high" selected"#),
|
|
2555 |
+ |
"{named}"
|
|
2556 |
+ |
);
|
|
2557 |
+ |
assert!(!named.contains(r#"value="system" selected"#), "{named}");
|
|
2558 |
+ |
|
|
2559 |
+ |
let following = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
|
|
2560 |
+ |
assert!(
|
|
2561 |
+ |
following.contains(r#"value="system" selected"#),
|
|
2562 |
+ |
"{following}"
|
|
2563 |
+ |
);
|
|
2564 |
+ |
}
|
|
2565 |
+ |
|
|
2566 |
+ |
#[test]
|
|
2567 |
+ |
fn a_theme_that_is_no_longer_installed_keeps_its_value() {
|
|
2568 |
+ |
// `push_options`' rule, met again: a value no row carries is a wrong
|
|
2569 |
+ |
// answer rather than an absent one, and dropping it would save a
|
|
2570 |
+ |
// different theme over the user's on the next write.
|
|
2571 |
+ |
let f = Field::theme("theme", "Theme", THEMES);
|
|
2572 |
+ |
let html = field_html(
|
|
2573 |
+ |
&f,
|
|
2574 |
+ |
&Filling::of(Value::Text("deleted-theme")),
|
|
2575 |
+ |
&Emit::default(),
|
|
2576 |
+ |
);
|
|
2577 |
+ |
assert!(html.contains(r#"data-unmatched="true""#), "{html}");
|
|
2578 |
+ |
assert!(
|
|
2579 |
+ |
html.contains(r#"<option value="deleted-theme" selected"#),
|
|
2580 |
+ |
"{html}"
|
|
2581 |
+ |
);
|
|
2582 |
+ |
}
|
|
2583 |
+ |
|
|
2584 |
+ |
#[test]
|
|
2585 |
+ |
fn the_follow_row_is_not_a_stray_value() {
|
|
2586 |
+ |
// The near-miss: `system` is carried by no `ThemeChoice`, so a check
|
|
2587 |
+ |
// that only walked the theme list would emit a duplicate unmatched row
|
|
2588 |
+ |
// beside the real one.
|
|
2589 |
+ |
let f = Field::theme("theme", "Theme", THEMES)
|
|
2590 |
+ |
.following(Choice::new("system", "Follow System"));
|
|
2591 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
|
|
2592 |
+ |
assert!(!html.contains("data-unmatched"), "{html}");
|
|
2593 |
+ |
}
|
|
2594 |
+ |
|
|
2595 |
+ |
#[test]
|
|
2596 |
+ |
fn a_machine_with_no_themes_still_gets_a_picker() {
|
|
2597 |
+ |
// `Field::themes`' own position: an app whose theme directories hold
|
|
2598 |
+ |
// nothing has exactly this, and the empty control says so on screen.
|
|
2599 |
+ |
let f =
|
|
2600 |
+ |
Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
|
|
2601 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
2602 |
+ |
assert!(html.contains("<select"), "{html}");
|
|
2603 |
+ |
assert!(!html.contains("<optgroup"), "{html}");
|
|
2604 |
+ |
assert!(html.contains("Follow System"), "{html}");
|
|
2605 |
+ |
}
|
| 2370 |
2606 |
|
}
|