| 382 |
382 |
|
//! that one page: audiofiles' library browser and goingson's filters are the
|
| 383 |
383 |
|
//! same shape.
|
| 384 |
384 |
|
//!
|
|
385 |
+ |
//! 0.38.0 describes the theme picker. [`FieldKind::Theme`], [`ThemeChoice`],
|
|
386 |
+ |
//! [`ThemeVariant`], [`Contrast`], [`Field::themes`] and [`Field::follows`] are
|
|
387 |
+ |
//! one screen's control named as furniture. Ruled by Max 2026-08-28
|
|
388 |
+ |
//! (`70028e00`), against the grouped option list that was the obvious answer.
|
|
389 |
+ |
//!
|
|
390 |
+ |
//! - **The measurement is what rejected `Choice::group`.** `optgroup` appears
|
|
391 |
+ |
//! at exactly one live site in the tree, in the one app not yet ported, and
|
|
392 |
+ |
//! the non-theme grouping count is zero. Two apps' grouped pickers were
|
|
393 |
+ |
//! deleted by their ports and both described replacements dropped the
|
|
394 |
+ |
//! grouping on purpose. So the thing that recurs is not option lists that
|
|
395 |
+ |
//! group; it is this picker, hand-written three times.
|
|
396 |
+ |
//! - **Two of the four facts cannot come from an app.** A theme's group and its
|
|
397 |
+ |
//! measured contrast tier come off the resolved theme, so the layer that
|
|
398 |
+ |
//! loaded it is the only party holding them. `Choice::new(id, "{name}
|
|
399 |
+ |
//! ({variant})")` is what all three apps wrote, and it turns structure into
|
|
400 |
+ |
//! prose and drops the tier entirely.
|
|
401 |
+ |
//! - **The order is the grouping**, rather than a returned list of groups. A
|
|
402 |
+ |
//! renderer that draws headings walks the run of one variant; one that cannot
|
|
403 |
+ |
//! still gets the useful order, and neither shape is made to flatten the
|
|
404 |
+ |
//! other's.
|
|
405 |
+ |
//! - **[`ThemeVariant`] is spelled twice on purpose.** This crate takes no
|
|
406 |
+ |
//! dependencies, so it cannot name `makeover::Variant`, and a renderer that
|
|
407 |
+ |
//! groups needs the groups as values. The adopter converts, in a three-arm
|
|
408 |
+ |
//! match.
|
|
409 |
+ |
//! - **The cost is stated rather than found later.** This is the first member
|
|
410 |
+ |
//! here that names a subject instead of a shape of answer. It stays narrow: a
|
|
411 |
+ |
//! theme picker, not a general host-resolved list. A second such list is when
|
|
412 |
+ |
//! the generalisation gets measured.
|
|
413 |
+ |
//!
|
| 385 |
414 |
|
//! 0.35.0 gives a suggestion its second line. [`Candidate`] is the entry in a
|
| 386 |
415 |
|
//! field's suggestion list: a value, a label, and the [`detail`] that orients
|
| 387 |
416 |
|
//! it. Ruled by Max 2026-08-21 (`1fcf2e9b`) after the combobox member shipped
|
| 3544 |
3573 |
|
/// the MNW server is a third consumer with 14 of them. A member designed
|
| 3545 |
3574 |
|
/// against nothing is still the rule; the measurement is what changed.
|
| 3546 |
3575 |
|
File,
|
|
3576 |
+ |
/// Which theme the app wears.
|
|
3577 |
+ |
///
|
|
3578 |
+ |
/// The one member here that names a *subject* rather than a shape of
|
|
3579 |
+ |
/// answer, and it is worth saying why that is not the door it looks like.
|
|
3580 |
+ |
/// Every other kind is a question a screen might ask about anything; this
|
|
3581 |
+ |
/// one is a specific question every app in the family asks, once, on its
|
|
3582 |
+ |
/// settings screen, and three of them wrote the same control by hand.
|
|
3583 |
+ |
///
|
|
3584 |
+ |
/// # It is furniture, and the measurement is what says so
|
|
3585 |
+ |
///
|
|
3586 |
+ |
/// The reading to resist is that this is [`Select`](Self::Select) with a
|
|
3587 |
+ |
/// grouped option list, and the grouped select is what was rejected to get
|
|
3588 |
+ |
/// here (`70028e00`, ruled by Max 2026-08-28). Grouping was measured across
|
|
3589 |
+ |
/// the tree first: `optgroup` appears at exactly **one** live site, in the
|
|
3590 |
+ |
/// one app not yet ported, and the non-theme grouping count is **zero**.
|
|
3591 |
+ |
/// So the recurring thing was never "option lists that group". It was this
|
|
3592 |
+ |
/// picker.
|
|
3593 |
+ |
///
|
|
3594 |
+ |
/// # What it carries that a select cannot
|
|
3595 |
+ |
///
|
|
3596 |
+ |
/// [`Field::themes`] rather than [`Field::options`], because a theme is
|
|
3597 |
+ |
/// four facts and an option is two. The two extra facts are the ones no
|
|
3598 |
+ |
/// app can supply without redoing work the theme layer has already done:
|
|
3599 |
+ |
/// which [`ThemeVariant`] group a theme is in, and how legible its muted
|
|
3600 |
+ |
/// text measured. `Choice::new(id, format!("{name} ({variant})"))` is what
|
|
3601 |
+ |
/// the three apps had, and it flattens the group into prose and loses the
|
|
3602 |
+ |
/// tier entirely.
|
|
3603 |
+ |
///
|
|
3604 |
+ |
/// [`Field::follows`] carries the entry that is not a theme.
|
|
3605 |
+ |
///
|
|
3606 |
+ |
/// # The cost, stated rather than discovered later
|
|
3607 |
+ |
///
|
|
3608 |
+ |
/// This puts one screen's shape into a vocabulary that otherwise holds
|
|
3609 |
+ |
/// none, which was the objection raised against it and accepted going in.
|
|
3610 |
+ |
/// The mitigation is narrowness: this describes a theme picker, not a
|
|
3611 |
+ |
/// general "list the host resolved" mechanism. A second host-resolved list
|
|
3612 |
+ |
/// is when that generalisation gets measured, and not before.
|
|
3613 |
+ |
///
|
|
3614 |
+ |
/// A renderer that has not heard of it draws a select over
|
|
3615 |
+ |
/// [`Field::themes`]' names and loses the grouping, which is the state
|
|
3616 |
+ |
/// every app was in before this member. Degrading to the status quo ante
|
|
3617 |
+ |
/// is the floor the member is designed against.
|
|
3618 |
+ |
///
|
|
3619 |
+ |
/// Added 0.38.0.
|
|
3620 |
+ |
Theme,
|
| 3547 |
3621 |
|
/// Carried through the form and never shown.
|
| 3548 |
3622 |
|
Hidden,
|
| 3549 |
3623 |
|
}
|
| 3615 |
3689 |
|
matches!(self, Self::Select | Self::Radio)
|
| 3616 |
3690 |
|
}
|
| 3617 |
3691 |
|
|
|
3692 |
+ |
/// Whether the kind reads [`Field::themes`] and [`Field::follows`].
|
|
3693 |
+ |
///
|
|
3694 |
+ |
/// One member answers yes, and it gets a name for
|
|
3695 |
+ |
/// [`takes_files`](Self::takes_files)'s reason rather than in spite of
|
|
3696 |
+ |
/// being alone: four renderers ask it before they read either member, and
|
|
3697 |
+ |
/// a `matches!` per renderer is where the next one goes missing.
|
|
3698 |
+ |
///
|
|
3699 |
+ |
/// Deliberately not folded into
|
|
3700 |
+ |
/// [`offers_options`](Self::offers_options). A theme picker offers no
|
|
3701 |
+ |
/// [`Choice`]es at all, so a renderer walking `options` for it walks an
|
|
3702 |
+ |
/// empty slice and draws an empty control.
|
|
3703 |
+ |
///
|
|
3704 |
+ |
/// Added 0.38.0.
|
|
3705 |
+ |
#[must_use]
|
|
3706 |
+ |
pub const fn offers_themes(self) -> bool {
|
|
3707 |
+ |
matches!(self, Self::Theme)
|
|
3708 |
+ |
}
|
|
3709 |
+ |
|
| 3618 |
3710 |
|
/// Whether the value runs to more than one line.
|
| 3619 |
3711 |
|
///
|
| 3620 |
3712 |
|
/// Named once here for [`temporal`](Self::temporal)'s reason: two kinds
|
| 3973 |
4065 |
|
}
|
| 3974 |
4066 |
|
}
|
| 3975 |
4067 |
|
|
|
4068 |
+ |
/// Which ambient mode a theme is written for.
|
|
4069 |
+ |
///
|
|
4070 |
+ |
/// The vocabulary's own spelling of what `makeover` calls a theme's variant,
|
|
4071 |
+ |
/// and the duplication is deliberate rather than an oversight. This crate has
|
|
4072 |
+ |
/// no dependencies by charter — it emits nothing, reads nothing and resolves
|
|
4073 |
+ |
/// nothing — so it cannot take the crate that owns the file format, and a
|
|
4074 |
+ |
/// renderer that must group a picker needs the three groups as values.
|
|
4075 |
+ |
///
|
|
4076 |
+ |
/// The two are kept in step by the app that converts between them, which is a
|
|
4077 |
+ |
/// three-arm `match` at each adopter and the price of the layering. If a fourth
|
|
4078 |
+ |
/// mode is ever authored, this enum and `makeover::Variant` move together.
|
|
4079 |
+ |
///
|
|
4080 |
+ |
/// Three, not two: one shipped theme is high contrast, and an app matching on
|
|
4081 |
+ |
/// light-or-dark alone files it under the wrong one.
|
|
4082 |
+ |
///
|
|
4083 |
+ |
/// Added 0.38.0.
|
|
4084 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
4085 |
+ |
#[non_exhaustive]
|
|
4086 |
+ |
pub enum ThemeVariant {
|
|
4087 |
+ |
/// Written for a light ambient mode.
|
|
4088 |
+ |
Light,
|
|
4089 |
+ |
/// Written for a dark ambient mode.
|
|
4090 |
+ |
Dark,
|
|
4091 |
+ |
/// Written to be legible before it is pretty.
|
|
4092 |
+ |
HighContrast,
|
|
4093 |
+ |
}
|
|
4094 |
+ |
|
|
4095 |
+ |
impl ThemeVariant {
|
|
4096 |
+ |
/// The machine spelling, matching the theme file's own `meta.variant`.
|
|
4097 |
+ |
///
|
|
4098 |
+ |
/// A data attribute, a stored value, a test assertion. Not a heading: what
|
|
4099 |
+ |
/// a group is *called* on screen is [`heading`](Self::heading).
|
|
4100 |
+ |
#[must_use]
|
|
4101 |
+ |
pub const fn as_str(self) -> &'static str {
|
|
4102 |
+ |
match self {
|
|
4103 |
+ |
ThemeVariant::Light => "light",
|
|
4104 |
+ |
ThemeVariant::Dark => "dark",
|
|
4105 |
+ |
ThemeVariant::HighContrast => "high-contrast",
|
|
4106 |
+ |
}
|
|
4107 |
+ |
}
|
|
4108 |
+ |
|
|
4109 |
+ |
/// What the group of themes in this variant is called on screen.
|
|
4110 |
+ |
///
|
|
4111 |
+ |
/// Here rather than at each renderer, which is the whole argument for the
|
|
4112 |
+ |
/// member existing: three renderers picking their own headings is one
|
|
4113 |
+ |
/// picker reading three ways, and the spellings below are the ones
|
|
4114 |
+ |
/// goingson's shipped picker used before it was described.
|
|
4115 |
+ |
#[must_use]
|
|
4116 |
+ |
pub const fn heading(self) -> &'static str {
|
|
4117 |
+ |
match self {
|
|
4118 |
+ |
ThemeVariant::Light => "Light",
|
|
4119 |
+ |
ThemeVariant::Dark => "Dark",
|
|
4120 |
+ |
ThemeVariant::HighContrast => "High Contrast",
|
|
4121 |
+ |
}
|
|
4122 |
+ |
}
|
|
4123 |
+ |
}
|
|
4124 |
+ |
|
|
4125 |
+ |
impl std::fmt::Display for ThemeVariant {
|
|
4126 |
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
4127 |
+ |
f.write_str(self.as_str())
|
|
4128 |
+ |
}
|
|
4129 |
+ |
}
|
|
4130 |
+ |
|
|
4131 |
+ |
/// How legible a theme measured, as a picker reports it.
|
|
4132 |
+ |
///
|
|
4133 |
+ |
/// A measurement carried into the description, which is unusual here and is the
|
|
4134 |
+ |
/// one case that earns it: the number comes off the theme's resolved colours,
|
|
4135 |
+ |
/// so the layer that loaded the theme is the only party that has it, and an app
|
|
4136 |
+ |
/// re-deriving it would be parsing every theme file a second time to learn what
|
|
4137 |
+ |
/// was already known. What a renderer does with it is a badge beside the name.
|
|
4138 |
+ |
///
|
|
4139 |
+ |
/// Ordered worst-first, matching `makeover::ContrastTier`, so the two sort the
|
|
4140 |
+ |
/// same way and an adopter's `match` cannot invert an ordering by accident.
|
|
4141 |
+ |
///
|
|
4142 |
+ |
/// Added 0.38.0.
|
|
4143 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
4144 |
+ |
#[non_exhaustive]
|
|
4145 |
+ |
pub enum Contrast {
|
|
4146 |
+ |
/// Muted text below the 3:1 floor for large text and UI parts.
|
|
4147 |
+ |
Low,
|
|
4148 |
+ |
/// Muted text clears 3:1 but not the 4.5:1 bar for normal text.
|
|
4149 |
+ |
Standard,
|
|
4150 |
+ |
/// Muted text meets WCAG AA on every panel ground.
|
|
4151 |
+ |
High,
|
|
4152 |
+ |
}
|
|
4153 |
+ |
|
|
4154 |
+ |
impl Contrast {
|
|
4155 |
+ |
/// The machine spelling, for a data attribute or a test.
|
|
4156 |
+ |
#[must_use]
|
|
4157 |
+ |
pub const fn as_str(self) -> &'static str {
|
|
4158 |
+ |
match self {
|
|
4159 |
+ |
Contrast::Low => "low",
|
|
4160 |
+ |
Contrast::Standard => "standard",
|
|
4161 |
+ |
Contrast::High => "high",
|
|
4162 |
+ |
}
|
|
4163 |
+ |
}
|
|
4164 |
+ |
|
|
4165 |
+ |
/// The short mark shown beside a theme's name.
|
|
4166 |
+ |
///
|
|
4167 |
+ |
/// One spelling for the tree, for [`ThemeVariant::heading`]'s reason. These
|
|
4168 |
+ |
/// are the marks audiofiles shipped before its picker was described, which
|
|
4169 |
+ |
/// is the only implementation that ever drew them.
|
|
4170 |
+ |
///
|
|
4171 |
+ |
/// [`Standard`](Self::Standard) is not the absence of a mark: a reader
|
|
4172 |
+ |
/// scanning a column of badges learns more from three marks than from two
|
|
4173 |
+ |
/// and a gap, and "OK" is the honest reading of a theme that clears the UI
|
|
4174 |
+ |
/// floor and misses the text one.
|
|
4175 |
+ |
#[must_use]
|
|
4176 |
+ |
pub const fn badge(self) -> &'static str {
|
|
4177 |
+ |
match self {
|
|
4178 |
+ |
Contrast::Low => "low",
|
|
4179 |
+ |
Contrast::Standard => "OK",
|
|
4180 |
+ |
Contrast::High => "AA",
|
|
4181 |
+ |
}
|
|
4182 |
+ |
}
|
|
4183 |
+ |
}
|
|
4184 |
+ |
|
|
4185 |
+ |
impl std::fmt::Display for Contrast {
|
|
4186 |
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
4187 |
+ |
f.write_str(self.as_str())
|
|
4188 |
+ |
}
|
|
4189 |
+ |
}
|
|
4190 |
+ |
|
|
4191 |
+ |
/// One theme, as a picker offers it.
|
|
4192 |
+ |
///
|
|
4193 |
+ |
/// Four facts where a [`Choice`] has two, and the two extra ones are why this
|
|
4194 |
+ |
/// is its own type rather than options with the variant folded into the label.
|
|
4195 |
+ |
/// Both are facts the theme layer resolved and neither survives being written
|
|
4196 |
+ |
/// into a string: a group is structure and a badge is a second column.
|
|
4197 |
+ |
///
|
|
4198 |
+ |
/// # No `unavailable`
|
|
4199 |
+ |
///
|
|
4200 |
+ |
/// [`Choice::unavailable`]'s counterpart is absent for its own sibling's
|
|
4201 |
+ |
/// reason. A theme that is installed can be picked, and a theme that is not
|
|
4202 |
+ |
/// installed is not in the list. There is no third state for a reason to
|
|
4203 |
+ |
/// explain.
|
|
4204 |
+ |
///
|
|
4205 |
+ |
/// `#[non_exhaustive]` from birth, which is the whole of what 0.28.0 cost the
|
|
4206 |
+ |
/// tree and is not being paid a third time.
|
|
4207 |
+ |
///
|
|
4208 |
+ |
/// Added 0.38.0.
|
|
4209 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
4210 |
+ |
#[non_exhaustive]
|
|
4211 |
+ |
pub struct ThemeChoice<'a> {
|
|
4212 |
+ |
/// What is submitted, and what the app stores.
|
|
4213 |
+ |
pub id: &'a str,
|
|
4214 |
+ |
/// What is read.
|
|
4215 |
+ |
pub name: &'a str,
|
|
4216 |
+ |
/// Which group it belongs to.
|
|
4217 |
+ |
pub variant: ThemeVariant,
|
|
4218 |
+ |
/// How legible its muted text measured.
|
|
4219 |
+ |
pub contrast: Contrast,
|
|
4220 |
+ |
}
|
|
4221 |
+ |
|
|
4222 |
+ |
impl<'a> ThemeChoice<'a> {
|
|
4223 |
+ |
/// A theme, with everything a picker needs to place and mark it.
|
|
4224 |
+ |
///
|
|
4225 |
+ |
/// Every fact is an argument and none is a builder, which is the opposite
|
|
4226 |
+ |
/// of [`Choice`]'s arrangement and is deliberate: a theme missing its
|
|
4227 |
+ |
/// variant has no group to sit in and a theme missing its tier has no badge
|
|
4228 |
+ |
/// to draw, so both are the control rather than embellishments on it. The
|
|
4229 |
+ |
/// same reasoning [`Field::range`] applies to its bounds.
|
|
4230 |
+ |
#[must_use]
|
|
4231 |
+ |
pub const fn new(
|
|
4232 |
+ |
id: &'a str,
|
|
4233 |
+ |
name: &'a str,
|
|
4234 |
+ |
variant: ThemeVariant,
|
|
4235 |
+ |
contrast: Contrast,
|
|
4236 |
+ |
) -> Self {
|
|
4237 |
+ |
Self {
|
|
4238 |
+ |
id,
|
|
4239 |
+ |
name,
|
|
4240 |
+ |
variant,
|
|
4241 |
+ |
contrast,
|
|
4242 |
+ |
}
|
|
4243 |
+ |
}
|
|
4244 |
+ |
}
|
|
4245 |
+ |
|
| 3976 |
4246 |
|
/// One field of a form.
|
| 3977 |
4247 |
|
///
|
| 3978 |
4248 |
|
/// Borrowed rather than owned: a description is built, read once by a renderer,
|
| 4259 |
4529 |
|
/// Which option is *current* is not here. That is the value, and the value
|
| 4260 |
4530 |
|
/// is renderer state.
|
| 4261 |
4531 |
|
pub options: &'a [Choice<'a>],
|
|
4532 |
+ |
/// The themes offered, in the order they are offered.
|
|
4533 |
+ |
///
|
|
4534 |
+ |
/// Empty for every kind [`FieldKind::offers_themes`] rejects, and sayable
|
|
4535 |
+ |
/// as empty for the one that accepts it: an app whose theme directories
|
|
4536 |
+ |
/// hold nothing has a picker offering only [`follows`](Self::follows),
|
|
4537 |
+ |
/// which is a true description of that machine.
|
|
4538 |
+ |
///
|
|
4539 |
+ |
/// **The order is the grouping.** Entries arrive sorted by
|
|
4540 |
+ |
/// [`ThemeVariant`] and then by [`Contrast`] within each variant, so a
|
|
4541 |
+ |
/// renderer that draws headings walks the run of one variant and a renderer
|
|
4542 |
+ |
/// that cannot still gets the useful order. Handing back groups would force
|
|
4543 |
+ |
/// the second renderer to flatten what the first wanted.
|
|
4544 |
+ |
///
|
|
4545 |
+ |
/// Nothing here sorts. The description carries the order it was given, and
|
|
4546 |
+ |
/// the sort belongs with whoever measured the tiers — `makeover::theme_options`
|
|
4547 |
+ |
/// is what produces it, and re-sorting here would be this crate deciding a
|
|
4548 |
+ |
/// question it cannot see the inputs to.
|
|
4549 |
+ |
///
|
|
4550 |
+ |
/// Which theme is *current* is not here. That is the value, and the value
|
|
4551 |
+ |
/// is renderer state, exactly as it is for [`options`](Self::options).
|
|
4552 |
+ |
///
|
|
4553 |
+ |
/// Added 0.38.0.
|
|
4554 |
+ |
pub themes: &'a [ThemeChoice<'a>],
|
|
4555 |
+ |
/// The entry that follows the ambient mode instead of naming a theme.
|
|
4556 |
+ |
///
|
|
4557 |
+ |
/// [`None`] for a picker that does not offer one, which is a real answer:
|
|
4558 |
+ |
/// an app whose host has no ambient mode to follow should not offer a row
|
|
4559 |
+ |
/// that does nothing.
|
|
4560 |
+ |
///
|
|
4561 |
+ |
/// A [`Choice`] rather than a bare label, because the *value* is the app's.
|
|
4562 |
+ |
/// Every store in the family spells it `system` today and none of them is
|
|
4563 |
+ |
/// obliged to; a description that hardcoded the spelling would be this
|
|
4564 |
+ |
/// crate holding a fact about somebody else's config table.
|
|
4565 |
+ |
///
|
|
4566 |
+ |
/// It is not a [`ThemeChoice`] with an absent variant. Following is a
|
|
4567 |
+ |
/// standing instruction that resolves differently as the desktop flips, and
|
|
4568 |
+ |
/// a theme id is an answer that does not — which is the distinction
|
|
4569 |
+ |
/// `makeover::ThemeSelection` exists to hold, carried here rather than
|
|
4570 |
+ |
/// blurred.
|
|
4571 |
+ |
///
|
|
4572 |
+ |
/// Added 0.38.0.
|
|
4573 |
+ |
pub follows: Option<Choice<'a>>,
|
| 4262 |
4574 |
|
/// What a file field takes, in the order a host offering the list shows it.
|
| 4263 |
4575 |
|
///
|
| 4264 |
4576 |
|
/// Empty for every kind [`FieldKind::takes_files`] rejects, and empty is
|
| 4435 |
4747 |
|
note: None,
|
| 4436 |
4748 |
|
placeholder: None,
|
| 4437 |
4749 |
|
options: &[],
|
|
4750 |
+ |
themes: &[],
|
|
4751 |
+ |
follows: None,
|
| 4438 |
4752 |
|
accept: &[],
|
| 4439 |
4753 |
|
multiple: false,
|
| 4440 |
4754 |
|
required: false,
|
| 4529 |
4843 |
|
Self::offering(FieldKind::Radio, name, label, options)
|
| 4530 |
4844 |
|
}
|
| 4531 |
4845 |
|
|
|
4846 |
+ |
/// A theme picker over the themes the host resolved.
|
|
4847 |
+ |
///
|
|
4848 |
+ |
/// A constructor for [`select`](Self::select)'s reason and one of its own.
|
|
4849 |
+ |
/// The shared reason: a theme picker built through [`new`](Self::new) has
|
|
4850 |
+ |
/// an empty [`themes`](Self::themes) list and draws an empty control. Its
|
|
4851 |
+ |
/// own: the list is the *only* thing this kind takes that a call site
|
|
4852 |
+ |
/// cannot get wrong by omission and can get wrong by substitution, since
|
|
4853 |
+ |
/// [`options`](Self::options) is right there and reads as if it would work.
|
|
4854 |
+ |
///
|
|
4855 |
+ |
/// [`following`](Self::following) is the builder rather than a fourth
|
|
4856 |
+ |
/// argument, because a picker with no follow-the-system row is a real
|
|
4857 |
+ |
/// picker and every renderer draws it honestly.
|
|
4858 |
+ |
#[must_use]
|
|
4859 |
+ |
pub const fn theme(name: &'a str, label: &'a str, themes: &'a [ThemeChoice<'a>]) -> Self {
|
|
4860 |
+ |
Self {
|
|
4861 |
+ |
themes,
|
|
4862 |
+ |
..Self::new(FieldKind::Theme, name, label)
|
|
4863 |
+ |
}
|
|
4864 |
+ |
}
|
|
4865 |
+ |
|
|
4866 |
+ |
/// The same picker, offering a row that tracks the ambient mode.
|
|
4867 |
+ |
///
|
|
4868 |
+ |
/// The [`Choice`] carries the value the app's own store spells it with.
|
|
4869 |
+ |
#[must_use]
|
|
4870 |
+ |
pub const fn following(mut self, follow: Choice<'a>) -> Self {
|
|
4871 |
+ |
self.follows = Some(follow);
|
|
4872 |
+ |
self
|
|
4873 |
+ |
}
|
|
4874 |
+ |
|
| 4532 |
4875 |
|
/// The shared body of the two constructors that take options.
|
| 4533 |
4876 |
|
///
|
| 4534 |
4877 |
|
/// Private, and keyed on the kind rather than exposed, because the two
|
| 7127 |
7470 |
|
assert_eq!(slid.step, None);
|
| 7128 |
7471 |
|
assert_eq!(slid.curve.step(), Some("0.001"));
|
| 7129 |
7472 |
|
}
|
|
7473 |
+ |
|
|
7474 |
+ |
#[test]
|
|
7475 |
+ |
fn a_theme_picker_offers_themes_and_no_options() {
|
|
7476 |
+ |
// The substitution hazard the constructor exists against: `options` is
|
|
7477 |
+ |
// right there and reads as if it would work, and a renderer walking it
|
|
7478 |
+ |
// for a theme picker draws an empty control.
|
|
7479 |
+ |
let themes = [
|
|
7480 |
+ |
ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High),
|
|
7481 |
+ |
ThemeChoice::new("dracula", "Dracula", ThemeVariant::Dark, Contrast::Standard),
|
|
7482 |
+ |
];
|
|
7483 |
+ |
let field = Field::theme("theme", "Theme", &themes);
|
|
7484 |
+ |
|
|
7485 |
+ |
assert_eq!(field.kind, FieldKind::Theme);
|
|
7486 |
+ |
assert!(field.kind.offers_themes());
|
|
7487 |
+ |
assert!(!field.kind.offers_options());
|
|
7488 |
+ |
assert_eq!(field.themes.len(), 2);
|
|
7489 |
+ |
assert!(field.options.is_empty());
|
|
7490 |
+ |
assert_eq!(field.follows, None);
|
|
7491 |
+ |
}
|
|
7492 |
+ |
|
|
7493 |
+ |
#[test]
|
|
7494 |
+ |
fn following_carries_the_store_s_own_spelling() {
|
|
7495 |
+ |
// Not hardcoded here: the value belongs to the app's config table, and
|
|
7496 |
+ |
// this crate holds no facts about somebody else's store.
|
|
7497 |
+ |
let field =
|
|
7498 |
+ |
Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
|
|
7499 |
+ |
let follow = field.follows.expect("the row was offered");
|
|
7500 |
+ |
assert_eq!(follow.value, "system");
|
|
7501 |
+ |
assert_eq!(follow.label, "Follow System");
|
|
7502 |
+ |
}
|
|
7503 |
+ |
|
|
7504 |
+ |
#[test]
|
|
7505 |
+ |
fn a_picker_with_nothing_resolved_is_sayable() {
|
|
7506 |
+ |
// A machine whose theme directories hold nothing. The description is
|
|
7507 |
+ |
// true and a renderer says so on screen rather than in a log, which is
|
|
7508 |
+ |
// `Field::options`' own arrangement.
|
|
7509 |
+ |
let field = Field::theme("theme", "Theme", &[]);
|
|
7510 |
+ |
assert!(field.themes.is_empty());
|
|
7511 |
+ |
}
|
|
7512 |
+ |
|
|
7513 |
+ |
#[test]
|
|
7514 |
+ |
fn every_kind_but_theme_offers_no_themes() {
|
|
7515 |
+ |
for kind in [
|
|
7516 |
+ |
FieldKind::Text,
|
|
7517 |
+ |
FieldKind::Select,
|
|
7518 |
+ |
FieldKind::Radio,
|
|
7519 |
+ |
FieldKind::Checkbox,
|
|
7520 |
+ |
FieldKind::File,
|
|
7521 |
+ |
FieldKind::Hidden,
|
|
7522 |
+ |
] {
|
|
7523 |
+ |
assert!(
|
|
7524 |
+ |
!kind.offers_themes(),
|
|
7525 |
+ |
"{kind:?} does not read Field::themes"
|
|
7526 |
+ |
);
|
|
7527 |
+ |
}
|
|
7528 |
+ |
}
|
|
7529 |
+ |
|
|
7530 |
+ |
#[test]
|
|
7531 |
+ |
fn a_contrast_tier_reads_worst_first() {
|
|
7532 |
+ |
// Matches `makeover::ContrastTier`, so an adopter's conversion cannot
|
|
7533 |
+ |
// invert an ordering by accident and a sort agrees across the seam.
|
|
7534 |
+ |
assert!(Contrast::Low < Contrast::Standard);
|
|
7535 |
+ |
assert!(Contrast::Standard < Contrast::High);
|
|
7536 |
+ |
}
|
|
7537 |
+ |
|
|
7538 |
+ |
#[test]
|
|
7539 |
+ |
fn the_groups_and_badges_have_one_spelling_each() {
|
|
7540 |
+ |
// The whole argument for these living here: three renderers picking
|
|
7541 |
+ |
// their own is one picker reading three ways.
|
|
7542 |
+ |
assert_eq!(ThemeVariant::Light.heading(), "Light");
|
|
7543 |
+ |
assert_eq!(ThemeVariant::Dark.heading(), "Dark");
|
|
7544 |
+ |
assert_eq!(ThemeVariant::HighContrast.heading(), "High Contrast");
|
|
7545 |
+ |
|
|
7546 |
+ |
assert_eq!(ThemeVariant::HighContrast.as_str(), "high-contrast");
|
|
7547 |
+ |
|
|
7548 |
+ |
assert_eq!(Contrast::High.badge(), "AA");
|
|
7549 |
+ |
assert_eq!(Contrast::Standard.badge(), "OK");
|
|
7550 |
+ |
assert_eq!(Contrast::Low.badge(), "low");
|
|
7551 |
+ |
}
|
|
7552 |
+ |
|
|
7553 |
+ |
#[test]
|
|
7554 |
+ |
fn the_variant_spelling_matches_the_theme_file_s_own() {
|
|
7555 |
+ |
// The seam this enum is duplicated across. `makeover::parse_meta` reads
|
|
7556 |
+ |
// `meta.variant` as one of these three strings; a rename on either side
|
|
7557 |
+ |
// that does not move together silently regroups every picker.
|
|
7558 |
+ |
for (variant, spelling) in [
|
|
7559 |
+ |
(ThemeVariant::Light, "light"),
|
|
7560 |
+ |
(ThemeVariant::Dark, "dark"),
|
|
7561 |
+ |
(ThemeVariant::HighContrast, "high-contrast"),
|
|
7562 |
+ |
] {
|
|
7563 |
+ |
assert_eq!(variant.as_str(), spelling);
|
|
7564 |
+ |
assert_eq!(variant.to_string(), spelling);
|
|
7565 |
+ |
}
|
|
7566 |
+ |
}
|
| 7130 |
7567 |
|
}
|