| 587 |
587 |
|
}
|
| 588 |
588 |
|
}
|
| 589 |
589 |
|
|
|
590 |
+ |
// ============================================================================
|
|
591 |
+ |
// Choosing a theme.
|
|
592 |
+ |
//
|
|
593 |
+ |
// The file half of this crate was always shared; the *selection* half was not,
|
|
594 |
+ |
// and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
|
|
595 |
+ |
// localStorage, Balanced Breakfast treats an absent value as follow-the-system
|
|
596 |
+ |
// and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
|
|
597 |
+ |
// in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
|
|
598 |
+ |
// disagreed about what a variant string means: this crate defaults a missing
|
|
599 |
+ |
// one to "dark" while alloy_tui parsed an unrecognized one as light.
|
|
600 |
+ |
//
|
|
601 |
+ |
// What cannot be shared is the store — localStorage, a synced config table and
|
|
602 |
+ |
// a TOML file are genuinely different places. What can be shared, and is here,
|
|
603 |
+ |
// is the *meaning*: one vocabulary for variants, one encoding for "what did the
|
|
604 |
+ |
// user choose", and one rule for turning that into an id that exists.
|
|
605 |
+ |
// ============================================================================
|
|
606 |
+ |
|
|
607 |
+ |
/// A theme's kind, as declared by `meta.variant`.
|
|
608 |
+ |
///
|
|
609 |
+ |
/// Three, not two: one shipped theme is `high-contrast`, and an app that
|
|
610 |
+ |
/// matched on light-or-dark alone would quietly file it under the wrong one.
|
|
611 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
|
612 |
+ |
#[serde(rename_all = "kebab-case")]
|
|
613 |
+ |
pub enum Variant {
|
|
614 |
+ |
Light,
|
|
615 |
+ |
Dark,
|
|
616 |
+ |
HighContrast,
|
|
617 |
+ |
}
|
|
618 |
+ |
|
|
619 |
+ |
impl Variant {
|
|
620 |
+ |
/// The spelling used in a theme file and in [`ThemeMeta::variant`].
|
|
621 |
+ |
#[must_use]
|
|
622 |
+ |
pub const fn as_str(self) -> &'static str {
|
|
623 |
+ |
match self {
|
|
624 |
+ |
Variant::Light => "light",
|
|
625 |
+ |
Variant::Dark => "dark",
|
|
626 |
+ |
Variant::HighContrast => "high-contrast",
|
|
627 |
+ |
}
|
|
628 |
+ |
}
|
|
629 |
+ |
|
|
630 |
+ |
/// Read a variant string, or `None` if it names none of them.
|
|
631 |
+ |
#[must_use]
|
|
632 |
+ |
pub fn parse(raw: &str) -> Option<Self> {
|
|
633 |
+ |
match raw {
|
|
634 |
+ |
"light" => Some(Variant::Light),
|
|
635 |
+ |
"dark" => Some(Variant::Dark),
|
|
636 |
+ |
"high-contrast" => Some(Variant::HighContrast),
|
|
637 |
+ |
_ => None,
|
|
638 |
+ |
}
|
|
639 |
+ |
}
|
|
640 |
+ |
}
|
|
641 |
+ |
|
|
642 |
+ |
impl std::fmt::Display for Variant {
|
|
643 |
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
644 |
+ |
f.write_str(self.as_str())
|
|
645 |
+ |
}
|
|
646 |
+ |
}
|
|
647 |
+ |
|
|
648 |
+ |
/// Anything unrecognized reads as dark, which is what [`parse_meta`] already
|
|
649 |
+ |
/// does with a missing one. Consumers that guessed light for an unknown string
|
|
650 |
+ |
/// were disagreeing with the crate that produced it.
|
|
651 |
+ |
impl From<&str> for Variant {
|
|
652 |
+ |
fn from(raw: &str) -> Self {
|
|
653 |
+ |
Variant::parse(raw).unwrap_or(Variant::Dark)
|
|
654 |
+ |
}
|
|
655 |
+ |
}
|
|
656 |
+ |
|
|
657 |
+ |
impl ThemeMeta {
|
|
658 |
+ |
/// This theme's variant as a value rather than a string.
|
|
659 |
+ |
#[must_use]
|
|
660 |
+ |
pub fn kind(&self) -> Variant {
|
|
661 |
+ |
Variant::from(self.variant.as_str())
|
|
662 |
+ |
}
|
|
663 |
+ |
}
|
|
664 |
+ |
|
|
665 |
+ |
/// The spelling of "follow whatever the system is doing", in every store.
|
|
666 |
+ |
pub const FOLLOW: &str = "system";
|
|
667 |
+ |
|
|
668 |
+ |
/// What the user chose, as opposed to what is being rendered.
|
|
669 |
+ |
///
|
|
670 |
+ |
/// The distinction is the whole point: `Follow` is a standing instruction that
|
|
671 |
+ |
/// resolves differently as the ambient mode changes, and a `Fixed` id is an
|
|
672 |
+ |
/// answer that does not. An app that stored only the rendered id could not tell
|
|
673 |
+ |
/// the two apart the next time the system flipped to dark.
|
|
674 |
+ |
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
675 |
+ |
pub enum ThemeSelection {
|
|
676 |
+ |
/// Track the ambient light/dark mode.
|
|
677 |
+ |
#[default]
|
|
678 |
+ |
Follow,
|
|
679 |
+ |
/// Always this theme.
|
|
680 |
+ |
Fixed(String),
|
|
681 |
+ |
}
|
|
682 |
+ |
|
|
683 |
+ |
impl ThemeSelection {
|
|
684 |
+ |
/// Read a stored selection. An empty or absent value is [`Follow`], which
|
|
685 |
+ |
/// is what an app with nothing saved yet should do.
|
|
686 |
+ |
///
|
|
687 |
+ |
/// [`Follow`]: ThemeSelection::Follow
|
|
688 |
+ |
#[must_use]
|
|
689 |
+ |
pub fn parse(raw: Option<&str>) -> Self {
|
|
690 |
+ |
match raw.map(str::trim) {
|
|
691 |
+ |
None | Some("" | FOLLOW) => ThemeSelection::Follow,
|
|
692 |
+ |
Some(id) => ThemeSelection::Fixed(id.to_string()),
|
|
693 |
+ |
}
|
|
694 |
+ |
}
|
|
695 |
+ |
|
|
696 |
+ |
/// The string to persist, whatever the store is.
|
|
697 |
+ |
#[must_use]
|
|
698 |
+ |
pub fn as_str(&self) -> &str {
|
|
699 |
+ |
match self {
|
|
700 |
+ |
ThemeSelection::Follow => FOLLOW,
|
|
701 |
+ |
ThemeSelection::Fixed(id) => id,
|
|
702 |
+ |
}
|
|
703 |
+ |
}
|
|
704 |
+ |
|
|
705 |
+ |
/// Turn a selection into a theme id that exists.
|
|
706 |
+ |
///
|
|
707 |
+ |
/// `ambient` is the light/dark mode the app learned however it can: a
|
|
708 |
+ |
/// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
|
|
709 |
+ |
/// from a terminal. `available` is what [`list_themes_from_dirs`] found.
|
|
710 |
+ |
///
|
|
711 |
+ |
/// A `Fixed` id that is no longer on disk falls through to the same path as
|
|
712 |
+ |
/// `Follow` rather than being returned anyway. Themes are deletable in
|
|
713 |
+ |
/// three of the four apps, and handing back an id that will fail to load
|
|
714 |
+ |
/// only moves the error somewhere less helpful.
|
|
715 |
+ |
///
|
|
716 |
+ |
/// The fallback chain is: the app's own default for the ambient mode if it
|
|
717 |
+ |
/// is installed, then any installed theme of that variant, then the app's
|
|
718 |
+ |
/// default regardless. The last step means this always returns something,
|
|
719 |
+ |
/// and an app with no theme directory at all gets the id it ships with and
|
|
720 |
+ |
/// the load error it would have had anyway.
|
|
721 |
+ |
#[must_use]
|
|
722 |
+ |
pub fn resolve(
|
|
723 |
+ |
&self,
|
|
724 |
+ |
ambient: Variant,
|
|
725 |
+ |
defaults: &ThemeDefaults,
|
|
726 |
+ |
available: &[ThemeMeta],
|
|
727 |
+ |
) -> String {
|
|
728 |
+ |
let installed = |id: &str| available.iter().any(|meta| meta.id == id);
|
|
729 |
+ |
|
|
730 |
+ |
if let ThemeSelection::Fixed(id) = self
|
|
731 |
+ |
&& installed(id)
|
|
732 |
+ |
{
|
|
733 |
+ |
return id.clone();
|
|
734 |
+ |
}
|
|
735 |
+ |
|
|
736 |
+ |
let preferred = defaults.for_variant(ambient);
|
|
737 |
+ |
if installed(preferred) {
|
|
738 |
+ |
return preferred.to_string();
|
|
739 |
+ |
}
|
|
740 |
+ |
available
|
|
741 |
+ |
.iter()
|
|
742 |
+ |
.find(|meta| meta.kind() == ambient)
|
|
743 |
+ |
.map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
|
|
744 |
+ |
}
|
|
745 |
+ |
}
|
|
746 |
+ |
|
|
747 |
+ |
impl std::fmt::Display for ThemeSelection {
|
|
748 |
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
749 |
+ |
f.write_str(self.as_str())
|
|
750 |
+ |
}
|
|
751 |
+ |
}
|
|
752 |
+ |
|
|
753 |
+ |
/// The themes an app falls back to, one per ambient mode.
|
|
754 |
+ |
///
|
|
755 |
+ |
/// App-specific on purpose: which theme is "the app's own" is the app's
|
|
756 |
+ |
/// identity, not this crate's business. What is shared is everything around it.
|
|
757 |
+ |
#[derive(Debug, Clone)]
|
|
758 |
+ |
pub struct ThemeDefaults {
|
|
759 |
+ |
light: String,
|
|
760 |
+ |
dark: String,
|
|
761 |
+ |
high_contrast: Option<String>,
|
|
762 |
+ |
}
|
|
763 |
+ |
|
|
764 |
+ |
impl ThemeDefaults {
|
|
765 |
+ |
pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
|
|
766 |
+ |
Self {
|
|
767 |
+ |
light: light.into(),
|
|
768 |
+ |
dark: dark.into(),
|
|
769 |
+ |
high_contrast: None,
|
|
770 |
+ |
}
|
|
771 |
+ |
}
|
|
772 |
+ |
|
|
773 |
+ |
/// Name a theme for a high-contrast ambient mode. Without one, that mode
|
|
774 |
+ |
/// falls back to the dark default, which is the safer of the two to read.
|
|
775 |
+ |
#[must_use]
|
|
776 |
+ |
pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
|
|
777 |
+ |
self.high_contrast = Some(id.into());
|
|
778 |
+ |
self
|
|
779 |
+ |
}
|
|
780 |
+ |
|
|
781 |
+ |
#[must_use]
|
|
782 |
+ |
pub fn for_variant(&self, variant: Variant) -> &str {
|
|
783 |
+ |
match variant {
|
|
784 |
+ |
Variant::Light => &self.light,
|
|
785 |
+ |
Variant::Dark => &self.dark,
|
|
786 |
+ |
Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
|
|
787 |
+ |
}
|
|
788 |
+ |
}
|
|
789 |
+ |
}
|
|
790 |
+ |
|
|
791 |
+ |
// ============================================================================
|
|
792 |
+ |
// Where themes are looked for.
|
|
793 |
+ |
//
|
|
794 |
+ |
// Four apps built this vector by hand, two of them byte-for-byte identically,
|
|
795 |
+ |
// and one of them built it backwards: the Alloy console pushed the user's own
|
|
796 |
+ |
// directory first, under a comment saying "highest precedence first", when both
|
|
797 |
+ |
// consumers of the vector resolve *last* wins. A user's custom theme lost to
|
|
798 |
+ |
// the packaged one of the same id.
|
|
799 |
+ |
//
|
|
800 |
+ |
// Hence a builder that names the tiers rather than a function taking a vector.
|
|
801 |
+ |
// The precedence is stated once, here, and a caller cannot express it backwards
|
|
802 |
+ |
// because the order is not theirs to choose.
|
|
803 |
+ |
// ============================================================================
|
|
804 |
+ |
|
|
805 |
+ |
/// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
|
|
806 |
+ |
///
|
|
807 |
+ |
/// Tiers are added in whatever order is convenient and always end up in
|
|
808 |
+ |
/// precedence order: the user's own themes win, then whatever the system
|
|
809 |
+ |
/// ships, then whatever the app bundles.
|
|
810 |
+ |
///
|
|
811 |
+ |
/// A directory that does not exist is dropped rather than carried, so callers
|
|
812 |
+ |
/// can offer every tier they might have without checking each one.
|
|
813 |
+ |
#[derive(Debug, Default, Clone)]
|
|
814 |
+ |
pub struct ThemeDirs {
|
|
815 |
+ |
bundled: Vec<PathBuf>,
|
|
816 |
+ |
system: Vec<PathBuf>,
|
|
817 |
+ |
custom: Option<PathBuf>,
|
|
818 |
+ |
}
|
|
819 |
+ |
|
|
820 |
+ |
impl ThemeDirs {
|
|
821 |
+ |
#[must_use]
|
|
822 |
+ |
pub fn new() -> Self {
|
|
823 |
+ |
Self::default()
|
|
824 |
+ |
}
|
|
825 |
+ |
|
|
826 |
+ |
/// Themes the app ships with. Lowest precedence.
|
|
827 |
+ |
///
|
|
828 |
+ |
/// Takes more than one because a Tauri app has two: the bundled resource
|
|
829 |
+ |
/// directory in production, and the tree `build.rs` materialized for a
|
|
830 |
+ |
/// `cargo run` that has no resource directory at all.
|
|
831 |
+ |
#[must_use]
|
|
832 |
+ |
pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
|
|
833 |
+ |
self.bundled.extend(dir);
|
|
834 |
+ |
self
|
|
835 |
+ |
}
|
|
836 |
+ |
|
|
837 |
+ |
/// Themes the machine ships, from an image or a package. Overrides bundled.
|
|
838 |
+ |
#[must_use]
|
|
839 |
+ |
pub fn system(mut self, dir: Option<PathBuf>) -> Self {
|
|
840 |
+ |
self.system.extend(dir);
|
|
841 |
+ |
self
|
|
842 |
+ |
}
|
|
843 |
+ |
|
|
844 |
+ |
/// The user's own themes. Highest precedence, and the only tier flagged
|
|
845 |
+ |
/// custom, which is what makes them exportable and deletable.
|
|
846 |
+ |
#[must_use]
|
|
847 |
+ |
pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
|
|
848 |
+ |
self.custom = dir;
|
|
849 |
+ |
self
|
|
850 |
+ |
}
|
|
851 |
+ |
|
|
852 |
+ |
/// The search path, lowest precedence first.
|
|
853 |
+ |
#[must_use]
|
|
854 |
+ |
pub fn build(self) -> Vec<(PathBuf, bool)> {
|
|
855 |
+ |
let mut dirs = Vec::new();
|
|
856 |
+ |
for dir in self.bundled.into_iter().chain(self.system) {
|
|
857 |
+ |
if dir.is_dir() {
|
|
858 |
+ |
dirs.push((dir, false));
|
|
859 |
+ |
}
|
|
860 |
+ |
}
|
|
861 |
+ |
if let Some(dir) = self.custom
|
|
862 |
+ |
&& dir.is_dir()
|
|
863 |
+ |
{
|
|
864 |
+ |
dirs.push((dir, true));
|
|
865 |
+ |
}
|
|
866 |
+ |
dirs
|
|
867 |
+ |
}
|
|
868 |
+ |
}
|
|
869 |
+ |
|
| 590 |
870 |
|
/// Extract the intent color sections into a flat `HashMap` with dotted keys
|
| 591 |
871 |
|
/// like `"surface.page"`, `"status.danger"`, `"category.one"`.
|
| 592 |
872 |
|
pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
|
| 1280 |
1560 |
|
assert!(load_theme(&[], "../evil").is_err());
|
| 1281 |
1561 |
|
}
|
| 1282 |
1562 |
|
|
|
1563 |
+ |
fn meta(id: &str, variant: &str) -> ThemeMeta {
|
|
1564 |
+ |
ThemeMeta {
|
|
1565 |
+ |
id: id.to_string(),
|
|
1566 |
+ |
name: id.to_string(),
|
|
1567 |
+ |
variant: variant.to_string(),
|
|
1568 |
+ |
is_custom: false,
|
|
1569 |
+ |
}
|
|
1570 |
+ |
}
|
|
1571 |
+ |
|
|
1572 |
+ |
fn defaults() -> ThemeDefaults {
|
|
1573 |
+ |
ThemeDefaults::new("flatwhite", "nord")
|
|
1574 |
+ |
}
|
|
1575 |
+ |
|
|
1576 |
+ |
// The three the shipped themes actually declare.
|
|
1577 |
+ |
#[test]
|
|
1578 |
+ |
fn every_shipped_variant_parses() {
|
|
1579 |
+ |
assert_eq!(Variant::parse("light"), Some(Variant::Light));
|
|
1580 |
+ |
assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
|
|
1581 |
+ |
assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
|
|
1582 |
+ |
assert_eq!(Variant::parse("sepia"), None);
|
|
1583 |
+ |
}
|
|
1584 |
+ |
|
|
1585 |
+ |
// parse_meta already defaults a *missing* variant to dark, so an
|
|
1586 |
+ |
// unrecognized one reading as light would have the crate disagreeing with
|
|
1587 |
+ |
// itself. alloy_tui did exactly that before this existed.
|
|
1588 |
+ |
#[test]
|
|
1589 |
+ |
fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
|
|
1590 |
+ |
assert_eq!(Variant::from("sepia"), Variant::Dark);
|
|
1591 |
+ |
assert_eq!(Variant::from(""), Variant::Dark);
|
|
1592 |
+ |
|
|
1593 |
+ |
let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
|
|
1594 |
+ |
assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
|
|
1595 |
+ |
}
|
|
1596 |
+ |
|
|
1597 |
+ |
#[test]
|
|
1598 |
+ |
fn a_selection_round_trips_through_any_store() {
|
|
1599 |
+ |
for (stored, expect) in [
|
|
1600 |
+ |
(Some("system"), ThemeSelection::Follow),
|
|
1601 |
+ |
(None, ThemeSelection::Follow),
|
|
1602 |
+ |
(Some(""), ThemeSelection::Follow),
|
|
1603 |
+ |
(Some(" "), ThemeSelection::Follow),
|
|
1604 |
+ |
(Some("nord"), ThemeSelection::Fixed("nord".into())),
|
|
1605 |
+ |
] {
|
|
1606 |
+ |
let parsed = ThemeSelection::parse(stored);
|
|
1607 |
+ |
assert_eq!(parsed, expect, "{stored:?}");
|
|
1608 |
+ |
assert_eq!(
|
|
1609 |
+ |
ThemeSelection::parse(Some(parsed.as_str())),
|
|
1610 |
+ |
expect,
|
|
1611 |
+ |
"what is written reads back as what was meant",
|
|
1612 |
+ |
);
|
|
1613 |
+ |
}
|
|
1614 |
+ |
}
|
|
1615 |
+ |
|
|
1616 |
+ |
// Nothing saved is follow-the-system, which is what Balanced Breakfast
|
|
1617 |
+ |
// expressed as an absent value and GoingsOn as a sentinel. Both are now the
|
|
1618 |
+ |
// same thing.
|
|
1619 |
+ |
#[test]
|
|
1620 |
+ |
fn nothing_chosen_yet_is_follow() {
|
|
1621 |
+ |
assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
|
|
1622 |
+ |
}
|
|
1623 |
+ |
|
|
1624 |
+ |
#[test]
|
|
1625 |
+ |
fn a_fixed_selection_wins_when_its_theme_is_installed() {
|
|
1626 |
+ |
let available = [meta("nord", "dark"), meta("flatwhite", "light")];
|
|
1627 |
+ |
let fixed = ThemeSelection::Fixed("nord".into());
|
|
1628 |
+ |
assert_eq!(
|
|
1629 |
+ |
fixed.resolve(Variant::Light, &defaults(), &available),
|
|
1630 |
+ |
"nord",
|
|
1631 |
+ |
"a chosen theme is not overridden by the ambient mode",
|
|
1632 |
+ |
);
|
|
1633 |
+ |
}
|
|
1634 |
+ |
|
|
1635 |
+ |
// Themes are deletable in three of the four apps. Handing back an id that
|
|
1636 |
+ |
// will fail to load only moves the error somewhere less helpful.
|
|
1637 |
+ |
#[test]
|
|
1638 |
+ |
fn a_fixed_selection_whose_theme_is_gone_falls_back() {
|
|
1639 |
+ |
let available = [meta("nord", "dark"), meta("flatwhite", "light")];
|
|
1640 |
+ |
let fixed = ThemeSelection::Fixed("deleted".into());
|
|
1641 |
+ |
assert_eq!(
|
|
1642 |
+ |
fixed.resolve(Variant::Light, &defaults(), &available),
|
|
1643 |
+ |
"flatwhite",
|
|
1644 |
+ |
);
|
|
1645 |
+ |
}
|
|
1646 |
+ |
|
|
1647 |
+ |
#[test]
|
|
1648 |
+ |
fn follow_picks_the_apps_default_for_the_ambient_mode() {
|
|
1649 |
+ |
let available = [meta("nord", "dark"), meta("flatwhite", "light")];
|
|
1650 |
+ |
let follow = ThemeSelection::Follow;
|
|
1651 |
+ |
assert_eq!(
|
|
1652 |
+ |
follow.resolve(Variant::Dark, &defaults(), &available),
|
|
1653 |
+ |
"nord",
|
|
1654 |
+ |
);
|
|
1655 |
+ |
assert_eq!(
|
|
1656 |
+ |
follow.resolve(Variant::Light, &defaults(), &available),
|
|
1657 |
+ |
"flatwhite",
|
|
1658 |
+ |
);
|
|
1659 |
+ |
}
|
|
1660 |
+ |
|
|
1661 |
+ |
// The behaviour Balanced Breakfast could not have: following the system
|
|
1662 |
+ |
// into a theme the user installed, when the app's own default is absent.
|
|
1663 |
+ |
#[test]
|
|
1664 |
+ |
fn follow_uses_any_installed_theme_of_the_right_variant() {
|
|
1665 |
+ |
let available = [meta("solarized-light", "light"), meta("mine", "dark")];
|
|
1666 |
+ |
assert_eq!(
|
|
1667 |
+ |
ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
|
|
1668 |
+ |
"mine",
|
|
1669 |
+ |
"the app's `nord` is not installed, but a dark theme is",
|
|
1670 |
+ |
);
|
|
1671 |
+ |
}
|
|
1672 |
+ |
|
|
1673 |
+ |
// Always returns something: an app with no theme directory gets the id it
|
|
1674 |
+ |
// ships with, and the load error it would have had anyway.
|
|
1675 |
+ |
#[test]
|
|
1676 |
+ |
fn an_empty_catalog_still_names_the_apps_default() {
|
|
1677 |
+ |
assert_eq!(
|
|
1678 |
+ |
ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
|
|
1679 |
+ |
"nord",
|
|
1680 |
+ |
);
|
|
1681 |
+ |
}
|
|
1682 |
+ |
|
|
1683 |
+ |
#[test]
|
|
1684 |
+ |
fn high_contrast_falls_back_to_dark_unless_named() {
|
|
1685 |
+ |
let plain = defaults();
|
|
1686 |
+ |
assert_eq!(plain.for_variant(Variant::HighContrast), "nord");
|
|
1687 |
+ |
|
|
1688 |
+ |
let named = defaults().high_contrast("sharp");
|
|
1689 |
+ |
assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
|
|
1690 |
+ |
}
|
|
1691 |
+ |
|
|
1692 |
+ |
// The bug this builder exists to prevent: the Alloy console pushed the
|
|
1693 |
+ |
// user's directory first under a comment reading "highest precedence
|
|
1694 |
+ |
// first", when both consumers of this vector resolve last-wins. A custom
|
|
1695 |
+ |
// theme lost to the packaged one of the same id.
|
|
1696 |
+ |
#[test]
|
|
1697 |
+ |
fn the_users_own_themes_outrank_everything() {
|
|
1698 |
+ |
let root = tempfile::tempdir().unwrap();
|
|
1699 |
+ |
let make = |name: &str| {
|
|
1700 |
+ |
let dir = root.path().join(name);
|
|
1701 |
+ |
std::fs::create_dir_all(&dir).unwrap();
|
|
1702 |
+ |
dir
|
|
1703 |
+ |
};
|
|
1704 |
+ |
let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
|
|
1705 |
+ |
|
|
1706 |
+ |
let dirs = ThemeDirs::new()
|
|
1707 |
+ |
.custom(Some(custom.clone()))
|
|
1708 |
+ |
.bundled(Some(bundled.clone()))
|
|
1709 |
+ |
.system(Some(system.clone()))
|
|
1710 |
+ |
.build();
|
|
1711 |
+ |
|
|
1712 |
+ |
assert_eq!(
|
|
1713 |
+ |
dirs,
|
|
1714 |
+ |
vec![(bundled, false), (system, false), (custom.clone(), true)],
|
|
1715 |
+ |
"lowest precedence first, whatever order the tiers were added in",
|
|
1716 |
+ |
);
|
|
1717 |
+ |
assert!(dirs.last().unwrap().1, "only the user's tier is custom");
|
|
1718 |
+ |
|
|
1719 |
+ |
// And the ordering means what the consumers think it means.
|
|
1720 |
+ |
for dir in dirs.iter().map(|(dir, _)| dir) {
|
|
1721 |
+ |
std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
|
|
1722 |
+ |
}
|
|
1723 |
+ |
assert_eq!(
|
|
1724 |
+ |
find_theme_path(&dirs, "shared").unwrap().0,
|
|
1725 |
+ |
custom.join("shared.toml"),
|
|
1726 |
+ |
"the user's copy is the one that loads",
|
|
1727 |
+ |
);
|
|
1728 |
+ |
}
|
|
1729 |
+ |
|
|
1730 |
+ |
#[test]
|
|
1731 |
+ |
fn a_directory_that_does_not_exist_is_dropped() {
|
|
1732 |
+ |
let root = tempfile::tempdir().unwrap();
|
|
1733 |
+ |
let real = root.path().join("real");
|
|
1734 |
+ |
std::fs::create_dir_all(&real).unwrap();
|
|
1735 |
+ |
|
|
1736 |
+ |
let dirs = ThemeDirs::new()
|
|
1737 |
+ |
.bundled(Some(root.path().join("nope")))
|
|
1738 |
+ |
.system(None)
|
|
1739 |
+ |
.custom(Some(real.clone()))
|
|
1740 |
+ |
.build();
|
|
1741 |
+ |
|
|
1742 |
+ |
assert_eq!(dirs, vec![(real, true)]);
|
|
1743 |
+ |
}
|
|
1744 |
+ |
|
|
1745 |
+ |
// A Tauri app has two bundled tiers: the resource dir in production and the
|
|
1746 |
+ |
// tree build.rs materialized for a dev run with no resource dir.
|
|
1747 |
+ |
#[test]
|
|
1748 |
+ |
fn more_than_one_bundled_tier_is_allowed() {
|
|
1749 |
+ |
let root = tempfile::tempdir().unwrap();
|
|
1750 |
+ |
let (first, second) = (root.path().join("a"), root.path().join("b"));
|
|
1751 |
+ |
std::fs::create_dir_all(&first).unwrap();
|
|
1752 |
+ |
std::fs::create_dir_all(&second).unwrap();
|
|
1753 |
+ |
|
|
1754 |
+ |
let dirs = ThemeDirs::new()
|
|
1755 |
+ |
.bundled(Some(first.clone()))
|
|
1756 |
+ |
.bundled(Some(second.clone()))
|
|
1757 |
+ |
.build();
|
|
1758 |
+ |
assert_eq!(dirs, vec![(first, false), (second, false)]);
|
|
1759 |
+ |
}
|
|
1760 |
+ |
|
| 1283 |
1761 |
|
#[test]
|
| 1284 |
1762 |
|
fn list_themes_from_dirs_finds_toml_files() {
|
| 1285 |
1763 |
|
let dir = tempfile::tempdir().unwrap();
|