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:
parent
62cfa66496
commit
a99be1499f
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue