diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 6e9b28e49..c0b03f77e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -162,7 +162,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // Style list. ui.vertical(|ui| { ui.set_width(200.0); - egui::ScrollArea::vertical().id_salt("ls-list").max_height(body_h).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("ls-list").max_height(body_h).min_scrolled_height(0.0).show(ui, |ui| { let mut entries = vec![(BLENDING, "Blending Options")]; entries.extend(STYLES.iter().copied()); for (id, name) in entries { @@ -201,26 +201,37 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.label(egui::RichText::new(tr("Not applied: tick it in the list to add it.")).weak()); return; }; - egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).show(ui, |ui| { - egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - g.walk("", &mut |_, p| { - if matches!(p.ui, ParamUi::Hidden) { - return; - } - let lbl = ui.label(&p.name); - let v = p.value_at(lt); - if let Some(nv) = control(ui, p, &v) { - actions.push(( - "prop.set".into(), - json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}), - )); - } - let path = g.match_path_of(p.uid).unwrap_or_default(); - app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name); - ui.end_row(); + // Both ways, in the room left under the heading: in a narrow window (or at a high UI + // Scale) the controls scroll sideways rather than being cut off. + egui::ScrollArea::both() + .id_salt("ls-controls") + .max_width(ui.available_width().max(0.0)) + .max_height(ui.available_height().max(0.0)) + .min_scrolled_width(0.0) + .min_scrolled_height(0.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + g.walk("", &mut |_, p| { + if matches!(p.ui, ParamUi::Hidden) { + return; + } + let lbl = ui.label(&p.name); + let v = p.value_at(lt); + let edit = ui.scope(|ui| control(ui, p, &v)); + if let Some(nv) = edit.inner { + actions.push(( + "prop.set".into(), + json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}), + )); + } + let path = g.match_path_of(p.uid).unwrap_or_default(); + app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name); + app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), edit.response.rect, &p.name); + ui.end_row(); + }); }); }); - }); }); } }); diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/ui_tail.rs index 6fcd4bcd0..aed59e56d 100644 --- a/crates/ui-egui/tests/ui_tail.rs +++ b/crates/ui-egui/tests/ui_tail.rs @@ -8,10 +8,14 @@ use egui_kittest::Harness; use serde_json::json; fn harness(w: f32) -> Harness<'static, EffectcraftApp> { + harness_at(vec2(w, 900.0)) +} + +fn harness_at(size: egui::Vec2) -> Harness<'static, EffectcraftApp> { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap(); s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(w, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + let mut h = Harness::builder().with_size(size).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); h } @@ -113,33 +117,93 @@ fn layer_style_dialog_adds_previews_and_cancels() { assert_eq!(styles(&mut h), ["stroke"]); } -/// #1062: the Layer Style dialog's OK and Cancel stay in view, below the list and the style's -/// controls (they were laid out past its bottom edge, under the fold of its scroll area, so a -/// click on them did nothing until the dialog was scrolled), also at 200 % UI Scale. -#[test] -fn layer_style_buttons_stay_in_view() { - let mut h = harness(1400.0); +/// The Layer Style dialog on the solid, opened from Layer ▸ Layer Styles ▸ Drop Shadow (which +/// adds the style) at `scale` % UI Scale. Returns the layer. +fn open_drop_shadow(h: &mut Harness<'_, EffectcraftApp>, scale: u32) -> u64 { let lid = h.state().session.active_comp().unwrap().layers[0].id.0; h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + h.state_mut().set_pref("appearance.uiScale", json!(scale)).unwrap(); + h.run_steps(3); let ctx = h.ctx.clone(); - // Whole: inside the window, and painted with none of it clipped away. - let in_view = |h: &Harness<'_, EffectcraftApp>, id: &str| { - let r = rect(h, id); - let clip = - h.output().shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Rect(s) if (s.rect.center() - r.center()).length() < 0.5)).map(|c| c.clip_rect); - assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{id} is cut off: {r:?} in {clip:?}"); - r.center() - }; - for (scale, button, kept) in [(100, "dialog.layerStyle.ok", 1), (200, "dialog.layerStyle.cancel", 1)] { - h.state_mut().set_pref("appearance.uiScale", json!(scale)).unwrap(); - h.run_steps(3); - effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); + // (Its layout settles a few frames after it opens.) + h.run_steps(5); + lid +} + +/// `id` whole: inside the window, and painted with none of it clipped away. +fn in_view(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = rect(h, id); + // (A shape it paints: the topmost lying inside it, not the panels behind the dialog.) + let clip = h.output().shapes.iter().rev().find(|c| matches!(&c.shape, egui::Shape::Rect(s) if r.expand(1.0).contains_rect(s.rect))).map(|c| c.clip_rect); + assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{id} is cut off: {r:?} in {clip:?}"); + r.center() +} + +fn style_value(h: &mut Harness<'_, EffectcraftApp>, lid: u64, path: &str) -> f64 { + h.state_mut().session.execute("prop.get", json!({"layer": lid, "path": format!("layerStyles/dropShadow/{path}")})).unwrap()["value"].as_f64().unwrap() +} + +/// #1062: the Layer Style dialog's OK and Cancel stay in view below the list and the style's +/// controls, at 100 % and 200 % UI Scale (they were laid out past its bottom edge, under the fold +/// of its scroll area, so a click on them did nothing until the dialog was scrolled). OK keeps an +/// edit; Cancel puts the project and its undo history back as they were before the dialog. +#[test] +fn layer_style_buttons_stay_in_view() { + for scale in [100, 200] { + for ok in [true, false] { + let mut h = harness(1400.0); + let before = (h.state().session.project.clone(), h.state().session.history.undo.len()); + let lid = open_drop_shadow(&mut h, scale); + let buttons = [in_view(&h, "dialog.layerStyle.ok"), in_view(&h, "dialog.layerStyle.cancel")]; + // An edit, as the dialog's controls make them. + h.state_mut() + .session + .execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 40, "merge": "layer-style-dialog"})) + .unwrap(); + h.run_steps(2); + click(&mut h, buttons[if ok { 0 } else { 1 }]); + let case = format!("{scale} %, {}", if ok { "OK" } else { "Cancel" }); + assert_eq!(h.state().dialog, None, "{case} closed the dialog"); + if ok { + assert_eq!(style_value(&mut h, lid, "distance"), 40.0, "{case} kept the edit"); + } else { + let after = (&h.state().session.project, h.state().session.history.undo.len()); + assert!(after.0.same_content(&before.0) && after.1 == before.1, "{case} put back the project and its history"); + } + } + } +} + +/// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so +/// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a +/// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow, +/// is scrolled to, seen whole and dragged. +#[test] +fn layer_style_controls_stay_reachable_in_a_small_window() { + for narrowed in [false, true] { + let mut h = harness_at(if narrowed { vec2(1600.0, 1000.0) } else { vec2(960.0, 600.0) }); + let lid = open_drop_shadow(&mut h, if narrowed { 100 } else { 200 }); + if narrowed { + h.set_size(vec2(960.0, 600.0)); + h.state_mut().set_pref("appearance.uiScale", json!(200)).unwrap(); + h.run_steps(5); + } + // Scroll the controls (under their heading) to their far end, both ways. + let over = rect(&h, "dialog.layerStyle.page").left_bottom() + vec2(10.0, 16.0); + h.input_mut().events.push(Event::PointerMoved(over)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(-2000.0, -2000.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); h.run_steps(5); - in_view(&h, "dialog.layerStyle.cancel"); - let at = in_view(&h, button); - click(&mut h, at); - assert_eq!(h.state().dialog, None, "{scale} %: {button} closed the dialog"); - assert_eq!(styles(&mut h).len(), kept, "{scale} %: {button}"); + let at = in_view(&h, "dialog.layerStyle.value.dropShadow/noise"); + let slider = rect(&h, "dialog.layerStyle.value.dropShadow/noise").left_center(); + drag(&mut h, slider + vec2(8.0, 0.0), slider + vec2(60.0, 0.0)); + assert!(style_value(&mut h, lid, "noise") > 0.0, "narrowed {narrowed}: Noise was dragged (at {at:?})"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles)); } }