Files
Ferrous-Solitaire/solitaire_engine/src/settings_plugin/mod.rs
T
funman300 28be65f092
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m22s
feat(engine): UI scale setting — 90/100/115/130% (Phase K)
Settings -> Accessibility gains a UI Scale row cycling four steps,
applied live through bevy::ui::UiScale: every menu, modal, and HUD
element scales while the table itself stays window-fit via
compute_layout. New Settings::ui_scale (serde default 1.0, sanitized
clamp to [0.9, 1.3]).

Safe-area anchors and modal scrim padding pre-divide physical insets
by the UI scale so post-multiplication lands exactly on the system
bars — without this, 90% would sink the bottom action bar into the
Android gesture zone. The anchor systems also re-run on UiScale
changes, not just inset changes.

Deferred from Phase K (noted in the doc): seeding the setting from the
Android system font scale on first run (needs JNI), and the 44px
touch-target audit (on-device).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:39:19 -07:00

663 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
//! and renders a Bevy UI Settings panel.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` — decrease SFX volume by `SFX_STEP`
//! - `]` — increase SFX volume by `SFX_STEP`
//! - `O` — open / close the Settings panel
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use bevy::window::{WindowMoved, WindowResized};
use solitaire_data::{
Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
};
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
};
use crate::resources::SettingsScrollPos;
use crate::theme::ThemeThumbnailPair;
mod input;
mod ui;
mod updates;
use input::*;
use ui::*;
use updates::*;
/// Side length of a swatch button in the card-back / background pickers.
/// Smaller than the smallest spacing rung so it stays a literal.
const SWATCH_PX: f32 = 40.0;
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
const ICON_BUTTON_PX: f32 = 32.0;
/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Whether the Settings panel is currently visible. Toggle with `O`.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScreen(pub bool);
/// Debounce window for persisting window-geometry changes, in seconds.
///
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
/// move drag, so writing to disk on every event would thrash the file
/// system. Instead the geometry-watch system records the pending value
/// and waits this long after the *last* event before saving.
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;
/// Tracks a pending window-geometry change so the saver can debounce
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct PendingWindowGeometry {
/// Most recent observed geometry. `None` when nothing is pending.
pub geometry: Option<WindowGeometry>,
/// `Time::elapsed_secs()` value at which `geometry` was last updated.
pub last_changed_secs: f32,
}
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// System set for the systems that mutate [`SettingsResource`] every frame
/// (hotkeys and window-geometry persistence). Ordered before
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
/// after this set (directly, or transitively via `.after(GameMutation)`)
/// so they observe the current frame's settings deterministically (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation;
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
pub struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
struct SfxVolumeText;
/// Marks the `Text` node showing the live music volume value.
#[derive(Component, Debug)]
struct MusicVolumeText;
/// Marks the `Text` node showing the current draw mode.
#[derive(Component, Debug)]
struct DrawModeText;
/// Marks the `Text` node showing the current theme.
#[derive(Component, Debug)]
struct ThemeText;
/// Marks the `Text` node showing the live sync status.
#[derive(Component, Debug)]
struct SyncStatusText;
/// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)]
struct CardBackText;
/// Marks the `Text` node showing the current animation speed.
#[derive(Component, Debug)]
struct AnimSpeedText;
/// Marks the `Text` node showing the active background index.
#[derive(Component, Debug)]
struct BackgroundText;
/// Marks the `Text` node showing the current color-blind mode state.
#[derive(Component, Debug)]
struct ColorBlindText;
/// Marks the `Text` node showing the current high-contrast mode state.
#[derive(Component, Debug)]
struct HighContrastText;
/// Marks the `Text` node showing the current reduce-motion mode state.
#[derive(Component, Debug)]
struct ReduceMotionText;
/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
struct TouchInputModeText;
/// Marks the `Text` node showing the current UI scale percentage.
#[derive(Component, Debug)]
struct UiScaleText;
/// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)]
struct TooltipDelayText;
/// Marks the `Text` node showing the live time-bonus-multiplier value.
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;
/// Marks the `Text` node showing the live replay-playback per-move
/// interval value. The Gameplay-section row beside this label lets the
/// player tune `Settings::replay_move_interval_secs`.
#[derive(Component, Debug)]
struct ReplayMoveIntervalText;
/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;
/// Marks the `Text` node showing the current "Smart window size"
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
/// negatively in `Settings::disable_smart_default_size`, so the
/// label inverts: "ON" = smart sizing enabled (the default).
#[derive(Component, Debug)]
struct SmartDefaultSizeText;
/// Marks the `Text` node showing the current "Share usage data" (analytics)
/// state ("ON" / "OFF") in the Privacy section.
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
#[derive(Component, Debug)]
struct SettingsScrollNode;
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
/// thumbnail means the picker should render a placeholder swatch — used
/// when the cache hasn't generated handles yet, or when a user theme
/// is missing one of the required preview SVGs.
#[derive(Debug, Clone)]
struct ThemePickerEntry {
/// Stable theme id (matches `ThemeMeta::id`).
id: String,
/// Player-facing label.
display_name: String,
/// Pre-generated picker preview pair, when ready. `None` collapses
/// the chip to its plain-text fallback.
thumbnails: Option<ThemeThumbnailPair>,
}
/// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)]
enum SettingsButton {
SfxDown,
SfxUp,
MusicDown,
MusicUp,
ToggleDrawMode,
CycleAnimSpeed,
/// Decrement the tooltip-hover dwell delay by one step.
TooltipDelayDown,
/// Increment the tooltip-hover dwell delay by one step.
TooltipDelayUp,
/// Decrement the cosmetic time-bonus multiplier by one step.
TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step.
TimeBonusUp,
/// Decrement the replay-playback per-move interval by one step
/// (i.e. speed playback up).
ReplayMoveIntervalDown,
/// Increment the replay-playback per-move interval by one step
/// (i.e. slow playback down).
ReplayMoveIntervalUp,
ToggleTheme,
ToggleColorBlind,
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
/// foreground / suit-red glyphs to higher-luminance variants per
/// `design-system.md` §Accessibility (#2).
ToggleHighContrast,
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
/// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion,
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode,
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
/// stays window-fit (Phase K).
CycleUiScale,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
/// When the visible label reads "ON", the launch-time window
/// sizer scales the window to ~70 % of the primary monitor on a
/// fresh install; "OFF" pins the literal 1280×800 baseline. The
/// flag only affects launches without saved geometry — the
/// player's last window size always wins.
ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// Matomo URL is configured.
ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
/// Disconnect from the sync server (shown when backend = SolitaireServer).
DisconnectSync,
/// Open the account-deletion confirmation modal.
DeleteAccount,
Done,
/// Select a specific card-back by index from the picker row.
SelectCardBack(usize),
/// Select a specific background by index from the picker row.
SelectBackground(usize),
/// Select a specific card-art theme by `meta.id` from the
/// `ThemeRegistry`. The string is owned so the click handler can
/// hand it directly to `Settings::selected_theme_id`.
SelectTheme(String),
}
impl SettingsButton {
/// Tab-walk priority — lower numbers visited first. Visual reading
/// order is top-to-bottom by section, left-to-right inside each row.
/// Two buttons in the same picker row receive the same `order`;
/// `handle_focus_keys` then breaks ties by entity index, which
/// matches `Children` spawn order inside each row.
fn focus_order(&self) -> i32 {
match self {
// Audio section
SettingsButton::SfxDown => 10,
SettingsButton::SfxUp => 11,
SettingsButton::MusicDown => 20,
SettingsButton::MusicUp => 21,
// Gameplay section
SettingsButton::ToggleDrawMode => 30,
SettingsButton::ToggleWinnableDealsOnly => 35,
SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47,
SettingsButton::TimeBonusUp => 48,
// Replay-speed slider — last Gameplay-section row, so it
// sits between TimeBonusUp (48) and the Cosmetic section.
SettingsButton::ReplayMoveIntervalDown => 49,
SettingsButton::ReplayMoveIntervalUp => 49,
// Smart-default-size toggle — sits at the end of Gameplay.
SettingsButton::ToggleSmartDefaultSize => 50,
// Privacy section — just before Sync.
SettingsButton::ToggleAnalytics => 89,
// Cosmetic section
SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60,
// Accessibility-section toggles sit alongside Color-blind so
// tab-walk visits all three a11y flags in the same vertical
// run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::CycleUiScale => 64,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
SettingsButton::DisconnectSync => 92,
SettingsButton::DeleteAccount => 93,
// Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness.
SettingsButton::Done => 100,
}
}
}
/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
/// Path to `settings.json`. `None` in headless/test mode.
pub storage_path: Option<PathBuf>,
/// When `false`, panel spawn/despawn systems are not registered.
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
pub ui_enabled: bool,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
ui_enabled: true,
}
}
}
impl SettingsPlugin {
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
pub fn headless() -> Self {
Self {
storage_path: None,
ui_enabled: false,
}
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<SyncConfigureRequestEvent>()
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
// also runs cleanly under `MinimalPlugins` (tests).
.add_message::<WindowResized>()
.add_message::<WindowMoved>()
// Settings changes land before game logic runs: the mutator
// chain (volume keys → geometry record → geometry persist) is a
// deterministic spine, and the whole set precedes GameMutation so
// every reader already ordered after GameMutation sees this
// frame's settings transitively (ambiguity burn-down, #143).
.configure_sets(
Update,
SettingsMutation
.after(crate::layout::LayoutSystem::UpdateOnResize)
.before(crate::game_plugin::GameMutation),
)
.add_systems(
Update,
(
handle_volume_keys,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
// State sync, not UI — runs even under `headless()`
// so the live `bevy::ui::UiScale` always mirrors the
// setting; rides the mutator spine so it is ordered
// after every settings writer this frame.
sync_ui_scale_resource,
)
.chain()
.in_set(SettingsMutation),
)
.add_systems(
Update,
(
toggle_settings_screen
.before(crate::ui_focus::FocusKeys)
.ambiguous_with(crate::hud_plugin::HudButtons),
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
)
.chain(),
);
if self.ui_enabled {
app.add_systems(
Update,
(
// Chained: a tab click must observe this frame's
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
(
sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons,
handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
handle_scan_themes,
update_sync_status_text,
update_card_back_text,
update_background_text,
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_replay_move_interval_text,
update_winnable_deals_only_text,
update_smart_default_size_text,
),
);
app.add_systems(
Update,
(
update_analytics_enabled_text,
attach_focusable_to_settings_buttons,
),
);
app.add_systems(Update, scroll_focus_into_view);
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else { return };
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
/// Pure helper: returns `true` when a pending geometry change has sat
/// quietly long enough to flush to disk.
///
/// Extracted so the debounce condition can be unit-tested without
/// spinning up a Bevy app.
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
(now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
}
/// Returns the geometry implied by an event pair `(width, height, x, y)`,
/// using each component from `existing` when the corresponding event-derived
/// value is `None`. Returns `None` when neither side supplies width/height.
///
/// Pure helper so the merge logic can be unit-tested without an `App`.
fn merge_geometry(
existing: Option<WindowGeometry>,
new_size: Option<(u32, u32)>,
new_pos: Option<(i32, i32)>,
) -> Option<WindowGeometry> {
let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
let (x, y) = new_pos
.or_else(|| existing.map(|g| (g.x, g.y)))
.unwrap_or((0, 0));
Some(WindowGeometry {
width,
height,
x,
y,
})
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
#[cfg(test)]
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
if unlocked.is_empty() {
return 0;
}
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
unlocked[(pos + 1) % unlocked.len()]
}
/// Records `WindowResized` and `WindowMoved` events into
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
/// into the latest pending geometry.
///
/// The actual disk write is debounced — see
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
/// hit on every pixel of a resize / move drag.
fn record_window_geometry_changes(
time: Res<Time>,
mut resized: MessageReader<WindowResized>,
mut moved: MessageReader<WindowMoved>,
settings: Res<SettingsResource>,
mut pending: ResMut<PendingWindowGeometry>,
) {
// Read .last() — only the final event matters for persistence; the
// intermediate sizes/positions are noise during a drag.
let new_size = resized.read().last().map(|ev| {
(
ev.width.round().max(0.0) as u32,
ev.height.round().max(0.0) as u32,
)
});
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
if new_size.is_none() && new_pos.is_none() {
return;
}
// Fold the new components into the existing pending value (if any),
// otherwise into the persisted geometry from settings.
let baseline = pending.geometry.or(settings.0.window_geometry);
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
return;
};
pending.geometry = Some(geometry);
pending.last_changed_secs = time.elapsed_secs();
}
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
/// `WindowMoved` events arriving), commits the pending geometry to
/// `SettingsResource` and writes `settings.json`. Skips the write when the
/// pending value already matches the settings (e.g. a resize that was
/// reverted, or a synthetic event with no geometry change).
fn persist_window_geometry_after_debounce(
time: Res<Time>,
mut pending: ResMut<PendingWindowGeometry>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
let Some(new_geom) = pending.geometry else {
return;
};
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
return;
}
// Always clear the pending slot regardless of whether we end up
// writing — otherwise an idempotent change would re-trigger this
// system every tick.
pending.geometry = None;
if settings.0.window_geometry == Some(new_geom) {
return;
}
settings.0.window_geometry = Some(new_geom);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests;