M13.5: Home ▸ Learn tab with interactive tutorials (learn.list/start/step/stop/state, coach card, target highlight)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:25:45 -04:00
parent baf4716246
commit 1b823eb852
9 changed files with 810 additions and 4 deletions

View File

@ -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
})
}

525
crates/engine/src/learn.rs Normal file
View File

@ -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<String>,
/// Commands that complete the step when they run (empty: a reading step, advanced with Next).
pub accepts: Vec<String>,
/// 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<Step>,
}
/// 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<Tutorial> {
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<Tutorial> {
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<u64>) -> 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<Value> {
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::<Vec<_>>()}))
.collect::<Vec<_>>()
))
}
fn start(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
s.learn = None;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
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());
}
}

View File

@ -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<jobs::JobRecord>,
pub next_task_id: u64,
/// The running Learn tutorial (Home ▸ Learn), see [`learn`].
pub learn: Option<learn::Progress>,
}
/// 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, &params)?;
self.learn_observe(id, &params);
if spec.journal {
self.journal.push((id.to_string(), params));
if self.journal.len() > 10_000 {

View File

@ -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" => {

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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<String> = 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<Rect> {
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 => {}
}
}

View File

@ -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;

View File

@ -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,