Skip to main content

max / makeover

2.1.0: the choosing half, which four apps had each re-rolled Loading a theme file was always shared here; choosing one was not. GoingsOn stores a "system" sentinel in localStorage, Balanced Breakfast reads an absent value as follow-the-system and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id in a synced config table, and the Alloy console parses COLORFGBG. Three key names for one concept. The store cannot be shared and is not: localStorage, a synced SQLite table and a TOML file are genuinely different places. The meaning can be, and now is. Variant makes the vocabulary a value. It was a String here and a hand-rolled match in alloy_tui, and the two disagreed: this crate defaults a missing variant to dark while that one read an unrecognized variant as light. Three values and not two, because a shipped theme is high-contrast and an app matching on light-or-dark alone files it under the wrong one. ThemeSelection keeps "what the user chose" apart from "what is rendered", which is the distinction an app storing only the rendered id cannot make the next time the system flips. resolve picks by variant, so following the system reaches any installed theme of the right kind rather than a hardcoded pair, which is a thing Balanced Breakfast could not do. A fixed id whose theme was deleted falls back rather than being handed back to fail at load. ThemeDirs exists because one app built the search vector backwards: the Alloy console pushed the user's directory first under a comment reading "highest precedence first", when both consumers of that vector resolve last-wins, so a custom theme lost to the packaged one of the same id. Naming the tiers means the order is not the caller's to reverse. Additive throughout. Nothing consumes it yet, by design: the apps migrate one at a time and this step cannot break any of them.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:49 UTC
Commit: 2bee5c70d1a9a0cb6deaa45ef30463db60738671
Parent: 16523b8
3 files changed, +523 insertions, -4 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.0.0"
3 + version = "2.1.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M README.md +44 -3
@@ -102,13 +102,54 @@ The theme ID is the filename without `.toml` (e.g., `catppuccin-mocha.toml` has
102 102 | `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states |
103 103 | `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block |
104 104
105 + ## Choosing a theme
106 +
107 + Loading a theme file was always shared; choosing one was not, and every app
108 + re-rolled it. These types are the shared half.
109 +
110 + | Item | Purpose |
111 + | --- | --- |
112 + | `Variant` | `light` / `dark` / `high-contrast`, as a value. `ThemeMeta::kind()` reads it |
113 + | `ThemeSelection` | `Follow` or `Fixed(id)` — what the user chose, not what is rendered |
114 + | `ThemeSelection::parse(stored)` | Read a stored value from any store; absent or `"system"` is `Follow` |
115 + | `ThemeSelection::as_str()` | The string to persist, whatever the store is |
116 + | `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists |
117 + | `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode |
118 + | `ThemeDirs` | Build the search path with the tiers named |
119 +
120 + The store stays the app's: `localStorage`, a config table, a TOML file. What is
121 + shared is the string it holds and what that string means, so `"system"` means
122 + the same thing in all of them, and the key is `theme` everywhere.
123 +
124 + `resolve` picks by `Variant`, so an app follows the system into any installed
125 + theme of the right kind rather than into a hardcoded pair. A `Fixed` ID whose
126 + theme has been deleted falls back rather than being handed back to fail later.
127 +
128 + ```rust
129 + let selection = ThemeSelection::parse(store.get("theme"));
130 + let defaults = ThemeDefaults::new("flatwhite", "nord");
131 + let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs));
132 + ```
133 +
105 134 ## Directory Priority
106 135
107 136 `list_themes_from_dirs` and `load_theme` accept a list of `(PathBuf, bool)` pairs. Later directories override earlier ones by theme ID. The `bool` marks whether the directory contains user-custom themes (`is_custom` on `ThemeMeta`).
108 137
109 - Typical setup for a Tauri app:
110 - 1. Bundled themes, packaged by the app or from `bundled_themes_dir()` (is_custom = false)
111 - 2. User themes from an app data directory (is_custom = true)
138 + Build it with `ThemeDirs` rather than by hand. The tiers are named, so the
139 + order is not the caller's to get backwards:
140 +
141 + ```rust
142 + let dirs = ThemeDirs::new()
143 + .bundled(bundled_themes_dir())
144 + .system(Some("/usr/share/myapp/themes".into()))
145 + .custom(config_dir.map(|c| c.join("themes")))
146 + .build();
147 + ```
148 +
149 + The user's themes win, then the system's, then the app's own. Directories that
150 + do not exist are dropped, so every tier can be offered unconditionally. Passing
151 + a hand-built vector still works; one app had it inverted, with a comment
152 + claiming the opposite of what the loader does, which is what this replaces.
112 153
113 154 ## License
114 155
M src/lib.rs +478
@@ -587,6 +587,286 @@ pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
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,6 +1560,204 @@ six = "#88c0d0"
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();