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

commit08804710ffa78838ad8155475f1de5b91c483d16
parent469a60dad1
authorLucas Galante <[email protected]>
date2026-07-21 12:24
feat: playbar pane — animation transport strip (PLAYBAR_IDX)

Play/pause, scrubable timeline with recessed track + accent playhead,
and frame readout (1-240 @ 24fps), docked full-width below the panes and
sized into the shared body_h the other panes now derive from. Toggled
via View > Show Playbar Pane (menu + Main-node toggle, persisted).
Paints through the modern paint() path: the render walk special-cases
PLAYBAR_IDX to paint_self and append_frame_text skips it so the text
isn't doubled.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs     |  65 +++++++++++++---
 src/main.rs    |   1 +
 src/playbar.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs |   3 +
 src/render.rs  |  13 +++-
 src/window.rs  |   5 ++
 6 files changed, 314 insertions(+), 12 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 6ef7888..76b8b75 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -36,6 +36,7 @@ use wayland_client::{
 
 use cce_ui::widget::{Adapted, Breadcrumb, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Label, Dropdown};
 use cce_ui::widget::UiContext;
+use crate::playbar::Playbar;
 use crate::viewport_3d::Viewport3D;
 use cce_ui::colors;
 use glam::{Mat4, Vec3};
@@ -86,8 +87,9 @@ pub const NODE_PALETTE_IDX: usize = 12;
 pub const SPREADSHEET_IDX: usize = 13;
 pub const SPREADSHEET_MENUBAR_IDX: usize = 14;
 pub const NETWORK_PANEL_IDX: usize = 15;
+pub const PLAYBAR_IDX: usize = 16;
 
-pub const WIDGET_COUNT: usize = 16;
+pub const WIDGET_COUNT: usize = 17;
 
 /// The roster, concretely typed (Phase 6bb): every slot's type is statically known — the
 /// old `Vec<Box<dyn WidgetHost>>` erased that and pinned `WidgetHost`'s full surface through the
@@ -112,6 +114,7 @@ pub struct WidgetSlots {
     pub spreadsheet: Adapted<Spreadsheet>,
     pub spreadsheet_menubar: Adapted<MenuBar>,
     pub network_panel: Adapted<PassivePlate>,
+    pub playbar: Adapted<Playbar>,
 }
 
 impl WidgetSlots {
@@ -137,6 +140,7 @@ impl WidgetSlots {
             SPREADSHEET_IDX => self.spreadsheet.draggable(),
             SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.draggable(),
             NETWORK_PANEL_IDX => self.network_panel.draggable(),
+            PLAYBAR_IDX => self.playbar.draggable(),
             _ => panic!("widget slot index out of range: {idx}"),
         }
     }
@@ -159,6 +163,7 @@ impl WidgetSlots {
             SPREADSHEET_IDX => self.spreadsheet.is_dragging(),
             SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.is_dragging(),
             NETWORK_PANEL_IDX => self.network_panel.is_dragging(),
+            PLAYBAR_IDX => self.playbar.is_dragging(),
             _ => panic!("widget slot index out of range: {idx}"),
         }
     }
@@ -181,6 +186,7 @@ impl WidgetSlots {
             SPREADSHEET_IDX => &self.spreadsheet,
             SPREADSHEET_MENUBAR_IDX => &self.spreadsheet_menubar,
             NETWORK_PANEL_IDX => &self.network_panel,
+            PLAYBAR_IDX => &self.playbar,
             _ => panic!("widget slot index out of range: {idx}"),
         }
     }
@@ -203,13 +209,14 @@ impl WidgetSlots {
             SPREADSHEET_IDX => &mut self.spreadsheet,
             SPREADSHEET_MENUBAR_IDX => &mut self.spreadsheet_menubar,
             NETWORK_PANEL_IDX => &mut self.network_panel,
+            PLAYBAR_IDX => &mut self.playbar,
             _ => panic!("widget slot index out of range: {idx}"),
         }
     }
 
     /// Per-slot dyn view in index order (the serialize path's input).
     pub fn dyn_refs(&self) -> [&dyn WidgetHost; WIDGET_COUNT] {
-        [&self.header, &self.content, &self.splitter1, &self.viewport, &self.splitter2, &self.param, &self.canvas, &self.left_menubar, &self.right_menubar, &self.param_menubar, &self.status, &self.breadcrumb, &self.node_palette, &self.spreadsheet, &self.spreadsheet_menubar, &self.network_panel]
+        [&self.header, &self.content, &self.splitter1, &self.viewport, &self.splitter2, &self.param, &self.canvas, &self.left_menubar, &self.right_menubar, &self.param_menubar, &self.status, &self.breadcrumb, &self.node_palette, &self.spreadsheet, &self.spreadsheet_menubar, &self.network_panel, &self.playbar]
     }
 }
 
