git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/playbar.rs (10.9K)

  1 //! The playbar pane: a full-width animation-transport strip docked below the
  2 //! 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::{Cap, 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     dragging: bool,
 25 }
 26 
 27 const PAD: f32 = 8.0;
 28 /// Width of the play/pause button box (square-ish, clamped to pane height).
 29 const BTN_W: f32 = 28.0;
 30 /// Width reserved right of the track for the frame readout.
 31 const READOUT_W: f32 = 110.0;
 32 
 33 impl Playbar {
 34     pub fn new() -> Adapted<Playbar> {
 35         Adapted::new(Self {
 36             playing: false,
 37             reversed: false,
 38             current_frame: 1.0,
 39             start_frame: 1.0,
 40             end_frame: 240.0,
 41             fps: 24.0,
 42             dragging: false,
 43         })
 44     }
 45 
 46     fn button_rect(&self, rect: Rect) -> Rect {
 47         let s = (rect.height - 2.0 * PAD).max(12.0).min(BTN_W);
 48         Rect { x: rect.x + PAD, y: rect.y + (rect.height - s) * 0.5, width: s, height: s }
 49     }
 50 
 51     fn track_rect(&self, rect: Rect) -> Rect {
 52         let b = self.button_rect(rect);
 53         let x = b.x + b.width + PAD;
 54         let h = (rect.height - 2.0 * PAD).max(8.0).min(16.0);
 55         Rect {
 56             x,
 57             y: rect.y + (rect.height - h) * 0.5,
 58             width: (rect.x + rect.width - READOUT_W - PAD - x).max(20.0),
 59             height: h,
 60         }
 61     }
 62 
 63     /// The playhead's normalized position over the frame range.
 64     fn t(&self) -> f32 {
 65         let range = self.end_frame - self.start_frame;
 66         if range <= 0.0 {
 67             0.0
 68         } else {
 69             ((self.current_frame - self.start_frame) / range).clamp(0.0, 1.0)
 70         }
 71     }
 72 
 73     fn scrub_to(&mut self, px: f32, track: Rect) {
 74         let t = ((px - track.x) / track.width.max(1.0)).clamp(0.0, 1.0);
 75         self.current_frame = (self.start_frame + t * (self.end_frame - self.start_frame)).round();
 76     }
 77 }
 78 
 79 impl Layout for Playbar {}
 80 
 81 impl Paint for Playbar {
 82     /// Subtree painter: `paint` authors the complete pane — geometry and text —
 83     /// so its Text prims pass through `paint_self` verbatim instead of the
 84     /// single-font own-labels re-derivation.
 85     fn paints_own_subtree(&self) -> bool {
 86         true
 87     }
 88 
 89     /// The pane IS its own plate, exactly the ParametersBg contract: the
 90     /// parameter plate's fill — tint, opacity, and blur-behind marker
 91     /// (`param_plate_fill`) — so it tracks the configured plate tint
 92     /// (`style.surface.param.color`) with the other panes, where the old
 93     /// hand-rolled PARAM_BG copy froze this pane at the built-in default.
 94     fn color(&self) -> [f32; 4] {
 95         colors::param_plate_fill()
 96     }
 97 
 98     fn solid_border(&self) -> Option<([f32; 4], f32)> {
 99         colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
100     }
101 
102     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
103         let r = cce_ui::layout::plate_corner_radius();
104         if r > 0.0 {
105             Some((r, (true, true, true, true)))
106         } else {
107             None
108         }
109     }
110 
111     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
112         if rect.width <= 0.0 || rect.height <= 0.0 {
113             return;
114         }
115         let relief = cce_ui::layout::control_relief();
116         let accent = colors::highlight_primary_color();
117 
118         // Play/pause button: raised plate under the DE relief styling (the
119         // Button transparent-fill degradation — edges only, the pane plate is
120         // the face), flat outline otherwise.
121         let b = self.button_rect(rect);
122         let br = cce_ui::layout::button_corner_radius().min(b.width * 0.5);
123         if relief {
124             let depth = cce_ui::layout::bevel_width().min(b.height * 0.2);
125             ctx.boss(b, (br, br, br, br), depth);
126         } else {
127             ctx.border(b, (br, br, br, br), [0.0; 4], [0.35, 0.35, 0.42, 0.9], 1.0);
128         }
129         let icon = [0.85, 0.86, 0.90, 0.95];
130         if self.playing {
131             // Pause: two bars.
132             let bw = b.width * 0.16;
133             let bh = b.height * 0.44;
134             let by = b.y + (b.height - bh) * 0.5;
135             ctx.quad(Rect { x: b.x + b.width * 0.32 - bw * 0.5, y: by, width: bw, height: bh }, icon);
136             ctx.quad(Rect { x: b.x + b.width * 0.68 - bw * 0.5, y: by, width: bw, height: bh }, icon);
137         } else {
138             // Play: triangle outline (no filled-triangle prim; the DE's line
139             // aesthetic reads fine here).
140             let (cx, cy) = (b.x + b.width * 0.54, b.y + b.height * 0.5);
141             let r = b.width * 0.24;
142             let (x0, y0) = (cx - r * 0.6, cy - r);
143             let (x1, y1) = (cx - r * 0.6, cy + r);
144             let (x2, y2) = (cx + r, cy);
145             ctx.vector(x0, y0, x1, y1, 1.5, icon, Cap::Round);
146             ctx.vector(x1, y1, x2, y2, 1.5, icon, Cap::Round);
147             ctx.vector(x2, y2, x0, y0, 1.5, icon, Cap::Round);
148         }
149 
150         // Timeline track: the toolkit's band (the one slider style) — a band the
151         // width of the track with its swell at the playhead, in its own shaded
152         // well, drawn by the Slider's painter.
153         let track = self.track_rect(rect);
154         let px = track.x + self.t() * track.width;
155         let band_color = if self.dragging { colors::slider_thumb_drag() } else { colors::slider_thumb() };
156         cce_ui::widget::input::slider::paint_band_shape(
157             ctx,
158             track.x,
159             track.width,
160             track.y + track.height * 0.5,
161             band_color,
162             &|x| cce_ui::widget::input::slider::band_profile(track.x, track.width, track.height, x, &[px], None),
163         );
164 
165         // Tick marks: frame steps on the 1-2-5 ladder, grown until minors sit
166         // >=6px apart. Every 5th step is a major — taller, brighter, and
167         // labeled with its frame number when the pane has room below the
168         // track and majors aren't crowded.
169         let range = (self.end_frame - self.start_frame).max(1.0);
170         let ppf = track.width / range;
171         let mut step = 1.0f32;
172         let cycle = [2.0f32, 2.5, 2.0];
173         let mut ci = 0;
174         while step * ppf < 6.0 {
175             step *= cycle[ci % 3];
176             ci += 1;
177         }
178         let major = step * 5.0;
179         let minor_col = [0.82, 0.84, 0.90, 0.30];
180         let major_col = [0.87, 0.89, 0.94, 0.55];
181         let below = rect.y + rect.height - (track.y + track.height);
182         let label_room = below >= 14.0 && major * ppf >= 34.0;
183         let mut f = (self.start_frame / step).ceil() * step;
184         while f <= self.end_frame + 0.001 {
185             let x = track.x + ((f - self.start_frame) / range) * track.width;
186             let is_major = (f / major - (f / major).round()).abs() < 1e-3;
187             if is_major {
188                 ctx.quad(Rect { x: x - 0.5, y: track.y, width: 1.0, height: track.height + 3.0 }, major_col);
189                 if label_room && x + 24.0 <= track.x + track.width {
190                     ctx.text(format!("{}", f.round() as i64), x + 3.0, track.y + track.height + 2.0, 9.0, [0x8a, 0x8a, 0x96]);
191                 }
192             } else {
193                 ctx.quad(
194                     Rect { x: x - 0.5, y: track.y + track.height * 0.45, width: 1.0, height: track.height * 0.55 },
195                     minor_col,
196                 );
197             }
198             f += step;
199         }
200 
201         // Playhead: a full-height line over the swell, in the DE accent.
202         ctx.quad(Rect { x: px - 1.0, y: track.y - 3.0, width: 2.0, height: track.height + 6.0 }, accent);
203 
204         // Frame readout.
205         let text = format!("{:>4} / {}", self.current_frame.round() as i64, self.end_frame.round() as i64);
206         let font_size = 12.0;
207         let tx = rect.x + rect.width - READOUT_W;
208         let ty = cce_ui::layout::align_text_y(rect.y, rect.height, font_size, 0.0);
209         ctx.text(text, tx, ty, font_size, [0xcc, 0xcc, 0xd4]);
210     }
211 }
212 
213 impl Input for Playbar {
214     fn is_dragging(&self) -> bool {
215         self.dragging
216     }
217 
218     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
219         let rect = ectx.rect;
220         match event {
221             Event::MouseButton { button: MouseButton::Left, state, x, y, .. } => match state {
222                 ElementState::Pressed => {
223                     let b = self.button_rect(rect);
224                     if *x >= b.x && *x <= b.x + b.width && *y >= b.y && *y <= b.y + b.height {
225                         // The button is the FORWARD transport: playing (either
226                         // direction) pauses; paused starts forward. Reverse is
227                         // the Down-arrow chord's domain.
228                         self.playing = !self.playing;
229                         if self.playing {
230                             self.reversed = false;
231                         }
232                         return true;
233                     }
234                     let t = self.track_rect(rect);
235                     // A generous vertical band around the slim track.
236                     if *x >= t.x && *x <= t.x + t.width && *y >= rect.y && *y <= rect.y + rect.height {
237                         self.dragging = true;
238                         self.scrub_to(*x, t);
239                         return true;
240                     }
241                     false
242                 }
243                 ElementState::Released => std::mem::take(&mut self.dragging),
244             },
245             Event::PointerMove { x, .. } => {
246                 if self.dragging {
247                     let t = self.track_rect(rect);
248                     self.scrub_to(*x, t);
249                     true
250                 } else {
251                     false
252                 }
253             }
254             _ => false,
255         }
256     }
257 
258     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
259         if !self.playing {
260             return false;
261         }
262         let dir = if self.reversed { -1.0 } else { 1.0 };
263         self.current_frame += dir * dt * self.fps;
264         let range = (self.end_frame - self.start_frame).max(1.0);
265         if self.current_frame > self.end_frame {
266             self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
267         } else if self.current_frame < self.start_frame {
268             // The reverse wrap, mirroring the forward one: run off the start,
269             // come back in from the end.
270             self.current_frame = self.end_frame - (self.start_frame - self.current_frame) % range;
271         }
272         true
273     }
274 }