From 1b823eb852b8d7ab00c41bfc6c795ede4dff1879 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:25:45 -0400 Subject: [PATCH] =?UTF-8?q?M13.5:=20Home=20=E2=96=B8=20Learn=20tab=20with?= =?UTF-8?q?=20interactive=20tutorials=20(learn.list/start/step/stop/state,?= =?UTF-8?q?=20coach=20card,=20target=20highlight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mod.rs | 1 + crates/engine/src/learn.rs | 525 +++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 + crates/ui-egui/src/control.rs | 4 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/panels/home.rs | 71 +++- crates/ui-egui/src/panels/learn.rs | 199 +++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 4 + 9 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/learn.rs create mode 100644 crates/ui-egui/src/panels/learn.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index d9e4feec..d9ef2816 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -182,6 +182,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(align_data::specs()); v.extend(content_fill::specs()); v.extend(stubs::specs()); + v.extend(crate::learn::specs()); v }) } diff --git a/crates/engine/src/learn.rs b/crates/engine/src/learn.rs new file mode 100644 index 00000000..cea72936 --- /dev/null +++ b/crates/engine/src/learn.rs @@ -0,0 +1,525 @@ +//! Learn: interactive tutorials on the Home screen (our own content, licensed with the repo). +//! +//! A tutorial is a list of steps. Each step names the UI element to highlight (an automation id, +//! see `docs/control-protocol.md`), the commands that complete it when the user performs them, +//! and the commands "Show me" runs to perform it for the user. Progress lives on the +//! [`Session`] (not the project), so opening the demo project in a step keeps the tutorial going. +//! +//! Commands: `learn.list`, `learn.start {id}`, `learn.step {action: next|back|showMe|goto, +//! index?}`, `learn.stop`, `learn.state`. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd, query}; + +/// One step of a tutorial. +#[derive(Clone, Debug, Serialize)] +pub struct Step { + pub title: String, + pub text: String, + /// Automation id of the element to highlight. `*` matches any run of characters and + /// `{layer}` stands for the first selected layer's id (see [`resolve_target`]). + pub target: Option, + /// Commands that complete the step when they run (empty: a reading step, advanced with Next). + pub accepts: Vec, + /// Parameters the completing command must carry (a subset match; `null` = any). + pub when: Value, + /// What "Show me" runs: (command, params), in order. + pub show: Vec<(String, Value)>, +} + +/// An interactive tutorial. +#[derive(Clone, Debug, Serialize)] +pub struct Tutorial { + pub id: String, + pub title: String, + pub summary: String, + /// Rough length in minutes. + pub minutes: u32, + pub steps: Vec, +} + +/// Where the user is in a tutorial. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Progress { + pub tutorial: String, + /// Index of the current step (`== steps.len()` once finished). + pub step: usize, + pub done: bool, +} + +struct S<'a> { + title: &'a str, + text: &'a str, + target: Option<&'a str>, + accepts: &'a [&'a str], + when: Value, + show: Vec<(&'a str, Value)>, +} + +fn step(s: S) -> Step { + Step { + title: s.title.into(), + text: s.text.into(), + target: s.target.map(str::to_string), + accepts: s.accepts.iter().map(|a| a.to_string()).collect(), + when: s.when, + show: s.show.into_iter().map(|(c, p)| (c.to_string(), p)).collect(), + } +} + +/// The built-in tutorials. +pub fn tutorials() -> Vec { + vec![ + Tutorial { + id: "animate-title".into(), + title: "Animate a title".into(), + summary: "Make a composition, type a title and fly it in with two position keyframes and an easy ease.".into(), + minutes: 4, + steps: vec![ + step(S { + title: "Make a composition", + text: "Everything you animate lives in a composition. Choose Composition ▸ New Composition (Cmd+N) and keep the HD 1920×1080 settings.", + target: Some("menu.Composition"), + accepts: &["comp.new"], + when: Value::Null, + show: vec![("comp.new", json!({"name": "Title Animation", "width": 1920, "height": 1080, "duration": 5}))], + }), + step(S { + title: "Type a title", + text: "Choose Layer ▸ New ▸ Text (or the Type tool) and type a short title. A new text layer appears at the top of the Timeline.", + target: Some("menu.Layer"), + accepts: &["layer.newText"], + when: Value::Null, + show: vec![("layer.newText", json!({"text": "Hello, motion", "size": 120}))], + }), + step(S { + title: "Start the animation", + text: "Twirl the text layer open, then Transform. Click the stopwatch beside Position: it turns blue and records a keyframe at the current time.", + target: Some("timeline.layer.{layer}.twirl"), + accepts: &["prop.toggleAnimation", "prop.addKey", "prop.toggleKey", "anim.addKeyframe"], + when: Value::Null, + show: vec![ + ("prop.set", json!({"path": "transform/position", "value": [-400.0, 540.0, 0.0]})), + ("prop.toggleAnimation", json!({"path": "transform/position", "value": true})), + ], + }), + step(S { + title: "Move in time", + text: "Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler above the layer bars.", + target: Some("timeline.ruler"), + accepts: &["time.set", "time.step", "time.go"], + when: Value::Null, + show: vec![("time.set", json!({"time": 1.0}))], + }), + step(S { + title: "Set the end position", + text: "Drag the title to the middle of the frame in the Composition viewer, or scrub the Position value. Because the stopwatch is on, a second keyframe appears by itself.", + target: Some("viewer.comp"), + accepts: &["prop.set"], + when: json!({"path": "transform/position"}), + show: vec![("prop.set", json!({"path": "transform/position", "value": [960.0, 540.0, 0.0]}))], + }), + step(S { + title: "Smooth it out", + text: "Select both keyframes (drag a box around them, or click the Position name) and press F9 for Easy Ease. The title now glides in and settles.", + target: Some("timeline.ruler"), + accepts: &["keys.easyEase"], + when: Value::Null, + show: vec![("keys.selectAll", json!({})), ("keys.easyEase", json!({}))], + }), + step(S { + title: "Watch it", + text: "Press the Spacebar (or Play in the Preview panel) to play your animation. Press it again to stop. That's it: you made your first animation.", + target: Some("panel.Preview"), + accepts: &["playback.toggle"], + when: Value::Null, + show: vec![("time.set", json!({"time": 0.0})), ("playback.toggle", json!({}))], + }), + ], + }, + Tutorial { + id: "track-attach".into(), + title: "Track and attach".into(), + summary: "Follow a moving element with the point tracker, then hang a null on the track and parent a layer to it.".into(), + minutes: 5, + steps: vec![ + step(S { + title: "Open the demo", + text: "We'll track the moving lower third in the demo project. Open it from Home ▸ Open Demo Project (or File ▸ Open Demo Project).", + target: Some("menu.File"), + accepts: &["file.openDemoProject"], + when: Value::Null, + show: vec![("file.openDemoProject", json!({}))], + }), + step(S { + title: "Pick what to track", + text: "Click the Lower Third layer in the Timeline, then press I to jump to where it starts (0:00:05:00). The tracker follows the pixels of the selected layer's source.", + target: Some("timeline.layer.*.row"), + accepts: &["layer.select"], + when: Value::Null, + show: vec![("layer.select", json!({"layers": ["Lower Third"]})), ("time.set", json!({"time": 5.5}))], + }), + step(S { + title: "Add a track point", + text: "Open Window ▸ Tracker and click Track Motion. A track point appears: the inner box is the feature to follow, the outer box is where to look for it on the next frame.", + target: Some("tracker.trackMotion"), + accepts: &["track.motion", "track.new"], + when: Value::Null, + show: vec![("track.motion", json!({}))], + }), + step(S { + title: "Analyze", + text: "Click Analyze forward ▶ in the Tracker panel. The point follows the feature frame by frame and leaves a path of keyframes behind.", + target: Some("tracker.analyze.forward"), + accepts: &["track.analyze"], + when: Value::Null, + show: vec![("track.analyze", json!({"direction": "forward", "end": 6.0, "wait": true}))], + }), + step(S { + title: "Make a null to carry the motion", + text: "Choose Layer ▸ New ▸ Null Object. A null is invisible; it only holds a transform, which makes it a handy anchor for tracked motion.", + target: Some("menu.Layer"), + accepts: &["layer.newNull"], + when: Value::Null, + show: vec![("layer.newNull", json!({"name": "Track Null"}))], + }), + step(S { + title: "Point the track at the null", + text: "Select Lower Third again, click Edit Target… in the Tracker panel and choose the null as the motion target.", + target: Some("tracker.editTarget"), + accepts: &["track.setTarget"], + when: Value::Null, + show: vec![("track.setTarget", json!({"layer": "Lower Third", "target": "Track Null"}))], + }), + step(S { + title: "Apply", + text: "Click Apply and keep X and Y. The null's Position now follows the track.", + target: Some("tracker.apply"), + accepts: &["track.apply"], + when: Value::Null, + show: vec![("track.apply", json!({"layer": "Lower Third", "dimensions": "xy"}))], + }), + step(S { + title: "Attach a layer", + text: "Drag a layer's pick whip (the spiral in Parent & Link) onto the null, or pick it from the menu. The child now rides along with the track.", + target: Some("timeline.layer.*.pickWhip"), + accepts: &["layer.setParent"], + when: Value::Null, + show: vec![("layer.setParent", json!({"layers": ["Tagline"], "parent": "Track Null"}))], + }), + ], + }, + Tutorial { + id: "3d-scene".into(), + title: "Make a 3D scene".into(), + summary: "Turn flat layers into 3D, light them, add a camera and look at the scene from outside the frame.".into(), + minutes: 5, + steps: vec![ + step(S { + title: "Make a composition", + text: "Choose Composition ▸ New Composition. Any size works; 1920×1080 is a good start.", + target: Some("menu.Composition"), + accepts: &["comp.new"], + when: Value::Null, + show: vec![("comp.new", json!({"name": "3D Scene", "width": 1920, "height": 1080, "duration": 5}))], + }), + step(S { + title: "Add a floor", + text: "Choose Layer ▸ New ▸ Solid and make a large solid in a calm colour. It will become the floor.", + target: Some("menu.Layer"), + accepts: &["layer.newSolid"], + when: Value::Null, + show: vec![("layer.newSolid", json!({"name": "Floor", "color": "#3c5a82", "width": 2400, "height": 2400}))], + }), + step(S { + title: "Make it 3D", + text: "Click the cube switch of the solid in the Timeline. The layer gains a Z position and X/Y/Z rotation.", + target: Some("timeline.layer.{layer}.switch.threeD"), + accepts: &["layer.setSwitch"], + when: json!({"switch": "threeD"}), + show: vec![("layer.setSwitch", json!({"switch": "threeD", "value": true}))], + }), + step(S { + title: "Lay it down", + text: "Set X Rotation to -80° (twirl open Transform and scrub the value) so the solid lies like a floor, and push its Position down a little.", + target: Some("timeline.layer.{layer}.twirl"), + accepts: &["prop.set"], + when: Value::Null, + show: vec![ + ("prop.set", json!({"path": "transform/rotationX", "value": -80.0})), + ("prop.set", json!({"path": "transform/position", "value": [960.0, 760.0, 0.0]})), + ], + }), + step(S { + title: "Light it", + text: "Choose Layer ▸ New ▸ Light and pick Spot. 3D layers now catch light and fall-off.", + target: Some("menu.Layer"), + accepts: &["layer.newLight"], + when: Value::Null, + show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))], + }), + step(S { + title: "Add a camera", + text: "Choose Layer ▸ New ▸ Camera. The comp is now seen through this camera; orbit it with the camera tools (press C).", + target: Some("menu.Layer"), + accepts: &["layer.newCamera"], + when: Value::Null, + show: vec![("layer.newCamera", json!({"name": "Camera"}))], + }), + step(S { + title: "Step outside the camera", + text: "In the 3D View popup under the viewer choose Custom View 1. You see the scene, the camera and the light from a working view.", + target: Some("viewer.view3d"), + accepts: &[ + "view.set3DView", + "view.3d.custom1", + "view.3d.custom2", + "view.3d.custom3", + "view.3d.front", + "view.3d.top", + "view.3d.left", + "view.3d.right", + "view.3d.back", + "view.3d.bottom", + ], + when: Value::Null, + show: vec![("view.3d.custom1", json!({}))], + }), + step(S { + title: "See past the frame", + text: "Click Extended Viewer beside the 3D View popup. The viewer now shows the layers beyond the comp's edges, with the frame outlined.", + target: Some("viewer.extendedViewer"), + accepts: &["view.extendedViewer"], + when: Value::Null, + show: vec![("view.extendedViewer", json!({"value": true}))], + }), + ], + }, + ] +} + +/// The tutorial with this id. +pub fn find(id: &str) -> Option { + tutorials().into_iter().find(|t| t.id == id) +} + +/// The automation id pattern of a step's target with `{layer}` filled in (`*` stays a wildcard). +pub fn resolve_target(target: &str, layer: Option) -> String { + match layer { + Some(l) => target.replace("{layer}", &l.to_string()), + None => target.replace("{layer}", "*"), + } +} + +/// Whether an automation id matches a target pattern (`*` = any run of characters). +pub fn target_matches(pattern: &str, id: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == id; + } + let mut rest = id; + for (i, part) in parts.iter().enumerate() { + if i == 0 { + let Some(r) = rest.strip_prefix(part) else { return false }; + rest = r; + } else if i == parts.len() - 1 { + return rest.ends_with(part); + } else { + let Some(at) = rest.find(part) else { return false }; + rest = &rest[at + part.len()..]; + } + } + true +} + +/// `want`'s keys all equal in `got` (`null` matches anything). +fn params_match(want: &Value, got: &Value) -> bool { + match want { + Value::Object(m) => m.iter().all(|(k, v)| got.get(k) == Some(v)), + _ => true, + } +} + +impl Session { + /// The current tutorial and step, if one is running. + pub fn learn_current(&self) -> Option<(Tutorial, usize)> { + let p = self.learn.as_ref()?; + Some((find(&p.tutorial)?, p.step)) + } + + /// A command ran: complete the current step when it is one the step accepts. + pub(crate) fn learn_observe(&mut self, id: &str, params: &Value) { + if id.starts_with("learn.") { + return; + } + let Some((t, i)) = self.learn_current() else { return }; + let Some(st) = t.steps.get(i) else { return }; + if st.accepts.iter().any(|a| a == id) && params_match(&st.when, params) { + self.learn_goto(&t, i + 1); + } + } + + fn learn_goto(&mut self, t: &Tutorial, i: usize) { + let n = t.steps.len(); + let i = i.min(n); + if let Some(p) = &mut self.learn { + p.step = i; + p.done = i >= n; + } + if i >= n { + self.toast(format!("Tutorial complete: {}", t.title)); + } + } +} + +fn state_json(s: &Session) -> Value { + match s.learn_current() { + Some((t, i)) => json!({ + "tutorial": t.id, + "title": t.title, + "step": i, + "steps": t.steps.len(), + "done": i >= t.steps.len(), + "current": t.steps.get(i).map(|st| json!({ + "title": st.title, + "text": st.text, + "target": st.target.as_deref().map(|x| resolve_target(x, s.state.selected_layers.first().map(|l| l.0))), + "accepts": st.accepts, + })), + }), + None => Value::Null, + } +} + +fn list(_: &mut Session, _: &Value) -> Result { + Ok(json!( + tutorials() + .iter() + .map(|t| json!({"id": t.id, "title": t.title, "summary": t.summary, "minutes": t.minutes, "steps": t.steps.iter().map(|s| s.title.clone()).collect::>()})) + .collect::>() + )) +} + +fn start(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "id").or_else(|| str_p(p, "tutorial")).ok_or_else(|| bad("learn.start", "missing `id`"))?; + let t = find(id).ok_or_else(|| bad("learn.start", format!("unknown tutorial `{id}` (see learn.list)")))?; + s.learn = Some(Progress { tutorial: t.id.clone(), step: 0, done: false }); + Ok(state_json(s)) +} + +fn step_cmd(s: &mut Session, p: &Value) -> Result { + let (t, i) = s.learn_current().ok_or_else(|| bad("learn.step", "no tutorial is running (learn.start)"))?; + match str_p(p, "action").unwrap_or("next") { + "next" => s.learn_goto(&t, i + 1), + "back" | "prev" | "previous" => s.learn_goto(&t, i.saturating_sub(1)), + "goto" => { + let n = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("learn.step", "goto needs `index`"))?; + s.learn_goto(&t, n as usize); + } + "showMe" | "show" => { + let st = t.steps.get(i).ok_or_else(|| bad("learn.step", "the tutorial is finished"))?; + for (c, params) in &st.show { + s.execute(c, params.clone()).map_err(|e| bad("learn.step", format!("Show me: {c}: {e}")))?; + } + // Steps whose commands don't complete them (or reading steps) still move on. + if s.learn.as_ref().is_some_and(|p| p.step == i) { + s.learn_goto(&t, i + 1); + } + } + a => return Err(bad("learn.step", format!("action must be next|back|showMe|goto, not `{a}`"))), + } + Ok(state_json(s)) +} + +fn stop(s: &mut Session, _: &Value) -> Result { + s.learn = None; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + query!("learn.list", "Learn Tutorials", "{} → [{id, title, summary, minutes, steps}]", list), + query!("learn.state", "Tutorial State", "{} → {tutorial, step, steps, done, current: {title, text, target, accepts}} | null", |s, _| Ok(state_json( + s + ))), + cmd!("learn.start", "Start Tutorial", [], None, "{id}", always, start), + cmd!("learn.step", "Tutorial Step", [], None, "{action?: next|back|showMe|goto, index?}", always, step_cmd), + cmd!("learn.stop", "Close Tutorial", [], None, "{}", always, stop), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn targets_match_with_wildcards() { + assert!(target_matches("timeline.layer.*.row", "timeline.layer.12.row")); + assert!(!target_matches("timeline.layer.*.row", "timeline.layer.12.bar")); + assert!(target_matches("menu.File", "menu.File")); + assert!(!target_matches("menu.File", "menu.File.2")); + assert_eq!(resolve_target("timeline.layer.{layer}.twirl", Some(7)), "timeline.layer.7.twirl"); + assert_eq!(resolve_target("timeline.layer.{layer}.twirl", None), "timeline.layer.*.twirl"); + } + + #[test] + fn every_step_names_real_commands() { + for t in tutorials() { + assert!(!t.steps.is_empty(), "{}", t.id); + for st in &t.steps { + for c in st.accepts.iter().chain(st.show.iter().map(|(c, _)| c)) { + assert!(crate::commands::find(c).is_some(), "{}: unknown command {c}", t.id); + } + assert!(!st.show.is_empty(), "{}: `{}` has no Show me", t.id, st.title); + } + } + } + + #[test] + fn tutorials_run_end_to_end_with_show_me() { + for id in ["animate-title", "3d-scene", "track-attach"] { + let mut s = Session::default(); + s.execute("learn.start", json!({"id": id})).unwrap(); + let n = find(id).unwrap().steps.len(); + for i in 0..n { + let st = s.execute("learn.step", json!({"action": "showMe"})).unwrap_or_else(|e| panic!("{id} step {i}: {e}")); + assert_eq!(st["step"], json!(i + 1), "{id}: {st}"); + } + assert_eq!(s.execute("learn.state", json!({})).unwrap()["done"], json!(true)); + } + } + + #[test] + fn performing_the_command_advances() { + let mut s = Session::default(); + let list = s.execute("learn.list", json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 3); + s.execute("learn.start", json!({"id": "animate-title"})).unwrap(); + // Unrelated commands don't advance. + s.execute("file.openDemoProject", json!({})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 0); + s.execute("comp.new", json!({"name": "Mine"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 1); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + let cur = s.execute("learn.state", json!({})).unwrap(); + let lid = s.state.selected_layers[0].0; + assert_eq!(cur["current"]["target"], json!(format!("timeline.layer.{lid}.twirl"))); + // Back and Next. + s.execute("learn.step", json!({"action": "back"})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 1); + s.execute("learn.step", json!({})).unwrap(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + // A matching command with the wrong parameters doesn't complete a `when` step. + s.execute("learn.start", json!({"id": "3d-scene"})).unwrap(); + s.execute("learn.step", json!({"action": "goto", "index": 2})).unwrap(); + s.execute("layer.setSwitch", json!({"switch": "shy", "value": true})).ok(); + assert_eq!(s.learn.as_ref().unwrap().step, 2); + s.execute("learn.stop", json!({})).unwrap(); + assert!(s.learn.is_none()); + assert!(s.execute("learn.step", json!({})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17d9b235..25c88881 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod commands; pub mod config; pub mod demo; pub mod jobs; +pub mod learn; pub mod links; pub mod logging; pub mod mask_track; @@ -351,6 +352,8 @@ pub struct Session { /// Finished tasks, newest last (Progress panel, `jobs.list`). pub job_log: Vec, pub next_task_id: u64, + /// The running Learn tutorial (Home ▸ Learn), see [`learn`]. + pub learn: Option, } /// A script to run (see [`Session::script`]). @@ -412,6 +415,7 @@ impl Default for Session { tasks: vec![], job_log: vec![], next_task_id: 1, + learn: None, } } } @@ -433,6 +437,7 @@ impl Session { } } let r = (spec.run)(self, ¶ms)?; + self.learn_observe(id, ¶ms); if spec.journal { self.journal.push((id.to_string(), params)); if self.journal.len() > 10_000 { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d60b2ed2..ff3db561 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -204,6 +204,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(b) = p.get("home").and_then(Value::as_bool) { app.ui.start_screen = b; } + // Home screen tab: "home" or "learn". + if let Some(tab) = p.get("homeTab").and_then(Value::as_str) { + app.ui.home_learn = tab == "learn"; + } ok(Value::Null) } "ui.panel.show" | "ui.panel.close" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e345e9c3..d873a333 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,9 +287,10 @@ impl EffectcraftApp { self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name)); self.ui.maximized = None; - // Learn: the Home screen (community links) in the Composition panel. + // Learn: the Home screen's Learn tab (tutorials) in the Composition panel. if name == "Learn" { self.ui.start_screen = true; + self.ui.home_learn = true; } } @@ -854,6 +855,7 @@ impl EffectcraftApp { panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx); + panels::learn::coach(self, &ctx); self.draw_toast(ui, full); } diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 42ded4b7..d53a0c08 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -173,7 +173,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::header::paint_logo(&p, Rect::from_min_size(pos2(x0, y), vec2(40.0, 40.0))); p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE); p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint); - y += 64.0; + y += 56.0; + // Home / Learn tabs. + for (k, (label, learn)) in [("Home", false), ("Learn", true)].into_iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0)); + let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click()); + let on = app.ui.home_learn == learn; + if on || resp.hovered() { + p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover }); + } + p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::medium(13.0), if on { Color32::WHITE } else { t.text_dim }); + if on { + p.line_segment([pos2(r.min.x + 12.0, r.max.y - 1.0), pos2(r.max.x - 12.0, r.max.y - 1.0)], Stroke::new(2.0, t.accent)); + } + app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); + if resp.clicked() { + app.ui.home_learn = learn; + } + } + y += 40.0; for (label, id, primary) in [ ("New Project", "file.newProject", true), ("Open Project…", "file.open", false), @@ -239,13 +257,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let community_bottom = cy + sib.len().div_ceil(per_row) as f32 * 28.0; - // ---- recent projects. + // ---- recent projects (or the Learn tab's tutorials). let (rx, mut ry, rw) = if wide { let rx = x0 + left_w + 40.0; (rx, rect.min.y + pad + 6.0, rect.max.x - pad - rx) } else { (x0, community_bottom + 24.0, left_w) }; + if app.ui.home_learn { + super::learn::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0))); + run_actions(app, &ctx, actions); + return; + } p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE); let entries = recent_entries(&app.session.prefs); if !entries.is_empty() { @@ -304,9 +327,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ry += row_h; } + run_actions(app, &ctx, actions); +} + +fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) { for (id, params) in actions { let before = (app.session.path.clone(), app.session.revision); - match crate::menus::invoke(app, &ctx, id, params) { + match crate::menus::invoke(app, ctx, id, params) { Err(e) => app.ui.status = e, // Leave Home unless only the list changed or a file dialog was cancelled. Ok(_) => { @@ -382,8 +409,46 @@ mod tests { "home.help.discord", "home.help.github", "home.recent.0", + "home.tab.home", + "home.tab.learn", ] { assert!(app.auto.find(id).is_some(), "{id}"); } } + + #[test] + fn learn_tab_lists_and_starts_tutorials() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.ui.start_screen = true; + app.ui.home_learn = true; + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + let frame = |app: &mut EffectcraftApp| { + ctx.run_ui(Default::default(), |ui| { + app.auto.begin_frame(); + show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))); + crate::panels::learn::coach(app, ui.ctx()); + }) + .textures_delta + .clear(); + }; + frame(&mut app); + frame(&mut app); + for t in effectcraft_engine::learn::tutorials() { + assert!(app.auto.find(&format!("home.learn.{}.start", t.id)).is_some(), "{}", t.id); + } + assert!(app.auto.find("home.recent.0").is_none()); + // Start through the session (as the Start button does); the coach card appears. + app.session.execute("learn.start", json!({"id": "animate-title"})).unwrap(); + app.ui.start_screen = false; + frame(&mut app); + frame(&mut app); + for id in ["learn.coach", "learn.showMe", "learn.next", "learn.close"] { + assert!(app.auto.find(id).is_some(), "{id}"); + } + // Show me performs the step and the coach moves on. + app.session.execute("learn.step", json!({"action": "showMe"})).unwrap(); + assert_eq!(app.session.learn.as_ref().unwrap().step, 1); + assert!(app.session.active_comp().is_some()); + } } diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs new file mode 100644 index 00000000..a184e230 --- /dev/null +++ b/crates/ui-egui/src/panels/learn.rs @@ -0,0 +1,199 @@ +//! Learn: the Home screen's tutorial list and the coach card that walks through a running +//! tutorial (engine model in `effectcraft_engine::learn`). The coach highlights the step's target +//! (an automation id) and moves on when the user performs the step's command; "Show me" runs it. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// The Learn tab of the Home screen: one card per tutorial with its steps and a Start button. +pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(area); + let (x, mut y, w) = (area.min.x, area.min.y, area.width()); + p.text(pos2(x, y + 6.0), Align2::LEFT_CENTER, "Learn", Tokens::semibold(16.0), Color32::WHITE); + p.text( + pos2(x, y + 26.0), + Align2::LEFT_CENTER, + "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + Tokens::ui(11.5), + t.text_faint, + ); + y += 44.0; + let tutorials = effectcraft_engine::learn::tutorials(); + let running = app.session.learn.as_ref().map(|l| l.tutorial.clone()); + let card_h = 128.0; + for tut in &tutorials { + let r = Rect::from_min_size(pos2(x, y), vec2(w, card_h - 10.0)); + if r.min.y > area.max.y { + break; + } + let resp = ui.interact(r, egui::Id::new(("learn-card", &tut.id)), Sense::click()); + p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) }); + // A small original badge: the step count in a circle. + let badge = Rect::from_min_size(pos2(r.min.x + 16.0, r.min.y + 16.0), vec2(44.0, 44.0)); + p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); + p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); + p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + let tx = badge.max.x + 16.0; + p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); + p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); + let text_w = (r.max.x - tx - 120.0).max(120.0); + let g = p.layout(tut.summary.clone(), Tokens::ui(12.0), t.text_dim, text_w); + p.galley(pos2(tx, r.min.y + 52.0), g, t.text_dim); + let steps: Vec = tut.steps.iter().map(|s| s.title.clone()).collect(); + let g = p.layout(steps.join(" · "), Tokens::ui(10.5), t.text_faint, text_w); + p.galley(pos2(tx, r.min.y + 84.0), g, t.text_faint); + let br = Rect::from_min_size(pos2(r.max.x - 104.0, r.min.y + 16.0), vec2(88.0, 28.0)); + let label = if running.as_deref() == Some(tut.id.as_str()) { "Resume" } else { "Start" }; + let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::new(("learn-start", &tut.id))).clicked(); + app.auto.add(&format!("home.learn.{}", tut.id), r, &tut.title); + app.auto.add(&format!("home.learn.{}.start", tut.id), br, label); + if clicked || resp.clicked() { + if running.as_deref() != Some(tut.id.as_str()) + && let Err(e) = app.session.execute("learn.start", json!({"id": tut.id})) + { + app.ui.status = e.to_string(); + } + app.ui.start_screen = false; + } + y += card_h; + } +} + +/// The automation element a step's target pattern points at (last frame's registry). +fn target_rect(app: &EffectcraftApp, pattern: &str) -> Option { + let lid = app.session.state.selected_layers.first().map(|l| l.0); + let pat = effectcraft_engine::learn::resolve_target(pattern, lid); + app.auto + .previous + .iter() + .find(|e| effectcraft_engine::learn::target_matches(&pat, &e.id) && e.rect[2] > 0.0 && e.rect[3] > 0.0) + .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))) +} + +/// The coach card (bottom right) and the highlight around the current step's target. +pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) { + let Some((tut, i)) = app.session.learn_current() else { return }; + let t = app.tokens; + let n = tut.steps.len(); + let screen = ctx.content_rect(); + let step = tut.steps.get(i).cloned(); + // Highlight the target (pulsing outline over everything but the card). + if app.ui.start_screen { + // The Home screen covers the panels: just the card. + } else if let Some(st) = &step + && let Some(tp) = &st.target + && let Some(r) = target_rect(app, tp) + { + let time = ctx.input(|i| i.time); + let pulse = (0.5 + 0.5 * (time * 4.0).sin()) as f32; + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("learn-highlight"))); + let hr = r.expand(3.0 + 2.0 * pulse); + p.rect_stroke(hr, 5.0, Stroke::new(2.0, t.accent.gamma_multiply(0.6 + 0.4 * pulse)), StrokeKind::Outside); + app.auto.add("learn.highlight", hr, tp); + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + } + let w = 340.0; + let mut action: Option<&str> = None; + let area = egui::Area::new(egui::Id::new("learn-coach")) + .order(egui::Order::Foreground) + .fixed_pos(pos2(screen.max.x - w - 20.0, screen.max.y - 250.0)) + .show(ctx, |ui| { + egui::Frame::new() + .fill(Color32::from_rgb(0x26, 0x28, 0x30)) + .stroke(Stroke::new(1.0, t.accent.gamma_multiply(0.7))) + .corner_radius(8.0) + .inner_margin(egui::Margin::same(14)) + .shadow(egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }) + .show(ui, |ui| { + ui.set_width(w - 28.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("LEARN").font(Tokens::semibold(10.0)).color(t.accent)); + ui.label(egui::RichText::new(&tut.title).font(Tokens::ui(11.0)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (cr, r) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + let r = r.on_hover_text("Close tutorial"); + crate::icons::paint(ui.painter(), cr, crate::icons::Icon::Close, if r.hovered() { Color32::WHITE } else { t.text_dim }); + app.auto.add("learn.close", r.rect, "Close tutorial"); + if r.clicked() { + action = Some("close"); + } + }); + }); + // Progress dots. + let (dr, _) = ui.allocate_exact_size(vec2(w - 28.0, 10.0), Sense::hover()); + for k in 0..n { + let c = pos2(dr.min.x + 4.0 + k as f32 * 12.0, dr.center().y); + let col = if k < i { + t.accent + } else if k == i { + Color32::WHITE + } else { + Color32::from_gray(80) + }; + ui.painter().circle_filled(c, 3.0, col); + } + ui.add_space(4.0); + match &step { + Some(st) => { + ui.label(egui::RichText::new(format!("Step {} of {n}: {}", i + 1, st.title)).font(Tokens::semibold(13.0)).color(Color32::WHITE)); + ui.add_space(2.0); + ui.label(egui::RichText::new(&st.text).font(Tokens::ui(12.0)).color(t.text)); + } + None => { + ui.label(egui::RichText::new("Tutorial complete").font(Tokens::semibold(13.0)).color(Color32::WHITE)); + ui.label( + egui::RichText::new("Nice work. Pick another tutorial on the Home screen's Learn tab.").font(Tokens::ui(12.0)).color(t.text), + ); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let back = ui.add_enabled(i > 0, egui::Button::new("Back")); + app.auto.add("learn.back", back.rect, "Back"); + if back.clicked() { + action = Some("back"); + } + if step.is_some() { + let show = ui.add(egui::Button::new("Show me")); + app.auto.add("learn.showMe", show.rect, "Show me"); + if show.clicked() { + action = Some("showMe"); + } + let next = ui.add(egui::Button::new("Next")); + app.auto.add("learn.next", next.rect, "Next"); + if next.clicked() { + action = Some("next"); + } + } else { + let more = ui.add(egui::Button::new("More tutorials")); + app.auto.add("learn.more", more.rect, "More tutorials"); + if more.clicked() { + action = Some("more"); + } + } + }); + }); + }); + app.auto.add("learn.coach", area.response.rect, &tut.title); + match action { + Some("close") => { + let _ = app.session.execute("learn.stop", json!({})); + } + Some("more") => { + let _ = app.session.execute("learn.stop", json!({})); + app.ui.home_learn = true; + app.ui.start_screen = true; + } + Some(a) => { + if let Err(e) = app.session.execute("learn.step", json!({"action": a})) { + app.ui.status = e.to_string(); + } + } + None => {} + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d9a9864a..ed766f0d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -22,6 +22,7 @@ pub mod info; pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; +pub mod learn; pub mod markers_ui; pub mod media_panels; pub mod misc; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 584cdd95..50031da7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -597,6 +597,9 @@ pub struct UiState { #[serde(default = "yes")] pub preview_audio: bool, pub start_screen: bool, + /// The Home screen shows its Learn tab (tutorials) instead of the recent projects. + #[serde(default)] + pub home_learn: bool, /// Composition ▸ Preview ▸ Cache Frames When Idle. pub cache_when_idle: bool, /// Tracker panel ▸ Motion Source chosen without a tracker yet (layer id). @@ -666,6 +669,7 @@ impl Default for UiState { preview_cache_first: false, preview_audio: true, start_screen: false, + home_learn: false, cache_when_idle: false, tracker_source: None, layer_panel: None,