Fix tests and two behaviours after the community batch

- Renderer: no cover step for adjustment layers (#686); their buffer is the comp below.
  Restores a single CPU step in split adjustment stacks.
- Effect Controls rename (#953): focus the name field the frame it exists. Focusing an id
  not yet drawn left AccessKit with a focus outside its tree (a panic in the consumer).
  The tests double-click at 60 fps, within egui's double-click time.
- i18n: Russian, French and German entries for Copy Frame to Clipboard and the extrusion
  Front/Bevel/Side/Back Color submenus added by today's other PRs.
- Tests: "fr" is now a valid language; the marker dialog's comment is focused on open
  (#922), so Enter-is-OK is tested with the focus elsewhere; the toolbar gradient tests
  give the selected layer its paint kind, since the toolbar mirrors it (#732).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-12 05:08:58 -04:00
co-authored by Claude Opus 5.5
parent bbc3a231c7
commit 61aaf83612
7 changed files with 69 additions and 21 deletions
+1 -1
View File
@@ -649,7 +649,7 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br"));
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
let saved = store.read(PREFS_FILE).unwrap();
for bad in [json!("fr"), json!(""), json!(17), json!(null)] {
for bad in [json!("xx"), json!(""), json!(17), json!(null)] {
assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err());
assert_eq!(s.prefs.general.language, "ja");
assert_eq!(store.read(PREFS_FILE).unwrap(), saved);
+4 -2
View File
@@ -1122,8 +1122,10 @@ impl<'a> Renderer<'a> {
// #659: an authored layer's buffer spans only its painted content while the stack works
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
// already has, so tiling or displacement can place pixels outside the content instead
// of losing them at its edge. Only when something will actually run.
if fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
// of losing them at its edge. Only when something will actually run, and not for an
// adjustment layer, whose buffer is the comp below and already spans it.
if !adjustment
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
target.cover(layer_size);
}
let mut mask_shapes = masks::shapes(ctx, layer);
+18
View File
@@ -4009,6 +4009,12 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
/// Russian (addition): the same (command, English label) keys as `JAPANESE`, one
/// entry per fixed menu entry, in the same order.
const RUSSIAN: &[(&str, &str, &str)] = &[
("comp.copyFrame", "Copy Frame to Clipboard", "Копировать кадр в буфер обмена"),
("", "Front", "Передняя грань"),
("", "Bevel", "Фаска"),
("", "Side", "Боковая грань"),
("", "Back", "Задняя грань"),
("layer.addTextAnimator", "Color", "Цвет"),
("", "EffectCraft", "EffectCraft"),
("app.about", "About EffectCraft...", "О EffectCraft..."),
("", "Settings...", "Настройки..."),
@@ -4632,6 +4638,12 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
/// French (addition): the same (command, English label) keys as `JAPANESE`, one
/// entry per fixed menu entry, in the same order.
const FRENCH: &[(&str, &str, &str)] = &[
("comp.copyFrame", "Copy Frame to Clipboard", "Copier l'image dans le presse-papiers"),
("", "Front", "Avant"),
("", "Bevel", "Biseau"),
("", "Side", "Côté"),
("", "Back", "Arrière"),
("layer.addTextAnimator", "Color", "Couleur"),
("", "EffectCraft", "EffectCraft"),
("app.about", "About EffectCraft...", "À propos d’EffectCraft..."),
("", "Settings...", "Préférences..."),
@@ -5255,6 +5267,12 @@ const FRENCH: &[(&str, &str, &str)] = &[
/// German (addition): the same (command, English label) keys as `JAPANESE`, one
/// entry per fixed menu entry, in the same order.
const GERMAN: &[(&str, &str, &str)] = &[
("comp.copyFrame", "Copy Frame to Clipboard", "Frame in die Zwischenablage kopieren"),
("", "Front", "Vorderseite"),
("", "Bevel", "Abschrägung"),
("", "Side", "Seite"),
("", "Back", "Rückseite"),
("layer.addTextAnimator", "Color", "Farbe"),
("", "EffectCraft", "EffectCraft"),
("app.about", "About EffectCraft...", "Über EffectCraft..."),
("", "Settings...", "Voreinstellungen..."),
+8 -2
View File
@@ -1127,6 +1127,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(mut value) = editing.as_ref().cloned() {
let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0));
let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0)));
// Focus the field the frame it first exists: focusing an id not yet on screen leaves
// the accessibility tree with a focus it doesn't contain.
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_id.with("start"))).is_some() {
crate::widgets::select_all(&ctx, response.id, &value);
response.request_focus();
}
app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name");
// Escape cancels. egui drops the field's focus on Escape in the same frame, so the
// key is checked on the focus being lost as well as held.
@@ -1170,7 +1176,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
if hresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone()));
ctx.memory_mut(|m| m.request_focus(rename_id.with("field")));
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
}
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name));
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
@@ -1202,7 +1208,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid}));
if ui.button(crate::i18n::tr("Rename")).clicked() {
ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone()));
ctx.memory_mut(|m| m.request_focus(rename_id.with("field")));
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
ui.close();
}
ui.separator();
+8
View File
@@ -504,6 +504,14 @@ mod tests {
// Enter is OK and Cmd+Backspace is Delete.
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
// The dialog opens with Comment focused (#922), a multi-line field where Enter types a
// new line; with the focus elsewhere, Enter is OK.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
app.auto.begin_frame();
crate::panels::dialogs::show(&mut app, ui.ctx());
});
out.textures_delta.clear();
ctx.memory_mut(|m| m.stop_text_input());
app.dialog_state.marker.chapter = "Entered".into();
crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.dialog.is_none());
+16 -7
View File
@@ -528,7 +528,7 @@ fn radial_center_stays_draggable_when_its_property_is_selected() {
#[test]
fn clicking_an_effect_parameter_activates_its_header_and_handles() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let name = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
click(&mut h, name);
@@ -660,12 +660,11 @@ fn file_and_text_parameters_have_widgets() {
#[test]
fn effect_control_name_can_be_renamed_from_its_header() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let name = format!("effectControls.effect.{}.name", x.point_fx);
let point = rect_of(&h, &name).center();
click(&mut h, point);
click(&mut h, point);
double_click(&mut h, point);
h.run_steps(3);
assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx)));
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
@@ -683,11 +682,10 @@ fn effect_control_name_can_be_renamed_from_its_header() {
#[test]
fn effect_rename_escape_keeps_the_name() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let point = rect_of(&h, &format!("effectControls.effect.{}.name", x.point_fx)).center();
click(&mut h, point);
click(&mut h, point);
double_click(&mut h, point);
h.run_steps(3);
assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx)));
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
@@ -699,3 +697,14 @@ fn effect_rename_escape_keeps_the_name() {
assert_eq!(name, "Point Control");
assert!(!ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx)));
}
/// Two presses within egui's double-click time (one frame apart).
fn double_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) {
h.event(egui::Event::PointerMoved(pos));
h.step();
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(2);
}
+14 -9
View File
@@ -28,7 +28,14 @@ fn setup(items: &[&str], tool: serde_json::Value, select: bool) -> (Harness<'sta
for kind in items {
s.execute("layer.addShapeItem", json!({"layer": l, "kind": kind})).unwrap();
}
s.execute("shape.toolOptions", tool).unwrap();
s.execute("shape.toolOptions", tool.clone()).unwrap();
// The toolbar shows a selected shape layer's own Fill / Stroke kind (#732), so the layer gets
// the kind too.
if select && let Some(o) = tool.as_object() {
let mut p = o.clone();
p.insert("layers".into(), json!([l]));
s.execute("shape.fillStroke", serde_json::Value::Object(p)).unwrap();
}
s.state.selected_layers = if select { vec![LayerId(l)] } else { vec![] };
s.state.selected_props.clear();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
@@ -104,16 +111,14 @@ fn a_gradient_stroke_swatch_edits_the_gradient_stroke() {
assert_eq!((target.layer, target.prop), (l, uid), "on the Gradient Stroke's Colors");
}
/// With no shape layer selected, or one without that gradient, the swatch opens the options (the
/// kind new shapes get) as before.
/// With no shape layer selected the swatch opens the options (the kind new shapes get) as before.
/// (A selected shape layer shows its own kind, #732, so a gradient kind always has its gradient.)
#[test]
fn without_a_selected_gradient_the_swatch_opens_the_options() {
for (items, select) in [(vec![], true), (vec!["gfill"], false)] {
let (mut h, _) = setup(&items, json!({"fillType": "linear"}), select);
click(&mut h, "header.fill", Modifiers::NONE);
assert_eq!(h.state().dialog, None, "{items:?} selected={select}: no editor");
assert!(options_open(&h, "fill"), "{items:?} selected={select}: Fill Options");
}
let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), false);
click(&mut h, "header.fill", Modifiers::NONE);
assert_eq!(h.state().dialog, None, "no editor");
assert!(options_open(&h, "fill"), "Fill Options");
}
/// The rest of the toolbar is unchanged: the words open the options, a Solid Color swatch opens the