M0.9: Effect Controls parity — sliders behind param twirls with range labels, About... header, no hex; headless snapshot feeds synthetic input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:45:01 -04:00
parent 62cfa66496
commit a99be1499f
4 changed files with 94 additions and 31 deletions

View File

@ -60,14 +60,23 @@ fn parse_args() -> Result<Args, String> {
Ok(a)
}
/// One frame, first queueing the app's synthetic input (kittest doesn't call
/// `raw_input_hook`); the harness runs one frame per queued event.
fn step_frame(h: &mut Harness<'_, EffectcraftApp>) {
for e in h.state_mut().take_synthetic_input() {
h.event(e);
}
h.step();
}
/// Step the app for `secs` of wall time so background frame renders land in the viewer.
fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) {
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
h.step();
step_frame(h);
std::thread::sleep(Duration::from_millis(30));
}
h.step();
step_frame(h);
}
fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> {
@ -110,7 +119,7 @@ fn main() {
// Requests are answered on a later frame (some wait for input to be processed).
let deadline = Instant::now() + Duration::from_secs(10);
let reply = loop {
harness.step();
step_frame(&mut harness);
if let Ok(v) = reply_rx.try_recv() {
break v;
}

View File

@ -517,6 +517,14 @@ impl EffectcraftApp {
}
}
impl EffectcraftApp {
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
std::mem::take(&mut self.synthetic)
}
}
impl eframe::App for EffectcraftApp {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
if !self.synthetic.is_empty() {

View File

@ -89,24 +89,18 @@ fn prop_row(
if let Some(nv) = nv {
set(actions, json!(nv));
}
// Slider track for slider params.
if let ParamUi::Slider { slider_min, slider_max, .. } = prop.ui {
let tr = Rect::from_min_max(pos2(vr.max.x + 10.0, cy - 2.0), pos2(r.max.x - 12.0, cy + 2.0));
if tr.width() > 40.0 {
p.rect_filled(tr, 2.0, t.field_bg);
let f = ((v - slider_min) / (slider_max - slider_min)).clamp(0.0, 1.0) as f32;
p.rect_filled(Rect::from_min_max(tr.min, pos2(tr.min.x + tr.width() * f, tr.max.y)), 2.0, t.accent.gamma_multiply(0.7));
let knob = pos2(tr.min.x + tr.width() * f, cy);
p.circle_filled(knob, 5.0, t.text);
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag());
app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name);
if (sresp.dragged() || sresp.clicked())
&& let Some(pt) = sresp.interact_pointer_pos()
{
let f = ((pt.x - tr.min.x) / tr.width()).clamp(0.0, 1.0) as f64;
set(actions, json!(slider_min + (slider_max - slider_min) * f));
// AE hides a slider param's slider until its twirl is opened (see `slider_row`).
if matches!(prop.ui, ParamUi::Slider { .. }) {
let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() {
if open {
app.ui.fx_slider_open.remove(&uid);
} else {
app.ui.fx_slider_open.insert(uid);
}
}
app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name);
}
}
Value::Vec2(_) | Value::Vec3(_) => {
@ -139,13 +133,6 @@ fn prop_row(
if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
}
p.text(
pos2(sr.max.x + 8.0, cy),
Align2::LEFT_CENTER,
effectcraft_engine::color::Rgba::from_array([c[0] as f32, c[1] as f32, c[2] as f32, 1.0]).to_hex(),
Tokens::mono(11.0),
t.text_dim,
);
}
Value::Bool(b) => {
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
@ -187,6 +174,48 @@ fn prop_row(
}
}
/// The twirled-open slider under a slider param: track, knob and the slider range's ends.
#[allow(clippy::too_many_arguments)]
fn slider_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
prop: &Property,
ectx: &EvalCtx,
r: Rect,
x0: f32,
actions: &mut Actions,
) {
let t = app.tokens;
let ParamUi::Slider { slider_min, slider_max, decimals, .. } = prop.ui else { return };
let Value::Scalar(v) = ectx.value(layer, prop) else { return };
let uid = prop.uid;
let tr = Rect::from_min_max(pos2(r.min.x + x0, r.min.y + 8.0), pos2(r.max.x - 16.0, r.min.y + 12.0));
if tr.width() < 40.0 {
return;
}
p.rect_filled(tr, 2.0, t.field_bg);
let f = ((v - slider_min) / (slider_max - slider_min)).clamp(0.0, 1.0) as f32;
p.rect_filled(Rect::from_min_max(tr.min, pos2(tr.min.x + tr.width() * f, tr.max.y)), 2.0, t.accent.gamma_multiply(0.7));
p.circle_filled(pos2(tr.min.x + tr.width() * f, tr.center().y), 5.0, t.text);
let dec = decimals as usize;
p.text(pos2(tr.min.x, tr.max.y + 4.0), Align2::LEFT_TOP, format!("{slider_min:.dec$}"), Tokens::ui(10.5), t.text_dim);
p.text(pos2(tr.max.x, tr.max.y + 4.0), Align2::RIGHT_TOP, format!("{slider_max:.dec$}"), Tokens::ui(10.5), t.text_dim);
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag());
app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name);
if (sresp.dragged() || sresp.clicked())
&& let Some(pt) = sresp.interact_pointer_pos()
{
let f = ((pt.x - tr.min.x) / tr.width()).clamp(0.0, 1.0) as f64;
actions.push((
"prop.set".into(),
json!({"layer": layer.id.0, "prop": uid, "value": slider_min + (slider_max - slider_min) * f, "merge": format!("ec-{uid}")}),
));
}
}
#[allow(clippy::too_many_arguments)]
fn group_rows(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
@ -212,6 +241,11 @@ fn group_rows(
continue;
}
prop_row(app, ui, p, layer, pr, ectx, r, 24.0 + 14.0 * depth as f32, actions);
if app.ui.fx_slider_open.contains(&pr.uid) {
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0));
*y += 34.0;
slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions);
}
}
Node::Group(sg) => {
*y += 24.0;
@ -291,7 +325,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text);
let reset = Rect::from_min_size(pos2(r.max.x - 92.0, r.min.y + 4.0), vec2(40.0, 18.0));
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0));
let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click());
bp.text(reset.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
@ -309,11 +343,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
let rm = Rect::from_min_size(pos2(r.max.x - 28.0, r.min.y + 4.0), vec2(18.0, 18.0));
if widgets::icon_button(ui, rm, Icon::Close, false, &t, egui::Id::new(("ec-rm", g.uid))).on_hover_text("Remove effect").clicked() {
actions.push(("effect.remove".into(), json!({"layer": layer.id.0, "effect": g.uid})));
// About…: the effect's name and category (AE's About dialog).
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0));
let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click());
bp.text(about.center(), Align2::CENTER_CENTER, "About...", Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");
let apop = egui::Id::new(("ec-about-pop", g.uid));
if aresp.clicked() {
widgets::open_popup(ui, apop);
}
if let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) {
let lines = vec![spec.name.to_string(), format!("Category: {}", spec.category), "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string()];
let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None);
}
app.auto.add(&format!("effectControls.effect.{}.remove", g.uid), rm, "Remove");
let hresp =
ui.interact(Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), Sense::click());
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);

View File

@ -298,6 +298,9 @@ pub struct UiState {
pub project_sort_desc: bool,
/// Effect Controls twirl state (group uids that are collapsed).
pub fx_closed: BTreeSet<u64>,
/// Slider params whose slider row is twirled open (AE hides sliders by default).
#[serde(default)]
pub fx_slider_open: BTreeSet<u64>,
/// Shape tool options.
pub fill_color: [f32; 3],
pub stroke_color: [f32; 3],
@ -331,6 +334,7 @@ impl Default for UiState {
project_sort: default_project_sort(),
project_sort_desc: false,
fx_closed: BTreeSet::new(),
fx_slider_open: BTreeSet::new(),
fill_color: [0.24, 0.55, 0.96],
stroke_color: [1.0, 1.0, 1.0],
stroke_width: 0.0,