Skip to content

Preset/palette completion: unified config layering + custom keybinds - #58

Merged
tamirelazar merged 82 commits into
devfrom
feat/preset-palette-completion
Jun 22, 2026
Merged

Preset/palette completion: unified config layering + custom keybinds#58
tamirelazar merged 82 commits into
devfrom
feat/preset-palette-completion

Conversation

@tamirelazar

Copy link
Copy Markdown
Owner

Promotes the feat/preset-palette-completion effort to dev. The branch unifies preset/config resolution behind a single layered pipeline, hardens config save/load and live-apply, retunes presets, and adds custom key bindings.

Highlights

Config layering & resolution

  • Profile / ProfileOverrides unify preset and config resolution; startup, live preset-switch, config-load, and reset all flow through one apply seam (apply_render_config / do_swap), resolving byte-identically to the former builder path.
  • PresetSimDefaults / RenderArtDefaults / AppRuntimeConfig declare per-preset sim, render, and app levers as optional overrides (no Preset::apply()).
  • Dirty-state guard overlay blocks lossy swaps via a canonical projection; resolved configs carry through the pending swap.

Config save/load robustness

  • Round-trips per-charset color-AA, population, trail_modulation, wind, palette flags, obstacles/attractors/terrain/species.
  • A single bad presets.toml no longer poisons all config IO.

Presets

  • New Vinescii (ASCII-rendered vines); per-preset visual mods; Constellation re-rolls init mode on reset + stagnation collapse detector; Mold wall-collapse fixed via trail-dependent respawn; collapse/divergence bug fixes; default launch routes to Organic.

Frames

  • Aspect-aware frame matte spacing / containment, live chrome on resize.

Custom keybinds (final piece)

  • Quick-keys 1/2/3 → Organic/Constellation/Vinescii; 47 bindable.
  • ~/.config/tslime/keybinds.toml binds keys 17 to any preset or saved config; user binds override defaults; invalid entries skipped silently.
  • Shift+17 A/B compare works for bound presets and configs (ComparisonTarget).
  • ? overlay shows live bindings. In-app editor deferred (tracked separately).

Stats

96 files changed, ~10.3k insertions / ~4.6k deletions across 76 commits (26 feat, 20 fix, 13 refactor, 10 test, 3 chore, 2 perf, 2 docs).

Test plan

  • cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings — clean.
  • cargo test — 1009 lib + 56 visual-regression + 30 integration pass.
  • Note: temporal OKLch goldens are platform-sensitive; CI (Linux) is the source of truth.

Add 6 OKLch palettes (jade, amber, slate, pastel, ink, copper) and 12
lever-showcase presets (drift, constellation, mosaic, marble, prism,
vellum, forge, wane, gossamer, codex, tide, and the now-removed lattice).
Extend RenderArtDefaults with color_aa and hue_shift, plumbed in runner.
Add IntensityMapping::sigmoid constructor.

Apply review triage: remove 17 presets (14 originals plus showcase aurora
and bloom and the new lattice) with their goldens and tests. Surviving
set is 29 presets and 22 palettes.
Introduce const PALETTES: &[PaletteSpec] in render/palette.rs as the
single identity table for built-in palettes. Display names, CLI parsing,
the saved-config palette index, and the gradient cache seed all derive
from it.

Kills the parse_palette_index hardcoded 0-21 indices that had to mirror
ALL_PALETTES order by hand (silent corruption on reorder); the index is
now derived via PALETTES position. Collapses the duplicated 23-arm name
matches in palette.rs and terminal/input.rs into one lookup, and the
22-arm CLI parse match into a name scan.

Gradient getters stay exhaustive matches (already compiler-checked and
get_256_gradient is a per-cell hot path). Behavior-preserving: all
palette goldens unchanged. Adds drift + round-trip tests.
Introduce const PRESETS: &[PresetSpec] in simulation/config.rs as the
single identity table for presets. CLI parsing (names + aliases), the
'Must be one of' error list, display names, the live quick-select keys
(number row + shifted compare), and the validation test all derive from
it.

