graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;
+ }
_ => {}
}
}