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:
parent
baf4716246
commit
1b823eb852
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms)?;
|
||||
self.learn_observe(id, ¶ms);
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params));
|
||||
if self.journal.len() > 10_000 {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue