Merge pull request #1057 from pravdomil/claude/shortcut-toggles

This commit is contained in:
Brandon Thomas committed 2026-10-13 04:29:03 -04:00
commit 767f1f6265
9 files changed
+91 -8

No files matched your search

+8 -1
View File
@@ -76,6 +76,13 @@ fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
"bicubic" => Sampling::Bicubic,
q => return Err(bad("layer.sampling", format!("sampling must be bilinear|bicubic, not `{q}`"))),
};
// `toggle`: layers that all have it already get the other one.
let all = s.project.comp(cid).is_some_and(|c| c.layers.iter().filter(|l| ids.contains(&l.id)).all(|l| l.switches.sampling == q));
let q = match q {
Sampling::Bilinear if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bicubic,
Sampling::Bicubic if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bilinear,
q => q,
};
s.edit("Layer Sampling", None, |proj, _| {
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.switches.sampling = q;
@@ -800,7 +807,7 @@ fn sequence(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.quality", "Quality", [], None, "{layers?, quality: best|draft|wireframe}", has_layers, quality),
cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic}", has_layers, sampling),
cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic, toggle?}", has_layers, sampling),
cmd!("layer.frameBlending", "Frame Blending", [], None, "{layers?, mode: off|frameMix|pixelMotion}", has_layers, frame_blending),
cmd!("layer.hideOtherVideo", "Hide Other Video", ["Layer", "Switches"], Some("Cmd+Alt+Shift+V"), "{layers?}", has_layers, hide_other_video),
cmd!("layer.showAllVideo", "Show All Video", ["Layer", "Switches"], None, "{comp?}", has_comp, show_all_video),
+4 -2
View File
@@ -127,8 +127,10 @@ fn snapping_options(s: &mut Session, p: &Value) -> Result<Value> {
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "channel").unwrap_or("rgb");
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
// Choosing the active single channel again returns to RGB, as in After Effects.
let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
// Choosing the active single channel again returns to RGB; not when that changes it between
// grayscale and colorized.
let same = c == s.state.viewer.channel && b_p(p, "colorized").is_none_or(|v| v == s.state.viewer.colorized);
let c = if same && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
s.state.viewer.channel = c;
if let Some(v) = b_p(p, "colorized") {
s.state.viewer.colorized = v;
+2 -2
View File
@@ -786,8 +786,8 @@ Layer
Draft | layer.quality {"quality":"draft"} | Cmd+Shift+U
Wireframe | layer.quality {"quality":"wireframe"} | Cmd+Alt+Shift+U
---
Bilinear | layer.sampling {"sampling":"bilinear"} | Alt+B
Bicubic | layer.sampling {"sampling":"bicubic"} | Alt+Shift+B
Bilinear | layer.sampling {"sampling":"bilinear","toggle":true} | Alt+B
Bicubic | layer.sampling {"sampling":"bicubic","toggle":true} | Alt+Shift+B
Switches
Hide Other Video | layer.hideOtherVideo
Show All Video | layer.showAllVideo
+24
View File
@@ -52,6 +52,30 @@ fn layer_quality_sampling_frame_blending_and_undo() {
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
}
/// `layer.sampling {toggle}` (Alt+B, Alt+Shift+B): layers that all have the sampling already get
/// the other one; without `toggle` it is only set.
#[test]
fn layer_sampling_toggles_when_every_layer_has_it() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let sampling = |s: &Session| [layer(s, a).switches.sampling, layer(s, b).switches.sampling];
let bicubic = json!({"layers": [a, b], "sampling": "bicubic", "toggle": true});
s.execute("layer.sampling", bicubic.clone()).unwrap();
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
s.execute("layer.sampling", bicubic.clone()).unwrap();
assert_eq!(sampling(&s), [Sampling::Bilinear; 2]);
s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bilinear", "toggle": true})).unwrap();
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
// Mixed layers all get the one asked for.
s.execute("layer.sampling", json!({"layers": [a], "sampling": "bilinear"})).unwrap();
s.execute("layer.sampling", bicubic).unwrap();
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
// Without `toggle` it stays.
s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bicubic"})).unwrap();
assert_eq!(sampling(&s), [Sampling::Bicubic; 2]);
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
/// other layers come back the next time.
#[test]
+10 -2
View File
@@ -408,6 +408,14 @@ fn fast_previews_and_show_channel_modes_have_default_keys() {
assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("green", false));
s.execute("view.channel.green", json!({})).unwrap();
assert_eq!(s.state.viewer.channel.id(), "rgb");
// Grayscale and colorized of one channel replace each other; the same again is RGB.
s.execute("view.channel.redColorized", json!({})).unwrap();
s.execute("view.channel.red", json!({})).unwrap();
assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", false));
s.execute("view.channel.redColorized", json!({})).unwrap();
assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true));
s.execute("view.channel.redColorized", json!({})).unwrap();
assert_eq!(s.state.viewer.channel.id(), "rgb");
}
/// Default keys for commands and menu entries that had none.
@@ -418,8 +426,8 @@ fn default_keys_for_commands_that_had_none() {
for (id, params, key) in [
("edit.pasteReversedKeyframes", json!(null), "Cmd+Shift+V"),
("edit.purge", json!({"what": "snapshot"}), "Cmd+Shift+F5"),
("layer.sampling", json!({"sampling": "bilinear"}), "Alt+B"),
("layer.sampling", json!({"sampling": "bicubic"}), "Alt+Shift+B"),
("layer.sampling", json!({"sampling": "bilinear", "toggle": true}), "Alt+B"),
("layer.sampling", json!({"sampling": "bicubic", "toggle": true}), "Alt+Shift+B"),
("view.res.custom", json!(null), "Cmd+Alt+J"),
("file.setProxy", json!(null), "Cmd+Alt+P"),
("file.openRecent", json!(null), "Cmd+Alt+Shift+P"),
+1 -1
View File
@@ -676,7 +676,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
crate::panels::timeline::zoom(app, ctx, k);
return Ok(Value::Null);
}
"timeline.zoomFit" => app.ui.timeline.pps = None,
"timeline.zoomFit" => crate::panels::timeline::toggle_fit_zoom(app, ctx),
"timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx),
"timeline.column" => {
let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?;
+15
View File
@@ -553,6 +553,21 @@ pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0);
}
/// `Shift+;`: show the whole comp; from there, go back to the span it was pressed in.
pub fn toggle_fit_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
let tl = &mut app.ui.timeline;
if let Some(pps) = tl.pps {
tl.before_fit = Some((tl.start, pps));
tl.pps = None;
tl.start = 0.0;
return;
}
let Some((start, pps)) = tl.before_fit.take() else { return };
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return };
let Some(comp) = app.session.active_comp_arc() else { return };
set_zoom(&mut app.ui.timeline, &comp, pps, fit_pps(w, &comp), start, 0.0);
}
/// Zoom the time ruler around the CTI.
pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) {
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return };
+4
View File
@@ -331,6 +331,9 @@ pub struct TimelineState {
/// Visible span start (seconds) and pixels per second; None = fit comp.
pub start: f64,
pub pps: Option<f64>,
/// The span (`start`, `pps`) Zoom to Fit Comp left, which it goes back to when pressed again.
#[serde(skip)]
pub before_fit: Option<(f64, f64)>,
pub scroll_y: f32,
/// Left column area width.
pub columns_w: f32,
@@ -415,6 +418,7 @@ impl Default for TimelineState {
TimelineState {
start: 0.0,
pps: None,
before_fit: None,
scroll_y: 0.0,
columns_w: 560.0,
outline_scroll: 0.0,
+23
View File
@@ -163,6 +163,29 @@ fn semicolon_toggles_frame_level_and_the_whole_comp() {
assert_eq!(h.state().ui.timeline.pps, None);
}
/// `Shift+;` zooms out to the whole comp, and again back in to the span it left.
#[test]
fn shift_semicolon_toggles_the_whole_comp_and_the_span_before() {
let (mut h, _) = harness();
h.state_mut().session.execute("time.set", json!({"time": 120.0})).unwrap();
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap();
h.run_steps(2);
let before = (h.state().ui.timeline.start, h.state().ui.timeline.pps);
assert!(before.1.is_some());
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap();
assert_eq!(h.state().ui.timeline.pps, None);
h.run_steps(2);
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap();
assert_eq!((h.state().ui.timeline.start, h.state().ui.timeline.pps), before);
// With nothing to go back to, the whole comp stays.
h.state_mut().ui.timeline.pps = None;
h.state_mut().ui.timeline.before_fit = None;
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap();
assert_eq!(h.state().ui.timeline.pps, None);
}
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) {
h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();