graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/playbar.rs (18.6K)
1 //! The playbar pane: a full-width animation-transport strip along the
2 //! window's bottom edge, below the other panes (`PLAYBAR_IDX`). App-owned on the narrow traits wrapped in
3 //! `Adapted<Playbar>`, like `Viewport3D`. Unlike the other panes it paints
4 //! through the modern `paint()` path — the designer's render walk special-cases
5 //! `PLAYBAR_IDX` to `paint_self` (geometry AND text) instead of the legacy
6 //! flat views, and skips it in `append_frame_text` so the text isn't doubled.
7
8 use cce_ui::colors;
9 use cce_ui::scene::layout::Rect;
10 use cce_ui::scene::paint::PaintCtx;
11 use cce_ui::widget::*;
12
13 #[derive(Debug, Clone)]
14 pub struct Playbar {
15 pub playing: bool,
16 /// Playback direction while `playing`: reverse runs the frame counter
17 /// down and wraps start→end. Simnets re-solve from their seed on every
18 /// backward frame (steps are not invertible), exactly like scrubbing.
19 pub reversed: bool,
20 pub current_frame: f32,
21 pub start_frame: f32,
22 pub end_frame: f32,
23 pub fps: f32,
24 /// Whether playback wraps at the range's end (on, the default and the
25 /// only behaviour until 2026-09-28) or stops there. Persisted in
26 /// state.kdl (`playbar_repeat`) and flipped by `toggle_playbar_repeat`;
27 /// this field is the one copy, read by the dialog's switch and
28 /// `save_settings`.
29 pub repeat: bool,
30 /// Whether the Previous Frame / Next Frame buttons flank the play
31 /// button — the playbar menu's Step Buttons switch
32 /// (`toggle_playbar_step_buttons`), persisted in state.kdl
33 /// (`playbar_step_buttons`). On by default. Off, the play button and
34 /// the track stand where they always did.
35 pub step_buttons: bool,
36 /// The window lip the transport stands clear of, on the left, the right
37 /// and the bottom: the pane is a SHELF of the window's edge (see the
38 /// PLAYBAR_IDX render arm), its rect running out to the window's sides
39 /// and bottom, and the lip is drawn over that band. Zero where the
40 /// playbar is laid out anywhere else.
41 pub frame: f32,
42 /// The frames the simulations hold, as runs: drawn as a strip along
43 /// the foot of the track, in the accent where they are cached and in
44 /// amber where they are stale. Set by the app from the sim cache
45 /// (`State::sync_playbar_cache`); empty with no simulation.
46 pub cache: Vec<CacheRun>,
47 dragging: bool,
48 }
49
50 /// What the simulations hold of a run of frames.
51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 pub enum CacheState {
53 /// Solved, of the chain as it is: playing or scrubbing here costs at
54 /// most a checkpoint interval's steps.
55 Cached,
56 /// Solved, but of the chain as it was before an edit.
57 Stale,
58 }
59
60 /// Frames `from..=to` and what is held of them.
61 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
62 pub struct CacheRun {
63 pub from: i32,
64 pub to: i32,
65 pub state: CacheState,
66 }
67
68 /// The stale strip's colour: the amber a bypassed node wears.
69 const STALE_COLOR: [f32; 4] = [1.0, 0.74, 0.18, 0.85];
70
71 const PAD: f32 = 8.0;
72 /// Space between two transport buttons.
73 const BTN_GAP: f32 = 4.0;
74 /// Width reserved right of the track for the frame readout.
75 const READOUT_W: f32 = 110.0;
76
77 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
78 enum Btn {
79 Prev,
80 Play,
81 Next,
82 }
83
84 impl Playbar {
85 pub fn new() -> Adapted<Playbar> {
86 Adapted::new(Self {
87 playing: false,
88 reversed: false,
89 current_frame: 1.0,
90 start_frame: 1.0,
91 end_frame: 240.0,
92 fps: 24.0,
93 repeat: true,
94 step_buttons: true,
95 frame: 0.0,
96 cache: Vec::new(),
97 dragging: false,
98 })
99 }
100
101 /// Start playing in a direction. With Repeat off a timeline stopped at
102 /// its far end has nowhere to go, so the press restarts it from the
103 /// near one, the way a transport's play does after a stop-at-end; with
104 /// Repeat on the next tick wraps anyway and the frame is left alone.
105 pub fn begin(&mut self, reversed: bool) {
106 self.playing = true;
107 self.reversed = reversed;
108 if !self.repeat {
109 if !reversed && self.current_frame >= self.end_frame {
110 self.current_frame = self.start_frame;
111 } else if reversed && self.current_frame <= self.start_frame {
112 self.current_frame = self.end_frame;
113 }
114 }
115 }
116
117 /// Step one whole frame, off the ROUNDED current frame: during playback
118 /// the playhead sits between frames, and stepping from the fractional
119 /// value would land off the frame grid. The chords and the buttons
120 /// share it; neither pauses a playing timeline.
121 pub fn step(&mut self, by: f32) {
122 self.current_frame = (self.current_frame.round() + by).clamp(self.start_frame, self.end_frame);
123 }
124
125 /// The part of the pane the transport is laid out in: the pane less the
126 /// window lip on its left, right and bottom ([`Playbar::frame`]).
127 fn content(&self, rect: Rect) -> Rect {
128 let f = self.frame;
129 Rect { x: rect.x + f, y: rect.y, width: (rect.width - 2.0 * f).max(0.0), height: (rect.height - f).max(0.0) }
130 }
131
132 /// The transport's square buttons, left to right: Previous Frame, Play,
133 /// Next Frame with the step buttons on, Play alone with them off. Glyph
134 /// buttons, so square at the toolkit's button height (clamped to the
135 /// pane), centred in the strip.
136 fn button_rects(&self, rect: Rect) -> Vec<(Btn, Rect)> {
137 let s = cce_ui::layout::button_height().min(rect.height).max(12.0);
138 let y = rect.y + (rect.height - s) * 0.5;
139 let order: &[Btn] = if self.step_buttons { &[Btn::Prev, Btn::Play, Btn::Next] } else { &[Btn::Play] };
140 order
141 .iter()
142 .enumerate()
143 .map(|(i, b)| (*b, Rect { x: rect.x + PAD + i as f32 * (s + BTN_GAP), y, width: s, height: s }))
144 .collect()
145 }
146
147 fn button_at(&self, rect: Rect, x: f32, y: f32) -> Option<Btn> {
148 self.button_rects(rect)
149 .into_iter()
150 .find(|(_, b)| x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height)
151 .map(|(btn, _)| btn)
152 }
153
154 /// Where a transport button is in the pane, for tests: Previous Frame
155 /// is -1, Play 0 and Next Frame 1. `None` for a button not shown.
156 pub fn transport_button_rect(&self, rect: Rect, which: i32) -> Option<Rect> {
157 let want = match which {
158 -1 => Btn::Prev,
159 0 => Btn::Play,
160 _ => Btn::Next,
161 };
162 self.button_rects(self.content(rect)).into_iter().find(|(b, _)| *b == want).map(|(_, r)| r)
163 }
164
165 fn track_rect(&self, rect: Rect) -> Rect {
166 let b = self.button_rects(rect).last().expect("the play button").1;
167 let x = b.x + b.width + PAD;
168 let h = (rect.height - 2.0 * PAD).max(8.0).min(16.0);
169 Rect {
170 x,
171 y: rect.y + (rect.height - h) * 0.5,
172 width: (rect.x + rect.width - READOUT_W - PAD - x).max(20.0),
173 height: h,
174 }
175 }
176
177 /// The playhead's normalized position over the frame range.
178 fn t(&self) -> f32 {
179 let range = self.end_frame - self.start_frame;
180 if range <= 0.0 {
181 0.0
182 } else {
183 ((self.current_frame - self.start_frame) / range).clamp(0.0, 1.0)
184 }
185 }
186
187 fn scrub_to(&mut self, px: f32, track: Rect) {
188 let t = ((px - track.x) / track.width.max(1.0)).clamp(0.0, 1.0);
189 self.current_frame = (self.start_frame + t * (self.end_frame - self.start_frame)).round();
190 }
191 }
192
193 impl Layout for Playbar {}
194
195 impl Paint for Playbar {
196 /// Subtree painter: `paint` authors the complete pane — geometry and text —
197 /// so its Text prims pass through `paint_self` verbatim instead of the
198 /// single-font own-labels re-derivation.
199 fn paints_own_subtree(&self) -> bool {
200 true
201 }
202
203 /// The pane IS its own plate, exactly the ParametersBg contract: the
204 /// parameter plate's fill — tint, opacity, and blur-behind marker
205 /// (`param_plate_fill`) — so it tracks the configured plate tint
206 /// (`style.surface.param.color`) with the other panes, where the old
207 /// hand-rolled PARAM_BG copy froze this pane at the built-in default.
208 fn color(&self) -> [f32; 4] {
209 colors::param_plate_fill()
210 }
211
212 fn solid_border(&self) -> Option<([f32; 4], f32)> {
213 colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
214 }
215
216 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
217 let r = cce_ui::layout::plate_corner_radius();
218 if r > 0.0 {
219 Some((r, (true, true, true, true)))
220 } else {
221 None
222 }
223 }
224
225 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
226 if rect.width <= 0.0 || rect.height <= 0.0 {
227 return;
228 }
229 let rect = self.content(rect);
230 let relief = cce_ui::layout::control_relief();
231
232 // The transport buttons: raised plates under the DE relief styling
233 // (the Button transparent-fill degradation — edges only, the pane
234 // plate is the face), flat outlines otherwise.
235 let icon = [0.85, 0.86, 0.90, 0.95];
236 for (btn, b) in self.button_rects(rect) {
237 let br = cce_ui::layout::button_corner_radius().min(b.width * 0.5);
238 if relief {
239 let depth = cce_ui::layout::bevel_width().min(b.height * 0.2);
240 ctx.boss(b, (br, br, br, br), depth);
241 } else {
242 ctx.border(b, (br, br, br, br), [0.0; 4], [0.35, 0.35, 0.42, 0.9], 1.0);
243 }
244 match btn {
245 Btn::Play => self.paint_play_icon(b, icon, ctx),
246 Btn::Prev | Btn::Next => paint_step_icon(b, btn == Btn::Next, icon, ctx),
247 }
248 }
249 // Timeline track: the toolkit's band (the one slider style) — a band the
250 // width of the track with its swell at the playhead, in its own shaded
251 // well, drawn by the Slider's painter.
252 let track = self.track_rect(rect);
253 let px = track.x + self.t() * track.width;
254 let band_color = if self.dragging { colors::slider_thumb_drag() } else { colors::slider_thumb() };
255 cce_ui::widget::input::slider::paint_band_shape(
256 ctx,
257 track.x,
258 track.width,
259 track.y + track.height * 0.5,
260 band_color,
261 &|x| cce_ui::widget::input::slider::band_profile(track.x, track.width, track.height, x, &[px], None),
262 );
263
264 // What the simulations hold: a strip along the foot of the track,
265 // under the ticks. A frame k is the span k ± 0.5, so a run of one
266 // frame is as wide as a frame and the runs meet edge to edge.
267 if !self.cache.is_empty() {
268 let range = (self.end_frame - self.start_frame).max(1.0);
269 let at = |f: f32| track.x + ((f - self.start_frame) / range * track.width).clamp(0.0, track.width);
270 let accent = colors::highlight_primary_color();
271 let h = 3.0f32.min(track.height * 0.25);
272 for run in &self.cache {
273 let x0 = at(run.from as f32 - 0.5);
274 let x1 = at(run.to as f32 + 0.5);
275 if x1 - x0 <= 0.0 {
276 continue;
277 }
278 let color = match run.state {
279 CacheState::Cached => [accent[0], accent[1], accent[2], 0.85],
280 CacheState::Stale => STALE_COLOR,
281 };
282 ctx.quad(Rect { x: x0, y: track.y + track.height - h, width: x1 - x0, height: h }, color);
283 }
284 }
285
286 // Tick marks: frame steps on the 1-2-5 ladder, grown until minors sit
287 // >=6px apart. Every 5th step is a major — taller, brighter, and
288 // labeled with its frame number when the pane has room below the
289 // track and majors aren't crowded.
290 let range = (self.end_frame - self.start_frame).max(1.0);
291 let ppf = track.width / range;
292 let mut step = 1.0f32;
293 let cycle = [2.0f32, 2.5, 2.0];
294 let mut ci = 0;
295 while step * ppf < 6.0 {
296 step *= cycle[ci % 3];
297 ci += 1;
298 }
299 let major = step * 5.0;
300 let minor_col = [0.82, 0.84, 0.90, 0.30];
301 let major_col = [0.87, 0.89, 0.94, 0.55];
302 let below = rect.y + rect.height - (track.y + track.height);
303 let label_room = below >= 14.0 && major * ppf >= 34.0;
304 let mut f = (self.start_frame / step).ceil() * step;
305 while f <= self.end_frame + 0.001 {
306 let x = track.x + ((f - self.start_frame) / range) * track.width;
307 let is_major = (f / major - (f / major).round()).abs() < 1e-3;
308 if is_major {
309 ctx.quad(Rect { x: x - 0.5, y: track.y, width: 1.0, height: track.height + 3.0 }, major_col);
310 if label_room && x + 24.0 <= track.x + track.width {
311 ctx.text(format!("{}", f.round() as i64), x + 3.0, track.y + track.height + 2.0, 9.0, [0x8a, 0x8a, 0x96]);
312 }
313 } else {
314 ctx.quad(
315 Rect { x: x - 0.5, y: track.y + track.height * 0.45, width: 1.0, height: track.height * 0.55 },
316 minor_col,
317 );
318 }
319 f += step;
320 }
321
322 // No playhead line: the band's swell already stands at the current
323 // frame (until 2026-10-06 an accent line was drawn over it too).
324
325 // Frame readout.
326 let text = format!("{:>4} / {}", self.current_frame.round() as i64, self.end_frame.round() as i64);
327 let font_size = 12.0;
328 let tx = rect.x + rect.width - READOUT_W;
329 let ty = cce_ui::layout::align_text_y(rect.y, rect.height, font_size, 0.0);
330 ctx.text(text, tx, ty, font_size, [0xcc, 0xcc, 0xd4]);
331 }
332 }
333
334 impl Playbar {
335 /// The play button's glyph: `pause` while playing, else `play`.
336 fn paint_play_icon(&self, b: Rect, icon: [f32; 4], ctx: &mut PaintCtx) {
337 ctx.icon(if self.playing { "pause" } else { "play" }, glyph_rect(b), icon);
338 }
339 }
340
341 /// A step button's glyph: cce-icons' `step-back` / `step-forward`, the
342 /// transport's frame-step pair.
343 fn paint_step_icon(b: Rect, forward: bool, icon: [f32; 4], ctx: &mut PaintCtx) {
344 ctx.icon(if forward { "step-forward" } else { "step-back" }, glyph_rect(b), icon);
345 }
346
347 /// Where a transport glyph stands in its button: a square half the
348 /// button's shorter side, centred — the footprint the hand-drawn triangle
349 /// and bars had. A missing icon set draws nothing; there is no character
350 /// to fall back to that is not itself a symbol.
351 fn glyph_rect(b: Rect) -> Rect {
352 let side = (b.width.min(b.height) * 0.5).round();
353 Rect { x: (b.x + (b.width - side) * 0.5).round(), y: (b.y + (b.height - side) * 0.5).round(), width: side, height: side }
354 }
355
356 impl Input for Playbar {
357 fn is_dragging(&self) -> bool {
358 self.dragging
359 }
360
361 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
362 let rect = self.content(ectx.rect);
363 match event {
364 Event::MouseButton { button: MouseButton::Left, state, x, y, .. } => match state {
365 ElementState::Pressed => {
366 match self.button_at(rect, *x, *y) {
367 // The play button is the FORWARD transport: playing
368 // (either direction) pauses; paused starts forward.
369 // Reverse is the Down-arrow chord's domain.
370 Some(Btn::Play) => {
371 if self.playing {
372 self.playing = false;
373 } else {
374 self.begin(false);
375 }
376 return true;
377 }
378 Some(Btn::Prev) => {
379 self.step(-1.0);
380 return true;
381 }
382 Some(Btn::Next) => {
383 self.step(1.0);
384 return true;
385 }
386 None => {}
387 }
388 let t = self.track_rect(rect);
389 // A generous vertical band around the slim track.
390 if *x >= t.x && *x <= t.x + t.width && *y >= rect.y && *y <= rect.y + rect.height {
391 self.dragging = true;
392 self.scrub_to(*x, t);
393 return true;
394 }
395 false
396 }
397 ElementState::Released => std::mem::take(&mut self.dragging),
398 },
399 Event::PointerMove { x, .. } => {
400 if self.dragging {
401 let t = self.track_rect(rect);
402 self.scrub_to(*x, t);
403 true
404 } else {
405 false
406 }
407 }
408 _ => false,
409 }
410 }
411
412 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
413 if !self.playing {
414 return false;
415 }
416 let dir = if self.reversed { -1.0 } else { 1.0 };
417 let shown = self.current_frame.round();
418 let mut next = self.current_frame + dir * dt * self.fps;
419 // Every frame is played: a tick moves the shown frame by one at
420 // most, landing ON the next frame when the time since the last tick
421 // would carry it further. So the rate is the fps while the frames
422 // keep up, and slows to a frame a tick when they do not — a
423 // simulation too slow for its rate plays every step slower rather
424 // than showing some of them. Until 2026-10-06 the playhead kept to
425 // the clock and the frames between two ticks were never drawn.
426 if (next.round() - shown).abs() > 1.0 {
427 next = shown + dir;
428 }
429 if !self.repeat && (next >= self.end_frame && !self.reversed || next <= self.start_frame && self.reversed) {
430 // Repeat off: land ON the last frame and stop there, so the
431 // final state of a simulation is what stays on screen.
432 self.current_frame = if self.reversed { self.start_frame } else { self.end_frame };
433 self.playing = false;
434 } else if self.repeat {
435 // The loop is every frame of the range, the end and the start
436 // each shown for a frame (frame k is shown over k ± 0.5), so the
437 // step past the end is to the start and the one before the start
438 // is to the end, in either direction.
439 let lo = self.start_frame - 0.5;
440 let frames = (self.end_frame - self.start_frame + 1.0).max(1.0);
441 self.current_frame = lo + (next - lo).rem_euclid(frames);
442 } else {
443 self.current_frame = next.clamp(self.start_frame, self.end_frame);
444 }
445 true
446 }
447 }