@@ -222,6 +229,7 @@ pub const SPLITTER_W: f32 = 6.0;
 
 pub const MIN_COLUMN: f32 = 120.0;
 pub const BREADCRUMB_H: f32 = 24.0;
+pub const PLAYBAR_H: f32 = 36.0;
 
 #[derive(Clone, Deserialize, Serialize)]
 pub struct ParamDef {
@@ -972,6 +980,7 @@ pub struct State {
     pub show_viewport: bool,
     pub show_parameters: bool,
     pub show_spreadsheet: bool,
+    pub show_playbar: bool,
     pub is_scrolling_trackpad: bool,
     pub last_scroll_time: Instant,
     pub scroll_accum_x: f32,
@@ -1437,7 +1446,7 @@ impl State {
                 let updated_params = self.param().node_params();
                 // Live pane state, so a pane toggle only fires the visibility
                 // action when it actually flips relative to what's on screen.
-                let cur_show = (self.show_network, self.show_viewport, self.show_parameters, self.show_spreadsheet);
+                let cur_show = (self.show_network, self.show_viewport, self.show_parameters, self.show_spreadsheet, self.show_playbar);
                 let dir = self.current_dir_mut();
                 if let Some(child) = dir.children.get_mut(slot_idx) {
                     let mut param_changed = false;
@@ -1465,6 +1474,7 @@ impl State {
                                         "Show Viewport Pane" => Some(cur_show.1),
                                         "Show Parameters Pane" => Some(cur_show.2),
                                         "Show Spreadsheet Pane" => Some(cur_show.3),
+                                        "Show Playbar Pane" => Some(cur_show.4),
                                         _ => None,
                                     };
                                     // execute_menu_action flips the pane, so only
@@ -1687,6 +1697,17 @@ impl State {
                 self.apply_layout();
                 self.sync_pane_focus();
             }
+            "Show Playbar Pane" => {
+                self.show_playbar = !self.show_playbar;
+                self.slots.playbar.set_visible(self.show_playbar);
+                let val = self.show_playbar;
+                self.menu_mut(HEADER_IDX).set_item_checked(2, 8, val);
+                // Not in the focus-cycle roster (get_next_visible_pane): the
+                // playbar is a transport strip with no menubar of its own.
+                self.rebuild_positions();
+                self.apply_layout();
+                self.sync_pane_focus();
+            }
             "Close Pane" => {
                 let mut parent_name = "";
                 if self.current_path.len() >= 1 {
@@ -1785,11 +1806,12 @@ impl State {
     /// switches show the real value even after panes/settings were changed
     /// through the menus or keyboard while another node was selected.
     fn refresh_main_node_live_toggles(&mut self, slot_idx: usize) {
-        let live: [(&str, bool); 11] = [
+        let live: [(&str, bool); 12] = [
             ("Show Network Pane", self.show_network),
             ("Show Viewport Pane", self.show_viewport),
             ("Show Parameters Pane", self.show_parameters),
             ("Show Spreadsheet Pane", self.show_spreadsheet),
+            ("Show Playbar Pane", self.show_playbar),
             ("Circular Pane", self.circular_network_pane),
             ("Square Aspect", self.square_viewport),
             ("Show Grid Guide", self.viewport().show_grid),
@@ -2511,7 +2533,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             "Main Menu".to_string(),
         ];
         let mut slots = Box::new(WidgetSlots {
-            header: MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Designer").with_label("Main Menu Bar").with_item("File", &["New Project", "Save", "Save As", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom", "Detach Circular Window", "Show Network Pane", "Show Viewport Pane", "Show Parameters Pane", "Show Spreadsheet Pane"]).with_item("Help", &["About"]).with_z_index(110).with_context_options(context_opts.clone(), 4),
+            header: MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Designer").with_label("Main Menu Bar").with_item("File", &["New Project", "Save", "Save As", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom", "Detach Circular Window", "Show Network Pane", "Show Viewport Pane", "Show Parameters Pane", "Show Spreadsheet Pane", "Show Playbar Pane"]).with_item("Help", &["About"]).with_z_index(110).with_context_options(context_opts.clone(), 4),
             content: Graph::new(),
             splitter1: Splitter::new(SPLITTER_W),
             viewport: Viewport3D::new(),
@@ -2531,6 +2553,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 mb
             },
             network_panel: PassivePlate::new([0.10, 0.10, 0.13, 0.95], false),
+            playbar: {
+                let mut pb = Playbar::new();
+                pb.set_visible(false);
+                pb
+            },
         });
 
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
@@ -2646,6 +2673,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             show_viewport: true,
             show_parameters: true,
             show_spreadsheet: false,
+            show_playbar: false,
             is_scrolling_trackpad: false,
             last_scroll_time: Instant::now(),
             scroll_accum_x: 0.0,
@@ -3000,9 +3028,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.positions[PARAM_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
             self.positions[STATUS_IDX] = (0.0, 0.0, 0.0, 0.0);
             self.positions[NODE_PALETTE_IDX] = (0.0, 0.0, self.width, self.height);
+            self.positions[PLAYBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
 
             self.slots.header.set_visible(false);
             self.slots.status.set_visible(false);
+            self.slots.playbar.set_visible(false);
             self.slots.content.set_visible(true);
             self.slots.network_panel.set_visible(true);
             self.slots.left_menubar.set_visible(false);
@@ -3017,6 +3047,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.slots.spreadsheet_menubar.set_visible(false);
         } else if self.detached_circular_network {
             // Parent process: Network pane is detached (hidden from main window)
+            let pb_h = if self.show_playbar { PLAYBAR_H } else { 0.0 };
+            let body_h = body_h - pb_h;
             let left_visible = false;
             let viewport_visible = self.show_viewport;
             let spreadsheet_visible = self.show_spreadsheet;
@@ -3091,6 +3123,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.positions[PARAM_MENUBAR_IDX] = (col_r_x, HEADER_H, col_r_w, if right_visible { MENUBAR_H } else { 0.0 });
             self.positions[STATUS_IDX] = (0.0, self.height - STATUS_H, self.width, STATUS_H);
             self.positions[NODE_PALETTE_IDX] = (0.0, 0.0, self.width, self.height);
+            self.positions[PLAYBAR_IDX] = (0.0, HEADER_H + body_h, self.width, pb_h);
 
             self.slots.header.set_visible(true);
             self.slots.status.set_visible(false);
@@ -3106,11 +3139,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.slots.param_menubar.set_visible(right_visible);
             self.slots.spreadsheet.set_visible(spreadsheet_visible);
             self.slots.spreadsheet_menubar.set_visible(spreadsheet_visible);
+            self.slots.playbar.set_visible(self.show_playbar);
         } else {
             let paginator_w = 0.0;
             let body_h = self.height - STATUS_H;
             self.positions[0] = (0.0, 0.0, 0.0, 0.0);
             if self.circular_network_pane {
+                let pb_h = if self.show_playbar { PLAYBAR_H } else { 0.0 };
+                let body_h = body_h - pb_h;
                 let left_visible = false;
                 let viewport_visible = self.show_viewport;
                 let spreadsheet_visible = self.show_spreadsheet;
@@ -3160,7 +3196,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 }
 
                 let gap = 18.0;
-                let max_r = ((self.width - paginator_w - 2.0 * gap).min(self.height - STATUS_H - 2.0 * gap) / 2.0).max(50.0);
+                let max_r = ((self.width - paginator_w - 2.0 * gap).min(body_h - 2.0 * gap) / 2.0).max(50.0);
                 self.circular_network_layout.r = self.circular_network_layout.r.clamp(50.0, max_r);
 
                 let r = self.circular_network_layout.r;
@@ -3169,7 +3205,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.circular_network_layout.x = self.circular_network_layout.x.clamp(min_x, max_x);
 
                 let min_y = gap + r;
-                let max_y = (self.height - STATUS_H - gap - r).max(min_y);
+                let max_y = (body_h - gap - r).max(min_y);
                 self.circular_network_layout.y = self.circular_network_layout.y.clamp(min_y, max_y);
 
                 let cx = self.circular_network_layout.x;
@@ -3201,6 +3237,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.positions[PARAM_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
                 self.positions[STATUS_IDX] = (0.0, self.height - STATUS_H, self.width, STATUS_H);
                 self.positions[NODE_PALETTE_IDX] = (0.0, 0.0, self.width, self.height);
+                self.positions[PLAYBAR_IDX] = (0.0, body_h, self.width, pb_h);
 
                 self.slots.header.set_visible(false);
                 self.slots.status.set_visible(false);
@@ -3216,25 +3253,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.slots.param_menubar.set_visible(false);
                 self.slots.spreadsheet.set_visible(spreadsheet_visible);
                 self.slots.spreadsheet_menubar.set_visible(false);
+                self.slots.playbar.set_visible(self.show_playbar);
             } else {
                 let gap = 18.0_f32;
+                let pb_off = if self.show_playbar { PLAYBAR_H + gap } else { 0.0 };
                 let (mut _fx, mut _fy, mut fw, mut _fh) = self.floating_network_layout;
                 fw = fw.clamp(150.0, (self.width - paginator_w - 2.0 * gap).max(150.0));
                 let fx = paginator_w + gap;
                 let fy = gap;
-                let fh = (self.height - STATUS_H - 2.0 * gap).max(100.0);
+                let fh = (self.height - STATUS_H - pb_off - 2.0 * gap).max(100.0);
                 self.floating_network_layout = (fx, fy, fw, fh);
 
                 let param_w = self.floating_param_width.clamp(150.0, (self.width - paginator_w - 2.0 * gap).max(150.0));
                 self.floating_param_width = param_w;
                 let param_x = self.width - gap - param_w;
                 let param_y = gap;
-                let param_h = (self.height - STATUS_H - 2.0 * gap).max(100.0);
+                let param_h = (self.height - STATUS_H - pb_off - 2.0 * gap).max(100.0);
 
                 let ss_x = if self.show_network { fx + fw + gap } else { paginator_w + gap };
                 let ss_w_end = if self.show_parameters { param_x - gap } else { self.width - gap };
                 let ss_w = (ss_w_end - ss_x).max(150.0);
-                let ss_y_end = self.height - STATUS_H - gap;
+                let ss_y_end = self.height - STATUS_H - pb_off - gap;
                 let ss_h = self.floating_spreadsheet_height.clamp(100.0, (ss_y_end - gap).max(100.0));
                 let ss_y = ss_y_end - ss_h;
                 self.floating_spreadsheet_height = ss_h;
@@ -3292,9 +3331,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.positions[PARAM_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
                 self.positions[STATUS_IDX] = (0.0, self.height - STATUS_H, self.width, STATUS_H);
                 self.positions[NODE_PALETTE_IDX] = (0.0, 0.0, self.width, self.height);
+                self.positions[PLAYBAR_IDX] = if self.show_playbar {
+                    (gap, self.height - STATUS_H - gap - PLAYBAR_H, self.width - 2.0 * gap, PLAYBAR_H)
+                } else {
+                    (0.0, 0.0, 0.0, 0.0)
+                };
 
                 self.slots.header.set_visible(false);
                 self.slots.status.set_visible(false);
+                self.slots.playbar.set_visible(self.show_playbar);
                 self.slots.content.set_visible(self.show_network);
                 self.slots.network_panel.set_visible(self.show_network);
                 self.slots.left_menubar.set_visible(false);
diff --git a/src/main.rs b/src/main.rs
index 81a26a1..43e012c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,6 +5,7 @@ pub mod application;
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
 use app::{CustomEvent, McpAction, ModifiersState};
+pub mod playbar;
 pub mod viewport_3d;
 pub mod api;
 pub mod window;
diff --git a/src/playbar.rs b/src/playbar.rs
new file mode 100644
index 0000000..ae9a445
--- /dev/null
+++ b/src/playbar.rs
@@ -0,0 +1,239 @@
+//! The playbar pane: a full-width animation-transport strip docked below the
+//! other panes (`PLAYBAR_IDX`). App-owned on the narrow traits wrapped in
+//! `Adapted<Playbar>`, like `Viewport3D`. Unlike the other panes it paints
+//! through the modern `paint()` path — the designer's render walk special-cases
+//! `PLAYBAR_IDX` to `paint_self` (geometry AND text) instead of the legacy
+//! flat views, and skips it in `append_frame_text` so the text isn't doubled.
+
+use cce_ui::colors;
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, PaintCtx};
+use cce_ui::widget::*;
+
+#[derive(Debug, Clone)]
+pub struct Playbar {
+    pub playing: bool,
+    pub current_frame: f32,
+    pub start_frame: f32,
+    pub end_frame: f32,
+    pub fps: f32,
+    dragging: bool,
+}
+
+const PAD: f32 = 8.0;
+/// Width of the play/pause button box (square-ish, clamped to pane height).
+const BTN_W: f32 = 28.0;
+/// Width reserved right of the track for the frame readout.
+const READOUT_W: f32 = 110.0;
+
+impl Playbar {
+    pub fn new() -> Adapted<Playbar> {
+        Adapted::new(Self {
+            playing: false,
+            current_frame: 1.0,
+            start_frame: 1.0,
+            end_frame: 240.0,
+            fps: 24.0,
+            dragging: false,
+        })
+    }
+
+    fn button_rect(&self, rect: Rect) -> Rect {
+        let s = (rect.height - 2.0 * PAD).max(12.0).min(BTN_W);
+        Rect { x: rect.x + PAD, y: rect.y + (rect.height - s) * 0.5, width: s, height: s }
+    }
+
+    fn track_rect(&self, rect: Rect) -> Rect {
+        let b = self.button_rect(rect);
+        let x = b.x + b.width + PAD;
+        let h = (rect.height - 2.0 * PAD).max(8.0).min(16.0);
+        Rect {
+            x,
+            y: rect.y + (rect.height - h) * 0.5,
+            width: (rect.x + rect.width - READOUT_W - PAD - x).max(20.0),
+            height: h,
+        }
+    }
+
+    /// The playhead's normalized position over the frame range.
+    fn t(&self) -> f32 {
+        let range = self.end_frame - self.start_frame;
+        if range <= 0.0 {
+            0.0
+        } else {
+            ((self.current_frame - self.start_frame) / range).clamp(0.0, 1.0)
+        }
+    }
+
+    fn scrub_to(&mut self, px: f32, track: Rect) {
+        let t = ((px - track.x) / track.width.max(1.0)).clamp(0.0, 1.0);
+        self.current_frame = (self.start_frame + t * (self.end_frame - self.start_frame)).round();
+    }
+}
+
+impl Layout for Playbar {}
+
+impl Paint for Playbar {
+    /// Subtree painter: `paint` authors the complete pane — geometry and text —
+    /// so its Text prims pass through `paint_self` verbatim instead of the
+    /// single-font own-labels re-derivation.
+    fn paints_own_subtree(&self) -> bool {
+        true
+    }
+
+    /// The pane IS its own plate, exactly the ParametersBg contract: PARAM_BG
+    /// scaled by the global plate opacity, alpha negated as the blur marker.
+    fn color(&self) -> [f32; 4] {
+        let mut c = colors::PARAM_BG;
+        c[3] *= cce_ui::layout::plate_opacity();
+        if colors::plate_blur() {
+            c[3] = -c[3].abs();
+        }
+        c
+    }
+
+    fn solid_border(&self) -> Option<([f32; 4], f32)> {
+        colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
+    }
+
+    fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+        let r = cce_ui::layout::plate_corner_radius();
+        if r > 0.0 {
+            Some((r, (true, true, true, true)))
+        } else {
+            None
+        }
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        if rect.width <= 0.0 || rect.height <= 0.0 {
+            return;
+        }
+        let relief = cce_ui::layout::control_relief();
+        let accent = colors::highlight_primary_color();
+
+        // Play/pause button: raised plate under the DE relief styling (the
+        // Button transparent-fill degradation — edges only, the pane plate is
+        // the face), flat outline otherwise.
+        let b = self.button_rect(rect);
+        let br = cce_ui::layout::button_corner_radius().min(b.width * 0.5);
+        if relief {
+            let depth = cce_ui::layout::bevel_width().min(b.height * 0.2);
+            ctx.boss(b, (br, br, br, br), depth);
+        } else {
+            ctx.border(b, (br, br, br, br), [0.0; 4], [0.35, 0.35, 0.42, 0.9], 1.0);
+        }
+        let icon = [0.85, 0.86, 0.90, 0.95];
+        if self.playing {
+            // Pause: two bars.
+            let bw = b.width * 0.16;
+            let bh = b.height * 0.44;
+            let by = b.y + (b.height - bh) * 0.5;
+            ctx.quad(Rect { x: b.x + b.width * 0.32 - bw * 0.5, y: by, width: bw, height: bh }, icon);
+            ctx.quad(Rect { x: b.x + b.width * 0.68 - bw * 0.5, y: by, width: bw, height: bh }, icon);
+        } else {
+            // Play: triangle outline (no filled-triangle prim; the DE's line
+            // aesthetic reads fine here).
+            let (cx, cy) = (b.x + b.width * 0.54, b.y + b.height * 0.5);
+            let r = b.width * 0.24;
+            let (x0, y0) = (cx - r * 0.6, cy - r);
+            let (x1, y1) = (cx - r * 0.6, cy + r);
+            let (x2, y2) = (cx + r, cy);
+            ctx.vector(x0, y0, x1, y1, 1.5, icon, Cap::Round);
+            ctx.vector(x1, y1, x2, y2, 1.5, icon, Cap::Round);
+            ctx.vector(x2, y2, x0, y0, 1.5, icon, Cap::Round);
+        }
+
+        // Timeline track: the Slider recessed-track look — under control_relief
+        // the carve alone defines the channel and the fill sits on its floor.
+        let track = self.track_rect(rect);
+        let tr = cce_ui::layout::slider_corner_radius().min(track.height * 0.5);
+        let recess_t = cce_ui::layout::bevel_width().min(track.height * 0.2);
+        if !relief {
+            ctx.rounded_rect(track, tr, (true, true, true, true), colors::slider_track());
+        }
+        if let Some(fill) = colors::slider_fill() {
+            let (fx, fy, fmax_w, fh) = if relief {
+                let inset = recess_t * 0.5;
+                (track.x + inset, track.y + inset, track.width - 2.0 * inset, track.height - 2.0 * inset)
+            } else {
+                (track.x, track.y, track.width, track.height)
+            };
+            let fill_w = (self.t() * fmax_w).max(0.0);
+            if fill_w > 0.5 {
+                ctx.rounded_rect(
+                    Rect { x: fx, y: fy, width: fill_w, height: fh },
+                    tr.min(fh / 2.0),
+                    (true, true, true, true),
+                    fill,
+                );
+            }
+        }
+        if relief {
+            ctx.recess(track, (tr, tr, tr, tr), recess_t);
+        }
+
+        // Playhead: a full-height line over the track, in the DE accent.
+        let px = track.x + self.t() * track.width;
+        ctx.quad(Rect { x: px - 1.0, y: track.y - 3.0, width: 2.0, height: track.height + 6.0 }, accent);
+
+        // Frame readout.
+        let text = format!("{:>4} / {}", self.current_frame.round() as i64, self.end_frame.round() as i64);
+        let font_size = 12.0;
+        let tx = rect.x + rect.width - READOUT_W;
+        let ty = cce_ui::layout::align_text_y(rect.y, rect.height, font_size, 0.0);
+        ctx.text(text, tx, ty, font_size, [0xcc, 0xcc, 0xd4]);
+    }
+}
+
+impl Input for Playbar {
+    fn is_dragging(&self) -> bool {
+        self.dragging
+    }
+
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        let rect = ectx.rect;
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state, x, y, .. } => match state {
+                ElementState::Pressed => {
+                    let b = self.button_rect(rect);
+                    if *x >= b.x && *x <= b.x + b.width && *y >= b.y && *y <= b.y + b.height {
+                        self.playing = !self.playing;
+                        return true;
+                    }
+                    let t = self.track_rect(rect);
+                    // A generous vertical band around the slim track.
+                    if *x >= t.x && *x <= t.x + t.width && *y >= rect.y && *y <= rect.y + rect.height {
+                        self.dragging = true;
+                        self.scrub_to(*x, t);
+                        return true;
+                    }
+                    false
+                }
+                ElementState::Released => std::mem::take(&mut self.dragging),
+            },
+            Event::PointerMove { x, .. } => {
+                if self.dragging {
+                    let t = self.track_rect(rect);
+                    self.scrub_to(*x, t);
+                    true
+                } else {
+                    false
+                }
+            }
+            _ => false,
+        }
+    }
+
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        if !self.playing {
+            return false;
+        }
+        self.current_frame += dt * self.fps;
+        if self.current_frame > self.end_frame {
+            let range = (self.end_frame - self.start_frame).max(1.0);
+            self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
+        }
+        true
+    }
+}
diff --git a/src/project.rs b/src/project.rs
index 09c5595..76e499f 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -268,6 +268,7 @@ impl State {
         let show_viewport = self.show_viewport;
         let show_parameters = self.show_parameters;
         let show_spreadsheet = self.show_spreadsheet;
+        let show_playbar = self.show_playbar;
         let bool_str = |b: bool| if b { "true" } else { "false" };
 
         let camera_nodes: Vec<String> = self.current_dir().children.iter()
@@ -368,6 +369,7 @@ impl State {
         ensure_param(main_node, "Show Viewport Pane", "toggle", bool_str(show_viewport), &[], None, None, None);
         ensure_param(main_node, "Show Parameters Pane", "toggle", bool_str(show_parameters), &[], None, None, None);
         ensure_param(main_node, "Show Spreadsheet Pane", "toggle", bool_str(show_spreadsheet), &[], None, None, None);
+        ensure_param(main_node, "Show Playbar Pane", "toggle", bool_str(show_playbar), &[], None, None, None);
 
         // Network Settings
         ensure_param(main_node, "Network Settings", "section", "", &[], None, None, None);
@@ -421,6 +423,7 @@ impl State {
                 "Show Viewport Pane" => set_toggle(p, show_viewport),
                 "Show Parameters Pane" => set_toggle(p, show_parameters),
                 "Show Spreadsheet Pane" => set_toggle(p, show_spreadsheet),
+                "Show Playbar Pane" => set_toggle(p, show_playbar),
                 _ => {}
             }
         }
diff --git a/src/render.rs b/src/render.rs
index 261974b..1b72b2c 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -7,7 +7,7 @@ use crate::app::{
     CONTENT_IDX, VIEWPORT_IDX, PARAM_IDX,
     BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
     SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
-    LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX,
+    LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
 };
 use crate::geometry::network_sphere_vertices_with_errors;
 use cce_ui::scene::layout::Rect;
@@ -174,6 +174,13 @@ impl State {
             let r = self.circular_network_layout.r;
             pc.circle(cx, cy, r, w.color());
             pc.arc(cx, cy, r, 3.0, 0.0, TAU, [0.35, 0.65, 0.95, 0.80 * self.network_opacity]);
+        } else if idx == PLAYBAR_IDX {
+            // Modern-paint pane: the plate from the legacy views like the other
+            // panes, then paint_self emits the transport controls — geometry AND
+            // text (a subtree painter; append_frame_text skips this slot so the
+            // text isn't doubled).
+            append_widget_plate(w, pc);
+            w.paint_self(&self.ui_context, pc);
         } else if idx == CONTENT_IDX {
             if !self.circular_network_pane {
                 append_widget_plate(w, pc);
@@ -429,7 +436,9 @@ impl State {
                 continue;
             }
             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
-            if is_menubar {
+            // The playbar's text is already in the geometry pass (subtree
+            // painter via paint_self — see paint_widget's PLAYBAR_IDX branch).
+            if is_menubar || i == PLAYBAR_IDX {
                 continue;
             }
             let is_node = i == CONTENT_IDX;
diff --git a/src/window.rs b/src/window.rs
index c2b592a..cc0e96d 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -271,6 +271,11 @@ impl State {
                             state.sync_nodes();
                             changed = true;
                         }
+                        8 => { // Show Playbar Pane
+                            state.execute_menu_action("Show Playbar Pane");
+                            state.sync_nodes();
+                            changed = true;
+                        }
                         _ => {}
                     }
                 }