graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(playbar): attach the playbar to the window's bottom edge
The playbar spans the window's full width, flush with the bottom edge,
its bottom corners the window's. The plates above stop a gap short of
its top, and the scale readout and the viewer-state line stand on it
instead of on its transport.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 13 +++++++++++++
src/app.rs | 15 ++++++++++++---
src/main.rs | 28 ++++++++++++++++++++++++++++
src/render.rs | 18 ++++++++++++++++--
4 files changed, 69 insertions(+), 5 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6834135..7f372a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3218,6 +3218,19 @@ Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
select family this app cannot implement until the Graph widget has
multi-selection, so taking `Shift+L` now would have to be given back later.
+### The playbar is attached to the bottom edge (since 2026-10-06)
+
+The playbar is a bar along the window's bottom edge, the full width
+(`(0, height - STATUS_H - PLAYBAR_H, width, PLAYBAR_H)` in the floating
+layout), where it floated a gap in from the sides and the bottom like the
+other plates. Its bottom corners are the window's, read off the rect by
+`pane_plate_radii`; its top corners are a plate's. What stands above it —
+the docked plates (`pb_off`, now `PLAYBAR_H` without a gap of its own) and
+the params HUD — stops a gap short of its top, and the viewport's
+bottom-anchored text, the scale readout and a viewer state's line, stands
+on it (`State::scene_text_floor`) rather than on its transport.
+`the_playbar_is_attached_to_the_bottom_edge` is the test.
+
### The playbar's right-click menu
A right press on the playbar's plate (`over_playbar`) opens the sixth
diff --git a/src/app.rs b/src/app.rs
index 53835e2..305056e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4715,7 +4715,9 @@ impl State {
let ss_x = (flush_left - self.floating_spreadsheet_inset_left.max(0.0)).max(gap);
let ss_end = (flush_right + self.floating_spreadsheet_inset_right.max(0.0)).min(self.width - gap);
let ss_w = (ss_end - ss_x).max(150.0);
- let pb_off = if self.show_playbar { PLAYBAR_H + gap } else { 0.0 };
+ // The playbar is attached to the bottom edge: what stands above it
+ // stops a gap short of its top, the gap the bottom edge gives it.
+ let pb_off = if self.show_playbar { PLAYBAR_H } else { 0.0 };
let ss_y_end = self.height - STATUS_H - pb_off - gap;
let max_h = if self.spreadsheet_tucks_left() || self.spreadsheet_tucks_right() {
ss_y_end - (gap + 100.0 + gap)
@@ -9486,7 +9488,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
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 };
+ // The playbar is attached to the bottom edge (below), so the
+ // plates above stop a gap short of its top.
+ let pb_off = if self.show_playbar { PLAYBAR_H } else { 0.0 };
// The stored widths and height are what the user ASKED for;
// the clamps below fit them to this window for drawing and are
// never written back. Storing the clamp made every transient
@@ -9635,7 +9639,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
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[PLAYBAR_IDX] = if self.show_playbar {
- (gap, self.height - STATUS_H - gap - PLAYBAR_H, self.width - 2.0 * gap, PLAYBAR_H)
+ // Attached to the window's bottom edge, the full width:
+ // its bottom corners are the window's (`pane_plate_radii`
+ // reads them off the rect), and the plates above stop a
+ // gap short of its top (`pb_off`). It floated a gap in
+ // from the sides and the bottom until 2026-10-06.
+ (0.0, self.height - STATUS_H - PLAYBAR_H, self.width, PLAYBAR_H)
} else {
(0.0, 0.0, 0.0, 0.0)
};
diff --git a/src/main.rs b/src/main.rs
index 0c9ef5b..36c0c44 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2081,6 +2081,34 @@ mod tests {
assert_eq!(state.point_number_labels().len(), all.len());
}
+ /// The playbar is attached to the window's bottom edge, the full width;
+ /// the plates above stop a gap short of its top, and the viewport's
+ /// bottom-anchored text stands on it rather than on its transport.
+ #[test]
+ fn the_playbar_is_attached_to_the_bottom_edge() {
+ use crate::app::{PLAYBAR_H, STATUS_H};
+ use crate::slots::{NETWORK_PANEL_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.execute_menu_action("Show Playbar Pane");
+ state.execute_menu_action("Show Spreadsheet Pane");
+ state.rebuild_positions();
+ state.apply_layout();
+ assert!(state.show_playbar && state.show_spreadsheet);
+ let pb = state.positions[PLAYBAR_IDX];
+ assert_eq!(pb, (0.0, 900.0 - STATUS_H - PLAYBAR_H, 1600.0, PLAYBAR_H), "flush to the bottom, the full width");
+ let flags = cce_ui::scene::paint::PlateSpec::window_corner_flags(cce_ui::scene::layout::Rect { x: pb.0, y: pb.1, width: pb.2, height: pb.3 }, 1600.0, 900.0);
+ assert_eq!(flags, (false, false, true, true), "its bottom corners are the window's");
+ for idx in [NETWORK_PANEL_IDX, SPREADSHEET_IDX] {
+ let (_, y, _, h) = state.positions[idx];
+ assert_eq!(y + h, pb.1 - 18.0, "the {idx} plate stops a gap above it");
+ }
+ assert_eq!(state.scene_text_floor(900.0), pb.1, "the scale readout stands on it");
+ state.execute_menu_action("Show Playbar Pane");
+ assert!(!state.show_playbar);
+ assert_eq!(state.scene_text_floor(900.0), 900.0);
+ }
+
/// The params HUD lives on the scene: laid out from the viewport, under
/// every plate, so where one covers it the plate takes the pointer and
/// the HUD draws nothing — but it stops a gap above the spreadsheet and
diff --git a/src/render.rs b/src/render.rs
index 81311a2..0cc439f 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1184,7 +1184,7 @@ impl State {
text.push_str(" · metric assumed");
}
pc.clip(rect(vx, vy, vw, vh), |pc| {
- pc.text(text, vx + 8.0, vy + vh - 16.0, 10.0, [0xaa, 0xaa, 0xbb]);
+ pc.text(text, vx + 8.0, self.scene_text_floor(vy + vh) - 16.0, 10.0, [0xaa, 0xaa, 0xbb]);
});
}
@@ -1192,6 +1192,20 @@ impl State {
/// through the cached scene mvp (like the point numbers above), drawn as
/// a ringed dot with its index, the control cage as faint segments
/// between them. Selected point draws larger and brighter.
+ /// Where the viewport's bottom-anchored text stands: the viewport's
+ /// bottom, or the playbar's top while it is shown — the playbar is
+ /// attached to the window's bottom edge, over the scene, and the scale
+ /// readout and the viewer-state line would otherwise sit on its
+ /// transport.
+ pub(crate) fn scene_text_floor(&self, bottom: f32) -> f32 {
+ let (_, py, pw, ph) = self.positions[crate::slots::PLAYBAR_IDX];
+ if self.show_playbar && pw > 0.0 && ph > 0.0 {
+ bottom.min(py)
+ } else {
+ bottom
+ }
+ }
+
fn append_viewer_state_overlay(&self, pc: &mut PaintCtx) {
let Some(tool) = &self.viewer_tool else { return };
if !self.show_viewport {
@@ -1254,7 +1268,7 @@ impl State {
let hud = tool.hud();
let size = 11.0;
let pad = 5.0;
- let y = vy + vh - 16.0 - (size + pad * 2.0) - 4.0;
+ let y = self.scene_text_floor(vy + vh) - 16.0 - (size + pad * 2.0) - 4.0;
let width = (hud.chars().count() as f32 * size * 0.52 + pad * 2.0).min(vw - 16.0);
pc.clip(rect(vx, vy, vw, vh), |pc| {
pc.quad(rect(vx + 8.0 - pad, y, width, size + pad * 2.0), [0.0, 0.0, 0.0, 0.55]);