git.lucas.co / cce-designer
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 }