pub use crate::cli::num_palettes;
use crate::cli::Palette;
use crate::cli::PauseStyle;
pub use crate::cli::ALL_PALETTES;
pub use crate::cli::NUM_PALETTES;
use crate::config_defaults::dither as dither_consts;
use crate::config_defaults::intensity::DEFAULT_PERLIN_SEED as PERLIN_SEED;
use crate::config_defaults::{agent as agent_consts, environment as env_consts, trail};
use crate::food_image::FOOD_IMAGE_PNG;
use crate::overlay::{OverlayState, OverlayType};
use crate::render::charset::Charset;
pub use crate::render::charset::ALL_CHARSETS;
use crate::render::dither::{DitherMatrix, DitherMode};
use crate::render::palette::IntensityMapping;
use crate::render::theme::{PanelStyle, ALL_THEMES, GRUVBOX_DARK};
use crate::simulation::config::{
Aspect, ChromeStyle, DiffusionKernel, InitMode, Preset, SimConfig, TerminalSizeThreshold,
TerrainType, TransitionStyle, Wind, WindowFrame, WindowPadding,
};
use crate::simulation::food::load_logo_from_memory;
use rand::Rng;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MousePosition {
pub x: usize,
pub y: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MouseInteractionMode {
Disabled,
Attract,
Repel,
}
pub fn num_charsets() -> usize {
ALL_CHARSETS.len()
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PendingSwap {
Preset(Preset),
Config(Box<crate::config_manager::NamedProfile>),
Reset,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteShiftSpeed {
Off,
Slow,
Medium,
Fast,
}
impl PaletteShiftSpeed {
pub fn degrees_per_second(&self) -> f32 {
match self {
PaletteShiftSpeed::Off => 0.0,
PaletteShiftSpeed::Slow => 5.0,
PaletteShiftSpeed::Medium => 15.0,
PaletteShiftSpeed::Fast => 45.0,
}
}
}
pub fn palette_shift_speed_of(hue_shift: f32) -> PaletteShiftSpeed {
if hue_shift <= 0.0 {
PaletteShiftSpeed::Off
} else if hue_shift <= 10.0 {
PaletteShiftSpeed::Slow
} else if hue_shift <= 30.0 {
PaletteShiftSpeed::Medium
} else {
PaletteShiftSpeed::Fast
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NotificationLevel {
Info,
Success,
Warning,
Error,
}
impl NotificationLevel {
pub fn icon(self) -> &'static str {
match self {
NotificationLevel::Info => "ℹ",
NotificationLevel::Success => "✓",
NotificationLevel::Warning => "⚠",
NotificationLevel::Error => "✗",
}
}
pub fn bg_color_256(self) -> u8 {
match self {
NotificationLevel::Info => 23, NotificationLevel::Success => 22, NotificationLevel::Warning => 94, NotificationLevel::Error => 52, }
}
pub fn accent_rgb(self) -> crate::render::palette::RgbColor {
use crate::render::palette::RgbColor;
match self {
NotificationLevel::Info => RgbColor {
r: 69,
g: 192,
b: 191,
}, NotificationLevel::Success => RgbColor {
r: 142,
g: 192,
b: 124,
}, NotificationLevel::Warning => RgbColor {
r: 215,
g: 153,
b: 33,
}, NotificationLevel::Error => RgbColor {
r: 251,
g: 73,
b: 52,
}, }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindDirection {
None,
North,
Northeast,
East,
Southeast,
South,
Southwest,
West,
Northwest,
}
impl WindDirection {
#[allow(clippy::wrong_self_convention)]
pub fn to_wind(&self) -> Option<Wind> {
match self {
WindDirection::None => None,
WindDirection::North => Some(Wind::new(0.0, -1.0)),
WindDirection::Northeast => Some(Wind::new(0.7, -0.7)),
WindDirection::East => Some(Wind::new(1.0, 0.0)),
WindDirection::Southeast => Some(Wind::new(0.7, 0.7)),
WindDirection::South => Some(Wind::new(0.0, 1.0)),
WindDirection::Southwest => Some(Wind::new(-0.7, 0.7)),
WindDirection::West => Some(Wind::new(-1.0, 0.0)),
WindDirection::Northwest => Some(Wind::new(-0.7, -0.7)),
}
}
pub fn from_wind(wind: Option<Wind>) -> Self {
let Some(w) = wind else {
return WindDirection::None;
};
if w.dx == 0.0 && w.dy == 0.0 {
return WindDirection::None;
}
let angle = w.dy.atan2(w.dx).to_degrees();
let norm = ((angle % 360.0) + 360.0) % 360.0;
match (norm / 45.0).round() as i32 % 8 {
0 => WindDirection::East,
1 => WindDirection::Southeast,
2 => WindDirection::South,
3 => WindDirection::Southwest,
4 => WindDirection::West,
5 => WindDirection::Northwest,
6 => WindDirection::North,
_ => WindDirection::Northeast,
}
}
pub fn name(&self) -> &'static str {
match self {
WindDirection::None => "None",
WindDirection::North => "N",
WindDirection::Northeast => "NE",
WindDirection::East => "E",
WindDirection::Southeast => "SE",
WindDirection::South => "S",
WindDirection::Southwest => "SW",
WindDirection::West => "W",
WindDirection::Northwest => "NW",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ChromeState {
#[default]
Minimal,
Expanded,
ModalPane,
FadingOut(f32),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ControlAction {
TogglePause,
Restart,
QuickKey(char),
CompareQuickKey(char),
AdjustTimeScale(f32),
CyclePalette,
CyclePaletteReverse,
CycleCharset,
CycleCharsetReverse,
CycleColorAa,
ToggleControls,
ToggleKeyboardHints,
CloseOverlays,
ToggleDither,
CycleDitherMode,
AdjustDitherIntensity(f32),
Quit,
AdjustSensorAngle(f32),
AdjustSensorDistance(f32),
AdjustTurnAngle(f32),
AdjustStepSize(f32),
AdjustDecay(f32),
AdjustDeposit(f32),
CycleDiffusionKernel,
AdjustDiffusionSigma(f32),
AdjustAttractorStrength(f32),
CycleMouseMode,
CycleWindDirection,
CycleWindDirectionReverse,
AdjustTerrainStrength(f32),
CycleTerrainType,
ToggleAutoNormalize,
CycleMotionBlur,
AdjustMaxBrightness(f32),
SaveFrameToPng,
ToggleFastMode,
CyclePaletteShiftSpeed,
ToggleInvertPalette,
ToggleReversePalette,
ToggleStatusBar,
CycleIntensityMapping,
CycleIntensityMappingReverse,
SetIntensityMapping(usize),
ResetToDefaults,
CycleOptionsCategory,
CycleOptionsCategoryReverse,
ToggleDashboard,
ToggleNotifications,
ShowConfigBrowser,
ShowConfigSaveDialog,
RandomizeParams,
Undo,
Redo,
CycleTheme,
CycleThemeReverse,
ShowPaletteEditor,
ToggleTrailAge,
ToggleTrailDelta,
ToggleGradientMagnitude,
CycleWindowFrame,
CycleWindowFrameReverse,
ToggleFullscreen,
CycleChrome,
#[cfg(feature = "audio")]
ToggleChoir,
ToggleControlsDepth,
ControlsFocusNext,
ControlsFocusPrev,
ControlsAdjustFocused(f32),
ControlsActivateFocused,
None,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParameterState {
pub sensor_angle: f32,
pub sensor_distance: f32,
pub rotation_angle: f32,
pub step_size: f32,
pub decay_factor: f32,
pub deposit_amount: f32,
pub diffusion_kernel: DiffusionKernel,
pub diffusion_sigma: f32,
pub attractor_strength: f32,
pub wind_direction: WindDirection,
pub terrain_type: TerrainType,
pub terrain_strength: f32,
pub max_brightness: f32,
pub palette_index: usize,
pub charset_index: usize,
pub invert_palette: bool,
pub reverse_palette: bool,
pub dither_mode: DitherMode,
pub motion_blur_frames: usize,
pub window_frame: WindowFrame,
pub color_aa: [crate::render::antialiasing::AaStrength; crate::render::charset::NUM_CHARSETS],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DefaultValues {
pub sensor_angle: f32,
pub sensor_distance: f32,
pub rotation_angle: f32,
pub step_size: f32,
pub decay_factor: f32,
pub deposit_amount: f32,
pub diffusion_kernel: DiffusionKernel,
pub diffusion_sigma: f32,
pub attractor_strength: f32,
pub wind_direction: WindDirection,
pub terrain_type: TerrainType,
pub terrain_strength: f32,
pub auto_normalize: bool,
pub motion_blur_frames: usize,
pub max_brightness: f32,
}
impl DefaultValues {
pub fn from_preset(preset: Preset) -> Self {
let config = SimConfig::from(preset);
let auto_normalize = crate::render_art_defaults::RenderArtDefaults::from(preset)
.auto_normalize
.unwrap_or(false);
Self::from_sim_config(&config, auto_normalize)
}
pub fn from_config(profile: &crate::config_manager::NamedProfile) -> Self {
match profile.overrides.resolve() {
Ok(p) => Self::from_sim_config(&p.sim, p.render.auto_normalize),
Err(_) => Self::from_preset(Preset::Organic),
}
}
pub(crate) fn from_sim_config(config: &SimConfig, auto_normalize: bool) -> Self {
Self {
sensor_angle: config.sensor_angle,
sensor_distance: config.sensor_distance,
rotation_angle: config.rotation_angle,
step_size: config.step_size,
decay_factor: config.decay_factor,
deposit_amount: config.deposit_amount,
diffusion_kernel: config.diffusion_kernel,
diffusion_sigma: config.diffusion_sigma,
attractor_strength: config.attractor_strength,
wind_direction: match config.wind {
None => WindDirection::None,
Some(w) => {
if w.dx > 0.0 && w.dy == 0.0 {
WindDirection::East
} else if w.dx < 0.0 && w.dy == 0.0 {
WindDirection::West
} else if w.dx == 0.0 && w.dy < 0.0 {
WindDirection::North
} else if w.dx == 0.0 && w.dy > 0.0 {
WindDirection::South
} else {
WindDirection::None
}
}
},
terrain_type: config.terrain,
terrain_strength: config.terrain_strength,
auto_normalize,
motion_blur_frames: 0,
max_brightness: config.max_brightness,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ComparisonTarget {
Preset(Preset),
Config(Box<crate::config_manager::NamedProfile>),
}
#[derive(Debug, Clone)]
pub struct TransitionAnim {
pub name: String,
pub tagline: Option<String>,
pub style: TransitionStyle,
pub start: f32,
}
#[derive(Debug, Clone)]
pub struct RuntimeState {
pub is_paused: bool,
pub pause_just_toggled: bool,
pub overlay_state: OverlayState,
pub comparison_target: ComparisonTarget,
pub controls_category_idx: usize,
pub controls_depth: crate::render::controls::ControlsDepth,
pub controls_focus: usize,
pub time_scale: f32,
pub current_preset: Preset,
pub(crate) active_source: crate::profile::ProfileSource,
pub palette_index: usize,
pub charset_index: usize,
pub color_aa: [crate::render::antialiasing::AaStrength; crate::render::charset::NUM_CHARSETS],
pub original_seed: u64,
pub original_init_mode: InitMode,
pub dither_mode: DitherMode,
pub last_dither_mode: Option<DitherMode>,
pub mouse_mode: MouseInteractionMode,
pub mouse_timeout: f32,
pub sensor_angle: f32,
pub sensor_distance: f32,
pub rotation_angle: f32,
pub step_size: f32,
pub decay_factor: f32,
pub deposit_amount: f32,
pub diffusion_kernel: DiffusionKernel,
pub diffusion_sigma: f32,
pub attractor_strength: f32,
pub wind_direction: WindDirection,
pub terrain_type: TerrainType,
pub terrain_strength: f32,
pub auto_normalize: bool,
pub motion_blur_frames: usize,
pub window_frame: WindowFrame,
pub chrome_style: ChromeStyle,
pub transition_style: TransitionStyle,
pub transition_tagline: bool,
pub active_transition: Option<TransitionAnim>,
pub base_chrome_state: ChromeState,
pub chrome_state: ChromeState,
pub aspect: Aspect,
pub window_padding: WindowPadding,
pub show_status_bar: bool,
pub min_sim_size: TerminalSizeThreshold,
pub min_frame_size: TerminalSizeThreshold,
pub max_brightness: f32,
pub fast_mode_enabled: bool,
pub palette_shift_speed: PaletteShiftSpeed,
pub invert_palette: bool,
pub reverse_palette: bool,
pub intensity_mapping: IntensityMapping,
pub intensity_mapping_index: usize,
pub saved_palette_name: Option<String>,
pub collapse_frame_counter: usize,
pub stagnation_frame_counter: usize,
pub prev_trail_sample: Vec<f32>,
pub warmup_counter: usize,
pub food_persist_counter: usize,
pub food_persist_enabled: bool,
pub initial_food_attractors: Vec<crate::simulation::config::Attractor>,
pub config_browser_selected_index: usize,
pub config_save_name_input: tui_input::Input,
pub default_values: DefaultValues,
pub(crate) app: crate::app_config::AppRuntimeConfig,
pub wind: Option<Wind>,
pub live_palette: Palette,
pub live_charset: Charset,
pub(crate) active_overrides: crate::profile_overrides::ProfileOverrides,
pub(crate) pending_swap: Option<PendingSwap>,
pub(crate) baseline_render: crate::render_art_defaults::ResolvedRenderConfig,
pub undo_stack: std::collections::VecDeque<ParameterState>,
pub redo_stack: std::collections::VecDeque<ParameterState>,
pub last_checkpoint_time: std::time::Instant,
pub fps_history: std::collections::VecDeque<f32>,
pub entropy_history: std::collections::VecDeque<f32>,
pub density_history: std::collections::VecDeque<f32>,
pub panel_style: PanelStyle,
pub theme_index: usize,
pub shift_held: bool,
pub pause_logo_cache: Option<(usize, usize, Vec<f32>)>,
pub pause_frame_counter: u64,
pub pause_style: PauseStyle,
pub pause_logo_enabled: bool,
pub pause_pulse_draw_mode: bool,
pub trail_age_enabled: bool,
pub trail_delta_enabled: bool,
pub gradient_magnitude_enabled: bool,
pub gradient_strength: f32,
pub trail_age_hue_range: f32,
pub trail_age_blend: f32,
pub trail_age_mode: crate::config_defaults::TrailAgeMode,
pub trail_age_reverse: bool,
pub trail_delta_strength: f32,
pub temporal_color: f32,
pub temporal_lag_frames: f32,
pub temporal_mode: crate::render::palette::TemporalMode,
pub temporal_accent: Option<crate::render::palette::RgbColor>,
pub afterglow: f32,
pub afterglow_rate: f32,
pub decay_gamma: f32,
pub diffuse_weight: f32,
pub deposit_curve: crate::simulation::config::DepositCurve,
pub deposit_scale: f32,
pub deposit_gamma: f32,
pub deposit_cap: f32,
pub palette_cycle: crate::render::palette::PaletteCycle,
pub glyph: crate::render::charset::GlyphConfig,
pub phase_clock: f32,
pub ambient_states: Vec<crate::render::ambient::AmbientState>,
pub notifications_enabled: bool,
}
impl RuntimeState {
#[allow(clippy::too_many_arguments)]
pub fn new(
seed: u64,
init_mode: InitMode,
initial_preset: Preset,
mouse_mode: MouseInteractionMode,
mouse_timeout: f32,
cli_config: &SimConfig,
pause_style: PauseStyle,
pause_logo_enabled: bool,
pause_pulse_draw_mode: bool,
) -> Self {
let default_values = DefaultValues::from_preset(initial_preset);
Self {
is_paused: false,
pause_just_toggled: false,
overlay_state: OverlayState::default(),
comparison_target: ComparisonTarget::Preset(initial_preset),
controls_category_idx: 0,
controls_depth: crate::render::controls::ControlsDepth::Closed,
controls_focus: 0,
time_scale: cli_config.time_scale,
current_preset: initial_preset,
active_source: crate::profile::ProfileSource::StartupCli,
palette_index: 0,
charset_index: 0,
color_aa: crate::config_defaults::DEFAULT_COLOR_AA,
original_seed: seed,
original_init_mode: init_mode,
dither_mode: DitherMode::None,
last_dither_mode: None,
mouse_mode,
mouse_timeout,
sensor_angle: cli_config.sensor_angle,
sensor_distance: cli_config.sensor_distance,
rotation_angle: cli_config.rotation_angle,
step_size: cli_config.step_size,
decay_factor: cli_config.decay_factor,
deposit_amount: cli_config.deposit_amount,
diffusion_kernel: cli_config.diffusion_kernel,
diffusion_sigma: cli_config.diffusion_sigma,
attractor_strength: cli_config.attractor_strength,
wind_direction: match cli_config.wind {
None => WindDirection::None,
Some(w) => {
if w.dx > 0.0 && w.dy == 0.0 {
WindDirection::East
} else if w.dx < 0.0 && w.dy == 0.0 {
WindDirection::West
} else if w.dx == 0.0 && w.dy < 0.0 {
WindDirection::North
} else if w.dx == 0.0 && w.dy > 0.0 {
WindDirection::South
} else {
WindDirection::None
}
}
},
terrain_type: cli_config.terrain,
terrain_strength: cli_config.terrain_strength,
auto_normalize: false,
motion_blur_frames: 0,
window_frame: cli_config.window_frame,
chrome_style: cli_config.chrome_style,
transition_style: cli_config.transition_style,
transition_tagline: cli_config.transition_tagline,
active_transition: None,
base_chrome_state: match cli_config.chrome_style {
ChromeStyle::Expanded => ChromeState::Expanded,
ChromeStyle::Minimal => ChromeState::Minimal,
ChromeStyle::Fullscreen => ChromeState::Minimal,
},
chrome_state: match cli_config.chrome_style {
ChromeStyle::Expanded => ChromeState::Expanded,
ChromeStyle::Minimal => ChromeState::Minimal,
ChromeStyle::Fullscreen => ChromeState::Minimal,
},
aspect: cli_config.aspect,
window_padding: cli_config.window_padding,
show_status_bar: cli_config.show_status_bar,
min_sim_size: cli_config.min_sim_size,
min_frame_size: cli_config.min_frame_size,
max_brightness: cli_config.max_brightness,
fast_mode_enabled: false,
palette_shift_speed: PaletteShiftSpeed::Off,
invert_palette: false,
reverse_palette: false,
intensity_mapping: crate::render::palette::IntensityMapping::linear(),
intensity_mapping_index: 0,
saved_palette_name: None,
collapse_frame_counter: 0,
stagnation_frame_counter: 0,
prev_trail_sample: Vec::new(),
warmup_counter: 0,
food_persist_counter: 0,
food_persist_enabled: false,
initial_food_attractors: Vec::new(),
config_browser_selected_index: 0,
config_save_name_input: tui_input::Input::default(),
default_values,
app: crate::app_config::AppRuntimeConfig::default(),
wind: cli_config.wind,
live_palette: Palette::Organic,
live_charset: Charset::HalfBlock,
active_overrides: crate::profile_overrides::ProfileOverrides::default(),
pending_swap: None,
baseline_render: crate::render_art_defaults::ResolvedRenderConfig::default(),
undo_stack: std::collections::VecDeque::with_capacity(50),
redo_stack: std::collections::VecDeque::with_capacity(50),
last_checkpoint_time: std::time::Instant::now(),
fps_history: std::collections::VecDeque::with_capacity(60),
entropy_history: std::collections::VecDeque::with_capacity(60),
density_history: std::collections::VecDeque::with_capacity(60),
panel_style: GRUVBOX_DARK,
theme_index: 0,
shift_held: false,
pause_logo_cache: None,
pause_frame_counter: 0,
pause_style,
pause_logo_enabled,
pause_pulse_draw_mode,
trail_age_enabled: false,
trail_delta_enabled: false,
gradient_magnitude_enabled: false,
gradient_strength: 0.3,
trail_age_hue_range: 15.0,
trail_age_blend: 0.5,
trail_age_mode: crate::config_defaults::TrailAgeMode::Bidirectional,
trail_age_reverse: true,
trail_delta_strength: 0.5,
temporal_color: 0.0,
temporal_lag_frames: 8.0,
temporal_mode: crate::render::palette::TemporalMode::Hue,
temporal_accent: None,
afterglow: 0.0,
afterglow_rate: 0.05,
decay_gamma: cli_config.decay_gamma,
diffuse_weight: cli_config.diffuse_weight,
deposit_curve: cli_config.deposit_curve,
deposit_scale: cli_config.deposit_scale,
deposit_gamma: cli_config.deposit_gamma,
deposit_cap: cli_config.deposit_cap,
palette_cycle: crate::render::palette::PaletteCycle::default(),
glyph: crate::render::charset::GlyphConfig::default(),
phase_clock: 0.0,
ambient_states: vec![crate::render::ambient::AmbientState::Base],
notifications_enabled: true,
}
}
pub(crate) fn set_render_baseline(
&mut self,
r: crate::render_art_defaults::ResolvedRenderConfig,
) {
self.baseline_render = r;
}
pub(crate) fn sync_sim_levers(&mut self, sim: &SimConfig) {
self.sensor_angle = sim.sensor_angle;
self.sensor_distance = sim.sensor_distance;
self.rotation_angle = sim.rotation_angle;
self.step_size = sim.step_size;
self.decay_factor = sim.decay_factor;
self.deposit_amount = sim.deposit_amount;
self.diffusion_kernel = sim.diffusion_kernel;
self.diffusion_sigma = sim.diffusion_sigma;
self.attractor_strength = sim.attractor_strength;
self.terrain_type = sim.terrain;
self.terrain_strength = sim.terrain_strength;
self.max_brightness = sim.max_brightness;
self.decay_gamma = sim.decay_gamma;
self.diffuse_weight = sim.diffuse_weight;
self.deposit_curve = sim.deposit_curve;
self.deposit_scale = sim.deposit_scale;
self.deposit_gamma = sim.deposit_gamma;
self.deposit_cap = sim.deposit_cap;
self.time_scale = sim.time_scale;
self.window_frame = sim.window_frame;
self.chrome_style = sim.chrome_style;
self.transition_style = sim.transition_style;
self.transition_tagline = sim.transition_tagline;
self.aspect = sim.aspect;
self.window_padding = sim.window_padding;
self.show_status_bar = sim.show_status_bar;
self.min_sim_size = sim.min_sim_size;
self.min_frame_size = sim.min_frame_size;
self.wind = sim.wind;
self.wind_direction = WindDirection::from_wind(sim.wind);
}
pub fn capture_parameter_state(&self) -> ParameterState {
ParameterState {
sensor_angle: self.sensor_angle,
sensor_distance: self.sensor_distance,
rotation_angle: self.rotation_angle,
step_size: self.step_size,
decay_factor: self.decay_factor,
deposit_amount: self.deposit_amount,
diffusion_kernel: self.diffusion_kernel,
diffusion_sigma: self.diffusion_sigma,
attractor_strength: self.attractor_strength,
wind_direction: self.wind_direction,
terrain_type: self.terrain_type,
terrain_strength: self.terrain_strength,
max_brightness: self.max_brightness,
palette_index: self.palette_index,
charset_index: self.charset_index,
invert_palette: self.invert_palette,
reverse_palette: self.reverse_palette,
dither_mode: self.dither_mode,
motion_blur_frames: self.motion_blur_frames,
window_frame: self.window_frame,
color_aa: self.color_aa,
}
}
pub fn apply_parameter_state(&mut self, state: ParameterState) {
self.sensor_angle = state.sensor_angle;
self.sensor_distance = state.sensor_distance;
self.rotation_angle = state.rotation_angle;
self.step_size = state.step_size;
self.decay_factor = state.decay_factor;
self.deposit_amount = state.deposit_amount;
self.diffusion_kernel = state.diffusion_kernel;
self.diffusion_sigma = state.diffusion_sigma;
self.attractor_strength = state.attractor_strength;
self.wind_direction = state.wind_direction;
self.terrain_type = state.terrain_type;
self.terrain_strength = state.terrain_strength;
self.max_brightness = state.max_brightness;
self.palette_index = state.palette_index;
self.charset_index = state.charset_index;
self.invert_palette = state.invert_palette;
self.reverse_palette = state.reverse_palette;
self.dither_mode = state.dither_mode;
self.motion_blur_frames = state.motion_blur_frames;
self.window_frame = state.window_frame;
self.color_aa = state.color_aa;
self.sync_live_from_index();
}
pub fn checkpoint(&mut self) {
if self.last_checkpoint_time.elapsed().as_millis() < 500 {
return;
}
let current = self.capture_parameter_state();
if let Some(last) = self.undo_stack.back() {
if last == ¤t {
return;
}
}
self.undo_stack.push_back(current);
if self.undo_stack.len() > 50 {
self.undo_stack.pop_front();
}
self.redo_stack.clear();
self.last_checkpoint_time = std::time::Instant::now();
}
pub fn force_checkpoint(&mut self) {
let current = self.capture_parameter_state();
self.undo_stack.push_back(current);
if self.undo_stack.len() > 50 {
self.undo_stack.pop_front();
}
self.redo_stack.clear();
self.last_checkpoint_time = std::time::Instant::now();
}
pub fn undo(&mut self) -> Option<ParameterState> {
if self.undo_stack.is_empty() {
return None;
}
let current = self.capture_parameter_state();
self.redo_stack.push_back(current);
let previous = self.undo_stack.pop_back().unwrap();
self.apply_parameter_state(previous.clone());
Some(previous)
}
pub fn redo(&mut self) -> Option<ParameterState> {
if self.redo_stack.is_empty() {
return None;
}
let current = self.capture_parameter_state();
self.undo_stack.push_back(current);
let next = self.redo_stack.pop_back().unwrap();
self.apply_parameter_state(next.clone());
Some(next)
}
pub fn toggle_pause(&mut self) {
self.is_paused = !self.is_paused;
}
pub fn preload_pause_logo(&mut self, term_width: usize, _term_height: usize) {
let pct = if term_width < 80 {
0.90
} else if term_width < 120 {
0.75
} else {
0.60
};
let logo_w = ((term_width as f32 * pct) as usize).clamp(30, 180);
let logo_h = ((logo_w as f32 / 2.67) as usize).max(6);
let pixel_w = logo_w * 2;
let pixel_h = logo_h * 2;
let map = load_logo_from_memory(FOOD_IMAGE_PNG, pixel_w, pixel_h, true)
.unwrap_or_else(|_| vec![0.0; pixel_w * pixel_h]);
self.pause_logo_cache = Some((logo_w, pixel_h, map));
}
pub fn toggle_controls(&mut self) {
self.overlay_state.toggle(OverlayType::Controls);
}
pub fn toggle_keyboard_hints(&mut self) {
self.overlay_state.toggle(OverlayType::KeyboardHints);
}
pub fn toggle_comparison(&mut self, target: ComparisonTarget) {
if self.overlay_state.is_open(OverlayType::PresetComparison)
&& self.comparison_target == target
{
self.overlay_state.close();
} else {
self.overlay_state.open(OverlayType::PresetComparison);
self.comparison_target = target;
}
}
pub fn any_overlay_open(&self) -> bool {
self.overlay_state.any_open()
}
pub fn close_all_overlays(&mut self) {
self.overlay_state.palette_editor = None;
self.overlay_state.close();
}
pub fn toggle_palette_editor(&mut self) {
self.overlay_state.toggle(OverlayType::PaletteEditor);
}
pub fn is_overlay_active(&self, overlay: OverlayType) -> bool {
self.overlay_state.is_open(overlay)
}
pub fn cycle_controls_category(&mut self, forward: bool) {
let total = crate::render::controls::registry::CATEGORY_NAMES.len();
if forward {
self.controls_category_idx = (self.controls_category_idx + 1) % total;
} else {
self.controls_category_idx = if self.controls_category_idx == 0 {
total - 1
} else {
self.controls_category_idx - 1
};
}
}
pub fn set_preset(&mut self, preset: Preset) {
self.force_checkpoint();
self.current_preset = preset;
self.default_values = DefaultValues::from_preset(preset);
}
pub fn adjust_time_scale(&mut self, delta: f32) {
self.checkpoint();
let new_scale = (self.time_scale + delta).clamp(0.5, 10.0);
self.time_scale = new_scale;
}
#[allow(clippy::type_complexity)]
pub const INTENSITY_MAPPINGS: &'static [(&'static str, fn() -> IntensityMapping)] = &[
("Linear", || IntensityMapping::linear()),
("Logarithmic", || IntensityMapping::logarithmic(10.0)),
("Exponential", || IntensityMapping::exponential(10.0)),
("Square Root", || {
IntensityMapping::new(vec![crate::render::palette::MappingSegment {
start: 0.0,
end: 1.0,
function: crate::render::palette::MappingFunction::SquareRoot,
}])
.unwrap()
}),
("Smoothstep", || IntensityMapping::smoothstep()),
("Split (Lin/Log)", || {
IntensityMapping::linear_log_split(10.0)
}),
("Quantize 6", || IntensityMapping::quantize(6)),
("Perlin", || {
IntensityMapping::perlin(0.15, 4.0, PERLIN_SEED)
}),
];
pub(crate) fn find_intensity_mapping_index(mapping: &IntensityMapping) -> usize {
for (i, (_, factory)) in Self::INTENSITY_MAPPINGS.iter().enumerate() {
if &factory() == mapping {
return i;
}
}
0
}
pub fn cycle_intensity_mapping(&mut self, reverse: bool) {
let count = Self::INTENSITY_MAPPINGS.len();
if reverse {
self.intensity_mapping_index = (self.intensity_mapping_index + count - 1) % count;
} else {
self.intensity_mapping_index = (self.intensity_mapping_index + 1) % count;
}
let (_, factory) = Self::INTENSITY_MAPPINGS[self.intensity_mapping_index];
self.intensity_mapping = factory();
}
pub fn intensity_mapping_name(&self) -> &'static str {
Self::INTENSITY_MAPPINGS[self.intensity_mapping_index].0
}
fn sync_live_from_index(&mut self) {
self.live_palette = ALL_PALETTES[self.palette_index].clone();
self.live_charset = ALL_CHARSETS[self.charset_index].clone();
}
pub fn cycle_palette(&mut self, num_palettes: usize) {
self.force_checkpoint();
self.palette_index = (self.palette_index + 1) % num_palettes;
self.sync_live_from_index();
}
pub fn cycle_palette_reverse(&mut self, num_palettes: usize) {
self.force_checkpoint();
if self.palette_index == 0 {
self.palette_index = num_palettes - 1;
} else {
self.palette_index -= 1;
}
self.sync_live_from_index();
}
pub fn cycle_charset(&mut self) {
self.force_checkpoint();
self.charset_index = (self.charset_index + 1) % ALL_CHARSETS.len();
self.sync_live_from_index();
}
pub fn cycle_charset_reverse(&mut self) {
self.force_checkpoint();
if self.charset_index == 0 {
self.charset_index = ALL_CHARSETS.len() - 1;
} else {
self.charset_index -= 1;
}
self.sync_live_from_index();
}
pub fn cycle_window_frame(&mut self) {
self.force_checkpoint();
self.window_frame = match self.window_frame {
WindowFrame::None => WindowFrame::Accented,
WindowFrame::Accented => WindowFrame::Glow,
WindowFrame::Glow => WindowFrame::Frame,
WindowFrame::Frame => WindowFrame::None,
};
}
pub fn cycle_window_frame_reverse(&mut self) {
self.force_checkpoint();
self.window_frame = match self.window_frame {
WindowFrame::None => WindowFrame::Frame,
WindowFrame::Accented => WindowFrame::None,
WindowFrame::Glow => WindowFrame::Accented,
WindowFrame::Frame => WindowFrame::Glow,
};
}
pub fn cycle_chrome_style(&mut self) {
use crate::simulation::config::ChromeStyle;
self.chrome_style = match self.chrome_style {
ChromeStyle::Minimal => ChromeStyle::Expanded,
ChromeStyle::Expanded => ChromeStyle::Fullscreen,
ChromeStyle::Fullscreen => ChromeStyle::Minimal,
};
let cs = match self.chrome_style {
ChromeStyle::Expanded => ChromeState::Expanded,
ChromeStyle::Minimal => ChromeState::Minimal,
ChromeStyle::Fullscreen => ChromeState::Minimal,
};
self.base_chrome_state = cs;
self.chrome_state = cs;
}
pub fn current_charset(&self) -> Charset {
ALL_CHARSETS[self.charset_index].clone()
}
pub fn current_color_aa(&self) -> crate::render::antialiasing::AaStrength {
self.color_aa[self.charset_index % self.color_aa.len()]
}
pub fn apply_cli_color_aa(&mut self, aa: crate::render::antialiasing::AaStrength) {
let i = self.charset_index;
self.color_aa[i] = aa;
}
pub fn apply_color_aa_all(&mut self, ov: &crate::profile_overrides::ProfileOverrides) {
if let Some(ref all) = ov.color_aa_all {
for (slot, aa) in self.color_aa.iter_mut().zip(all.iter()) {
*slot = *aa;
}
} else if let Some(aa) = ov.color_aa {
let i = self.charset_index % self.color_aa.len();
self.color_aa[i] = aa;
}
}
pub(crate) fn is_dirty(&self, sim: &SimConfig, palette: Palette, charset: Charset) -> bool {
let live = crate::config_manager::capture_overrides(sim, palette, charset, self);
use crate::profile_overrides::project;
match (project(&live), project(&self.active_overrides)) {
(Ok(a), Ok(b)) => a != b,
_ => true,
}
}
pub fn cycle_color_aa(&mut self) -> bool {
if !crate::render::antialiasing::charset_aa_eligible(&self.current_charset()) {
return false;
}
let i = self.charset_index % self.color_aa.len();
self.color_aa[i] = self.color_aa[i].cycle();
true
}
pub fn current_palette(&self, palettes: &[Palette; NUM_PALETTES]) -> Palette {
palettes[self.palette_index].clone()
}
pub fn toggle_dither(&mut self) {
self.force_checkpoint();
self.dither_mode = match self.dither_mode {
DitherMode::None => {
if let Some(last) = self.last_dither_mode {
last
} else {
DitherMode::Ordered {
intensity: dither_consts::DEFAULT_INTENSITY,
matrix: DitherMatrix::Bayer4x4,
}
}
}
mode => {
self.last_dither_mode = Some(mode);
DitherMode::None
}
};
}
pub fn cycle_dither_mode(&mut self) {
self.force_checkpoint();
self.dither_mode = match self.dither_mode {
DitherMode::None => DitherMode::Ordered {
intensity: dither_consts::DEFAULT_INTENSITY,
matrix: DitherMatrix::Bayer4x4,
},
DitherMode::Ordered {
intensity: _,
matrix: _,
} => DitherMode::ErrorDiffusion { serpentine: true },
DitherMode::ErrorDiffusion { .. } => DitherMode::Hybrid {
edge_threshold: dither_consts::DEFAULT_HYBRID_EDGE_THRESHOLD,
intensity: dither_consts::DEFAULT_INTENSITY,
matrix: DitherMatrix::Bayer4x4,
},
DitherMode::Hybrid { .. } => DitherMode::None,
};
if self.dither_mode != DitherMode::None {
self.last_dither_mode = Some(self.dither_mode);
}
}
pub fn adjust_dither_intensity(&mut self, delta: f32) {
self.checkpoint();
self.dither_mode = match self.dither_mode {
DitherMode::Ordered { intensity, matrix } => {
let new_intensity = (intensity + delta).clamp(0.0, 1.0);
DitherMode::Ordered {
intensity: new_intensity,
matrix,
}
}
DitherMode::Hybrid {
edge_threshold,
intensity,
matrix,
} => {
let new_intensity = (intensity + delta).clamp(0.0, 1.0);
DitherMode::Hybrid {
edge_threshold,
intensity: new_intensity,
matrix,
}
}
_ => self.dither_mode,
};
}
pub fn adjust_sensor_angle(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.sensor_angle + delta).clamp(
agent_consts::MIN_SENSOR_ANGLE,
agent_consts::MAX_SENSOR_ANGLE,
);
let at_bound = (new_value - self.sensor_angle).abs() < 0.01;
self.sensor_angle = new_value;
at_bound
}
pub fn adjust_sensor_distance(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.sensor_distance + delta).clamp(
agent_consts::MIN_SENSOR_DISTANCE,
agent_consts::MAX_SENSOR_DISTANCE,
);
let at_bound = (new_value - self.sensor_distance).abs() < 0.01;
self.sensor_distance = new_value;
at_bound
}
pub fn adjust_rotation_angle(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.rotation_angle + delta).clamp(
agent_consts::MIN_ROTATION_ANGLE,
agent_consts::MAX_ROTATION_ANGLE,
);
let at_bound = (new_value - self.rotation_angle).abs() < 0.01;
self.rotation_angle = new_value;
at_bound
}
pub fn adjust_step_size(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.step_size + delta)
.clamp(agent_consts::MIN_STEP_SIZE, agent_consts::MAX_STEP_SIZE);
let at_bound = (new_value - self.step_size).abs() < 0.01;
self.step_size = new_value;
at_bound
}
pub fn adjust_decay(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value =
(self.decay_factor + delta).clamp(trail::MIN_DECAY_FACTOR, trail::MAX_DECAY_FACTOR);
let at_bound = (new_value - self.decay_factor).abs() < 0.001;
self.decay_factor = new_value;
at_bound
}
pub fn adjust_deposit(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.deposit_amount + delta).clamp(
agent_consts::MIN_DEPOSIT_AMOUNT,
agent_consts::MAX_DEPOSIT_AMOUNT,
);
let at_bound = (new_value - self.deposit_amount).abs() < 0.01;
self.deposit_amount = new_value;
at_bound
}
pub fn cycle_diffusion_kernel(&mut self) {
self.force_checkpoint();
self.diffusion_kernel = match self.diffusion_kernel {
DiffusionKernel::Mean3x3 => DiffusionKernel::Gaussian,
DiffusionKernel::Gaussian => DiffusionKernel::Mean3x3,
};
}
pub fn adjust_diffusion_sigma(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.diffusion_sigma + delta)
.clamp(trail::MIN_DIFFUSION_SIGMA, trail::MAX_DIFFUSION_SIGMA);
let at_bound = (new_value - self.diffusion_sigma).abs() < 0.01;
self.diffusion_sigma = new_value;
at_bound
}
pub fn adjust_attractor_strength(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.attractor_strength + delta).clamp(
env_consts::MIN_ATTRACTOR_STRENGTH,
env_consts::MAX_ATTRACTOR_STRENGTH,
);
let at_bound = (new_value - self.attractor_strength).abs() < 0.01;
self.attractor_strength = new_value;
at_bound
}
pub fn cycle_mouse_mode(&mut self) {
self.force_checkpoint();
self.mouse_mode = match self.mouse_mode {
MouseInteractionMode::Disabled => MouseInteractionMode::Attract,
MouseInteractionMode::Attract => MouseInteractionMode::Repel,
MouseInteractionMode::Repel => MouseInteractionMode::Disabled,
};
}
pub fn cycle_wind_direction(&mut self) {
self.force_checkpoint();
self.wind_direction = match self.wind_direction {
WindDirection::None => WindDirection::North,
WindDirection::North => WindDirection::Northeast,
WindDirection::Northeast => WindDirection::East,
WindDirection::East => WindDirection::Southeast,
WindDirection::Southeast => WindDirection::South,
WindDirection::South => WindDirection::Southwest,
WindDirection::Southwest => WindDirection::West,
WindDirection::West => WindDirection::Northwest,
WindDirection::Northwest => WindDirection::None,
};
}
pub fn cycle_wind_direction_reverse(&mut self) {
self.force_checkpoint();
self.wind_direction = match self.wind_direction {
WindDirection::None => WindDirection::Northwest,
WindDirection::North => WindDirection::None,
WindDirection::Northeast => WindDirection::North,
WindDirection::East => WindDirection::Northeast,
WindDirection::Southeast => WindDirection::East,
WindDirection::South => WindDirection::Southeast,
WindDirection::Southwest => WindDirection::South,
WindDirection::West => WindDirection::Southwest,
WindDirection::Northwest => WindDirection::West,
};
}
pub fn adjust_terrain_strength(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.terrain_strength + delta).clamp(
env_consts::MIN_TERRAIN_STRENGTH,
env_consts::MAX_TERRAIN_STRENGTH,
);
let at_bound = (new_value - self.terrain_strength).abs() < 0.01;
self.terrain_strength = new_value;
at_bound
}
pub fn cycle_terrain_type(&mut self) {
self.force_checkpoint();
self.terrain_type = match self.terrain_type {
TerrainType::None => TerrainType::Smooth,
TerrainType::Smooth => TerrainType::Turbulent,
TerrainType::Turbulent => TerrainType::Mixed,
TerrainType::Mixed => TerrainType::None,
};
}
pub fn toggle_auto_normalize(&mut self) {
self.force_checkpoint();
self.auto_normalize = !self.auto_normalize;
}
pub fn cycle_motion_blur(&mut self) {
self.force_checkpoint();
self.motion_blur_frames = match self.motion_blur_frames {
0 => 3,
3 => 5,
5 => 7,
7 => 0,
_ => 0,
};
}
pub fn adjust_max_brightness(&mut self, delta: f32) -> bool {
self.checkpoint();
let new_value = (self.max_brightness + delta)
.clamp(trail::MIN_MAX_BRIGHTNESS, trail::MAX_MAX_BRIGHTNESS);
let at_bound = (new_value - self.max_brightness).abs() < 0.01;
self.max_brightness = new_value;
at_bound
}
pub fn toggle_fast_mode(&mut self) {
self.force_checkpoint();
self.fast_mode_enabled = !self.fast_mode_enabled;
}
pub fn cycle_palette_shift_speed(&mut self) {
self.force_checkpoint();
self.palette_shift_speed = match self.palette_shift_speed {
PaletteShiftSpeed::Off => PaletteShiftSpeed::Slow,
PaletteShiftSpeed::Slow => PaletteShiftSpeed::Medium,
PaletteShiftSpeed::Medium => PaletteShiftSpeed::Fast,
PaletteShiftSpeed::Fast => PaletteShiftSpeed::Off,
};
}
pub fn toggle_invert_palette(&mut self) {
self.force_checkpoint();
self.invert_palette = !self.invert_palette;
}
pub fn toggle_reverse_palette(&mut self) {
self.force_checkpoint();
self.reverse_palette = !self.reverse_palette;
}
pub fn toggle_dashboard(&mut self) {
self.overlay_state.toggle(OverlayType::Dashboard);
}
pub fn on_pause(&mut self) {
if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
self.chrome_state = ChromeState::Expanded; }
}
pub fn on_unpause(&mut self) {
if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
self.chrome_state = ChromeState::Minimal;
}
}
pub fn on_unpause_with_fade(&mut self) {
if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
self.chrome_state = ChromeState::FadingOut(1.0);
}
}
const FADE_DURATION_SECS: f32 = 0.5;
pub fn advance_fade(&mut self, dt_secs: f32) {
if let ChromeState::FadingOut(ref mut p) = self.chrome_state {
*p -= dt_secs / Self::FADE_DURATION_SECS;
if *p <= 0.0 {
self.chrome_state = ChromeState::Minimal;
}
}
}
pub fn advance_phase(&mut self, dt: f32) {
const WRAP: f32 = 100_000.0;
self.phase_clock = (self.phase_clock + dt) % WRAP;
}
pub fn on_modal_open(&mut self) {
if self.base_chrome_state == ChromeState::Expanded {
self.chrome_state = ChromeState::ModalPane;
} else {
self.chrome_state = ChromeState::Minimal;
}
}
pub fn on_modal_close(&mut self) {
if self.any_overlay_open() {
return; }
if self.base_chrome_state == ChromeState::Expanded || self.is_paused {
self.chrome_state = ChromeState::Expanded;
} else {
self.chrome_state = ChromeState::Minimal;
}
}
pub fn reset_transient(&mut self) {
self.force_checkpoint();
self.auto_normalize = false;
self.motion_blur_frames = 0;
self.fast_mode_enabled = false;
}
pub fn randomize_params(&mut self) {
self.force_checkpoint();
let mut rng = rand::thread_rng();
self.sensor_angle = rng.gen_range(15.0..60.0);
self.rotation_angle = rng.gen_range(15.0..60.0);
self.step_size = rng.gen_range(0.5..2.5);
self.decay_factor = rng.gen_range(0.80..0.98);
self.deposit_amount = rng.gen_range(2.0..10.0);
self.diffusion_kernel = if rng.gen_bool(0.3) {
DiffusionKernel::Gaussian
} else {
DiffusionKernel::Mean3x3
};
self.terrain_type = match rng.gen_range(0..4) {
0 => TerrainType::None,
1 => TerrainType::Smooth,
2 => TerrainType::Turbulent,
_ => TerrainType::Mixed,
};
self.terrain_strength = rng.gen_range(0.5..3.0);
self.palette_index = rng.gen_range(0..ALL_PALETTES.len());
self.live_palette = ALL_PALETTES[self.palette_index].clone();
self.max_brightness = rng.gen_range(10.0..40.0);
}
pub fn push_msg(&mut self, level: NotificationLevel, text: String) {
if !self.notifications_enabled {
return;
}
let now = self.phase_clock;
self.ambient_states
.retain(|s| !matches!(s, crate::render::ambient::AmbientState::Msg { .. }));
self.ambient_states
.push(crate::render::ambient::msg(level, text, now));
}
pub fn show_notification(&mut self, message: String) {
self.push_msg(NotificationLevel::Info, message);
}
pub fn announce_preset_switch(&mut self, name: &str, tagline: &str) {
match self.transition_style {
TransitionStyle::Off => {}
TransitionStyle::Toast => {
self.show_notification(format!("Applied preset: {name}"));
}
style => {
let tagline = if self.transition_tagline {
Some(tagline.to_string())
} else {
None
};
self.active_transition = Some(TransitionAnim {
name: name.to_string(),
tagline,
style,
start: self.phase_clock,
});
}
}
}
pub fn show_notification_with_level(&mut self, message: String, level: NotificationLevel) {
self.push_msg(level, message);
}
pub fn cycle_theme(&mut self) {
self.theme_index = (self.theme_index + 1) % ALL_THEMES.len();
self.panel_style = ALL_THEMES[self.theme_index].style();
}
pub fn cycle_theme_reverse(&mut self) {
self.theme_index = self
.theme_index
.checked_sub(1)
.unwrap_or(ALL_THEMES.len() - 1);
self.panel_style = ALL_THEMES[self.theme_index].style();
}
pub fn current_theme_name(&self) -> &'static str {
ALL_THEMES[self.theme_index].name()
}
pub fn is_in_warmup(&self, warmup_frames: usize) -> bool {
warmup_frames > 0 && self.warmup_counter < warmup_frames
}
pub fn increment_warmup(&mut self) {
self.warmup_counter += 1;
}
pub fn reset_warmup(&mut self) {
self.warmup_counter = 0;
}
pub fn track_entropy(&mut self, entropy: f32, threshold: f32, duration_frames: usize) -> bool {
if entropy < threshold {
self.collapse_frame_counter += 1;
self.collapse_frame_counter >= duration_frames
} else {
self.collapse_frame_counter = 0;
false
}
}
pub fn track_stagnation(
&mut self,
blended: &[f32],
epsilon: f32,
duration_frames: usize,
) -> bool {
const STRIDE: usize = 64;
let sample: Vec<f32> = blended.iter().step_by(STRIDE).copied().collect();
let stagnant = if !sample.is_empty() && self.prev_trail_sample.len() == sample.len() {
let mut sum_abs = 0.0f32;
let mut max_mag = 1e-6f32;
for (cur, prev) in sample.iter().zip(self.prev_trail_sample.iter()) {
sum_abs += (cur - prev).abs();
max_mag = max_mag.max(cur.abs());
}
let mean_change = sum_abs / sample.len() as f32;
(mean_change / max_mag) < epsilon
} else {
false
};
self.prev_trail_sample = sample;
if stagnant {
self.stagnation_frame_counter += 1;
self.stagnation_frame_counter >= duration_frames
} else {
self.stagnation_frame_counter = 0;
false
}
}
pub fn reset_collapse_counter(&mut self) {
self.collapse_frame_counter = 0;
self.stagnation_frame_counter = 0;
self.prev_trail_sample.clear();
}
pub fn update_history(&mut self, fps: f32, entropy: f32, density: f32) {
self.fps_history.push_back(fps);
if self.fps_history.len() > 60 {
self.fps_history.pop_front();
}
self.entropy_history.push_back(entropy);
if self.entropy_history.len() > 60 {
self.entropy_history.pop_front();
}
self.density_history.push_back(density);
if self.density_history.len() > 60 {
self.density_history.pop_front();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::PauseStyle;
use crate::simulation::config::SimConfig;
fn create_test_runtime_state() -> RuntimeState {
RuntimeState::new(
42,
InitMode::Random,
Preset::Network,
MouseInteractionMode::Disabled,
0.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
)
}
#[test]
fn notifications_enabled_by_default_and_push_msg_surfaces_a_toast() {
let mut rs = create_test_runtime_state();
assert!(rs.notifications_enabled, "default should be enabled");
rs.push_msg(NotificationLevel::Info, "hello".to_string());
assert!(
rs.ambient_states
.iter()
.any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. })),
"a Msg should be surfaced when notifications are enabled"
);
}
#[test]
fn disabled_notifications_suppress_push_msg() {
let mut rs = create_test_runtime_state();
rs.notifications_enabled = false;
rs.push_msg(NotificationLevel::Info, "hidden".to_string());
assert!(
!rs.ambient_states
.iter()
.any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. })),
"no Msg should be surfaced when notifications are disabled"
);
}
#[test]
fn reset_transient_clears_transient_flags() {
let mut rs = create_test_runtime_state();
rs.auto_normalize = true;
rs.motion_blur_frames = 5;
rs.fast_mode_enabled = true;
rs.reset_transient();
assert!(!rs.auto_normalize);
assert_eq!(rs.motion_blur_frames, 0);
assert!(!rs.fast_mode_enabled);
}
#[test]
fn test_palette_shift_speed_cycling() {
let mut state = create_test_runtime_state();
assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Off);
state.cycle_palette_shift_speed();
assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Slow);
state.cycle_palette_shift_speed();
assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Medium);
state.cycle_palette_shift_speed();
assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Fast);
state.cycle_palette_shift_speed();
assert_eq!(state.palette_shift_speed, PaletteShiftSpeed::Off);
}
#[test]
fn test_time_scale_adjustment() {
let mut state = create_test_runtime_state();
assert_eq!(state.time_scale, 1.0);
state.adjust_time_scale(0.5);
assert_eq!(state.time_scale, 1.5);
state.adjust_time_scale(-0.5);
assert_eq!(state.time_scale, 1.0);
}
#[test]
fn test_controls_toggle() {
let mut state = create_test_runtime_state();
assert!(!state.overlay_state.is_open(OverlayType::Controls));
state.toggle_controls();
assert!(state.overlay_state.is_open(OverlayType::Controls));
state.toggle_controls();
assert!(!state.overlay_state.is_open(OverlayType::Controls));
}
#[test]
fn test_any_overlay_open() {
let mut state = create_test_runtime_state();
assert!(!state.any_overlay_open());
state.overlay_state.open(OverlayType::Controls);
assert!(state.any_overlay_open());
state.overlay_state.close();
state.overlay_state.open(OverlayType::Dashboard);
assert!(state.any_overlay_open());
}
#[test]
fn test_close_all_overlays() {
let mut state = create_test_runtime_state();
state.overlay_state.open(OverlayType::Controls);
state.overlay_state.open(OverlayType::Dashboard);
state.close_all_overlays();
assert!(!state.overlay_state.is_open(OverlayType::Controls));
assert!(!state.overlay_state.is_open(OverlayType::Dashboard));
assert!(!state.any_overlay_open());
}
#[test]
fn test_close_all_overlays_clears_palette_editor_sub_state() {
use crate::render::palette_editor::PaletteEditorState;
let mut state = create_test_runtime_state();
state
.overlay_state
.open_palette_editor(PaletteEditorState::new(&Palette::Forest));
assert!(state.overlay_state.is_palette_editor_open());
assert!(state.overlay_state.palette_editor.is_some());
state.close_all_overlays();
assert!(!state.overlay_state.is_palette_editor_open());
assert!(state.overlay_state.palette_editor.is_none());
assert!(!state.any_overlay_open());
}
#[test]
fn test_controls_category_cycling() {
let mut state = create_test_runtime_state();
assert_eq!(state.controls_category_idx, 0);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 1);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 2);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 3);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 4);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 5);
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 0);
state.cycle_controls_category(false);
assert_eq!(state.controls_category_idx, 5);
}
#[test]
fn runtime_state_starts_closed_focus_zero() {
let s = create_test_runtime_state();
assert_eq!(
s.controls_depth,
crate::render::controls::ControlsDepth::Closed
);
assert_eq!(s.controls_focus, 0);
}
#[test]
fn test_wind_direction_cycling() {
let mut state = create_test_runtime_state();
assert_eq!(state.wind_direction, WindDirection::None);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::North);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::Northeast);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::East);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::Southeast);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::South);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::Southwest);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::West);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::Northwest);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::None);
}
#[test]
fn test_wind_direction_names() {
assert_eq!(WindDirection::None.name(), "None");
assert_eq!(WindDirection::North.name(), "N");
assert_eq!(WindDirection::Southwest.name(), "SW");
}
#[test]
fn test_runtime_state_randomize() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
let orig_angle = state.sensor_angle;
state.randomize_params();
assert!(state.sensor_angle != orig_angle || state.rotation_angle != 45.0);
}
#[test]
fn test_parameter_state_roundtrip() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
state.sensor_angle = 12.3;
let p = state.capture_parameter_state();
state.sensor_angle = 45.6;
state.apply_parameter_state(p);
assert_eq!(state.sensor_angle, 12.3);
}
#[test]
fn test_palette_shift_speed() {
assert_eq!(PaletteShiftSpeed::Off.degrees_per_second(), 0.0);
assert_eq!(PaletteShiftSpeed::Fast.degrees_per_second(), 45.0);
}
#[test]
fn test_runtime_state_notifications() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
state.show_notification("test".to_string());
let has_msg = state
.ambient_states
.iter()
.any(|s| matches!(s, crate::render::ambient::AmbientState::Msg { .. }));
assert!(
has_msg,
"show_notification should push a Msg into ambient_states"
);
}
#[test]
fn test_runtime_state_warmup() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
assert!(!state.is_in_warmup(0));
assert!(state.is_in_warmup(10));
state.increment_warmup();
assert_eq!(state.warmup_counter, 1);
state.reset_warmup();
assert_eq!(state.warmup_counter, 0);
}
#[test]
fn test_runtime_state_entropy_tracking() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
assert!(!state.track_entropy(15.0, 10.0, 5)); assert!(state.track_entropy(5.0, 10.0, 1)); state.reset_collapse_counter();
assert_eq!(state.collapse_frame_counter, 0);
}
#[test]
fn test_runtime_state_stagnation_tracking() {
let mut state = create_test_runtime_state();
let static_frame = vec![0.5f32; 256];
assert!(!state.track_stagnation(&static_frame, 0.004, 3));
assert!(!state.track_stagnation(&static_frame, 0.004, 3));
assert!(!state.track_stagnation(&static_frame, 0.004, 3));
assert!(state.track_stagnation(&static_frame, 0.004, 3));
let moving_frame = vec![0.9f32; 256];
assert!(!state.track_stagnation(&moving_frame, 0.004, 3));
assert_eq!(state.stagnation_frame_counter, 0);
}
#[test]
fn test_runtime_state_history() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
state.update_history(60.0, 5.0, 0.5);
assert_eq!(state.fps_history.len(), 1);
for _ in 0..65 {
state.update_history(60.0, 5.0, 0.5);
}
assert_eq!(state.fps_history.len(), 60);
}
#[test]
fn test_runtime_state_actions() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
state.toggle_pause();
assert!(state.is_paused);
state.toggle_pause();
assert!(!state.is_paused);
state.toggle_controls();
assert!(state.overlay_state.is_open(OverlayType::Controls));
state.toggle_controls();
assert!(!state.overlay_state.is_open(OverlayType::Controls));
state.toggle_keyboard_hints();
assert!(state.overlay_state.is_open(OverlayType::KeyboardHints));
state.toggle_keyboard_hints();
assert!(!state.overlay_state.is_open(OverlayType::KeyboardHints));
state.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
assert!(state.overlay_state.is_open(OverlayType::PresetComparison));
state.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
assert!(!state.overlay_state.is_open(OverlayType::PresetComparison));
assert!(!state.any_overlay_open());
state.overlay_state.open(OverlayType::Dashboard);
assert!(state.any_overlay_open());
state.close_all_overlays();
assert!(!state.any_overlay_open());
state.cycle_controls_category(true);
assert_eq!(state.controls_category_idx, 1);
state.cycle_controls_category(false);
assert_eq!(state.controls_category_idx, 0);
state.set_preset(Preset::Fire);
assert_eq!(state.current_preset, Preset::Fire);
state.adjust_time_scale(0.5);
assert_eq!(state.time_scale, 1.5);
state.cycle_palette(10);
assert_eq!(state.palette_index, 1);
state.cycle_palette_reverse(10);
assert_eq!(state.palette_index, 0);
state.toggle_dither();
assert!(matches!(state.dither_mode, DitherMode::Ordered { .. }));
state.toggle_dither();
assert_eq!(state.dither_mode, DitherMode::None);
state.cycle_dither_mode();
assert!(matches!(state.dither_mode, DitherMode::Ordered { .. }));
state.adjust_sensor_angle(10.0);
assert!(state.sensor_angle > 22.5);
use crate::simulation::config::DiffusionKernel;
state.cycle_diffusion_kernel();
assert_eq!(state.diffusion_kernel, DiffusionKernel::Mean3x3);
state.cycle_mouse_mode();
assert_eq!(state.mouse_mode, MouseInteractionMode::Attract);
state.cycle_wind_direction();
assert_eq!(state.wind_direction, WindDirection::North);
}
#[test]
fn test_runtime_state_undo_redo() {
let mut state = RuntimeState::new(
42,
InitMode::Random,
Preset::Organic,
MouseInteractionMode::Disabled,
3.0,
&SimConfig::default(),
PauseStyle::Vignette,
false,
false,
);
let orig_angle = state.sensor_angle;
state.force_checkpoint();
state.adjust_sensor_angle(10.0);
assert_ne!(state.sensor_angle, orig_angle);
state.undo();
assert_eq!(state.sensor_angle, orig_angle);
state.redo();
assert_ne!(state.sensor_angle, orig_angle);
}
#[test]
fn test_palette_shift_speed_degrees() {
assert_eq!(PaletteShiftSpeed::Off.degrees_per_second(), 0.0);
assert_eq!(PaletteShiftSpeed::Slow.degrees_per_second(), 5.0);
assert_eq!(PaletteShiftSpeed::Medium.degrees_per_second(), 15.0);
assert_eq!(PaletteShiftSpeed::Fast.degrees_per_second(), 45.0);
}
#[test]
fn test_motion_blur_cycling() {
let mut state = create_test_runtime_state();
assert_eq!(state.motion_blur_frames, 0);
state.cycle_motion_blur();
assert_eq!(state.motion_blur_frames, 3);
state.cycle_motion_blur();
assert_eq!(state.motion_blur_frames, 5);
state.cycle_motion_blur();
assert_eq!(state.motion_blur_frames, 7);
state.cycle_motion_blur();
assert_eq!(state.motion_blur_frames, 0);
}
#[test]
fn test_randomize_params_updates() {
let mut state = create_test_runtime_state();
state.wind_direction = WindDirection::North;
use crate::simulation::config::TerrainType;
state.terrain_type = TerrainType::None;
state.palette_index = 0;
state.randomize_params();
assert_eq!(state.wind_direction, WindDirection::North);
}
#[test]
fn test_chrome_base_minimal_pause_expands() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Minimal;
state.on_pause();
assert_eq!(state.chrome_state, ChromeState::Expanded);
}
#[test]
fn test_chrome_base_minimal_unpause_collapses() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Expanded;
state.is_paused = false;
state.on_unpause();
assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn test_chrome_base_minimal_modal_open_stays_collapsed() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Minimal;
state.on_modal_open();
assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn test_chrome_base_expanded_modal_open_is_modal_pane() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Expanded;
state.on_modal_open();
assert_eq!(state.chrome_state, ChromeState::ModalPane);
}
#[test]
fn test_chrome_pause_with_overlay_open_stays_collapsed() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Minimal;
state
.overlay_state
.open(crate::overlay::OverlayType::KeyboardHints);
state.on_pause();
assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn test_chrome_base_minimal_modal_close_no_pause() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::ModalPane;
state.is_paused = false;
state.on_modal_close();
assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn test_chrome_base_minimal_modal_close_while_paused() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::ModalPane;
state.is_paused = true;
state.on_modal_close();
assert_eq!(state.chrome_state, ChromeState::Expanded);
}
#[test]
fn test_chrome_base_expanded_stays_expanded_across_pause() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Expanded;
state.chrome_state = ChromeState::Expanded;
state.on_pause();
assert_eq!(state.chrome_state, ChromeState::Expanded);
state.on_unpause();
assert_eq!(state.chrome_state, ChromeState::Expanded);
}
#[test]
fn test_chrome_unpause_enters_fading_state() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Expanded;
state.is_paused = true;
state.on_unpause_with_fade();
assert!(matches!(state.chrome_state, ChromeState::FadingOut(_)));
}
#[test]
fn test_chrome_fade_snap_back_on_pause() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::FadingOut(0.5);
state.on_pause(); assert_eq!(state.chrome_state, ChromeState::Expanded);
}
#[test]
fn test_chrome_fade_advance_to_zero() {
let mut state = create_test_runtime_state();
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::FadingOut(0.1);
state.advance_fade(0.2); assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn test_cycle_chrome_style_reaches_expanded_then_fullscreen() {
use crate::simulation::config::ChromeStyle;
let mut state = create_test_runtime_state();
state.chrome_style = ChromeStyle::Minimal;
state.base_chrome_state = ChromeState::Minimal;
state.chrome_state = ChromeState::Minimal;
state.cycle_chrome_style();
assert_eq!(state.chrome_style, ChromeStyle::Expanded);
assert_eq!(state.base_chrome_state, ChromeState::Expanded);
assert_eq!(state.chrome_state, ChromeState::Expanded);
state.cycle_chrome_style();
assert_eq!(state.chrome_style, ChromeStyle::Fullscreen);
assert_eq!(state.base_chrome_state, ChromeState::Minimal);
state.cycle_chrome_style();
assert_eq!(state.chrome_style, ChromeStyle::Minimal);
assert_eq!(state.base_chrome_state, ChromeState::Minimal);
assert_eq!(state.chrome_state, ChromeState::Minimal);
}
#[test]
fn color_aa_default_is_strong_for_braille_only() {
use crate::render::antialiasing::AaStrength;
use crate::render::charset::Charset;
let rs = create_test_runtime_state();
assert_eq!(rs.color_aa[3], AaStrength::Strong);
for (i, c) in ALL_CHARSETS.iter().enumerate() {
if *c != Charset::Braille {
assert_eq!(rs.color_aa[i], AaStrength::Off);
}
}
}
#[test]
fn cycle_color_aa_is_per_charset_and_skips_ineligible() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.charset_index = 4;
assert_eq!(rs.current_color_aa(), AaStrength::Off);
assert!(rs.cycle_color_aa());
assert_eq!(rs.current_color_aa(), AaStrength::Subtle);
assert_eq!(rs.color_aa[3], AaStrength::Strong);
rs.charset_index = 1;
assert!(!rs.cycle_color_aa());
assert_eq!(rs.current_color_aa(), AaStrength::Off);
}
#[test]
fn color_aa_round_trips_undo() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.charset_index = 4; rs.cycle_color_aa(); let snap = rs.capture_parameter_state();
rs.cycle_color_aa(); rs.apply_parameter_state(snap);
assert_eq!(rs.color_aa[4], AaStrength::Subtle);
}
#[test]
fn apply_cli_color_aa_sets_current_charset_aa() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.charset_index = 4;
rs.apply_cli_color_aa(AaStrength::Subtle);
assert_eq!(rs.color_aa[4], AaStrength::Subtle);
}
#[test]
fn test_wind_direction_values() {
assert!(WindDirection::None.to_wind().is_none());
assert!(WindDirection::North.to_wind().is_some());
assert!(WindDirection::Northeast.to_wind().is_some());
assert!(WindDirection::East.to_wind().is_some());
assert!(WindDirection::Southeast.to_wind().is_some());
assert!(WindDirection::South.to_wind().is_some());
assert!(WindDirection::Southwest.to_wind().is_some());
assert!(WindDirection::West.to_wind().is_some());
assert!(WindDirection::Northwest.to_wind().is_some());
let north = WindDirection::North.to_wind().unwrap();
assert_eq!(north.dx, 0.0);
assert_eq!(north.dy, -1.0);
}
#[test]
fn reset_transient_does_not_touch_color_aa_slots() {
use crate::render::antialiasing::AaStrength;
let mut rs = create_test_runtime_state();
rs.charset_index = 0;
let braille_default = crate::config_defaults::DEFAULT_COLOR_AA[3];
let non_default = if braille_default == AaStrength::Strong {
AaStrength::Off
} else {
AaStrength::Strong
};
rs.color_aa[3] = non_default;
assert_eq!(rs.color_aa[3], non_default);
rs.reset_transient();
assert_eq!(
rs.color_aa[3], non_default,
"reset_transient must not restore color_aa; the seam does that"
);
}
#[test]
fn runtime_state_defaults_active_source_to_startup_cli() {
let rs = create_test_runtime_state();
assert_eq!(rs.active_source, crate::profile::ProfileSource::StartupCli);
}
#[test]
fn reset_reproduces_active_overrides() {
use crate::profile_overrides::ProfileOverrides;
use crate::simulation::config::Preset;
let mut rs = create_test_runtime_state();
let bare_ov = ProfileOverrides {
preset: Some(Preset::Organic),
..Default::default()
};
rs.active_overrides = bare_ov.clone();
rs.auto_normalize = true;
rs.motion_blur_frames = 5;
rs.fast_mode_enabled = true;
rs.reset_transient();
assert!(
!rs.auto_normalize,
"reset_transient must clear auto_normalize"
);
assert_eq!(
rs.motion_blur_frames, 0,
"reset_transient must clear motion_blur_frames"
);
assert!(
!rs.fast_mode_enabled,
"reset_transient must clear fast_mode_enabled"
);
assert_eq!(
rs.active_overrides, bare_ov,
"reset_transient must not modify active_overrides"
);
}
fn clean_session(ov: crate::profile_overrides::ProfileOverrides) -> (RuntimeState, SimConfig) {
let profile = ov.resolve().expect("active_overrides must resolve");
let sim_config = profile.sim.clone();
let mut rs = create_test_runtime_state();
rs.decay_gamma = sim_config.decay_gamma;
rs.diffuse_weight = sim_config.diffuse_weight;
rs.deposit_curve = sim_config.deposit_curve;
rs.deposit_scale = sim_config.deposit_scale;
rs.deposit_gamma = sim_config.deposit_gamma;
rs.deposit_cap = sim_config.deposit_cap;
let r = &profile.render;
rs.live_palette = r.palette.clone();
rs.live_charset = r.charset.clone();
rs.charset_index = ALL_CHARSETS
.iter()
.position(|c| *c == r.charset)
.unwrap_or(0);
rs.color_aa = crate::config_defaults::DEFAULT_COLOR_AA;
rs.color_aa[rs.charset_index] = r.color_aa;
rs.palette_shift_speed = palette_shift_speed_of(r.hue_shift);
rs.intensity_mapping = r.intensity_mapping.clone();
rs.palette_cycle = r.palette_cycle;
rs.glyph = r.glyph;
rs.temporal_color = r.temporal_color;
rs.temporal_lag_frames = r.temporal_lag_frames;
rs.temporal_mode = r.temporal_mode;
rs.temporal_accent = r.temporal_accent;
rs.afterglow = r.afterglow;
rs.afterglow_rate = r.afterglow_rate;
rs.auto_normalize = r.auto_normalize;
rs.app = profile.app.clone();
rs.wind = sim_config.wind;
rs.active_overrides = ov;
(rs, sim_config)
}
#[test]
fn clean_preset_swap_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
preset: Some(Preset::Mold),
..Default::default()
};
let (rs, sim) = clean_session(ov);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a freshly-applied bare preset must not read dirty (projection incomplete?)"
);
}
#[test]
fn clean_constellation_swap_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
preset: Some(Preset::Constellation),
..Default::default()
};
let (rs, sim) = clean_session(ov);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a freshly-applied Constellation preset must not read dirty"
);
}
#[test]
fn clean_color_aa_scalar_session_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
use crate::render::antialiasing::AaStrength;
let ov = ProfileOverrides {
color_aa: Some(AaStrength::Subtle),
..Default::default()
};
let (rs, sim) = clean_session(ov);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a clean --color-aa subtle session must not read dirty"
);
}
#[test]
fn mutated_is_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
preset: Some(Preset::Mold),
..Default::default()
};
let (rs, mut sim) = clean_session(ov);
sim.sensor_angle += 7.0;
assert!(
rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a bumped sensor_angle must read dirty"
);
}
#[test]
fn flag_edit_is_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
preset: Some(Preset::Mold),
..Default::default()
};
let (mut rs, sim) = clean_session(ov);
rs.reverse_palette = !rs.reverse_palette;
assert!(
rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"toggling reverse_palette must read dirty (apply-only flag)"
);
}
#[test]
fn shift_speed_edit_is_dirty_but_intra_bucket_is_not() {
use crate::profile_overrides::project;
use crate::profile_overrides::ProfileOverrides;
let off = ProfileOverrides {
hue_shift: Some(0.0),
..Default::default()
};
let medium = ProfileOverrides {
hue_shift: Some(20.0),
..Default::default()
};
assert_ne!(
project(&off).unwrap(),
project(&medium).unwrap(),
"Off vs Medium hue_shift must project unequal (cross-bucket)"
);
let medium_a = ProfileOverrides {
hue_shift: Some(16.0),
..Default::default()
};
let medium_b = ProfileOverrides {
hue_shift: Some(20.0),
..Default::default()
};
assert_eq!(
project(&medium_a).unwrap(),
project(&medium_b).unwrap(),
"two hue_shifts in the same bucket must project equal (raw value excluded)"
);
}
#[test]
fn startup_cli_with_overrides_unedited_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
use clap::Parser;
let args = crate::cli::Args::parse_from(["tslime", "--sensor-angle", "33"]);
let ov = ProfileOverrides::from_args(&args).expect("from_args");
let (rs, sim) = clean_session(ov);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"an unedited --sensor-angle 33 startup session must not read dirty"
);
}
#[test]
fn pending_swap_parks_and_takes_once() {
let mut rs = create_test_runtime_state();
assert!(rs.pending_swap.is_none());
rs.pending_swap = Some(PendingSwap::Preset(Preset::Mold));
rs.overlay_state.open(OverlayType::DirtyGuard);
assert!(rs.overlay_state.is_open(OverlayType::DirtyGuard));
assert_eq!(
rs.pending_swap.take(),
Some(PendingSwap::Preset(Preset::Mold))
);
assert!(
rs.pending_swap.is_none(),
"take() must clear the parked swap"
);
}
#[test]
fn pending_swap_cancel_clears_without_swapping() {
let mut rs = create_test_runtime_state();
rs.pending_swap = Some(PendingSwap::Config(Box::new(
crate::config_manager::NamedProfile {
name: "demo".to_string(),
description: None,
overrides: Default::default(),
},
)));
rs.overlay_state.open(OverlayType::DirtyGuard);
rs.overlay_state.close();
rs.pending_swap = None;
assert!(rs.pending_swap.is_none());
assert!(!rs.overlay_state.is_open(OverlayType::DirtyGuard));
}
#[test]
fn startup_cli_with_overrides_dirty_after_edit() {
use crate::profile_overrides::ProfileOverrides;
use clap::Parser;
let args = crate::cli::Args::parse_from(["tslime", "--sensor-angle", "33"]);
let ov = ProfileOverrides::from_args(&args).expect("from_args");
let (rs, mut sim) = clean_session(ov);
sim.sensor_angle = 40.0;
assert!(
rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"editing sensor_angle away from the startup value must read dirty"
);
}
#[test]
fn clean_preset_swap_with_obstacles_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
preset: Some(Preset::PetriDish),
..Default::default()
};
let (rs, sim) = clean_session(ov);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a freshly-applied PetriDish preset (with obstacles) must not read dirty"
);
}
#[test]
fn clean_preset_swap_with_trail_modulation_is_not_dirty() {
use crate::profile_overrides::ProfileOverrides;
let ov_slime = ProfileOverrides {
preset: Some(Preset::Slime),
..Default::default()
};
let (rs, sim) = clean_session(ov_slime);
assert!(
!rs.is_dirty(&sim, rs.live_palette.clone(), rs.live_charset.clone()),
"a freshly-applied Slime preset (trail_modulation: Some) must not read dirty"
);
let ov_dt = ProfileOverrides {
preset: Some(Preset::DynamicTendrils),
..Default::default()
};
let (rs2, sim2) = clean_session(ov_dt);
assert!(
!rs2.is_dirty(&sim2, rs2.live_palette.clone(), rs2.live_charset.clone()),
"a freshly-applied DynamicTendrils preset (trail_modulation: Some) must not read dirty"
);
}
#[test]
fn controls_actions_exist() {
let _ = [
ControlAction::ToggleControlsDepth,
ControlAction::ControlsFocusNext,
ControlAction::ControlsFocusPrev,
ControlAction::ControlsAdjustFocused(1.0),
ControlAction::ControlsActivateFocused,
];
}
#[test]
fn default_values_from_config_resolves_overrides() {
use crate::config_manager::NamedProfile;
use crate::profile_overrides::ProfileOverrides;
let ov = ProfileOverrides {
sensor_angle: Some(33.0),
..Default::default()
};
let profile = NamedProfile {
name: "t".to_string(),
description: None,
overrides: ov,
};
let dv = DefaultValues::from_config(&profile);
assert!((dv.sensor_angle - 33.0).abs() < 0.01);
}
#[test]
fn toggle_comparison_sets_target_and_toggles_off() {
let mut rs = create_test_runtime_state();
rs.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
assert!(rs.overlay_state.is_open(OverlayType::PresetComparison));
assert_eq!(
rs.comparison_target,
ComparisonTarget::Preset(Preset::Network)
);
rs.toggle_comparison(ComparisonTarget::Preset(Preset::Network));
assert!(!rs.overlay_state.is_open(OverlayType::PresetComparison));
}
}
#[cfg(test)]
mod phase_tests {
use super::*;
#[test]
fn advance_phase_accumulates_dt() {
let mut rs = RuntimeState::new(
42,
InitMode::Random,
Preset::Network,
MouseInteractionMode::Disabled,
0.0,
&crate::simulation::config::SimConfig::default(),
crate::cli::PauseStyle::Vignette,
false,
false,
);
let before = rs.phase_clock;
rs.advance_phase(0.5);
assert!((rs.phase_clock - (before + 0.5)).abs() < 1e-6);
}
}