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

commit7b342e70e446188201f9d7657e326b2bd2a759f5
parent8495f3fe2b
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 10:14
feat: the playbar has a right-click menu

The transport's commands, the Repeat switch, and the timeline's settings
as slider rows: Playback Rate (a setting, saved in state.kdl) and the
frame range (the project's, saved in its file).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md      |  23 +++++-
 src/app.rs     | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.rs    |  89 +++++++++++++++++++++
 src/project.rs |  12 +++
 src/window.rs  |   1 +
 5 files changed, 355 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1436262..84a42f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -223,7 +223,7 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
 gone from cce-ui with the wgpu path).
 
-- `src/app.rs` (~10.8k lines) — the heart: `State` (the entire app model), `McpAction` /
+- `src/app.rs` (~11.9k lines) — the heart: `State` (the entire app model), `McpAction` /
   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
   two halves of the old render loop. GPU mesh updates are staged CPU-side
@@ -2423,6 +2423,27 @@ 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's right-click menu
+
+A right press on the playbar's plate (`over_playbar`) opens the sixth
+`context_menu` consumer (2026-09-30), the viewport menu's shape:
+`playbar_menu_rows` / `handle_playbar_menu_click` /
+`run_playbar_menu_action`, `PlaybarMenuAction`. Rows: the transport's
+commands by id (Play / Pause, Play / Pause Reverse, Go To Start Frame),
+the **Repeat Playback** switch with its mark, then three slider rows —
+**Playback Rate** (1–120 fps by one), **Start Frame** (1–999) and **End
+Frame** (2–1000). An end moved past the other carries it a frame ahead,
+and the playhead is kept inside the range. The rate is a setting,
+`playbar_fps` in state.kdl beside `playbar_repeat`, saved on a wheel
+notch or a drag's release; the range is the PROJECT's —
+`ProjectViewState::frame_range`, keyed into `pane_layout_json` so it
+dirties the title, absent in an older save which keeps the live range.
+The four slider hooks in `handle_event` ask `slider_menu_open` and drain
+through `drain_menu_slider`, so the viewport's and the playbar's sliders
+share them without either being named there. `get_state` reports `fps`
+in its `playbar` block. `the_playbar_menu_sets_the_rate_and_the_range`
+drives it by pointer.
+
 ### Display mode: the viewport menu, and smooth shading
 
 The viewport's right-click menu has two SUBMENUS (since 2026-09-29;
diff --git a/src/app.rs b/src/app.rs
index 24e2dbf..b638717 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -309,6 +309,12 @@ pub struct ProjectViewState {
     /// last-used look: what a new project and an older save open with.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub display: Option<DisplaySettings>,
+    /// The playbar's frame range, `(start, end)` — project state, since a
+    /// simulation is made for a length; absent in an older save, which
+    /// keeps the live range. Set from the playbar menu's Start Frame and
+    /// End Frame sliders (2026-09-30).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub frame_range: Option<(i32, i32)>,
 }
 
 /// The display half of `DesignSettings` — see [`ProjectViewState::display`].
@@ -491,6 +497,24 @@ pub enum ViewportMenuAction {
 }
 
 
+/// The playbar's right-click menu (2026-09-30): the transport's commands,
+/// its Repeat switch, and the settings that are the timeline's — the
+/// playback rate and the frame range — as slider rows, the viewport menu's
+/// shape. Rows dispatch as the network menu's do, by command id.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum PlaybarMenuAction {
+    /// Run `command::by_id(id)`; a toggle command's row carries its mark.
+    Command(&'static str),
+    /// Playback Rate, in frames a second: 1–120 by one.
+    FpsSlider,
+    /// The frame range's near end, 1–999 by one; kept below the far end.
+    StartFrameSlider,
+    /// The frame range's far end, 2–1000 by one; kept above the near end.
+    EndFrameSlider,
+    /// A "-" row: engraved, inert.
+    Separator,
+}
+
 /// The network editor's right-click context menu (on empty space — a press on
 /// a node still opens that node's menu). Every row but the separator names a
 /// COMMAND ID rather than a piece of work, so the labels cannot drift from the