Collapses the duplicated 29-arm name matches in config.rs and
terminal/input.rs into Preset::name(), the 29-arm FromStr parse + its
hand-maintained error string into preset_from_name/preset_name_list, and
the 14 hardcoded quick-key arms into two catalog-driven guard arms
(preset_for_set_key / preset_for_compare_key).

Per-preset sim params (Preset::apply) and render defaults
(RenderArtDefaults) stay as separate exhaustive matches — the table
carries identity only, preserving the sim/render split. Behavior-
preserving: all preset goldens unchanged. Adds round-trip + quick-key
consistency tests.
…_weight

These two SimConfig fields were assigned unconditionally in
ConfigBuilder::assemble() from non-optional CLI args whose defaults
equal the global defaults. A preset that set decay_gamma or
diffuse_weight in apply() had its value silently overwritten unless
the user also passed the matching flag. RuntimeState::new hardcoded
both to 1.0 and runner re-clobbered diffuse_weight from raw args,
compounding the loss across the live-edit/undo paths.

Make the args Option<f32> (unset = let the preset choose), guard the
builder assignments like the neighbouring deposit_* knobs, and read
both from the assembled config in RuntimeState::new. Affected presets:
Lumen (decay_gamma 0.8), Wane (decay_gamma 0.6), Marble
(diffuse_weight 0.8). Lumen golden regenerated on Linux.

afterglow/afterglow_rate share the same defect class but route through
a separate args-direct render path; deferred to the layering refactor.
Introduce PresetSimDefaults, the sim-layer counterpart to
RenderArtDefaults: a struct of Option<T> simulation levers resolved
per Preset and layered into SimConfig by config_builder. This gives sim
levers the same 'preset suggests, CLI overrides, default fills'
precedence that render levers already have, without merging the sim and
render config structs.

Currently carries only boundary_mode. config_builder applies the preset
layer before the existing CLI --boundary-mode override, so the CLI flag
still wins. Behaviour-preserving: no preset declares a boundary mode
yet, so every preset still resolves to the global default (Bounce) and
goldens are unchanged. Per-preset declarations land in the next commit.
River piles up on wall contact with the default Bounce boundary, and
Ripple's rings need to re-enter the field; both read better wrapped.
Declare boundary-mode wrap for them via PresetSimDefaults. An explicit
--boundary-mode flag still overrides the preset.

River golden regenerated (now wraps); added a ripple visual-regression
golden to lock the new behaviour. Goldens generated on Linux.
Grow PresetSimDefaults into the full sim spec (Tasks 2+3 combined):
- Replace the Option<BoundaryMode>-only struct with a complete declarative
  struct covering all sim levers (sensor/rotation/step, decay, diffusion,
  deposit curve, boundary, wind, obstacles, species_configs).
- Add Default impl mirroring SimConfig::default() exactly so unset fields
  resolve byte-identically.
- Add apply_to(&self, &mut SimConfig) to materialize the spec.
- Port all 30 Preset arms from Preset::apply() into From<Preset>, one
  exhaustive match arm per variant using ..Self::default() fill. No _ arm
  so the compiler enforces exhaustiveness for future presets.
- afterglow lines intentionally dropped from Lumen/Etching/Drift/Forge/
  Gossamer (moves to RenderArtDefaults in a later commit).
- Patch config_builder to read boundary_mode as a plain BoundaryMode
  (not Option) from the spec; CLI --boundary-mode flag still wins.
Remove the 637-line Preset::apply() imperative block and route
From<Preset> for SimConfig through PresetSimDefaults::apply_to instead.
`resolve_render_config()` was hardcoding `AaStrength::Off` as the
`color_aa` fallback, which would overwrite Braille's per-charset
`Strong` default once Task 13 wires the resolver into startup.

Replace with `DEFAULT_COLOR_AA[charset_index]` so Braille keeps Strong
and all other charsets keep Off. Add two regression tests that verify
the per-charset behaviour: braille→Strong and halfblock→Off.

Also fix the misleading doc comment that said "else `AaStrength::Off`".
Replace scattered per-lever startup resolution (palette index,
intensity, palette_cycle, glyph, color_aa, hue→speed, afterglow)
in runner.rs with resolve_render_config() + apply_render_config().

