Layer Styles: the controls scroll both ways above the buttons (#1062)

Keeping OK and Cancel in view took away the outer scroll area that also
scrolled the dialog sideways, and the controls only scrolled up and
down. In a narrow window (960x600 at 200 % UI Scale leaves the controls
about 190 points next to the 200-point list) they ran past the dialog's
right edge with no way to reach them.

The controls now scroll both ways in the room left under their heading,
with no minimum scrolled size, so a small budget can't hide the end of
them; the list scrolls in its height the same way. Each control also
registers dialog.layerStyle.value.<style>/<path> next to its label's id.

Tests:
- layer_style_controls_stay_reachable_in_a_small_window: in a 960x600
  window at 200 %, and opened large then narrowed, Noise is scrolled to,
  painted whole and dragged; fails before (it lies past the window).
- layer_style_buttons_stay_in_view now checks both buttons at 100 % and
  200 %, each case in a fresh app with a real edit: OK keeps it, Cancel
  puts the project and its undo history back as before the dialog.
This commit is contained in:
tylerjenningsw committed 2026-10-12 22:42:53 -04:00
1 parent bf911d18bc
commit de0f573620
2 files changed
+118 -43

No files matched your search

@@ -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();
});
});
});
});
});
}
});
+88 -24
View File
@@ -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));
}
}