@@ -1602,6 +1626,14 @@ pub struct DesignSettings {
     /// property of a scene, so it does not ride the project file.
     #[serde(default = "default_true")]
     pub playbar_repeat: bool,
+    /// The playback rate in frames a second — the playbar menu's Playback
+    /// Rate. Top-level like `playbar_repeat`, and for the same reason.
+    #[serde(default = "default_playbar_fps")]
+    pub playbar_fps: f32,
+}
+
+fn default_playbar_fps() -> f32 {
+    24.0
 }
 
 impl Default for DesignSettings {
@@ -1612,6 +1644,7 @@ impl Default for DesignSettings {
             default_project: None,
             gpu: default_gpu(),
             playbar_repeat: true,
+            playbar_fps: default_playbar_fps(),
         }
     }
 }
@@ -2069,6 +2102,9 @@ pub struct State {
     /// it holds across a re-layout of the pane.
     pub param_menu_active: bool,
     pub param_menu_actions: Vec<ParamMenuAction>,
+    /// The playbar's right-click menu, the same contract.
+    pub playbar_menu_active: bool,
+    pub playbar_menu_actions: Vec<PlaybarMenuAction>,
     pub param_menu_target: Option<(String, String)>,
     /// Copy Parameter's clipboard: (node id, parameter name). An id, so a
     /// rename between the copy and the paste still pastes the right path.
@@ -2640,6 +2676,7 @@ impl State {
             vs.collapsed_panes,
             splitters,
             vs.dock_tabs,
+            vs.frame_range,
             vs.viewport_pin,
             vs.params_pin,
             vs.spreadsheet_pin,
@@ -2721,6 +2758,7 @@ impl State {
             default_project: self.default_project_setting.clone(),
             gpu: self.gpu_preference.clone(),
             playbar_repeat: self.slots.playbar.inner().repeat,
+            playbar_fps: self.slots.playbar.inner().fps,
         };
         settings.save();
         self.last_design_mod_time = {
@@ -2947,7 +2985,7 @@ impl State {
             || self.is_panning
             || self.grid_cursor_drag.is_some()
             || self.viewer_tool.as_ref().map_or(false, |t| t.drag.is_some())
-            || (self.viewport_menu_open() && cce_ui::widget::context_menu::slider_dragging())
+            || (self.slider_menu_open() && cce_ui::widget::context_menu::slider_dragging())
             || self.dialog_visible()
     }
 
@@ -5282,6 +5320,168 @@ impl State {
         false
     }
 
+    /// The playbar menu's rows and what each does: the transport (Play /
+    /// Pause, Play / Pause Reverse, Go To Start Frame); the Repeat switch,
+    /// marked from the live flag; then the timeline's settings as slider
+    /// rows — Playback Rate, Start Frame, End Frame. Split from the open so
+    /// a test can read it, as the viewport menu's is.
+    pub(crate) fn playbar_menu_rows(&self) -> (Vec<String>, Vec<PlaybarMenuAction>) {
+        let mut options = Vec::new();
+        let mut actions = Vec::new();
+        let mark = |on: bool| if on { "●" } else { "○" };
+        let mut command = |options: &mut Vec<String>, actions: &mut Vec<PlaybarMenuAction>, id: &'static str| {
+            let Some(c) = crate::command::by_id(id) else { return };
+            let label = match self.command_toggle_state(id) {
+                Some(on) => format!("{} {}", mark(on), c.label),
+                None => c.label.to_string(),
+            };
+            options.push(label);
+            actions.push(PlaybarMenuAction::Command(id));
+        };
+        let mut row = |options: &mut Vec<String>, actions: &mut Vec<PlaybarMenuAction>, text: &str, a: PlaybarMenuAction| {
+            options.push(text.to_string());
+            actions.push(a);
+        };
+        command(&mut options, &mut actions, "play_pause");
+        command(&mut options, &mut actions, "play_pause_reverse");
+        command(&mut options, &mut actions, "frame_start");
+        row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
+        command(&mut options, &mut actions, "toggle_playbar_repeat");
+        row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
+        row(&mut options, &mut actions, "Playback Rate", PlaybarMenuAction::FpsSlider);
+        row(&mut options, &mut actions, "Start Frame", PlaybarMenuAction::StartFrameSlider);
+        row(&mut options, &mut actions, "End Frame", PlaybarMenuAction::EndFrameSlider);
+        (options, actions)
+    }
+
+    /// The slider a playbar menu row carries, from the live value.
+    pub(crate) fn playbar_menu_slider(&self, action: PlaybarMenuAction) -> Option<cce_ui::widget::context_menu::MenuSlider> {
+        use cce_ui::widget::context_menu::MenuSlider;
+        let pb = self.slots.playbar.inner();
+        Some(match action {
+            PlaybarMenuAction::FpsSlider => MenuSlider { value: pb.fps.clamp(1.0, 120.0).round(), min: 1.0, max: 120.0, step: 1.0, decimals: 0, suffix: " fps" },
+            PlaybarMenuAction::StartFrameSlider => MenuSlider { value: pb.start_frame.round().clamp(1.0, 999.0), min: 1.0, max: 999.0, step: 1.0, decimals: 0, suffix: "" },
+            PlaybarMenuAction::EndFrameSlider => MenuSlider { value: pb.end_frame.round().clamp(2.0, 1000.0), min: 2.0, max: 1000.0, step: 1.0, decimals: 0, suffix: "" },
+            _ => return None,
+        })
+    }
+
+    /// Land a playbar slider: the rate, or an end of the range, the other
+    /// end kept a frame clear of it and the playhead kept inside. The rate
+    /// is a setting and saved with them; the range is the project's and
+    /// dirties it.
+    fn land_playbar_menu_slider(&mut self, action: PlaybarMenuAction, v: f32) {
+        let pb = self.slots.playbar.inner_mut();
+        match action {
+            PlaybarMenuAction::FpsSlider => pb.fps = v.round().clamp(1.0, 120.0),
+            PlaybarMenuAction::StartFrameSlider => {
+                pb.start_frame = v.round().clamp(1.0, 999.0);
+                pb.end_frame = pb.end_frame.max(pb.start_frame + 1.0);
+            }
+            PlaybarMenuAction::EndFrameSlider => {
+                pb.end_frame = v.round().clamp(2.0, 1000.0);
+                pb.start_frame = pb.start_frame.min(pb.end_frame - 1.0);
+            }
+            _ => return,
+        }
+        pb.current_frame = pb.current_frame.clamp(pb.start_frame, pb.end_frame);
+        self.viewport_dirty = true;
+        self.update_window_title();
+    }
+
+    pub(crate) fn open_playbar_context_menu(&mut self) {
+        let (options, actions) = self.playbar_menu_rows();
+        let target = self.slots.get_dyn(PLAYBAR_IDX).base().id();
+        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        for (i, a) in actions.iter().enumerate() {
+            if let Some(slider) = self.playbar_menu_slider(*a) {
+                cce_ui::widget::context_menu::set_row_slider(i, slider);
+            }
+        }
+        self.playbar_menu_actions = actions;
+        self.playbar_menu_active = true;
+    }
+
+    pub fn playbar_menu_open(&self) -> bool {
+        cce_ui::widget::context_menu::is_visible() && self.playbar_menu_active
+    }
+
+    fn close_playbar_menu(&mut self) {
+        cce_ui::widget::context_menu::hide();
+        self.playbar_menu_active = false;
+        self.playbar_menu_actions.clear();
+    }
+
+    /// Whether the pointer is over the playbar's plate.
+    pub fn over_playbar(&self, px: f32, py: f32) -> bool {
+        let (x, y, w, h) = self.positions[PLAYBAR_IDX];
+        self.show_playbar && w > 0.0 && h > 0.0 && px >= x && px < x + w && py >= y && py < y + h
+    }
+
+    /// Route a left press while the playbar menu is open — the viewport
+    /// menu's contract: a slider row is worked and keeps the menu up, any
+    /// other row runs and closes it.
+    fn handle_playbar_menu_click(&mut self) -> bool {
+        if !self.playbar_menu_open() {
+            return false;
+        }
+        if cce_ui::widget::context_menu::slider_press(self.cursor_x, self.cursor_y) {
+            self.drain_playbar_menu_slider(false);
+            return true;
+        }
+        if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
+            let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
+            let picked = idx.and_then(|i| self.playbar_menu_actions.get(i).copied());
+            self.close_playbar_menu();
+            if let Some(action) = picked {
+                self.run_playbar_menu_action(action);
+            }
+            return true;
+        }
+        self.close_playbar_menu();
+        false
+    }
+
+    pub(crate) fn run_playbar_menu_action(&mut self, action: PlaybarMenuAction) {
+        if let PlaybarMenuAction::Command(id) = action {
+            self.run_command(id);
+        }
+    }
+
+    /// Land what a playbar menu slider did; `persist` saves the rate with
+    /// the settings, as the viewport menu's drain does.
+    pub(crate) fn drain_playbar_menu_slider(&mut self, persist: bool) -> bool {
+        let Some((idx, v)) = cce_ui::widget::context_menu::take_slider_change() else {
+            if persist {
+                self.save_settings();
+            }
+            return false;
+        };
+        if let Some(action) = self.playbar_menu_actions.get(idx).copied() {
+            self.land_playbar_menu_slider(action, v);
+            if persist {
+                self.save_settings();
+            }
+        }
+        true
+    }
+
+    /// Whichever menu with slider rows is open — the viewport's or the
+    /// playbar's — drained: the four slider hooks in `handle_event` ask
+    /// this so neither menu has to be named there.
+    fn drain_menu_slider(&mut self, persist: bool) -> bool {
+        if self.playbar_menu_open() {
+            self.drain_playbar_menu_slider(persist)
+        } else {
+            self.drain_viewport_menu_slider(persist)
+        }
+    }
+
+    /// A menu whose rows carry sliders is open.
+    fn slider_menu_open(&self) -> bool {
+        self.viewport_menu_open() || self.playbar_menu_open()
+    }
+
     /// One row-menu action on one parameter, by node id and name — the
     /// entry the menu, a test and any future command share.
     pub fn run_param_action(&mut self, node_id: &str, pname: &str, action: ParamMenuAction) {
@@ -6917,6 +7117,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         });
 
         slots.playbar.inner_mut().repeat = settings.playbar_repeat;