RuntimeState drops the four initial_* render fields (palette_index,
charset_index, color_aa, intensity_mapping) in favour of
baseline_render: ResolvedRenderConfig, seeded at startup and
reapplied by the ResetToDefaults handler via apply_render_config.
reset_to_defaults() retains only sim-param and window_frame restores;
the 13 render levers are fully owned by the apply path.

52/52 visual-regression goldens unchanged (behaviour-preserving).
Adds 4 tests to the cli.rs #[cfg(test)] module that lock the
resolve_render_config / apply_render_config resolution semantics
introduced in Commit 3:

- switch_identity_to_art_on_enables_temporal_and_afterglow: Lumen
  resolves to Slime palette, temporal_color > 0, afterglow > 0.
- switch_art_on_to_identity_disables_levers: Network resolves to
  temporal_color=0 and afterglow=0; also exercises the Task-11
  set_compute_temporal toggle-clear path.
- model_b_cli_palette_persists: --palette ocean survives a post-parse
  preset mutation to Lumen (simulates a live preset-switch).
- model_b_cli_sensor_angle_persists: --sensor-angle 30 survives a
  post-parse preset mutation through ConfigBuilder.assemble().

Full-state switch→tweak→reset test (reset_to_defaults + apply_render_
config against a live Renderer) is not included: Renderer construction
requires a real terminal + crossterm backend, making it impractical in
a #[cfg(test)] context. The Task-11 toggle-clear units plus the above
resolution tests provide coverage of the observable state layers.

Verification matrix: cargo fmt --check PASS; clippy --all-features
-D warnings PASS; cargo test --lib PASS (912); preset_config_snapshot
PASS; Linux visual_regression PASS (52/52, no golden changes).
Wire Profile::resolve_from_args into run_simulation startup (replaces
the previous separate to_sim_config + resolve_render_config calls).
Add active_source: ProfileSource to RuntimeState; runner.rs sets it
to Preset(p) or StartupCli based on args.preset at startup.

Remove #[allow(dead_code)] from ProfileSource and resolve_from_args now
that they have callsites; keep allow on SavedConfig variant (Task 3).
Trail-modulated presets (Pulse, Flocking, Ripple, Vortex36, DynamicTendrils)
lost their per-species trail_modulation on save/reload because SpeciesArg — the
captured/persisted form — had no field for it, so resolve restored None.

- Derive Serialize/Deserialize on PointConfig (flat 15xf32 struct).
- Add a persistence-only Option<PointConfig> to SpeciesArg (CLI FromStr leaves
  it None; sourced from the live SpeciesConfig on capture).
- Thread it through capture_overrides and resolve's species branch.
- Regression test: capture -> TOML -> reload -> resolve preserves modulation.

The dirty-guard still normalises trail_modulation to None on both sides since it
is not runtime-editable; comment updated to reflect it is now capturable.
…onsts

The grid_color hex (0xffffff) and the skip_warmup / auto_reset / grid /
grid_adaptive bool defaults were inline literals in AppRuntimeConfig::default.
Add named consts (DEFAULT_GRID_COLOR_HEX, DEFAULT_GRID_ENABLED,
DEFAULT_GRID_ADAPTIVE, DEFAULT_SKIP_WARMUP, DEFAULT_AUTO_RESET) so every default
sources from config_defaults; assert against them in the default test.
…g swap

Two config-load-path follow-ups:

- The terminal-resize handler sized buffers against the frozen startup
  config.chrome_style. After loading a config that changed chrome and then
  resizing, the window layout used stale chrome. Read runtime_state.chrome_style
  (live) instead; the init-time closure stays on the startup value (pre-swap).

- do_swap's Config arm re-read all configs from disk to find the one already
  selected in the browser. PendingSwap::Config now carries the resolved
  NamedProfile (boxed to keep the enum small), resolved once at selection time
  and surviving the dirty guard intact, so the load no longer re-lists.
…ng seam

apply_to_runtime_state was fully dead in production (the apply_overrides /
resolve seam replaced it), but ~20 round-trip tests still exercised it as a
parallel apply path — giving false confidence in code the runner never runs.

