graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(playbar): show the cached and the stale frames of the simulations
A strip along the foot of the track: the accent over the frames every
simnet holds, amber over those held from the chain as it was before an
edit (the frames a mixed solve went on across, or a whole solve whose
simnet was edited since it ran).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 34 +++++++++++++++++++-
src/app.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/geometry.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++--------
src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++
src/playbar.rs | 49 +++++++++++++++++++++++++++++
5 files changed, 337 insertions(+), 15 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e45798f..f06d20c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2354,7 +2354,8 @@ being frames of the solve as it was. So:
- the frame in hand stands as it is, and the edit shows from the next
frame forward, the solve going on from the state in hand;
-- such a solve is MIXED (`SimSolve::mixed`): its earlier frames are of
+- such a solve is MIXED (`SimSolve::edited_at`, the frame in hand at the
+ edit): its earlier frames are of
the chain as it was. A scrub BACK from it has no checkpoint to resume
from and does not keep the frame it leaves (which a scrub back from a
clean solve does), so it re-solves from the seed with the edit in from
@@ -3339,6 +3340,37 @@ end and the start half a frame each, and with the cap would have stepped
over the start. With Repeat off, landing on the far end stops there.
`playback_plays_every_frame_however_late_the_tick` is the test.
+### The playbar shows what is cached, and what is stale (since 2026-10-06)
+
+A strip along the foot of the playbar's track says what the simulations
+hold: the accent where frames are CACHED, amber (the bypass tint) where
+they are STALE, nothing where they are not held. `SimCache::solved` reads
+each simnet's solve onto the timeline (`SolvedRange`): every frame from
+its start up to the furthest one in hand or kept as a checkpoint is
+cached, since the latest state and the checkpoints put any of them within
+an interval's steps; the frames up to an edit the solve went on across
+(`SimSolve::edited_at` — see "An edit is in from the next frame") are
+stale. `app::playbar_cache_runs` combines the simnets frame by frame —
+a frame is held when every simnet in the tree holds it (at or before its
+start it holds its seed), stale when any holds it stale — and runs it
+together; `State::sync_playbar_cache` hands the runs to
+`Playbar::cache` from the tick, worked out again only when the cache's
+`revision`, the geometry version or the frame range moves. A solve whose
+simnet has been edited since it ran is stale WHOLE: the simnet's
+`chain_hash` (its subtree, the half of the solve key that needs no
+evaluation) differs from the solve's, which is the case of a simnet
+nothing on screen reads, so the edit is in the tree and not yet solved.
+An upstream edit to an unsolved simnet's SEED is not seen that way (the
+seed needs an evaluation); the disk cache is not shown. A solve of a
+simnet deleted since counts for nothing.
+
+So: play to 60, cyan 1–60; edit the chain, amber 2–60 (the seed frame is
+the seed); play on to 100, cyan 61–100 after it; scrub back, and the
+mixed solve begins again from the seed — cyan up to where it stands.
+`the_playbar_cache_runs_say_what_is_cached_and_what_is_stale` is the
+rule, `the_playbar_shows_the_cached_and_the_stale_frames` a simnet
+played, edited and scrubbed. Checked in a shadow session.
+
### The playbar's right-click menu
A right press on the playbar's plate (`over_playbar`) opens the sixth
diff --git a/src/app.rs b/src/app.rs
index 5ee0378..d6efd03 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1252,6 +1252,54 @@ pub fn strip_meta_children(root: &mut FsNode) {
/// lowercased, as Houdini names its nodes (`sphere1`, `camera1`), since a
/// path convention with exceptions is two conventions. Empty comes back as
/// `node`, since a node with no name has no path at all.
+/// The playbar's cache strip, frame by frame over `start..=end` and run
+/// together: a frame is CACHED when every simnet in the tree has it in hand
+/// (at or before its start it shows its seed, which every simnet has), STALE
+/// when every one has it and one of them has it from the chain as it was —
+/// before an edit it went on across (`stale_to`), or before an edit not yet
+/// solved (`chains` differing from the solve's) — and in no run otherwise.
+/// A solve of a simnet not in `chains` (deleted since) does not count, and
+/// with none that does there is nothing to show.
+pub fn playbar_cache_runs(
+ solved: &[crate::geometry::SolvedRange],
+ chains: &std::collections::HashMap<String, u64>,
+ start: i32,
+ end: i32,
+) -> Vec<crate::playbar::CacheRun> {
+ use crate::playbar::{CacheRun, CacheState};
+ let sims: Vec<(&crate::geometry::SolvedRange, bool)> =
+ solved.iter().filter_map(|r| chains.get(&r.id).map(|c| (r, *c != r.chain))).collect();
+ let mut runs: Vec<CacheRun> = Vec::new();
+ if sims.is_empty() {
+ return runs;
+ }
+ for f in start..=end {
+ let mut held = Some(CacheState::Cached);
+ for (r, edited) in &sims {
+ let this = if f <= r.start {
+ Some(CacheState::Cached)
+ } else if f > r.reach {
+ None
+ } else if *edited || r.stale_to.is_some_and(|t| f <= t) {
+ Some(CacheState::Stale)
+ } else {
+ Some(CacheState::Cached)
+ };
+ held = match (held, this) {
+ (None, _) | (_, None) => None,
+ (Some(CacheState::Stale), _) | (_, Some(CacheState::Stale)) => Some(CacheState::Stale),
+ _ => Some(CacheState::Cached),
+ };
+ }
+ let Some(state) = held else { continue };
+ match runs.last_mut() {
+ Some(last) if last.state == state && last.to == f - 1 => last.to = f,
+ _ => runs.push(CacheRun { from: f, to: f, state }),
+ }
+ }
+ runs
+}
+
pub fn sanitize_node_name(name: &str) -> String {
let lowered = name.to_lowercase();
let trimmed = lowered.trim();
@@ -3006,6 +3054,11 @@ pub struct State {
/// Solved simulation states, kept across frames so playing forward costs one
/// step per frame instead of re-solving from the start frame every redraw.
pub sim_cache: crate::geometry::SimCache,
+ /// What the playbar's cache strip was last worked out from: the sim
+ /// cache's revision, the geometry version (an edit moves it, and may
+ /// make what is cached stale without solving anything) and the frame
+ /// range. See [`State::sync_playbar_cache`].
+ pub playbar_cache_key: Option<(u64, u64, i32, i32)>,
/// The GPU image of the page the viewport shows. Owned here, so
/// replacing a page frees the one it replaces.
pub page_image: Option<u32>,
@@ -3844,6 +3897,44 @@ impl State {
self.slots.playbar.inner().start_frame.round() as i32
}
+ /// Hand the playbar what the simulations hold ([`playbar_cache_runs`]),
+ /// worked out again only when the cache, the geometry or the frame
+ /// range has moved. Only simnets still in the tree count, and a
+ /// solve whose simnet has been edited since it ran is stale whole —
+ /// the edit is in the tree but not solved, as for a simnet nothing on
+ /// screen reads. Returns whether the strip changed.
+ pub fn sync_playbar_cache(&mut self) -> bool {
+ let (start, end) = {
+ let pb = self.slots.playbar.inner();
+ (pb.start_frame.round() as i32, pb.end_frame.round() as i32)
+ };
+ let key = (self.sim_cache.revision(), self.rt_geometry_version, start, end);
+ if self.playbar_cache_key == Some(key) {
+ return false;
+ }
+ self.playbar_cache_key = Some(key);
+ let solved = self.sim_cache.solved();
+ let mut chains = std::collections::HashMap::new();
+ if !solved.is_empty() {
+ fn walk(n: &FsNode, solved: &[crate::geometry::SolvedRange], out: &mut std::collections::HashMap<String, u64>) {
+ if n.node_type.eq_ignore_ascii_case("simnet") && solved.iter().any(|r| r.id == n.id) {
+ out.insert(n.id.clone(), crate::geometry::chain_hash(n));
+ }
+ for c in &n.children {
+ walk(c, solved, out);
+ }
+ }
+ walk(&self.fs_root, &solved, &mut chains);
+ }
+ let runs = playbar_cache_runs(&solved, &chains, start, end);
+ let pb = self.slots.playbar.inner_mut();
+ if pb.cache == runs {
+ return false;
+ }
+ pb.cache = runs;
+ true
+ }
+
pub fn get_col_geometries(&self) -> (f32, f32, f32, f32, f32, f32) {
let left_visible = self.show_network && !self.circular_network_pane && !self.is_detached_network && !self.detached_circular_network;
let center_visible = self.show_viewport || self.show_spreadsheet;
@@ -8559,6 +8650,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
network_menu_active: false,
network_menu_actions: Vec::new(),
sim_cache: crate::geometry::SimCache::default(),
+ playbar_cache_key: None,
page_image: None,
page_shown: None,
page_composed: None,
@@ -12858,7 +12950,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.read_panel_offsets();
}
- tick_changed || panned || reclaimed || glow_animating || plate_animating || frame_moved || config_changed || light_moved
+ let cache_moved = self.sync_playbar_cache();
+
+ tick_changed || panned || reclaimed || glow_animating || plate_animating || frame_moved || config_changed || light_moved || cache_moved
}
/// Flush CPU-staged mesh updates to the renderer's persistent meshes.
diff --git a/src/geometry.rs b/src/geometry.rs
index 853c5ad..6728625 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1117,11 +1117,18 @@ struct SimSolve {
/// from the nearest one behind it instead of the seed. See
/// [`Checkpoint`].
checkpoints: Checkpoints,
- /// Whether the solve went on across an edit (a key change) from the
- /// frame then in hand, so its earlier frames are of the chain as it
- /// was: a scrub back does not keep the frame it leaves, and the solve
- /// begins again from the seed.
- mixed: bool,
+ /// Where the solve went on across an edit (a key change): the frame
+ /// then in hand, so it and the frames before it are of the chain as it
+ /// was — STALE, which the playbar shows. `None` for a solve of one key
+ /// throughout. A scrub back from a mixed solve does not keep the frame
+ /// it leaves, and the solve begins again from the seed.
+ edited_at: Option<i32>,
+ /// The timeline frame the solve shows its seed at (its Start Frame),
+ /// so what it holds can be put on the timeline.
+ start: i32,
+ /// [`chain_hash`] of the simnet as solved: what the app compares the
+ /// tree with to see that an edit has not been solved yet.
+ chain: u64,
}
/// A frame of a solve, kept in memory: its state and what its last substep
@@ -1217,6 +1224,9 @@ impl Checkpoints {
#[derive(Default)]
pub struct SimCache {
entries: std::collections::HashMap<String, SimSolve>,
+ /// Moved by every solve put back, so a reader of [`SimCache::solved`]
+ /// can tell when to read again.
+ revision: u64,
/// Runs of a chain since this cache was made — every substep of every
/// simnet. What a solve COST, for the tests that a scrub resumes and
/// does not re-solve.
@@ -1226,6 +1236,7 @@ pub struct SimCache {
impl SimCache {
pub fn clear(&mut self) {
self.entries.clear();
+ self.revision += 1;
}
/// See [`SimCache::steps_run`].
@@ -1233,6 +1244,32 @@ impl SimCache {
self.steps_run
}
+ /// See [`SimCache::revision`].
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ /// What each simnet's solve holds, on the timeline: every frame from
+ /// its start up to the furthest one in hand or kept as a checkpoint is
+ /// cached — the latest state and the checkpoints between them put any
+ /// of those frames within an interval's steps — and the frames up to
+ /// an edit taken in mid-solve are stale.
+ pub fn solved(&self) -> Vec<SolvedRange> {
+ self.entries
+ .iter()
+ .map(|(id, e)| {
+ let reach = e.checkpoints.kept.last().map_or(e.frame, |c| c.frame.max(e.frame));
+ SolvedRange {
+ id: id.clone(),
+ start: e.start,
+ reach: e.start + reach,
+ stale_to: e.edited_at.map(|f| e.start + f),
+ chain: e.chain,
+ }
+ })
+ .collect()
+ }
+
/// The frames simnet `id` has checkpoints at, ascending — relative to
/// its start frame, as the solve counts them.
pub fn checkpoint_frames(&self, id: &str) -> Vec<i32> {
@@ -1240,6 +1277,22 @@ impl SimCache {
}
}
+/// One simnet's solve as [`SimCache::solved`] reads it, in timeline frames.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SolvedRange {
+ /// The simnet's node id.
+ pub id: String,
+ /// The frame it shows its seed at.
+ pub start: i32,
+ /// The furthest frame cached.
+ pub reach: i32,
+ /// The last stale frame: the solve went on across an edit there, so
+ /// the frames after `start` up to this one are of the chain as it was.
+ pub stale_to: Option<i32>,
+ /// [`chain_hash`] of the simnet as solved.
+ pub chain: u64,
+}
+
/// The simulation half of an evaluation: which frame the graph is being evaluated
/// at, the solve cache, and the feedback stack that makes iteration possible.
///
@@ -1304,11 +1357,14 @@ impl<'a> EvalSim<'a> {
/// node in the chain restarts the sim) and the seed geometry (so an upstream change
/// does too).
fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
+ sim_solve_key_of(chain_hash(simnet), seed)
+}
+
+/// [`sim_solve_key`] from a [`chain_hash`] already in hand.
+fn sim_solve_key_of(chain: u64, seed: &Detail) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
- if let Ok(json) = serde_json::to_string(simnet) {
- json.hash(&mut h);
- }
+ chain.hash(&mut h);
seed.num_points().hash(&mut h);
for p in seed.positions() {
for c in p {
@@ -1318,6 +1374,18 @@ fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
h.finish()
}
+/// Hash of a simnet's own subtree, the half of [`sim_solve_key`] that needs
+/// no evaluation: a solve whose chain differs from the tree's is of a chain
+/// that has been edited since, and is stale until it is solved again.
+pub fn chain_hash(simnet: &FsNode) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ if let Ok(json) = serde_json::to_string(simnet) {
+ json.hash(&mut h);
+ }
+ h.finish()
+}
+
/// Evaluate with neither error reporting nor a persistent sim cache. Any simnet
/// reached this way solves at frame 0 — that is, shows its seed — because there
/// is no timeline in scope to say otherwise.
@@ -7211,7 +7279,8 @@ pub fn resolve_simnet_geometry_with_errors(
.unwrap_or_default()
};
- let key = sim_solve_key(target, &seed);
+ let chain = chain_hash(target);
+ let key = sim_solve_key_of(chain, &seed);
// The frame this sim shows its seed at. Empty means "follow the timeline",
// which is what every sim did before this parameter existed; a number
// decouples when a simulation starts from when the shot does, so two sims
@@ -7242,14 +7311,14 @@ pub fn resolve_simnet_geometry_with_errors(
// after it is not resumed from by a scrub BACK: the frame it
// leaves is not kept, so what was solved before the edit is
// gone and the solve begins again from the seed.
- if e.mixed && e.frame > due { None } else { Some(e) }
+ if e.edited_at.is_some() && e.frame > due { None } else { Some(e) }
} else if e.frame > 0 && e.frame <= due {
- Some(SimSolve { key, frame: e.frame, state: e.state, prev: e.prev, checkpoints: Checkpoints::default(), mixed: true })
+ Some(SimSolve { key, frame: e.frame, state: e.state, prev: e.prev, checkpoints: Checkpoints::default(), edited_at: Some(e.frame), start: e.start, chain: e.chain })
} else {
None
}
});
- let mixed = prior.as_ref().is_some_and(|e| e.mixed);
+ let edited_at = prior.as_ref().and_then(|e| e.edited_at);
let mut checkpoints = Checkpoints::default();
let mut cached: Option<(Detail, Detail, i32)> = None;
if let Some(prior) = prior {
@@ -7358,8 +7427,9 @@ pub fn resolve_simnet_geometry_with_errors(
}
sim.cache.entries.insert(
target.id.clone(),
- SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, mixed },
+ SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, edited_at, start: start_frame, chain },
);
+ sim.cache.revision += 1;
Some(state)
}
diff --git a/src/main.rs b/src/main.rs
index 0f10b3e..006627b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18019,6 +18019,83 @@ mod tests {
}
}
+ /// The playbar's cache strip, as a rule: a frame is cached when every
+ /// simnet in the tree holds it, stale when one of them holds it from
+ /// the chain as it was — before an edit the solve went on across, or
+ /// anywhere in a solve whose simnet was edited since — and in no run
+ /// otherwise. A solve of a simnet no longer in the tree counts for
+ /// nothing.
+ #[test]
+ fn the_playbar_cache_runs_say_what_is_cached_and_what_is_stale() {
+ use crate::geometry::SolvedRange;
+ use crate::playbar::{CacheRun, CacheState::*};
+ let run = |from, to, state| CacheRun { from, to, state };
+ let solve = |id: &str, start, reach, stale_to| SolvedRange { id: id.into(), start, reach, stale_to, chain: 7 };
+ let chains = |ids: &[&str]| ids.iter().map(|i| (i.to_string(), 7u64)).collect::<std::collections::HashMap<_, _>>();
+ let runs = crate::app::playbar_cache_runs;
+
+ assert_eq!(runs(&[solve("a", 1, 20, None)], &chains(&["a"]), 1, 100), vec![run(1, 20, Cached)]);
+ assert_eq!(runs(&[solve("a", 1, 30, Some(20))], &chains(&["a"]), 1, 100), vec![run(1, 1, Cached), run(2, 20, Stale), run(21, 30, Cached)]);
+ // Edited since it was solved: stale up to where it reached; the seed is the seed.
+ let mut edited = chains(&["a"]);
+ edited.insert("a".into(), 8);
+ assert_eq!(runs(&[solve("a", 1, 30, None)], &edited, 1, 100), vec![run(1, 1, Cached), run(2, 30, Stale)]);
+ // Two simnets: a frame is held when both hold it, and before a
+ // simnet's start it holds its seed.
+ assert_eq!(
+ runs(&[solve("a", 1, 30, None), solve("b", 10, 20, Some(15))], &chains(&["a", "b"]), 1, 100),
+ vec![run(1, 10, Cached), run(11, 15, Stale), run(16, 20, Cached)]
+ );
+ // Deleted: not in the tree, nothing to show; and the range clips.
+ assert!(runs(&[solve("gone", 1, 30, None)], &chains(&["a"]), 1, 100).is_empty());
+ assert_eq!(runs(&[solve("a", 1, 30, None)], &chains(&["a"]), 5, 12), vec![run(5, 12, Cached)]);
+ }
+
+ /// The playbar shows what a simnet's solve holds as it is played, edited
+ /// and scrubbed: the frames played are cached; an edit at frame 20 makes
+ /// 2–20 stale (the solve goes on from the frame in hand); playing on
+ /// caches the frames after it; and a scrub back clears the stale frames,
+ /// the solve beginning again from the seed.
+ #[test]
+ fn the_playbar_shows_the_cached_and_the_stale_frames() {
+ use crate::playbar::{CacheRun, CacheState::*};
+ let run = |from, to, state| CacheRun { from, to, state };
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ let mut redraw = false;
+ state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+ let slot = state.current_dir().children.iter().position(|c| c.name == "sim").unwrap();
+ if !state.current_dir().children[slot].geometry_visible {
+ state.apply_action(McpAction::ToggleGeometry { slot }, &mut redraw).unwrap();
+ }
+ let go = |state: &mut State, frame: f32| {
+ state.slots.playbar.inner_mut().current_frame = frame;
+ state.tick_frame(1.0 / 60.0);
+ };
+ let strip = |state: &State| state.slots.playbar.inner().cache.clone();
+ for f in 1..=20 {
+ go(&mut state, f as f32);
+ }
+ assert_eq!(strip(&state), vec![run(1, 20, Cached)]);
+
+ state.apply_action(McpAction::SetParam { slot, name: "substeps".into(), value: "2".into() }, &mut redraw).unwrap();
+ state.tick_frame(1.0 / 60.0);
+ assert_eq!(strip(&state), vec![run(1, 1, Cached), run(2, 20, Stale)], "the frames solved before the edit are stale");
+
+ for f in 21..=25 {
+ go(&mut state, f as f32);
+ }
+ assert_eq!(strip(&state), vec![run(1, 1, Cached), run(2, 20, Stale), run(21, 25, Cached)]);
+
+ go(&mut state, 10.0);
+ assert_eq!(strip(&state), vec![run(1, 10, Cached)], "a scrub back solves again from the seed");
+ // A scrub back in a clean solve keeps the frame it left.
+ go(&mut state, 5.0);
+ assert_eq!(strip(&state), vec![run(1, 10, Cached)]);
+ }
+
/// Repeat off: playback stops ON the last frame instead of wrapping,
/// in either direction, and a play press on a timeline stopped at its
/// far end restarts from the near one. The setting is a palette toggle
diff --git a/src/playbar.rs b/src/playbar.rs
index e2c5e8f..e50accf 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -39,9 +39,35 @@ pub struct Playbar {
/// and bottom, and the lip is drawn over that band. Zero where the
/// playbar is laid out anywhere else.
pub frame: f32,
+ /// The frames the simulations hold, as runs: drawn as a strip along
+ /// the foot of the track, in the accent where they are cached and in
+ /// amber where they are stale. Set by the app from the sim cache
+ /// (`State::sync_playbar_cache`); empty with no simulation.
+ pub cache: Vec<CacheRun>,
dragging: bool,
}
+/// What the simulations hold of a run of frames.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CacheState {
+ /// Solved, of the chain as it is: playing or scrubbing here costs at
+ /// most a checkpoint interval's steps.
+ Cached,
+ /// Solved, but of the chain as it was before an edit.
+ Stale,
+}
+
+/// Frames `from..=to` and what is held of them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CacheRun {
+ pub from: i32,
+ pub to: i32,
+ pub state: CacheState,
+}
+
+/// The stale strip's colour: the amber a bypassed node wears.
+const STALE_COLOR: [f32; 4] = [1.0, 0.74, 0.18, 0.85];
+
const PAD: f32 = 8.0;
/// Space between two transport buttons.
const BTN_GAP: f32 = 4.0;
@@ -67,6 +93,7 @@ impl Playbar {
repeat: true,
step_buttons: true,
frame: 0.0,
+ cache: Vec::new(),
dragging: false,
})
}
@@ -234,6 +261,28 @@ impl Paint for Playbar {
&|x| cce_ui::widget::input::slider::band_profile(track.x, track.width, track.height, x, &[px], None),
);
+ // What the simulations hold: a strip along the foot of the track,
+ // under the ticks. A frame k is the span k ± 0.5, so a run of one
+ // frame is as wide as a frame and the runs meet edge to edge.
+ if !self.cache.is_empty() {
+ let range = (self.end_frame - self.start_frame).max(1.0);
+ let at = |f: f32| track.x + ((f - self.start_frame) / range * track.width).clamp(0.0, track.width);
+ let accent = colors::highlight_primary_color();
+ let h = 3.0f32.min(track.height * 0.25);
+ for run in &self.cache {
+ let x0 = at(run.from as f32 - 0.5);
+ let x1 = at(run.to as f32 + 0.5);
+ if x1 - x0 <= 0.0 {
+ continue;
+ }
+ let color = match run.state {
+ CacheState::Cached => [accent[0], accent[1], accent[2], 0.85],
+ CacheState::Stale => STALE_COLOR,
+ };
+ ctx.quad(Rect { x: x0, y: track.y + track.height - h, width: x1 - x0, height: h }, color);
+ }
+ }
+
// Tick marks: frame steps on the 1-2-5 ladder, grown until minors sit
// >=6px apart. Every 5th step is a major — taller, brighter, and
// labeled with its frame number when the pane has room below the