diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..0686dddc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod menu_keys; pub mod menus; pub mod native_menu; pub mod panels; +mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; @@ -1559,7 +1560,7 @@ impl eframe::App for EffectcraftApp { self.session.end_recovery(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); @@ -1573,6 +1574,9 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } + if pointer_wrap::prepare_input(ctx, raw_input) { + self.session.history.merge_key = None; + } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -1618,6 +1622,8 @@ impl eframe::App for EffectcraftApp { } self.frame(ui); let ctx = ui.ctx().clone(); + #[cfg(not(target_arch = "wasm32"))] + pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 39b7580e..c94a8dab 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -212,7 +212,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Width:")); ui.horizontal(|ui| { let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); let r = ui.checkbox(&mut d.lock_aspect, lbl); @@ -224,7 +224,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.end_row(); ui.label(tr("Height:")); let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.height", r.rect, "Height"); if d.lock_aspect && oh != d.height && oh > 0 { d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32; @@ -254,7 +254,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Frame Rate:")); ui.horizontal(|ui| { // Any rate can be typed; the list holds the common ones. - let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3))); auto.add("dialog.comp.frameRate", r.rect, "Frame Rate"); let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| { for f in FRAME_RATES { @@ -320,19 +320,19 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(egui::RichText::new(tr("Motion Blur")).strong()); ui.end_row(); ui.label(tr("Shutter Angle:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"))); auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle"); ui.end_row(); ui.label(tr("Shutter Phase:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"))); auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase"); ui.end_row(); ui.label(tr("Samples Per Frame:")); - let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.samples).range(2..=64))); auto.add("dialog.comp.samples", r.rect, "Samples Per Frame"); ui.end_row(); ui.label(tr("Adaptive Sample Limit:")); - let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256))); auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit"); ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f123906b..ead96989 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -360,14 +360,14 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (ow, oh) = (size[0], size[1]); ui.label(tr("Width:")); ui.horizontal(|ui| { - let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); ui.label(tr("Height:")); - let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.height", r.rect, "Height"); ui.end_row(); if lock && ow > 0 && oh > 0 { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b..6113c866 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -188,13 +188,13 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Zoom")); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px"))); let mut aov = angle_of_view(w, d.zoom); - if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°"))).changed() { d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan(); } let mut fl = focal_for_zoom(w, d.zoom); - if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm"))).changed() { d.zoom = zoom_for_focal(w, fl); } }); @@ -204,15 +204,15 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { - ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")); + ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); - ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px"))); ui.end_row(); ui.label(tr("Blur Level")); - ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %"))); ui.end_row(); }); ui.add_space(14.0); @@ -271,15 +271,15 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.color_edit_button_rgb(&mut d.color); ui.end_row(); ui.label(tr("Intensity")); - ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); ui.end_row(); let kind = KINDS[d.kind.min(3)]; if kind == LightKind::Spot { ui.label(tr("Cone Angle")); - ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"))); ui.end_row(); ui.label(tr("Cone Feather")); - ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"))); ui.end_row(); } if kind != LightKind::Ambient { @@ -292,20 +292,20 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); if d.falloff > 0 { ui.label(tr("Radius")); - ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); ui.label(tr("Falloff Distance")); - ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } ui.label(tr("Shadows")); ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); ui.end_row(); ui.label(tr("Shadow Diffusion")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px"))); ui.end_row(); } }); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..c264e74e 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -377,8 +377,8 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Point => { let mut a = v.as_vec2(); - let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); - let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1)); + let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1))); + let r2 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1))); if r1.changed() || r2.changed() { out = Some(json!(a)); } @@ -386,7 +386,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Angle => { let mut a = v.as_f64(); - let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1))); if r.changed() { out = Some(json!(a)); } @@ -415,7 +415,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } } }); - let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + let r3 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1))); app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); @@ -428,7 +428,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt // One percentage for every axis. let comps = v.components(); let mut x = comps.first().copied().unwrap_or(100.0); - let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1))); if r.changed() { out = Some(json!(vec![x; comps.len().max(1)])); } @@ -438,7 +438,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt let mut x = v.as_f64(); let r = match p.ui { ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)), - _ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2))), }; if r.changed() { out = Some(json!(x)); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index a9468451..88987553 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -769,7 +769,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect + ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect } FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 3e170c12..f4574a7d 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -126,7 +126,7 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str } fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { - let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix))); app.auto.add(id, r.rect, id); } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea..c6b6abbe 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -103,10 +103,10 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { ParamUi::Slider { slider_min, slider_max, decimals, .. } => { ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize)) } - ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")), - ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")), - ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")), - _ => ui.add(egui::DragValue::new(&mut y).speed(0.1)), + ParamUi::Percent => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("%"))), + ParamUi::Angle => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("°"))), + ParamUi::Pixels => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.25).suffix(" px"))), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.1))), }; (r.changed() && y != *x).then(|| json!(y)) } diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 89a428a6..c3631604 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -128,11 +128,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tr("Time:")); - let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.time", r.rect, "Time"); ui.end_row(); ui.label(tr("Duration:")); - let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.duration", r.rect, "Duration"); ui.end_row(); ui.label(tr("Comment:")); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ac79d8f0..11350e07 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -108,7 +108,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -119,7 +119,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Float(lo, hi, unit) => { let mut x = v.as_f64().unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 63ab06e8..13a5fa13 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -508,7 +508,8 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.horizontal(|ui| { let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); - let r = ui.add_enabled(d.blur_on, egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")); + let r = + ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); @@ -539,7 +540,7 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) .response; app.auto.add("dialog.trackOptions.action", r.rect, "If Confidence is Below action"); ui.label(crate::i18n::tr("If Confidence is Below")); - let r = ui.add(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %"))); app.auto.add("dialog.trackOptions.threshold", r.rect, "Confidence threshold"); }); (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackOptions"); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 8d41eb73..c4f79ed4 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -675,11 +675,13 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let mut ev = vo.exposure as f64; let resp = ui.put( er, - egui::DragValue::new(&mut ev) - .speed(0.05) - .range(-40.0..=40.0) - .fixed_decimals(1) - .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + crate::widgets::drag_value( + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ), ); let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)")); app.auto.add("viewer.exposure", er, "Adjust Exposure"); diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs new file mode 100644 index 00000000..bb36adca --- /dev/null +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -0,0 +1,462 @@ +//! Continuous numeric scrubbing across native monitor edges. + +use egui::{Context, Event, Id, PointerButton, Pos2, RawInput, Response, Vec2}; +#[cfg(any(not(target_arch = "wasm32"), test))] +use egui::{Rect, pos2, vec2}; + +#[derive(Clone, Default)] +struct State { + numeric: Option<(Id, u64)>, + offset: Vec2, + pending: Option<(Pos2, Pos2)>, + disabled: bool, +} + +fn state_id() -> Id { + Id::new("numeric-pointer-wrap") +} + +/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags. +pub(crate) fn track(response: &Response) { + if response.dragged_by(PointerButton::Primary) { + let pass = response.ctx.cumulative_pass_nr(); + response.ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + state.numeric = Some((response.id, pass)); + }); + } +} + +/// Keep egui's drag coordinates continuous while the visible native pointer wraps. +/// Rebase only when the OS acknowledges a successful warp: queued events from before +/// it must not be interpreted as another monitor-width movement. A coalesced event +/// may include a little real movement beyond the requested destination. +/// Returns whether a numeric gesture ended, so the app can close its Undo group. +pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { + let lost_focus = !raw.focused || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false))); + let (down, last_pos, modifiers) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers)); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if lost_focus + && down + && state.numeric.is_some() + && let Some(pos) = last_pos + { + // The OS may deliver the eventual button-up to another app. Explicitly + // finish this scrub so focus returning cannot restart it or leave Undo merged. + raw.events.push(Event::PointerButton { pos: pos - state.offset, button: PointerButton::Primary, pressed: false, modifiers }); + } + let touch = raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); + if lost_focus + || touch + || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. })) + { + let ended = state.numeric.is_some(); + *state = State { disabled: touch, ..Default::default() }; + return ended; + } + if state.numeric.is_none() { + return false; + } + // Mouse capture can report leaving the client area mid-scrub. Keep the + // drag's previous coordinate until its next captured movement arrives. + raw.events.retain(|e| !matches!(e, Event::PointerGone)); + for event in &mut raw.events { + let p = match event { + Event::PointerMoved(p) | Event::PointerButton { pos: p, .. } => p, + _ => continue, + }; + if let Some((from, to)) = state.pending + && p.distance_sq(to) < p.distance_sq(from) + { + state.offset += from - to; + state.pending = None; + } + *p += state.offset; + } + false + }) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn wrap_target(p: Pos2, bounds: Rect) -> Option { + if !p.is_finite() || !bounds.is_finite() || bounds.width() < 32.0 || bounds.height() < 32.0 { + return None; + } + let axis = |v: f32, lo: f32, hi: f32| { + if v <= lo + 2.0 { + hi - 8.0 + } else if v >= hi - 2.0 { + lo + 8.0 + } else { + v + } + }; + let to = pos2(axis(p.x, bounds.min.x, bounds.max.x), axis(p.y, bounds.min.y, bounds.max.y)); + (to != p).then_some(to) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn finish_frame(ctx: &Context, bounds: Rect, warp: impl FnOnce(Pos2) -> Option) { + let state = ctx.data(|d| d.get_temp::(state_id())).unwrap_or_default(); + let active = state.numeric.is_some_and(|(id, pass)| pass == ctx.cumulative_pass_nr() && ctx.is_being_dragged(id)); + let (down, focused, virtual_pos) = ctx.input(|i| (i.pointer.primary_down(), i.focused, i.pointer.latest_pos())); + if !active || !down || !focused { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + if state.disabled || state.pending.is_some() { + return; + } + let Some(from) = virtual_pos.map(|p| p - state.offset) else { return }; + let Some(to) = wrap_target(from, bounds) else { return }; + let destination = warp(to); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if let Some(to) = destination { + state.pending = Some((from, to)); + } else { + // Unsupported backends keep ordinary dragging; do not retry/log every frame. + state.disabled = true; + } + }); + if destination.is_some() { + ctx.request_repaint(); + } +} + +/// Native monitor coordinates are physical pixels; egui input uses UI points. +/// Use the monitor containing the original press, including negative desktop origins. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn finish_native(ctx: &Context, frame: &eframe::Frame) { + let pass = ctx.cumulative_pass_nr(); + if !ctx.data(|d| d.get_temp::(state_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + let Some(window) = frame.winit_window() else { return }; + let Ok(origin) = window.inner_position() else { return }; + let ppp = ctx.pixels_per_point(); + if !ppp.is_finite() || ppp <= 0.0 { + return; + } + let Some(press) = ctx.input(|i| i.pointer.press_origin()) else { return }; + let screen_press = press * ppp + vec2(origin.x as f32, origin.y as f32); + let monitor = window + .available_monitors() + .find(|m| { + let p = m.position(); + let s = m.size(); + Rect::from_min_size(pos2(p.x as f32, p.y as f32), vec2(s.width as f32, s.height as f32)).contains(screen_press) + }) + .or_else(|| window.current_monitor()); + let Some(monitor) = monitor else { return }; + let p = monitor.position(); + let s = monitor.size(); + let bounds = Rect::from_min_size( + pos2((p.x as f64 - origin.x as f64) as f32, (p.y as f64 - origin.y as f64) as f32) / ppp, + vec2(s.width as f32, s.height as f32) / ppp, + ); + finish_frame(ctx, bounds, |to| { + // Reuse winit's physical position type obtained above, now client-relative. + let mut target = origin; + target.x = (to.x * ppp).round() as i32; + target.y = (to.y * ppp).round() as i32; + match window.set_cursor_position(target) { + // Remember the actual device pixel to avoid fractional-DPI drift on every wrap. + Ok(()) => Some(pos2(target.x as f32 / ppp, target.y as f32 / ppp)), + Err(e) => { + log::warn!("numeric cursor wrap is unavailable: {e}"); + None + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Event, Id, Modifiers, PointerButton, pos2, vec2}; + + struct Scrub { + ctx: Context, + value: f64, + bounds: Rect, + time: f64, + accept_warp: bool, + warps: Vec, + standard: bool, + ordinary: bool, + modifiers: Modifiers, + } + + impl Scrub { + fn new() -> Self { + Self { + ctx: Context::default(), + value: 0.0, + bounds: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + time: 0.0, + accept_warp: true, + warps: Vec::new(), + standard: false, + ordinary: false, + modifiers: Modifiers::NONE, + } + } + + fn frame(&mut self, events: Vec, focused: bool) { + self.time += 0.02; + let mut raw = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(self.time), + events, + focused, + ..Default::default() + }; + raw.events.push(Event::ModifiersChanged(self.modifiers)); + prepare_input(&self.ctx, &mut raw); + let _ = self.ctx.run_ui(raw, |ui| { + if self.standard { + ui.put( + Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), + crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)), + ); + } else if self.ordinary { + ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag()); + } else { + let (_, value, _) = crate::widgets::hot_number_at( + ui, + pos2(100.0, 100.0), + Id::new("scrub"), + self.value, + 1.0, + (-1e9, 1e9), + 1, + "", + &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark), + ); + if let Some(v) = value { + self.value = v; + } + } + finish_frame(ui.ctx(), self.bounds, |p| { + self.warps.push(p); + self.accept_warp.then_some(p) + }); + }); + } + + fn move_to(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p)], true); + } + + fn begin(&mut self) { + self.frame(vec![], true); + let p = pos2(105.0, 108.0); + self.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + true, + ); + self.move_to(p + vec2(10.0, 0.0)); + } + } + + #[test] + fn numeric_drag_wraps_repeatedly_without_counting_the_teleport() { + let mut s = Scrub::new(); + s.begin(); + for _ in 0..5 { + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().expect("numeric drag must wrap at the monitor edge"); + assert!(target.x < 16.0); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before, "the warp itself must not change the value"); + s.move_to(target + vec2(20.0, 0.0)); + assert_eq!(s.value, before + 20.0); + } + assert_eq!(s.warps.len(), 5); + s.move_to(pos2(0.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert!(target.x > 780.0); + let before = s.value; + s.move_to(target - vec2(3.0, 0.0)); + assert_eq!(s.value, before - 3.0, "coalesced movement after a left-edge warp is preserved"); + } + + #[test] + fn normal_pointer_movement_does_not_warp() { + let mut s = Scrub::new(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + } + + #[test] + fn standard_editor_wraps_at_top_bottom_and_corner_without_a_value_jump() { + let mut s = Scrub::new(); + s.standard = true; + s.begin(); + for edge in [pos2(200.0, 0.0), pos2(200.0, 599.0), pos2(799.0, 599.0)] { + s.move_to(edge); + let target = *s.warps.last().unwrap(); + assert_ne!(target, edge); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before); + s.move_to(target + vec2(5.0, -5.0)); + assert_eq!(s.value, before + 10.0); + } + } + + #[test] + fn release_and_focus_loss_end_wrapping_and_restore_normal_hit_testing() { + for lose_focus in [false, true] { + let mut s = Scrub::new(); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + s.move_to(target); + let value = s.value; + let events = if lose_focus { + vec![Event::WindowFocused(false)] + } else { + vec![Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }] + }; + s.frame(events, !lose_focus); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 1); + assert_eq!(s.value, value); + assert_eq!(s.ctx.input(|i| i.pointer.latest_pos()), Some(pos2(799.0, 108.0))); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 2, "the next independent drag still wraps"); + } + } + + #[test] + fn object_drag_does_not_wrap_and_failed_warp_is_not_retried() { + let mut s = Scrub::new(); + s.ordinary = true; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + + let mut s = Scrub::new(); + s.accept_warp = false; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + for _ in 0..3 { + s.move_to(pos2(799.0, 108.0)); + } + assert_eq!(s.warps.len(), 1); + let value = s.value; + s.move_to(pos2(779.0, 108.0)); + assert_eq!(s.value, value - 20.0); + } + + #[test] + fn modifiers_keep_their_sensitivity_after_wrapping() { + for (modifiers, multiplier) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1)] { + let mut s = Scrub::new(); + s.modifiers = modifiers; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + let value = s.value; + s.move_to(target + vec2(10.0, 0.0)); + assert!((s.value - value - 10.0 * multiplier).abs() < 1e-8); + } + } + + #[test] + fn monitor_rect_can_extend_beyond_the_app_and_have_a_negative_origin() { + let mut s = Scrub::new(); + s.bounds = Rect::from_min_size(pos2(-200.0, -100.0), vec2(1200.0, 800.0)); + s.begin(); + s.move_to(pos2(999.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert_eq!(target, pos2(-192.0, 108.0)); + let value = s.value; + s.frame(vec![Event::PointerGone, Event::PointerMoved(target)], true); + assert_eq!(s.value, value); + s.move_to(target + vec2(10.0, 0.0)); + assert_eq!(s.value, value + 10.0); + } + + #[test] + fn real_properties_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + use crate::{EffectcraftApp, dock::PanelKind}; + use effectcraft_engine::project::LayerId; + use eframe::App; + use serde_json::json; + + for textbox in [false, true] { + let mut session = effectcraft_host::session(); + // This patch also works without the optional TextBox plugin. + if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { + continue; + } + session.execute("comp.new", json!({"width": 32, "height": 16})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap(); + if textbox { + session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); + } + let path = if textbox { "effects/#1/paddingX" } else { "transform/position" }; + let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap(); + let uid = prop.uid; + let original = prop.value.clone(); + let undo = session.history.undo.len(); + let panel = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; + let id = if textbox { format!("effectControls.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; + let mut app = EffectcraftApp::new(session); + app.show_panel(panel); + app.toggle_maximize(panel); + let ctx = Context::default(); + let bounds = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut frame = eframe::Frame::_new_kittest(); + let mut time = 0.0; + let mut step = |app: &mut EffectcraftApp, events| { + time += 1.0 / 60.0; + let mut raw = RawInput { screen_rect: Some(bounds), time: Some(time), events, ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut warp = None; + let _ = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx(), &mut frame); + app.ui(ui, &mut frame); + finish_frame(ui.ctx(), bounds, |p| { + warp = Some(p); + Some(p) + }); + }); + warp + }; + for _ in 0..4 { + step(&mut app, vec![]); + } + for gesture in 1..=2 { + let r = app.auto.find(&id).unwrap().rect; + let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + step( + &mut app, + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + ); + step(&mut app, vec![Event::PointerMoved(p + vec2(10.0, 0.0))]); + let mut last = p; + for _ in 0..3 { + let target = step(&mut app, vec![Event::PointerMoved(pos2(1599.0, p.y))]).expect("real property scrubs wrap"); + let before = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.clone(); + step(&mut app, vec![Event::PointerMoved(target)]); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, before); + last = target + vec2(10.0, 0.0); + step(&mut app, vec![Event::PointerMoved(last)]); + } + step(&mut app, vec![Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + assert_eq!(app.session.history.undo.len(), undo + gesture, "each drag is one undo step: {panel:?}"); + } + app.session.execute("edit.undo", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, original); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..7b271caf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -104,6 +104,7 @@ pub fn hot_number_at( return (er, out, out.is_some()); } let resp = ui.interact(rect, id, Sense::click_and_drag()); + crate::pointer_wrap::track(&resp); let col = if resp.hovered() || resp.dragged() { t.accent_hover } else { t.hot_text }; ui.painter().galley_with_override_text_color(rect.min + vec2(2.0, 2.0), galley, col); if resp.hovered() || resp.dragged() { @@ -154,6 +155,15 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s (rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done) } +/// Standard numeric editors use the same continuous native scrubbing as hot text. +pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget { + move |ui: &mut Ui| { + let response = ui.add(value); + crate::pointer_wrap::track(&response); + response + } +} + /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. /// (Only then is the focus requested: a request resets what keys the field keeps for itself,