- Repoint each round-trip test to the shipping path: capture -> (TOML) ->
  resolve, asserting on the resolved Profile { sim, render } instead of poking a
  RuntimeState. Persist-only levers (reverse/invert/food_persist) assert the
  override's unwrap_or(false) semantics; per-charset AA asserts via the real
  rs.apply_color_aa_all seam.
- Delete the 185-line function and its now-unused imports/helper block.
- Refresh stale comments that referenced it.

No production behavior change; full gate green, goldens byte-identical.
auto_normalize was runtime-only (CLI flag -> loop-local current_auto_normalize
+ AdaptiveBrightness, toggled by B). Thread it through the full render seam so a
preset can default it ON and a clean session stays non-dirty.

- RenderArtDefaults.auto_normalize: Option<bool> (None = inherit, default off)
- ResolvedRenderConfig.auto_normalize: bool
- ProfileOverrides.auto_normalize: Option<bool>; from_args emits None-on-absent
  (Some(false) would shadow a preset default); resolve_render merges
  self.or(art).unwrap_or(false)
- capture_overrides serializes the explicit live value for dirty parity
- apply_render_config writes rs.auto_normalize; DefaultValues::from_preset
  reflects the preset's art default
- runner seeds the loop-local + AdaptiveBrightness from rs, and rebuilds them
  after every swap (reset, preset, config) so a clean preset switch honors it
- all 4 headless export paths read the resolved render.auto_normalize, so
  --preset ... --export-* honors the preset
…sent

Add PresetAppDefaults (third declarative seam) so presets can opt in to
auto_reset without the flag being present on the CLI. Constellation sets
auto_reset=true. resolve_app now falls through: explicit override → preset
default → AppRuntimeConfig::default(). from_args emits None for auto_reset
when --auto-reset is absent (same None-on-absent pattern as auto_normalize).
Pulse→Slime, Flocking→Vines, Ripple→Smoke, Lumen→Mold.

Old names (pulse, flocking, ripple, lumen) continue to work as
case-insensitive CLI aliases via the PRESETS alias table.

Updated all consumer sites: enum variants, PRESETS table, match arms
in preset_sim_defaults/render_art_defaults/profile_overrides, and
all internal test references. Replaced stale --preset help string
with a generic pointer to the README. Renamed golden files
ripple_preset→smoke_preset and lumen_preset→mold_preset (content
unchanged; same simulation parameters).
Rename test functions to match the shipped preset names (Mold/Smoke):
- preset_sim_defaults: lumen_arm→mold_arm, only_river_and_ripple→only_river_and_smoke
- profile_overrides: test_only_river_and_ripple→test_only_river_and_smoke,
  test_ripple_and_river→test_smoke_and_river; inline comment Pulse/Flocking/Ripple→Slime/Vines/Smoke

Convert 5 cli.rs test cases from --preset lumen to canonical --preset mold;
update accompanying comments and assertions.

Rewrite doc comments on Preset::Slime/Vines/Smoke in config.rs and the
inline match-arm comments in preset_sim_defaults.rs to reflect the new
identities (no parameter values changed).
…ines/exploratory)

Add render-layer art arms (charset/palette/color-aa/auto-normalize) and
sim-layer window_frame/kernel/brightness tweaks for the showcase presets.
Update EXISTING_PRESETS identity invariants and Mold palette assertions to
the new truth. Fix a dirty-guard false positive: project() now builds the
canonical per-charset color_aa array from the fully-resolved render.color_aa
(scalar + preset-art + default) instead of the raw override scalar, so a
clean preset-only swap whose art layer sets color_aa (Slime) no longer reads
dirty; the clean_session test mirror now also carries the resolved
auto_normalize flag.
Identical sim to Vines; render layer applies Charset::Ascii.
Adds enum variant, PRESETS entry (alias vines-ascii), sim arm
(Vines body duplicated verbatim), render arm, snapshot token,
and identity test.
Add InitMode::random over all 9 variants and a non-mutating runner-local
resolver effective_init_mode(preset, base) that returns a fresh random mode
only for Constellation. Route every production sim.reset re-seed site through
it (collapse auto-reset, manual r/R restart, and the apply_overrides restart
seam). The baseline original_init_mode and config preferred_init_mode are left
untouched, so dirty-projection reports no permanent edit after a reset.
…/slime/vines/vinescii

