mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
Merge main into M13.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
|
||||
//!
|
||||
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
|
||||
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
|
||||
//! offset that is converted into its parent's space before it is added to Position (Separate
|
||||
//! Dimensions writes X/Y Position), at the current time (a key when Position is animated). All
|
||||
//! moves are one undo step.
|
||||
|
||||
use effectcraft_geom::{Mat4, Vec3};
|
||||
use effectcraft_keyframe::Value as KValue;
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
|
||||
struct Placed {
|
||||
id: LayerId,
|
||||
/// `[x0, y0, x1, y1]` in comp space.
|
||||
b: [f64; 4],
|
||||
/// Comp space → the parent's space (the space Position lives in).
|
||||
to_parent: Mat4,
|
||||
}
|
||||
|
||||
fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
|
||||
let mut out = vec![];
|
||||
for &id in ids {
|
||||
let Some(l) = comp.layer(id) else { continue };
|
||||
if l.transform().is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = effectcraft_render::content_bounds(&ctx, l) else { continue };
|
||||
let w = ctx.world_matrix(l);
|
||||
let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| w.apply(Vec3::from([p[0], p[1], 0.0])));
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in pts {
|
||||
x0 = x0.min(p.x);
|
||||
y0 = y0.min(p.y);
|
||||
x1 = x1.max(p.x);
|
||||
y1 = y1.max(p.y);
|
||||
}
|
||||
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
|
||||
let mut par = Mat4::IDENTITY;
|
||||
let mut cur = l.parent;
|
||||
let mut guard = 0;
|
||||
while let Some(pid) = cur {
|
||||
guard += 1;
|
||||
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
|
||||
par = ctx.local_matrix(p) * par;
|
||||
cur = p.parent;
|
||||
}
|
||||
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
|
||||
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Move layers by comp-space offsets, as one undo step.
|
||||
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
|
||||
let t = s.time();
|
||||
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
|
||||
s.edit(label, None, |proj, _| {
|
||||
for (lid, to_parent, d) in &moves {
|
||||
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let o = to_parent.apply(Vec3::from([0.0, 0.0, 0.0]));
|
||||
let q = to_parent.apply(Vec3::from([d[0], d[1], 0.0]));
|
||||
let dp = [q.x - o.x, q.y - o.y, q.z - o.z];
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let three = l.is_3d();
|
||||
let tr = l.transform_mut().ok_or_else(|| bad("layer.align", "layer has no transform"))?;
|
||||
if tr.get("positionX").is_some() {
|
||||
let dims = if three { 3 } else { 2 };
|
||||
for (k, m) in ["positionX", "positionY", "positionZ"].iter().enumerate().take(dims) {
|
||||
if let Some(pr) = tr.get_mut(m) {
|
||||
let v = pr.value_at(lt).as_f64() + dp[k];
|
||||
pr.set_value_at(lt, KValue::Scalar(v));
|
||||
}
|
||||
}
|
||||
} else if let Some(pr) = tr.get_mut("position") {
|
||||
let cur = pr.value_at(lt);
|
||||
let a = cur.to_json();
|
||||
let get = |k: usize| a.get(k).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let nv = json!([get(0) + dp[0], get(1) + dp[1], get(2) + if three { dp[2] } else { 0.0 }]);
|
||||
if let Some(v) = cur.coerce_json(&nv) {
|
||||
pr.set_value_at(lt, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"moved": moved}))
|
||||
}
|
||||
|
||||
const EDGES: &str = "left|hcenter|right|top|vcenter|bottom";
|
||||
|
||||
/// The coordinate of an edge (or centre) of bounds; `true` for horizontal edges.
|
||||
fn edge_of(b: &[f64; 4], edge: &str) -> Option<(f64, bool)> {
|
||||
Some(match edge {
|
||||
"left" => (b[0], true),
|
||||
"hcenter" | "horizontalCenter" => ((b[0] + b[2]) * 0.5, true),
|
||||
"right" => (b[2], true),
|
||||
"top" => (b[1], false),
|
||||
"vcenter" | "verticalCenter" => ((b[1] + b[3]) * 0.5, false),
|
||||
"bottom" => (b[3], false),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "layer.align";
|
||||
let edge = str_p(p, "edge").ok_or_else(|| bad(c, format!("missing `edge` ({EDGES})")))?;
|
||||
let to = str_p(p, "to").unwrap_or("composition");
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let items = placed(s, cid, &ids)?;
|
||||
if items.is_empty() {
|
||||
return Err(bad(c, "no layers with bounds to align"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let target: [f64; 4] = match to {
|
||||
"composition" | "comp" => [0.0, 0.0, comp.width as f64, comp.height as f64],
|
||||
"selection" => {
|
||||
if items.len() < 2 {
|
||||
return Err(bad(c, "aligning to the selection needs two or more layers"));
|
||||
}
|
||||
items.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |a, it| {
|
||||
[a[0].min(it.b[0]), a[1].min(it.b[1]), a[2].max(it.b[2]), a[3].max(it.b[3])]
|
||||
})
|
||||
}
|
||||
x => return Err(bad(c, format!("unknown `to` `{x}` (selection|composition)"))),
|
||||
};
|
||||
let (goal, horiz) = edge_of(&target, edge).ok_or_else(|| bad(c, format!("unknown edge `{edge}` ({EDGES})")))?;
|
||||
let moves = items
|
||||
.into_iter()
|
||||
.map(|it| {
|
||||
let (v, _) = edge_of(&it.b, edge).unwrap_or((goal, horiz));
|
||||
let d = if horiz { [goal - v, 0.0] } else { [0.0, goal - v] };
|
||||
(it.id, it.to_parent, d)
|
||||
})
|
||||
.collect();
|
||||
apply_moves(s, cid, "Align Layers", moves)
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "layer.distribute";
|
||||
let mode = str_p(p, "mode").or(str_p(p, "edge")).ok_or_else(|| bad(c, format!("missing `mode` ({EDGES})")))?;
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let mut items = placed(s, cid, &ids)?;
|
||||
if items.len() < 3 {
|
||||
return Err(bad(c, "distributing needs three or more layers"));
|
||||
}
|
||||
edge_of(&items[0].b, mode).ok_or_else(|| bad(c, format!("unknown mode `{mode}` ({EDGES})")))?;
|
||||
let key = |it: &Placed| edge_of(&it.b, mode).map_or(0.0, |e| e.0);
|
||||
items.sort_by(|a, b| key(a).total_cmp(&key(b)));
|
||||
let (first, last) = (key(&items[0]), key(&items[items.len() - 1]));
|
||||
let n = items.len() - 1;
|
||||
let horiz = edge_of(&items[0].b, mode).is_some_and(|e| e.1);
|
||||
let moves = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
let goal = first + (last - first) * i as f64 / n as f64;
|
||||
let dv = goal - key(it);
|
||||
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
|
||||
})
|
||||
.collect();
|
||||
apply_moves(s, cid, "Distribute Layers", moves)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.align",
|
||||
"Align Layers",
|
||||
[],
|
||||
None,
|
||||
"{edge: left|hcenter|right|top|vcenter|bottom, to?: composition|selection (default composition), layers?}",
|
||||
has_layers,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
"layer.distribute",
|
||||
"Distribute Layers",
|
||||
[],
|
||||
None,
|
||||
"{mode: left|hcenter|right|top|vcenter|bottom (edges or centres), layers?}",
|
||||
has_layers,
|
||||
distribute
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str, w: u32, h: u32, pos: [f64; 2]) -> u64 {
|
||||
let id = s.execute("layer.newSolid", json!({"name": name, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [pos[0], pos[1], 0.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: u64) -> [f64; 4] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
placed(s, cid, &[LayerId(id)]).unwrap()[0].b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_composition_and_selection_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "A", 50, 40, [100.0, 100.0]);
|
||||
let b = solid(&mut s, "B", 80, 20, [250.0, 180.0]);
|
||||
s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap();
|
||||
assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6);
|
||||
// One step undoes both.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((bounds(&s, a)[0] - 75.0).abs() < 1e-6);
|
||||
assert!((bounds(&s, b)[0] - 210.0).abs() < 1e-6);
|
||||
s.execute("layer.align", json!({"edge": "bottom", "to": "composition", "layers": [a, b]})).unwrap();
|
||||
assert!((bounds(&s, a)[3] - 300.0).abs() < 1e-6 && (bounds(&s, b)[3] - 300.0).abs() < 1e-6);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Selection: to the selection's right edge (B's) and vertical centre.
|
||||
s.execute("layer.align", json!({"edge": "right", "to": "selection", "layers": [a, b]})).unwrap();
|
||||
assert!((bounds(&s, a)[2] - 290.0).abs() < 1e-6 && (bounds(&s, b)[2] - 290.0).abs() < 1e-6);
|
||||
s.execute("layer.align", json!({"edge": "vcenter", "to": "selection", "layers": [a, b]})).unwrap();
|
||||
let (ca, cb) = (bounds(&s, a), bounds(&s, b));
|
||||
assert!(((ca[1] + ca[3]) / 2.0 - (cb[1] + cb[3]) / 2.0).abs() < 1e-6);
|
||||
assert!(s.execute("layer.align", json!({"edge": "left", "to": "selection", "layers": [a]})).is_err());
|
||||
assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_honours_parent_scale_and_rotation() {
|
||||
let mut s = session();
|
||||
let parent = solid(&mut s, "P", 10, 10, [200.0, 150.0]);
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90.0})).unwrap();
|
||||
let child = solid(&mut s, "C", 20, 10, [30.0, 10.0]);
|
||||
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
|
||||
s.execute("layer.align", json!({"edge": "top", "layers": [child]})).unwrap();
|
||||
assert!(bounds(&s, child)[1].abs() < 1e-6, "{:?}", bounds(&s, child));
|
||||
s.execute("layer.align", json!({"edge": "hcenter", "layers": [child]})).unwrap();
|
||||
let b = bounds(&s, child);
|
||||
assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1e-6 && b[1].abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_edges_and_centres() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "A", 20, 20, [50.0, 50.0]);
|
||||
let b = solid(&mut s, "B", 60, 20, [90.0, 100.0]);
|
||||
let c = solid(&mut s, "C", 20, 20, [350.0, 200.0]);
|
||||
s.execute("layer.distribute", json!({"mode": "hcenter", "layers": [a, b, c]})).unwrap();
|
||||
let cx = |id| {
|
||||
let b = bounds(&s, id);
|
||||
(b[0] + b[2]) / 2.0
|
||||
};
|
||||
assert!((cx(b) - 200.0).abs() < 1e-6 && (cx(a) - 50.0).abs() < 1e-6 && (cx(c) - 350.0).abs() < 1e-6);
|
||||
s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b, c]})).unwrap();
|
||||
assert!((bounds(&s, b)[1] - 115.0).abs() < 1e-6);
|
||||
assert!(s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
|
||||
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.iter().any(|l| l.transform().is_some_and(|t| t.get("positionX").is_some())));
|
||||
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
|
||||
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always),
|
||||
fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always),
|
||||
// Preview.
|
||||
fe!("playback.toggle", "Play Current Preview", ["Composition", "Preview"], Some("Space"), "{}", has_comp),
|
||||
fe!(
|
||||
"playback.toggle",
|
||||
"Play Current Preview",
|
||||
["Composition", "Preview"],
|
||||
Some("Space"),
|
||||
"{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (whose Preview panel options to use; default spacebar)}",
|
||||
has_comp
|
||||
),
|
||||
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
|
||||
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
|
||||
// Viewer.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button,
|
||||
//! shortcut and viewer/timeline gesture maps to one of these.
|
||||
|
||||
mod align;
|
||||
mod align_data;
|
||||
mod anim;
|
||||
pub mod anim_tools;
|
||||
@@ -182,9 +183,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(autotrace::specs());
|
||||
v.extend(scene_detect::specs());
|
||||
v.extend(align_data::specs());
|
||||
v.extend(align::specs());
|
||||
v.extend(content_fill::specs());
|
||||
v.extend(stubs::specs());
|
||||
v.extend(crate::learn::specs());
|
||||
v.extend(crate::preview::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod media_cache;
|
||||
pub mod menus;
|
||||
pub mod offload;
|
||||
pub mod prefs;
|
||||
pub mod preview;
|
||||
pub mod psd_import;
|
||||
pub mod render_queue;
|
||||
pub mod roto;
|
||||
|
||||
@@ -281,6 +281,8 @@ pub struct Prefs {
|
||||
pub recent_presets: Vec<String>,
|
||||
/// Character panel: recently used font families, newest first.
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Preview panel: every shortcut's options.
|
||||
pub preview: crate::preview::PreviewSettings,
|
||||
/// Top-level keys this version doesn't know (kept for newer versions).
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
@@ -312,6 +314,7 @@ impl Default for Prefs {
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
recent_fonts: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
extra: Extra::new(),
|
||||
}
|
||||
}
|
||||
@@ -446,6 +449,7 @@ impl Prefs {
|
||||
recent_footage: std::mem::take(&mut self.recent_footage),
|
||||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
..d
|
||||
};
|
||||
return Ok(());
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! Preview panel settings (After Effects' Preview panel): five keyboard shortcuts (Spacebar,
|
||||
//! Shift+Spacebar, Numpad 0, Shift+Numpad 0, Alt+Numpad 0), each with its own saved options —
|
||||
//! what to include (video, audio, overlays, layer controls), Loop, Cache Before Playback, Range,
|
||||
//! Play From, Frame Rate, Skip, Resolution, Full Screen, "If caching, play cached frames" and
|
||||
//! "Move time to preview time".
|
||||
//!
|
||||
//! The settings live in [`crate::prefs::Prefs::preview`] (saved with the other settings).
|
||||
//! [`plan`] turns one shortcut's options, a comp and the current time into the frames to play;
|
||||
//! the frontend's playback loop follows the plan.
|
||||
//!
|
||||
//! Commands: `playback.settings.get {shortcut?}`, `playback.settings.set {shortcut?, values?,
|
||||
//! <field>: value…, current?}`.
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
|
||||
/// A Preview panel shortcut.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PreviewShortcut {
|
||||
#[default]
|
||||
Spacebar,
|
||||
ShiftSpacebar,
|
||||
Numpad0,
|
||||
ShiftNumpad0,
|
||||
AltNumpad0,
|
||||
}
|
||||
|
||||
impl PreviewShortcut {
|
||||
pub const ALL: [PreviewShortcut; 5] = [Self::Spacebar, Self::ShiftSpacebar, Self::Numpad0, Self::ShiftNumpad0, Self::AltNumpad0];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spacebar => "Spacebar",
|
||||
Self::ShiftSpacebar => "Shift+Spacebar",
|
||||
Self::Numpad0 => "Numpad 0",
|
||||
Self::ShiftNumpad0 => "Shift+Numpad 0",
|
||||
Self::AltNumpad0 => "Alt+Numpad 0",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spacebar => "spacebar",
|
||||
Self::ShiftSpacebar => "shiftSpacebar",
|
||||
Self::Numpad0 => "numpad0",
|
||||
Self::ShiftNumpad0 => "shiftNumpad0",
|
||||
Self::AltNumpad0 => "altNumpad0",
|
||||
}
|
||||
}
|
||||
/// From an id (`shiftSpacebar`) or a label (`Shift+Spacebar`).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let k = s.to_ascii_lowercase().replace([' ', '+', '-', '_'], "");
|
||||
Self::ALL.into_iter().find(|x| x.id().to_ascii_lowercase() == k || x.label().to_ascii_lowercase().replace([' ', '+'], "") == k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Range of a preview.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PreviewRange {
|
||||
WorkArea,
|
||||
#[default]
|
||||
WorkAreaExtended,
|
||||
EntireDuration,
|
||||
/// The pre-roll before and post-roll after the current time.
|
||||
AroundCurrentTime,
|
||||
}
|
||||
|
||||
impl PreviewRange {
|
||||
pub const ALL: [PreviewRange; 4] = [Self::WorkArea, Self::WorkAreaExtended, Self::EntireDuration, Self::AroundCurrentTime];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::WorkArea => "Work Area",
|
||||
Self::WorkAreaExtended => "Work Area Extended By Current Time",
|
||||
Self::EntireDuration => "Entire Duration",
|
||||
Self::AroundCurrentTime => "Play Around Current Time",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where playback starts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlayFrom {
|
||||
RangeStart,
|
||||
#[default]
|
||||
CurrentTime,
|
||||
}
|
||||
|
||||
impl PlayFrom {
|
||||
pub const ALL: [PlayFrom; 2] = [Self::RangeStart, Self::CurrentTime];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RangeStart => "Range Start",
|
||||
Self::CurrentTime => "Current Time",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolution of a preview (Auto: the viewer's own).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PreviewResolution {
|
||||
#[default]
|
||||
Auto,
|
||||
Full,
|
||||
Half,
|
||||
Third,
|
||||
Quarter,
|
||||
/// Every n-th pixel ([`PreviewPreset::custom_resolution`]).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl PreviewResolution {
|
||||
pub const ALL: [PreviewResolution; 6] = [Self::Auto, Self::Full, Self::Half, Self::Third, Self::Quarter, Self::Custom];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "Auto",
|
||||
Self::Full => "Full",
|
||||
Self::Half => "Half",
|
||||
Self::Third => "Third",
|
||||
Self::Quarter => "Quarter",
|
||||
Self::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
/// Pixel step (1 Full … 4 Quarter, `custom` for Custom); `None` for Auto.
|
||||
pub fn factor(self, custom: u32) -> Option<u32> {
|
||||
match self {
|
||||
Self::Auto => None,
|
||||
Self::Full => Some(1),
|
||||
Self::Half => Some(2),
|
||||
Self::Third => Some(3),
|
||||
Self::Quarter => Some(4),
|
||||
Self::Custom => Some(custom.clamp(1, 40)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of one shortcut.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PreviewPreset {
|
||||
pub include_video: bool,
|
||||
pub include_audio: bool,
|
||||
/// Grids, guides and safe margins stay visible while playing.
|
||||
pub include_overlays: bool,
|
||||
/// Layer handles, paths and motion paths stay visible while playing.
|
||||
pub include_layer_controls: bool,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
/// Render the whole range before playing it.
|
||||
pub cache_before_playback: bool,
|
||||
pub range: PreviewRange,
|
||||
/// Play Around Current Time: seconds before and after the current time.
|
||||
pub pre_roll: f64,
|
||||
pub post_roll: f64,
|
||||
pub play_from: PlayFrom,
|
||||
/// Frames per second; `None` = Auto (the comp's rate).
|
||||
pub frame_rate: Option<f64>,
|
||||
/// Frames skipped between the frames played (0 = every frame).
|
||||
pub skip: u32,
|
||||
pub resolution: PreviewResolution,
|
||||
/// Custom resolution: render every n-th pixel (1–40).
|
||||
pub custom_resolution: u32,
|
||||
/// Play in a full-screen viewer.
|
||||
pub full_screen: bool,
|
||||
/// With Cache Before Playback, pressing the shortcut again while caching plays the frames
|
||||
/// already cached.
|
||||
pub play_cached_frames: bool,
|
||||
/// When the preview stops, the current time moves to the last frame shown (off: it returns
|
||||
/// to where the preview started).
|
||||
pub move_time_to_preview_time: bool,
|
||||
}
|
||||
|
||||
impl Default for PreviewPreset {
|
||||
fn default() -> Self {
|
||||
PreviewPreset {
|
||||
include_video: true,
|
||||
include_audio: true,
|
||||
include_overlays: true,
|
||||
include_layer_controls: false,
|
||||
loop_: true,
|
||||
cache_before_playback: false,
|
||||
range: PreviewRange::WorkAreaExtended,
|
||||
pre_roll: 2.0,
|
||||
post_roll: 2.0,
|
||||
play_from: PlayFrom::CurrentTime,
|
||||
frame_rate: None,
|
||||
skip: 0,
|
||||
resolution: PreviewResolution::Auto,
|
||||
custom_resolution: 2,
|
||||
full_screen: false,
|
||||
play_cached_frames: true,
|
||||
move_time_to_preview_time: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewPreset {
|
||||
/// The factory options of a shortcut.
|
||||
pub fn default_for(s: PreviewShortcut) -> Self {
|
||||
let d = PreviewPreset::default();
|
||||
match s {
|
||||
PreviewShortcut::Spacebar => d,
|
||||
// A lighter preview: every other frame.
|
||||
PreviewShortcut::ShiftSpacebar => PreviewPreset { skip: 1, ..d },
|
||||
PreviewShortcut::Numpad0 => PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d },
|
||||
PreviewShortcut::ShiftNumpad0 => {
|
||||
PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, skip: 1, resolution: PreviewResolution::Half, ..d }
|
||||
}
|
||||
// Audio only.
|
||||
PreviewShortcut::AltNumpad0 => PreviewPreset { include_video: false, range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d },
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(&mut self) {
|
||||
self.pre_roll = if self.pre_roll.is_finite() { self.pre_roll.clamp(0.0, 3600.0) } else { 2.0 };
|
||||
self.post_roll = if self.post_roll.is_finite() { self.post_roll.clamp(0.0, 3600.0) } else { 2.0 };
|
||||
self.frame_rate = self.frame_rate.filter(|r| r.is_finite() && *r > 0.0).map(|r| r.clamp(0.01, 999.0));
|
||||
self.skip = self.skip.min(99);
|
||||
self.custom_resolution = self.custom_resolution.clamp(1, 40);
|
||||
if !self.include_video && !self.include_audio {
|
||||
self.include_video = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Preview panel: the shortcut shown in the panel and every shortcut's options.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PreviewSettings {
|
||||
/// The shortcut whose options the panel shows (and the play button uses).
|
||||
pub current: PreviewShortcut,
|
||||
pub spacebar: PreviewPreset,
|
||||
pub shift_spacebar: PreviewPreset,
|
||||
pub numpad0: PreviewPreset,
|
||||
pub shift_numpad0: PreviewPreset,
|
||||
pub alt_numpad0: PreviewPreset,
|
||||
}
|
||||
|
||||
impl Default for PreviewSettings {
|
||||
fn default() -> Self {
|
||||
use PreviewShortcut as S;
|
||||
PreviewSettings {
|
||||
current: S::Spacebar,
|
||||
spacebar: PreviewPreset::default_for(S::Spacebar),
|
||||
shift_spacebar: PreviewPreset::default_for(S::ShiftSpacebar),
|
||||
numpad0: PreviewPreset::default_for(S::Numpad0),
|
||||
shift_numpad0: PreviewPreset::default_for(S::ShiftNumpad0),
|
||||
alt_numpad0: PreviewPreset::default_for(S::AltNumpad0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewSettings {
|
||||
pub fn get(&self, s: PreviewShortcut) -> &PreviewPreset {
|
||||
match s {
|
||||
PreviewShortcut::Spacebar => &self.spacebar,
|
||||
PreviewShortcut::ShiftSpacebar => &self.shift_spacebar,
|
||||
PreviewShortcut::Numpad0 => &self.numpad0,
|
||||
PreviewShortcut::ShiftNumpad0 => &self.shift_numpad0,
|
||||
PreviewShortcut::AltNumpad0 => &self.alt_numpad0,
|
||||
}
|
||||
}
|
||||
pub fn get_mut(&mut self, s: PreviewShortcut) -> &mut PreviewPreset {
|
||||
match s {
|
||||
PreviewShortcut::Spacebar => &mut self.spacebar,
|
||||
PreviewShortcut::ShiftSpacebar => &mut self.shift_spacebar,
|
||||
PreviewShortcut::Numpad0 => &mut self.numpad0,
|
||||
PreviewShortcut::ShiftNumpad0 => &mut self.shift_numpad0,
|
||||
PreviewShortcut::AltNumpad0 => &mut self.alt_numpad0,
|
||||
}
|
||||
}
|
||||
/// The options of the shortcut shown in the panel.
|
||||
pub fn active(&self) -> &PreviewPreset {
|
||||
self.get(self.current)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a preview plays: frames `start..=end` (comp frames), starting at `first`, every
|
||||
/// `step`-th frame, `fps` frames shown per second.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PreviewPlan {
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
pub first: i64,
|
||||
pub step: i64,
|
||||
/// Comp frames advanced per second of playback (the frame rate; Skip doesn't change speed).
|
||||
pub fps: f64,
|
||||
pub looping: bool,
|
||||
pub cache_first: bool,
|
||||
pub video: bool,
|
||||
pub audio: bool,
|
||||
/// Where the current time goes when the preview stops without moving it.
|
||||
pub origin: Tick,
|
||||
}
|
||||
|
||||
impl PreviewPlan {
|
||||
/// The frame shown `elapsed` seconds after the start; `None` once a non-looping preview has
|
||||
/// played to its end.
|
||||
pub fn frame_at(&self, elapsed: f64) -> Option<i64> {
|
||||
let n = ((elapsed.max(0.0) * self.fps) / self.step as f64).floor() as i64;
|
||||
self.frame_after(self.first, n)
|
||||
}
|
||||
|
||||
/// The frame `n` played frames after `from` (wrapping when looping).
|
||||
pub fn frame_after(&self, from: i64, n: i64) -> Option<i64> {
|
||||
let f = from + n * self.step;
|
||||
if f <= self.end {
|
||||
return Some(f);
|
||||
}
|
||||
if !self.looping {
|
||||
return None;
|
||||
}
|
||||
// Loop back to the range start on the skip grid.
|
||||
let len = ((self.end - self.start) / self.step + 1).max(1);
|
||||
let past = (f - self.end - 1) / self.step;
|
||||
Some(self.start + past.rem_euclid(len) * self.step)
|
||||
}
|
||||
|
||||
/// Every frame the preview shows, in range order.
|
||||
pub fn frames(&self) -> impl Iterator<Item = i64> + '_ {
|
||||
(self.start..=self.end).step_by(self.step.max(1) as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan of a preview of `comp` with options `p`, started at comp time `now`.
|
||||
pub fn plan(p: &PreviewPreset, comp: &Comp, now: Tick) -> PreviewPlan {
|
||||
let fr = comp.frame_rate;
|
||||
let last = fr.frame_at(comp.duration - comp.frame_duration()).max(0);
|
||||
let cur = fr.frame_at(now).clamp(0, last);
|
||||
let (wa, wb) = (fr.frame_at(comp.work_area.0), fr.frame_at(comp.work_area.1 - comp.frame_duration()));
|
||||
let (wa, wb) = (wa.clamp(0, last), wb.clamp(0, last).max(wa.clamp(0, last)));
|
||||
let (start, end) = match p.range {
|
||||
PreviewRange::WorkArea => (wa, wb),
|
||||
PreviewRange::WorkAreaExtended => (wa.min(cur), wb.max(cur)),
|
||||
PreviewRange::EntireDuration => (0, last),
|
||||
PreviewRange::AroundCurrentTime => {
|
||||
let pre = (p.pre_roll * fr.as_f64()).round() as i64;
|
||||
let post = (p.post_roll * fr.as_f64()).round() as i64;
|
||||
((cur - pre).max(0), (cur + post).min(last))
|
||||
}
|
||||
};
|
||||
let step = p.skip as i64 + 1;
|
||||
let first = match p.play_from {
|
||||
PlayFrom::RangeStart => start,
|
||||
// From the current time when it is inside the range (and not on its last frame).
|
||||
PlayFrom::CurrentTime if cur >= start && cur < end => start + (cur - start) / step * step,
|
||||
PlayFrom::CurrentTime => start,
|
||||
};
|
||||
PreviewPlan {
|
||||
start,
|
||||
end,
|
||||
first,
|
||||
step,
|
||||
fps: p.frame_rate.unwrap_or_else(|| fr.as_f64()),
|
||||
looping: p.loop_,
|
||||
cache_first: p.cache_before_playback,
|
||||
video: p.include_video,
|
||||
audio: p.include_audio,
|
||||
origin: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut_p(s: &Session, p: &Value, cmd: &str) -> Result<PreviewShortcut> {
|
||||
match str_p(p, "shortcut") {
|
||||
Some(k) => {
|
||||
PreviewShortcut::parse(k).ok_or_else(|| bad(cmd, format!("unknown shortcut `{k}` (spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0)")))
|
||||
}
|
||||
None => Ok(s.prefs.preview.current),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sc = shortcut_p(s, p, "playback.settings.get")?;
|
||||
let mut out = serde_json::to_value(s.prefs.preview.get(sc)).map_err(|e| bad("playback.settings.get", e.to_string()))?;
|
||||
out["shortcut"] = json!(sc.id());
|
||||
out["current"] = json!(s.prefs.preview.current.id());
|
||||
if let Some(c) = s.active_comp() {
|
||||
let pl = plan(s.prefs.preview.get(sc), c, s.time());
|
||||
out["plan"] = json!({"start": pl.start, "end": pl.end, "first": pl.first, "step": pl.step, "fps": pl.fps});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "playback.settings.set";
|
||||
let sc = shortcut_p(s, p, c)?;
|
||||
let mut fields = serde_json::Map::new();
|
||||
if let Some(Value::Object(m)) = p.get("values") {
|
||||
fields.extend(m.clone());
|
||||
}
|
||||
if let Value::Object(m) = p {
|
||||
for (k, v) in m {
|
||||
if !matches!(k.as_str(), "shortcut" | "values" | "current" | "reset") {
|
||||
fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next = s.prefs.preview.clone();
|
||||
if p.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
*next.get_mut(sc) = PreviewPreset::default_for(sc);
|
||||
}
|
||||
let mut cur = serde_json::to_value(next.get(sc)).map_err(|e| bad(c, e.to_string()))?;
|
||||
for (k, v) in fields {
|
||||
if cur.get(&k).is_none() {
|
||||
return Err(bad(c, format!("unknown preview setting `{k}`")));
|
||||
}
|
||||
// Frame rate: a number, or "auto"/null.
|
||||
let v = if k == "frameRate" && (v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("auto"))) { Value::Null } else { v };
|
||||
cur[k.as_str()] = v;
|
||||
}
|
||||
let mut pr: PreviewPreset = serde_json::from_value(cur).map_err(|e| bad(c, e.to_string()))?;
|
||||
pr.normalize();
|
||||
*next.get_mut(sc) = pr;
|
||||
if let Some(k) = str_p(p, "current") {
|
||||
next.current = PreviewShortcut::parse(k).ok_or_else(|| bad(c, format!("unknown shortcut `{k}`")))?;
|
||||
} else if p.get("shortcut").is_some() && p.as_object().is_some_and(|m| m.len() == 1) {
|
||||
// `{shortcut}` alone shows that shortcut in the panel.
|
||||
next.current = sc;
|
||||
}
|
||||
s.prefs.preview = next;
|
||||
s.save_prefs();
|
||||
get(s, &json!({"shortcut": sc.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!(
|
||||
"playback.settings.get",
|
||||
"Preview Settings",
|
||||
"{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (default: the one shown in the Preview panel)} → options + plan {start, end, first, step, fps}",
|
||||
get
|
||||
),
|
||||
cmd!(
|
||||
"playback.settings.set",
|
||||
"Change Preview Settings",
|
||||
[],
|
||||
None,
|
||||
"{shortcut?, current?: shortcut shown in the panel, reset?: bool, values?: {…}, includeVideo?, includeAudio?, includeOverlays?, includeLayerControls?, loop?, cacheBeforePlayback?, range?: workArea|workAreaExtended|entireDuration|aroundCurrentTime, preRoll?, postRoll?: seconds, playFrom?: rangeStart|currentTime, frameRate?: fps|\"auto\", skip?, resolution?: auto|full|half|third|quarter|custom, customResolution?, fullScreen?, playCachedFrames?, moveTimeToPreviewTime?}",
|
||||
always,
|
||||
set
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
fn comp_session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 320, "height": 180, "frameRate": 24, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_and_play_from() {
|
||||
let mut s = comp_session();
|
||||
let mut c = s.active_comp().unwrap().clone();
|
||||
c.work_area = (Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(4.0));
|
||||
let at = |sec: f64| Tick::from_seconds_f64(sec);
|
||||
let mut p = PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..Default::default() };
|
||||
let pl = plan(&p, &c, at(1.0));
|
||||
assert_eq!((pl.start, pl.end, pl.first), (48, 95, 48));
|
||||
// Extended by the current time (before the work area).
|
||||
p.range = PreviewRange::WorkAreaExtended;
|
||||
let pl = plan(&p, &c, at(1.0));
|
||||
assert_eq!((pl.start, pl.end), (24, 95));
|
||||
// ... and after it.
|
||||
let pl = plan(&p, &c, at(6.0));
|
||||
assert_eq!((pl.start, pl.end), (48, 144));
|
||||
p.range = PreviewRange::EntireDuration;
|
||||
p.play_from = PlayFrom::CurrentTime;
|
||||
let pl = plan(&p, &c, at(5.0));
|
||||
assert_eq!((pl.start, pl.end, pl.first), (0, 239, 120));
|
||||
p.range = PreviewRange::AroundCurrentTime;
|
||||
p.pre_roll = 1.0;
|
||||
p.post_roll = 0.5;
|
||||
let pl = plan(&p, &c, at(5.0));
|
||||
assert_eq!((pl.start, pl.end, pl.first), (96, 132, 120));
|
||||
// Pre-roll clamps at the comp start.
|
||||
let pl = plan(&p, &c, at(0.25));
|
||||
assert_eq!(pl.start, 0);
|
||||
s.execute("playback.settings.set", json!({"range": "workArea"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_rate_and_loop() {
|
||||
let s = comp_session();
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
let p = PreviewPreset { range: PreviewRange::EntireDuration, play_from: PlayFrom::RangeStart, skip: 2, frame_rate: Some(12.0), ..Default::default() };
|
||||
let pl = plan(&p, &c, Tick::ZERO);
|
||||
assert_eq!(pl.step, 3);
|
||||
assert_eq!(pl.fps, 12.0);
|
||||
// 12 comp frames per second, every third shown.
|
||||
assert_eq!(pl.frame_at(0.0), Some(0));
|
||||
assert_eq!(pl.frame_at(0.26), Some(3));
|
||||
assert_eq!(pl.frame_at(1.0), Some(12));
|
||||
assert!(pl.frames().all(|f| f % 3 == 0));
|
||||
// Loop wraps onto the skip grid; no loop ends.
|
||||
assert_eq!(pl.frame_after(237, 1), Some(0));
|
||||
let pl = PreviewPlan { looping: false, ..pl };
|
||||
assert_eq!(pl.frame_after(237, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_commands_round_trip() {
|
||||
let mut s = comp_session();
|
||||
let r = s.execute("playback.settings.get", json!({})).unwrap();
|
||||
assert_eq!(r["shortcut"], "spacebar");
|
||||
assert_eq!(r["range"], "workAreaExtended");
|
||||
// Each shortcut keeps its own options.
|
||||
let r = s
|
||||
.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "skip": 3, "frameRate": 15, "resolution": "quarter", "loop": false}))
|
||||
.unwrap();
|
||||
assert_eq!(r["skip"], 3);
|
||||
assert_eq!(r["frameRate"], 15.0);
|
||||
assert_eq!(r["plan"]["step"], 4);
|
||||
let sp = s.execute("playback.settings.get", json!({"shortcut": "Spacebar"})).unwrap();
|
||||
assert_eq!(sp["skip"], 0);
|
||||
assert_eq!(sp["frameRate"], Value::Null);
|
||||
// Auto rate, panel shortcut, reset.
|
||||
s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "frameRate": "auto", "current": "shiftNumpad0"})).unwrap();
|
||||
assert_eq!(s.prefs.preview.current, PreviewShortcut::ShiftNumpad0);
|
||||
assert_eq!(s.prefs.preview.shift_numpad0.frame_rate, None);
|
||||
assert!(s.execute("playback.settings.set", json!({"bogus": 1})).is_err());
|
||||
assert!(s.execute("playback.settings.set", json!({"shortcut": "f13"})).is_err());
|
||||
s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "reset": true})).unwrap();
|
||||
assert_eq!(s.prefs.preview.shift_numpad0, PreviewPreset::default_for(PreviewShortcut::ShiftNumpad0));
|
||||
// Persisted with the settings.
|
||||
let v: Value = serde_json::from_str(&s.prefs.to_json()).unwrap();
|
||||
assert_eq!(v["preview"]["current"], "shiftNumpad0");
|
||||
let back: crate::prefs::Prefs = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.preview, s.prefs.preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_names() {
|
||||
for s in PreviewShortcut::ALL {
|
||||
assert_eq!(PreviewShortcut::parse(s.id()), Some(s));
|
||||
assert_eq!(PreviewShortcut::parse(s.label()), Some(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user