Window > Timeline panel

Floating Timeline panel driving the engine timeline.* commands: Create
Video Timeline, frame counter + prev/next, playhead scrubber, fps,
delete. UI stays in ui-egui (behaviour is engine commands). Parity
608 -> 609; FLOOR 604. Snapshot-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:21:23 -04:00
parent 39a014d7fc
commit c080ed3e72
5 changed files with 122 additions and 1 deletions

View File

@ -22,6 +22,7 @@ pub mod comps_ui;
pub mod control;
pub mod dialogs;
mod variables_ui;
mod timeline_ui;
pub mod doc_props_ui;
pub mod enable_rules;
pub mod export_dialog;
@ -545,6 +546,7 @@ impl eframe::App for PhotocraftApp {
preset_panels::windows(self, &ctx);
type_panels_ui::windows(self, &ctx);
analysis_ui::windows(self, &ctx);
timeline_ui::windows(self, &ctx);
workspace_ui::windows(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);

View File

@ -124,6 +124,10 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va
if let Some(r) = crate::variables_ui::menu(app, id, &params) {
return r;
}
// Window > Timeline panel.
if let Some(r) = crate::timeline_ui::menu(app, id, &params) {
return r;
}
if id == "window.panel.brushes" {
// Window › Brushes opens the Brush Settings window on its presets tab.
app.ui.panels.brush_settings = true;
@ -404,6 +408,9 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
if let Some(c) = crate::preset_panels::checked(app, id) {
return Some(c);
}
if let Some(c) = crate::timeline_ui::checked(app, id) {
return Some(c);
}
if let Some(c) = crate::type_panels_ui::checked(app, id) {
return Some(c);
}

View File

@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG;
use crate::menus::is_live;
/// Minimum number of live catalog items. Raise it when parity grows; never lower it.
pub const FLOOR: usize = 603;
pub const FLOOR: usize = 604;
/// One top-level menu's coverage.
#[derive(Clone, Debug, serde::Serialize)]

View File

@ -498,6 +498,7 @@ pub struct UiState {
/// Ruler/Count/Note tools, Measurement Log and Notes panels (see `analysis_ui`).
#[serde(default)]
pub analysis: crate::analysis_ui::AnalysisUi,
pub timeline: crate::timeline_ui::TimelineUi,
/// Slice and Slice Select tools (see `slice_ui`).
#[serde(default)]
pub slices: crate::slice_ui::SliceUi,
@ -562,6 +563,7 @@ impl Default for UiState {
presets_ui: Default::default(),
type_panels: Default::default(),
analysis: Default::default(),
timeline: Default::default(),
slices: Default::default(),
shell: Default::default(),
layer_filter: Vec::new(),

View File

@ -0,0 +1,110 @@
//! Window › Timeline: a floating panel that creates and drives the document timeline. All actions
//! run the engine `timeline.*` commands, so the control channel drives it the same way.
use egui::{vec2, RichText};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::theme::Tokens;
use crate::PhotocraftApp;
/// Timeline panel view state.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineUi {
pub open: bool,
}
/// Menu checkmark for Window › Timeline.
pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
(id == "window.panel.timeline").then_some(app.ui.timeline.open)
}
/// Toggle the Timeline panel.
pub fn menu(app: &mut PhotocraftApp, id: &str, _params: &Value) -> Option<Result<Value, String>> {
if id == "window.panel.timeline" {
app.ui.timeline.open = !app.ui.timeline.open;
return Some(Ok(json!({ "open": app.ui.timeline.open })));
}
None
}
/// Render the Timeline panel.
pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !app.ui.timeline.open {
return;
}
let tl = app.session.active().and_then(|d| d.doc.timeline.clone());
let have_doc = app.session.active().is_some();
let mut act: Option<(&str, Value)> = None;
let mut close = false;
let t = Tokens::get(ctx);
crate::analysis_ui::panel_window(app, ctx, "timeline", "Timeline", vec2(0.0, 520.0), 660.0, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("Timeline").strong().color(t.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::icons::button(ui, "x", 20.0, false, "Close").clicked() {
close = true;
}
});
});
ui.separator();
if !have_doc {
ui.weak("Open a document to use the timeline.");
return;
}
match &tl {
None => {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
if crate::widgets::primary_button(ui, "Create Video Timeline", 200.0).clicked() {
act = Some(("timeline.create", json!({ "duration": 30, "fps": 30.0 })));
}
ui.add_space(4.0);
ui.weak("30 frames @ 30 fps");
});
}
Some(tl) => {
ui.horizontal(|ui| {
if crate::icons::button(ui, "chevron-left", 22.0, false, "Previous frame").clicked() {
act = Some(("timeline.previousFrame", json!({})));
}
ui.label(RichText::new(format!("Frame {} / {}", tl.current + 1, tl.duration)).color(t.text).monospace());
if crate::icons::button(ui, "chevron-right", 22.0, false, "Next frame").clicked() {
act = Some(("timeline.nextFrame", json!({})));
}
ui.separator();
ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::icons::button(ui, "trash", 20.0, false, "Delete Timeline").clicked() {
act = Some(("timeline.delete", json!({})));
}
ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim));
});
});
ui.add_space(6.0);
// Scrubber: frame playhead across the duration.
if tl.duration > 1 {
let mut frame = tl.current as f64;
let resp = ui.add(
egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64)
.integer()
.show_value(false)
.text("playhead"),
);
if resp.changed() {
act = Some(("timeline.setFrame", json!({ "frame": frame as u64 })));
}
}
}
}
});
if close {
app.ui.timeline.open = false;
}
if let Some((cmd, p)) = act {
let _ = app.run(cmd, p);
}
}