README: reflect pulse→slime, flocking→vines, ripple→smoke, lumen→mold renames; add
vinescii; note aliases; update preset count to 30; point at config sources of truth.
CHANGELOG: add entries for vinescii addition, rename pass, and help-text fix.
tests/visual_regression.rs: add four new test fns (organic, slime, vines, vinescii)
mirroring the existing network/exploratory pattern; goldens to be generated separately.
… to organic

Default launch and `--preset organic` now resolve identically:
- Organic sim arm = SimConfig::default() (decay 0.5, max_brightness 100);
  the old 0.85/20 was an over-saturating blob. Render mods (Braille+Vibrant)
  stay in RenderArtDefaults.
- clap default_value="organic" on the preset arg routes a bare launch through
  the Organic preset everywhere (config, classification, telemetry); no separate
  hard-coded default config set. Args::default() struct stays None for tests.

Mold: retune to a stable branching network. Wider sensor (15->22.5) + rotation
(30->50) + boundary Bounce->Wrap; persistence (decay 0.85) preserved. The old
regime condensed the whole population onto one wall-hugging diagonal filament.

Constellation: fix inverted collapse detector. track_entropy fired on
entropy > threshold, but healthy patterns sit ~4-6 on the 0..8 brightness-value
entropy scale, so the 0.95 default was true every frame -> auto-reset presets
restarted every ~90 frames. Flipped to < threshold (collapse = low entropy);
corrected three stale 'above threshold' docs.

River: render arm Ocean palette + Braille; sim arm Accented window frame.
Vines/Vinescii: auto_normalize. River+Vines leave the EXISTING_PRESETS identity set.

capture_overrides: capture boundary_mode (was hardcoded None while window_frame
was captured), so a Wrap preset round-trips through projection instead of reading
falsely dirty in the save/undo guard.

Regenerated 41 goldens (37 from the default->organic routing, plus
mold/river/vines/vinescii preset goldens and the sim-config snapshot).
The entropy detector measured brightness-value diversity, not motion, so
coherent-but-alive Constellation patterns (rotating rings, slow blobs) read
as collapsed and triggered false restarts within ~3s.

Make --collapse-threshold an Option; unset now defers to a per-preset
default via PresetAppDefaults. Constellation defaults it to 0 (entropy is
always >= 0, so the detector never fires), leaving only the stagnation
detector. Other presets keep the global 0.95 default; explicit
--collapse-threshold still overrides on any preset.
Batched live-review work:
- Add --frame-matte-cols/--frame-matte-rows: background gap between the
  window frame border and the simulation, threaded through ProfileOverrides
  and render config.
- Trim window-frame modes to none/accented/glow/frame.
- Add a stagnation collapse detector (near-static patterns) alongside entropy.
- Retune preset boundaries/params (mold Wrap->Bounce, exploratory) and fix
  assorted preset bugs; regenerate affected goldens.
- Config save/load and runtime-state adjustments.
Rebind number-row quick-keys to the launch presets (1=Organic,
2=Constellation, 3=Vinescii) and let users bind keys 1-7 to any preset
or saved config via ~/.config/tslime/keybinds.toml.

- keybind_manager: parse + validate keybinds.toml (pool 1-7, preset or
  config target, file-level fallback to built-ins, entry-level silent
  skip with last-valid-wins, Windows USERPROFILE fallback)
- user binds override built-in defaults; resolution centralized in the
  runner; config quick-keys load through the shared dirty-guard route
- extract dispatch_swap helper, collapsing the dirty-guard-or-swap block
  from four duplicated runner sites
- input emits QuickKey/CompareQuickKey for 1-7 / Shift+1-7; unbound keys
  show a hint toast
- generalize A/B comparison to ComparisonTarget so saved configs are
  comparable (resolved values shown as the B side)
