diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d39ce8c1..431696d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 515d87fa..8f535725 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, ¶ms) { return r; } + // Window > Timeline panel. + if let Some(r) = crate::timeline_ui::menu(app, id, ¶ms) { + 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 { 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); } diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index a52ac33c..c7847da9 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -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)] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 43a924ab..f2f87370 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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(), diff --git a/crates/ui-egui/src/timeline_ui.rs b/crates/ui-egui/src/timeline_ui.rs new file mode 100644 index 00000000..10f6945c --- /dev/null +++ b/crates/ui-egui/src/timeline_ui.rs @@ -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 { + (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> { + 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); + } +}