+        slots.playbar.inner_mut().fps = settings.playbar_fps.clamp(1.0, 120.0);
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
             viewport.show_grid = settings.viewport.show_grid_enabled;
             viewport.show_origin = settings.viewport.show_origin_enabled;
@@ -7012,6 +7213,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             viewport_menu_actions: Vec::new(),
             param_menu_active: false,
             param_menu_actions: Vec::new(),
+            playbar_menu_active: false,
+            playbar_menu_actions: Vec::new(),
             param_menu_target: None,
             copied_param: None,
             rename_target: None,
@@ -9212,11 +9415,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 }
                 // Over an open viewport menu the wheel is the menu's: a
                 // slider row steps, and nothing scrolls or orbits beneath.
-                if self.viewport_menu_open()
+                if self.slider_menu_open()
                     && cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y)
                 {
                     if cce_ui::widget::context_menu::mouse_wheel(delta, self.cursor_x, self.cursor_y) {
-                        self.drain_viewport_menu_slider(true);
+                        self.drain_menu_slider(true);
                     }
                     return true;
                 }
@@ -9419,7 +9622,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 // Track hover on the node/viewport/network context menus so
                 // the highlight follows.
-                if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open())
+                if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open() || self.playbar_menu_open())
                     && cce_ui::widget::context_menu::cursor_moved(self.cursor_x, self.cursor_y)
                 {
                     changed = true;
@@ -9427,8 +9630,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // A held menu slider follows the pointer (cursor_moved moved
                 // it); land the value, and let nothing else read this motion
                 // as a drag of its own.
-                if self.viewport_menu_open() && cce_ui::widget::context_menu::slider_dragging() {
-                    self.drain_viewport_menu_slider(false);
+                if self.slider_menu_open() && cce_ui::widget::context_menu::slider_dragging() {
+                    self.drain_menu_slider(false);
                     return true;
                 }
 
@@ -9645,7 +9848,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     && *button == MouseButton::Left
                     && cce_ui::widget::context_menu::slider_release()
                 {
-                    self.drain_viewport_menu_slider(true);
+                    self.drain_menu_slider(true);
                     return true;
                 }
                 if *btn_state == ElementState::Pressed {
@@ -9822,6 +10025,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 return true;
                             }
                         }
+                        // The playbar's menu, same contract.
+                        if self.playbar_menu_open() {
+                            if *button == MouseButton::Left && self.handle_playbar_menu_click() {
+                                return true;
+                            }
+                            self.close_playbar_menu();
+                            if *button == MouseButton::Left {
+                                return true;
+                            }
+                        }
                         // The parameter row menu, same contract again.
                         if self.param_menu_open() {
                             if *button == MouseButton::Left && self.handle_param_menu_click() {
@@ -9844,6 +10057,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 self.open_param_context_menu(slot, pname);
                                 return true;
                             }
+                            // A right press on the playbar opens its menu.
+                            if self.over_playbar(self.cursor_x, self.cursor_y) {
+                                self.close_node_menu();
+                                self.close_viewport_menu();
+                                self.close_network_menu();
+                                self.open_playbar_context_menu();
+                                return true;
+                            }
                         }
 
                         if *button == MouseButton::Left && self.circular_network_pane {
@@ -10653,6 +10874,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             self.close_viewport_menu();
                         } else if self.param_menu_open() {
                             self.close_param_menu();
+                        } else if self.playbar_menu_open() {
+                            self.close_playbar_menu();
                         } else if self.network_menu_open() {
                             self.close_network_menu();
                         } else {
@@ -10925,6 +11148,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                          self.default_project_setting = settings.default_project.clone();
                          self.gpu_preference = settings.gpu.clone();
                          self.slots.playbar.inner_mut().repeat = settings.playbar_repeat;
+                         self.slots.playbar.inner_mut().fps = settings.playbar_fps.clamp(1.0, 120.0);
                          self.square_viewport = settings.viewport.square;
                          self.grid_thickness = settings.viewport.grid_thickness;
                          self.viewport_mut().show_grid = settings.viewport.show_grid_enabled;
diff --git a/src/main.rs b/src/main.rs
index 7a7aaf1..b8777d7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2438,6 +2438,95 @@ mod tests {
         assert!(extent(0.25, 1) > 0.0);
     }
 
+    /// The playbar has a right-click menu: the transport's commands, the
+    /// Repeat switch with its mark, and the timeline's settings as slider
+    /// rows — Playback Rate, saved with the settings, and the frame range,
+    /// which is the project's, saved in its file and dirtying it.
+    #[test]
+    fn the_playbar_menu_sets_the_rate_and_the_range() {
+        use crate::app::PlaybarMenuAction as A;
+        use crate::slots::PLAYBAR_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{context_menu, ElementState, MouseButton, MouseScrollDelta};
+        let dir = std::env::temp_dir().join(format!("cce-designer-playbar-menu-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.execute_menu_action("Show Playbar Pane");
+        state.rebuild_positions();
+        state.apply_layout();
+        state.save_to_file(&dir).expect("save");
+        assert!(!state.has_unsaved_changes());
+
+        let (options, actions) = state.playbar_menu_rows();
+        let groups: Vec<Vec<A>> = actions.split(|a| *a == A::Separator).map(|g| g.to_vec()).collect();
+        assert_eq!(
+            groups,
+            vec![
+                vec![A::Command("play_pause"), A::Command("play_pause_reverse"), A::Command("frame_start")],
+                vec![A::Command("toggle_playbar_repeat")],
+                vec![A::FpsSlider, A::StartFrameSlider, A::EndFrameSlider],
+            ]
+        );
+        let repeat = actions.iter().position(|a| *a == A::Command("toggle_playbar_repeat")).unwrap();
+        assert!(options[repeat].starts_with("● "), "{}", options[repeat]);
+
+        // A right press on the plate opens it.
+        let (px, py, pw, ph) = state.positions[PLAYBAR_IDX];
+        assert!(pw > 0.0 && ph > 0.0, "the playbar is laid out");
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: (px + pw * 0.5) as f64, y: (py + ph * 0.5) as f64 } });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
+        assert!(state.playbar_menu_open(), "a right press on the playbar opens its menu");
+        assert!(!state.viewport_menu_open());
+
+        // The rate: a wheel notch over its row is a frame a second, saved.
+        let i = actions.iter().position(|a| *a == A::FpsSlider).unwrap();
+        let sl = context_menu::slider(i).expect("a slider");
+        assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (1.0, 120.0, 1.0, " fps"));
+        assert_eq!(sl.value, 24.0);
+        state.cursor_x = context_menu::x() + 20.0;
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 6.0) });
+        assert_eq!(state.slots.playbar.inner().fps, 30.0);
+        assert!(state.playbar_menu_open(), "a slider row keeps the menu up");
+        let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+        assert_eq!(crate::app::DesignSettings::from_kdl_str(&kdl).playbar_fps, 30.0, "persisted");
+        assert!(!state.has_unsaved_changes(), "the rate is a setting, not the project's");
+
+        // The range: an end moved past the other carries it along, and the
+        // playhead stays inside.
+        state.slots.playbar.inner_mut().current_frame = 200.0;
+        let i = actions.iter().position(|a| *a == A::EndFrameSlider).unwrap();
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, -100.0) });
+        let pb = state.slots.playbar.inner();
+        assert_eq!(pb.end_frame, 140.0, "{}", pb.end_frame);
+        assert_eq!(pb.current_frame, 140.0, "the playhead is kept inside");
+        assert!(state.has_unsaved_changes(), "the range is the project's");
+        let i = actions.iter().position(|a| *a == A::StartFrameSlider).unwrap();
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 150.0) });
+        let pb = state.slots.playbar.inner();
+        assert_eq!((pb.start_frame, pb.end_frame), (151.0, 152.0), "the far end is carried a frame ahead of the near");
+
+        // A command row runs and closes.
+        let i = actions.iter().position(|a| *a == A::Command("toggle_playbar_repeat")).unwrap();
+        state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(!state.playbar_menu_open());
+        assert!(!state.slots.playbar.inner().repeat, "Repeat was flipped");
+
+        // The range rides the project file.
+        state.save_to_file(&dir).expect("save");
+        assert!(!state.has_unsaved_changes());
+        let mut again = State::new(false);
+        again.resize(1600.0, 900.0, 1.0);
+        again.load_from_file(&dir).expect("load");
+        let pb = again.slots.playbar.inner();
+        assert_eq!((pb.start_frame, pb.end_frame), (151.0, 152.0));
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     /// Camera Pivot Size is a slider under Show Camera Pivot, over 0–1:
     /// the wheel steps a twentieth, re-bakes the marker and nothing else,
     /// and saves.
diff --git a/src/project.rs b/src/project.rs
index 1767e55..b52d827 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -167,6 +167,10 @@ impl State {
             params_pin: Self::pin_name(self.params_pin),
             spreadsheet_pin: Self::pin_name(self.spreadsheet_pin),
             plates,
+            frame_range: {
+                let pb = self.slots.playbar.inner();
+                Some((pb.start_frame.round() as i32, pb.end_frame.round() as i32))
+            },
             default_view: Some(crate::app::DefaultCameraView {
                 square: self.square_viewport,
                 show_pivot: self.viewport().show_camera_pivot,
@@ -325,6 +329,14 @@ impl State {
         // Absent (an older save, or one written before pane state moved off
         // the meta node) keeps the live layout — the same rule the collapse
         // list and the splitters follow.
+        if let Some((start, end)) = vs.frame_range {
+            if start >= 1 && end > start {
+                let pb = self.slots.playbar.inner_mut();
+                pb.start_frame = start as f32;
+                pb.end_frame = end as f32;
+                pb.current_frame = pb.current_frame.clamp(pb.start_frame, pb.end_frame);
+            }
+        }
         if let Some(open) = &vs.visible_panes {
             for (name, get, action) in Self::PANE_FLAGS {
                 let desired = open.iter().any(|n| n == name);
diff --git a/src/window.rs b/src/window.rs
index a4d4dcb..fb8d1cd 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -264,6 +264,7 @@ impl State {
                 "playing": pb.playing,
                 "reversed": pb.reversed,
                 "repeat": pb.repeat,
+                "fps": pb.fps,
                 "start_frame": pb.start_frame.round() as i64,
                 "end_frame": pb.end_frame.round() as i64,
             });