- the ? keyboard-reference overlay shows live per-key bindings
- docs: README controls + custom-keybinds section, CHANGELOG
Bounce alone accumulates a wall-parallel trail that eventually condenses
all agents onto one wall-hugging filament (collapse by ~6000f). Add a
gentle trail-dependent respawn (every 90 frames, scatter agents sitting
in high-trail back into the field, probability up to 5x where brightest)
so the runaway feedback can never close. Holds a crisp large-celled vein
network out to 40000f.
capture_overrides was hardcoding time_scale: None, meaning any
live time-scale adjustment (via +/- keys) was silently dropped
on config save and ignored on reload.

Emit Some(rs.time_scale) so the live value round-trips through
save → TOML → reload → resolve. Adds a unit test that verifies
capture produces Some(non-default) and that resolve preserves it.
When a saved config's overrides fail to resolve, the comparison
overlay was silently falling back to Organic defaults via
DefaultValues::from_config without any indication to the user.

Call profile.overrides.resolve() directly in the Config arm of
build_overlay: on Ok, build defaults from the resolved sim
config; on Err, append " (unresolved)" to the label and use
Organic defaults for the comparison columns so the dialog still
renders. Also exposes DefaultValues::from_sim_config as
pub(crate) to enable this call site.
When start_x >= self.width, the expression `self.width - start_x`
wraps on usize, producing a very large panel_width and causing
out-of-bounds index panics in the panel-background loop.

Add a start_x < self.width guard to the panel branch condition and
replace the subtraction with saturating_sub so overflow is
impossible. Adds a regression test that calls the function with
start_x equal to and beyond the buffer width, asserting no panic.
calculate_position was using saturating_sub(5) which only accounts
for the 6 main panel lines, missing the 1-line title-box drawn one
row above. The dialog appeared shifted up by 1 row.

Change to saturating_sub(7) to account for all 7 visible rows:
6 main panel lines (border + 4 content rows + border) plus the
title-box line floated above.
Respawn probability scaled on the raw, unbounded pheromone value, so
max_probability_multiplier was not a real cap (brightest cells scattered
~100% regardless of the knob). Add RespawnConfig::trail_rescale and
normalize x = (trail * trail_rescale).clamp(0,1) before the multiplier,
mirroring PointConfig. Retune Mold (base 0.0067 x mult 150 = 1.0 max
probability at saturation, rescale 0.0033) to honest, bounded params.

Headless probe (300x150, 50k agents, 40000f): edge:interior trail holds
~2.0-2.4 with full coverage; respawn-off collapses to ~7.5 by 1500f.
RespawnConfig now derives Serialize/Deserialize. ProfileOverrides gains a
respawn_config: Option<RespawnConfig> field (serialized; None from CLI).
capture_overrides captures the full struct (copy); resolve_sim applies it
before the scalar respawn_interval override so --respawn-interval still wins.
project() continues to normalize both sides to Default so the dirty guard
stays blind to a field that is not runtime-editable.

Fixes silent data loss when saving a Mold config and reloading it: previously
only the interval scalar round-tripped, so base_probability, trail_dependent,
max_probability_multiplier, and trail_rescale were silently reset to defaults
and the reloaded Mold could wall-collapse.

Test: test_respawn_config_round_trips_through_toml (confirmed fail->pass).
@tamirelazar
tamirelazar merged commit 063effe into dev Jun 22, 2026
3 checks passed
@tamirelazar
tamirelazar deleted the feat/preset-palette-completion branch June 22, 2026 21:27
tamirelazar added a commit that referenced this pull request Jun 24, 2026
Resolve conflicts between the UI-system unification and dev's
launch-quick-keys / custom-keybinds feature (#58):

- KeyboardHintsOverlay::build_overlay now takes both `st: &PanelStyle`
  (UI-system theming) and `user_binds` (dev's live key-label lookup);
  all call sites and tests updated to the 3-arg signature.
- runner.rs: keep both the UI-system controls-dispatch/ambient helpers
  and dev's resolve_bind quick-key resolver; thread panel_style + user_binds
  into the keyboard-hints build.
- overlay.rs comparison header keeps dev's dynamic column label with the
  UI-system spacing token (add_empty_n(spacing::ROW)).
- input.rs / state.rs: keep both sides' added tests.
- README: keep both the Controls-panel-navigation and Custom-Keybinds docs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant