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

commit1e3668910857d092e9238cf20fb6efa952b18916
parent0806eb6f0e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 19:13
feat: the playbar has previous and next frame buttons, which its menu can hide

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |  14 ++++-
 src/app.rs      |  24 ++++++--
 src/command.rs  |   3 +
 src/dialog.rs   |   1 +
 src/main.rs     |  70 ++++++++++++++++++++++-
 src/playbar.rs  | 169 ++++++++++++++++++++++++++++++++++++++++++--------------
 src/shortcut.rs |   2 +
 src/window.rs   |   1 +
 8 files changed, 233 insertions(+), 51 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 742c9e3..c327627 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2751,7 +2751,8 @@ A right press on the playbar's plate (`over_playbar`) opens the sixth
 `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 —
+the **Repeat Playback** and **Show Step Buttons** switches with their
+marks, 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,
@@ -2765,6 +2766,17 @@ 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.
 
+**The step buttons** (since 2026-10-01): Previous Frame and Next Frame
+stand either side of the play button (|◁ ▷ ▷|), each a whole frame off
+the ROUNDED frame and inside the range, without pausing —
+`Playbar::step`, which the Left / Right chords (`frame_prev` /
+`frame_next`) share. Show Step Buttons (`toggle_playbar_step_buttons`,
+unbound, a switch in the palette too) takes them away and the track
+widens into their room; on by default, persisted as top-level
+`playbar_step_buttons` in state.kdl beside `playbar_repeat`, and reported
+as `step_buttons` in `get_state`'s playbar block.
+`the_playbar_step_buttons_step_and_can_be_hidden` is the test.
+
 ### 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 7afb033..3ce737d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2038,6 +2038,10 @@ pub struct DesignSettings {
     /// Rate. Top-level like `playbar_repeat`, and for the same reason.
     #[serde(default = "default_playbar_fps")]
     pub playbar_fps: f32,
+    /// Whether the playbar shows its Previous / Next Frame buttons —
+    /// `toggle_playbar_step_buttons`. Top-level like `playbar_repeat`.
+    #[serde(default = "default_true")]
+    pub playbar_step_buttons: bool,
 }
 
 fn default_playbar_fps() -> f32 {
@@ -2053,6 +2057,7 @@ impl Default for DesignSettings {
             gpu: default_gpu(),
             playbar_repeat: true,
             playbar_fps: default_playbar_fps(),
+            playbar_step_buttons: true,
         }
     }
 }
@@ -3175,6 +3180,7 @@ impl State {
             gpu: self.gpu_preference.clone(),
             playbar_repeat: self.slots.playbar.inner().repeat,
             playbar_fps: self.slots.playbar.inner().fps,
+            playbar_step_buttons: self.slots.playbar.inner().step_buttons,
         };
         settings.save();
         self.last_design_mod_time = {
@@ -5748,6 +5754,7 @@ impl State {
         command(&mut options, &mut actions, "frame_start");
         row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
         command(&mut options, &mut actions, "toggle_playbar_repeat");
+        command(&mut options, &mut actions, "toggle_playbar_step_buttons");
         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);
@@ -7590,6 +7597,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().step_buttons = settings.playbar_step_buttons;
         let wire_style = cce_ui::widget::display::WireStyle::parse(&settings.viewport.node_wire_style);
         slots.content.inner_mut().set_wire_style(wire_style);
         slots.content2.inner_mut().set_wire_style(wire_style);
@@ -9759,14 +9767,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 pb.repeat = !pb.repeat;
                 settings_changed = true;
             }
-            // Whole-frame stepping off the ROUNDED current frame: during
-            // playback the playhead sits between frames, and stepping from
-            // the fractional value would land off the frame grid. The scene
-            // rebuild follows from tick_frame's last_sim_frame diff.
+            Action::TogglePlaybarStepButtons => {
+                let pb = self.slots.playbar.inner_mut();
+                pb.step_buttons = !pb.step_buttons;
+                settings_changed = true;
+            }
+            // Whole-frame stepping (`Playbar::step`, which the playbar's
+            // own step buttons share). The scene rebuild follows from
+            // tick_frame's last_sim_frame diff.
             Action::FrameNext | Action::FramePrev => {
                 let step = if action == Action::FrameNext { 1.0 } else { -1.0 };
-                let pb = self.slots.playbar.inner_mut();
-                pb.current_frame = (pb.current_frame.round() + step).clamp(pb.start_frame, pb.end_frame);
+                self.slots.playbar.inner_mut().step(step);
             }
             // Rewind: pause whatever is playing and land on the start frame.
             // The scene rebuild follows from tick_frame's last_sim_frame
@@ -11610,6 +11621,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().step_buttons = settings.playbar_step_buttons;
                          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;
diff --git a/src/command.rs b/src/command.rs
index 90d347b..178e554 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -250,6 +250,9 @@ pub const COMMANDS: &[Command] = &[
     // (off). A setting rather than a transport key, so it ships unbound and
     // carries a switch in the palette; persisted in state.kdl.
     Command { id: "toggle_playbar_repeat", label: "Repeat Playback", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarRepeat), default_chord: None },
+    // The Previous / Next Frame buttons either side of the play button: a
+    // switch in the playbar menu and the palette, saved with the settings.
+    Command { id: "toggle_playbar_step_buttons", label: "Show Step Buttons", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarStepButtons), default_chord: None },
 ];
 
 pub fn by_id(id: &str) -> Option<&'static Command> {
diff --git a/src/dialog.rs b/src/dialog.rs
index 5ae6f03..1ea8cda 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -2003,6 +2003,7 @@ impl State {
             "show_parameters_pane" => self.show_parameters,
             "show_playbar_pane" => self.show_playbar,
             "toggle_playbar_repeat" => self.slots.playbar.inner().repeat,
+            "toggle_playbar_step_buttons" => self.slots.playbar.inner().step_buttons,
             "toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
             _ => return None,
         })
diff --git a/src/main.rs b/src/main.rs
index 405115c..d272d90 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2603,7 +2603,7 @@ mod tests {
             groups,
             vec![
                 vec![A::Command("play_pause"), A::Command("play_pause_reverse"), A::Command("frame_start")],
-                vec![A::Command("toggle_playbar_repeat")],
+                vec![A::Command("toggle_playbar_repeat"), A::Command("toggle_playbar_step_buttons")],
                 vec![A::FpsSlider, A::StartFrameSlider, A::EndFrameSlider],
                 vec![
                     A::Plate(crate::plate_menu::PlateMenuAction::Collapse),
@@ -2670,6 +2670,74 @@ mod tests {
         let _ = fs::remove_dir_all(&dir);
     }
 
+    /// The playbar's Previous / Next Frame buttons step a whole frame by
+    /// pointer, either side of the play button, and the playbar menu's
+    /// Show Step Buttons switch takes them away — the track widening into
+    /// their room — and saves the choice.
+    #[test]
+    fn the_playbar_step_buttons_step_and_can_be_hidden() {
+        use crate::app::PlaybarMenuAction as A;
+        use crate::slots::PLAYBAR_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::scene::layout::Rect;
+        use cce_ui::widget::{context_menu, ElementState, MouseButton};
+        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();
+        let (px, py, pw, ph) = state.positions[PLAYBAR_IDX];
+        let rect = Rect { x: px, y: py, width: pw, height: ph };
+        assert!(state.slots.playbar.inner().step_buttons, "on by default");
+        let press = |state: &mut State, r: Rect| {
+            let (x, y) = (r.x + r.width * 0.5, r.y + r.height * 0.5);
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        };
+        let pb = state.slots.playbar.inner();
+        let prev = pb.transport_button_rect(rect, -1).expect("a Previous Frame button");
+        let play = pb.transport_button_rect(rect, 0).expect("a play button");
+        let next = pb.transport_button_rect(rect, 1).expect("a Next Frame button");
+        assert!(prev.x + prev.width < play.x && play.x + play.width < next.x, "|< > >| left to right");
+
+        state.slots.playbar.inner_mut().current_frame = 10.4;
+        press(&mut state, next);
+        assert_eq!(state.slots.playbar.inner().current_frame, 11.0, "a whole frame on, off the rounded one");
+        press(&mut state, prev);
+        press(&mut state, prev);
+        assert_eq!(state.slots.playbar.inner().current_frame, 9.0);
+        assert!(!state.slots.playbar.inner().playing, "a step does not start playback");
+        state.slots.playbar.inner_mut().current_frame = 1.0;
+        press(&mut state, prev);
+        assert_eq!(state.slots.playbar.inner().current_frame, 1.0, "held inside the range");
+
+        // The menu's switch hides them, and is saved.
+        let (options, actions) = state.playbar_menu_rows();
+        let i = actions.iter().position(|a| *a == A::Command("toggle_playbar_step_buttons")).expect("a Step Buttons row");
+        assert!(options[i].starts_with("● "), "{}", options[i]);
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: (play.x + 400.0) 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());
+        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::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(!state.playbar_menu_open());
+        let pb = state.slots.playbar.inner();
+        assert!(!pb.step_buttons);
+        assert!(pb.transport_button_rect(rect, -1).is_none() && pb.transport_button_rect(rect, 1).is_none());
+        assert_eq!(pb.transport_button_rect(rect, 0).unwrap().x, prev.x, "the play button takes the first place");
+        let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+        assert!(!crate::app::DesignSettings::from_kdl_str(&kdl).playbar_step_buttons, "persisted");
+
+        // Where Next Frame stood is the track now: a press there scrubs.
+        state.slots.playbar.inner_mut().current_frame = 50.0;
+        press(&mut state, next);
+        assert_ne!(state.slots.playbar.inner().current_frame, 51.0, "no step button there any more");
+        state.run_command("toggle_playbar_step_buttons");
+        assert!(state.slots.playbar.inner().step_buttons);
+    }
+
     /// 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/playbar.rs b/src/playbar.rs
index 90fd708..681d176 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -27,15 +27,30 @@ pub struct Playbar {
     /// this field is the one copy, read by the dialog's switch and
     /// `save_settings`.
     pub repeat: bool,
+    /// Whether the Previous Frame / Next Frame buttons flank the play
+    /// button — the playbar menu's Step Buttons switch
+    /// (`toggle_playbar_step_buttons`), persisted in state.kdl
+    /// (`playbar_step_buttons`). On by default. Off, the play button and
+    /// the track stand where they always did.
+    pub step_buttons: bool,
     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;
+/// Space between two transport buttons.
+const BTN_GAP: f32 = 4.0;
 /// Width reserved right of the track for the frame readout.
 const READOUT_W: f32 = 110.0;
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Btn {
+    Prev,
+    Play,
+    Next,
+}
+
 impl Playbar {
     pub fn new() -> Adapted<Playbar> {
         Adapted::new(Self {
@@ -46,6 +61,7 @@ impl Playbar {
             end_frame: 240.0,
             fps: 24.0,
             repeat: true,
+            step_buttons: true,
             dragging: false,
         })
     }
@@ -66,13 +82,47 @@ impl Playbar {
         }
     }
 
-    fn button_rect(&self, rect: Rect) -> Rect {
+    /// Step one whole frame, off the ROUNDED current frame: during playback
+    /// the playhead sits between frames, and stepping from the fractional
+    /// value would land off the frame grid. The chords and the buttons
+    /// share it; neither pauses a playing timeline.
+    pub fn step(&mut self, by: f32) {
+        self.current_frame = (self.current_frame.round() + by).clamp(self.start_frame, self.end_frame);
+    }
+
+    /// The transport's square buttons, left to right: Previous Frame, Play,
+    /// Next Frame with the step buttons on, Play alone with them off.
+    fn button_rects(&self, rect: Rect) -> Vec<(Btn, 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 }
+        let y = rect.y + (rect.height - s) * 0.5;
+        let order: &[Btn] = if self.step_buttons { &[Btn::Prev, Btn::Play, Btn::Next] } else { &[Btn::Play] };
+        order
+            .iter()
+            .enumerate()
+            .map(|(i, b)| (*b, Rect { x: rect.x + PAD + i as f32 * (s + BTN_GAP), y, width: s, height: s }))
+            .collect()
+    }
+
+    fn button_at(&self, rect: Rect, x: f32, y: f32) -> Option<Btn> {
+        self.button_rects(rect)
+            .into_iter()
+            .find(|(_, b)| x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height)
+            .map(|(btn, _)| btn)
+    }
+
+    /// Where a transport button is in the pane, for tests: Previous Frame
+    /// is -1, Play 0 and Next Frame 1. `None` for a button not shown.
+    pub fn transport_button_rect(&self, rect: Rect, which: i32) -> Option<Rect> {
+        let want = match which {
+            -1 => Btn::Prev,
+            0 => Btn::Play,
+            _ => Btn::Next,
+        };
+        self.button_rects(rect).into_iter().find(|(b, _)| *b == want).map(|(_, r)| r)
     }
 
     fn track_rect(&self, rect: Rect) -> Rect {
-        let b = self.button_rect(rect);
+        let b = self.button_rects(rect).last().expect("the play button").1;
         let x = b.x + b.width + PAD;
         let h = (rect.height - 2.0 * PAD).max(8.0).min(16.0);
         Rect {
@@ -138,38 +188,23 @@ impl Paint for Playbar {
         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);
-        }
+        // The transport buttons: raised plates under the DE relief styling
+        // (the Button transparent-fill degradation — edges only, the pane
+        // plate is the face), flat outlines otherwise.
         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);
+        for (btn, b) in self.button_rects(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);
+            }
+            match btn {
+                Btn::Play => self.paint_play_icon(b, icon, ctx),
+                Btn::Prev | Btn::Next => paint_step_icon(b, btn == Btn::Next, icon, ctx),
+            }
         }
-
         // Timeline track: the toolkit's band (the one slider style) — a band the
         // width of the track with its swell at the playhead, in its own shaded
         // well, drawn by the Slider's painter.
@@ -233,6 +268,44 @@ impl Paint for Playbar {
     }
 }
 
+impl Playbar {
+    fn paint_play_icon(&self, b: Rect, icon: [f32; 4], ctx: &mut PaintCtx) {
+        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);
+        }
+    }
+}
+
+/// A step button's icon: a triangle outline toward the step and a bar at
+/// its point, the transport's frame-step glyph (|◁ and ▷|).
+fn paint_step_icon(b: Rect, forward: bool, icon: [f32; 4], ctx: &mut PaintCtx) {
+    let dir = if forward { 1.0 } else { -1.0 };
+    let (cx, cy) = (b.x + b.width * 0.5, b.y + b.height * 0.5);
+    let r = b.width * 0.2;
+    let (x0, x1) = (cx - dir * r * 0.75, cx + dir * r * 0.65);
+    ctx.vector(x0, cy - r, x0, cy + r, 1.5, icon, Cap::Round);
+    ctx.vector(x0, cy + r, x1, cy, 1.5, icon, Cap::Round);
+    ctx.vector(x1, cy, x0, cy - r, 1.5, icon, Cap::Round);
+    let bx = x1 + dir * 1.5;
+    ctx.vector(bx, cy - r, bx, cy + r, 1.5, icon, Cap::Round);
+}
+
 impl Input for Playbar {
     fn is_dragging(&self) -> bool {
         self.dragging
@@ -243,17 +316,27 @@ impl Input for Playbar {
         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 {
-                        // The button is the FORWARD transport: playing (either
-                        // direction) pauses; paused starts forward. Reverse is
-                        // the Down-arrow chord's domain.
-                        if self.playing {
-                            self.playing = false;
-                        } else {
-                            self.begin(false);
+                    match self.button_at(rect, *x, *y) {
+                        // The play button is the FORWARD transport: playing
+                        // (either direction) pauses; paused starts forward.
+                        // Reverse is the Down-arrow chord's domain.
+                        Some(Btn::Play) => {
+                            if self.playing {
+                                self.playing = false;
+                            } else {
+                                self.begin(false);
+                            }
+                            return true;
                         }
-                        return true;
+                        Some(Btn::Prev) => {
+                            self.step(-1.0);
+                            return true;
+                        }
+                        Some(Btn::Next) => {
+                            self.step(1.0);
+                            return true;
+                        }
+                        None => {}
                     }
                     let t = self.track_rect(rect);
                     // A generous vertical band around the slim track.
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 349534b..439bbf8 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -17,6 +17,8 @@ pub enum Action {
     ToggleShowOccluded,
     /// Whether playback wraps at the end of the frame range or stops there.
     TogglePlaybarRepeat,
+    /// Whether the playbar shows its Previous / Next Frame buttons.
+    TogglePlaybarStepButtons,
     /// The three point overlays on the visible scene — markers, index
     /// numbers, normal whiskers. Global display settings reached from the
     /// command palette; they were per-node `meta` child preferences until
diff --git a/src/window.rs b/src/window.rs
index 075abfe..98986b4 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -264,6 +264,7 @@ impl State {
                 "playing": pb.playing,
                 "reversed": pb.reversed,
                 "repeat": pb.repeat,
+                "step_buttons": pb.step_buttons,
                 "fps": pb.fps,
                 "start_frame": pb.start_frame.round() as i64,
                 "end_frame": pb.end_frame.round() as i64,