Merge pull request #639 from storytold/claude/community-pr-review-lwtrlr

Land community PRs, batch 2, with follow-ups
This commit is contained in:
BFlat
2026-10-11 01:44:49 +07:00
committed by GitHub
37 changed files with 1454 additions and 166 deletions
+90 -21
View File
@@ -1,39 +1,52 @@
//! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it.
//!
//! Some graphics drivers crash EffectCraft as its window's device is created (an access
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver
//! can't be caught, so each launch leaves a marker in the settings folder until its window has
//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to
//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's
//! own override) still wins over both. The marker is locked while its launch runs, so a second
//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts
//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch.
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after
//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for
//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch).
//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
//! Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use effectcraft_engine::prefs::{PREFS_FILE, Prefs};
use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window draws.
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option<Backends>,
/// Switched to OpenGL because the previous launch never drew: the message to show.
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
/// to show.
pub notice: Option<String>,
marker: Option<PathBuf>,
/// Holds the marker's lock until the window draws (or the process ends).
/// Holds the marker's lock until the launch settles (or the process ends).
lock: Option<File>,
/// When the window drew its first frame, until the launch settles.
first_frame: Option<Instant>,
}
impl Launch {
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
/// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker.
/// `env_override`: `WGPU_BACKEND` is set.
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
@@ -45,11 +58,11 @@ impl Launch {
if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"),
Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"),
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"),
}
notice = Some(
"EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \
"EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \
(Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \
Automatic again."
.to_string(),
@@ -64,11 +77,33 @@ impl Launch {
(None, None)
}
};
Launch { backends, notice, marker, lock }
Launch { backends, notice, marker, lock, first_frame: None }
}
/// The window drew its first frame: the launch made it.
pub fn drawn(&mut self) {
/// The window drew a frame. Until the launch has settled, returns how long that still takes
/// (ask for a repaint then, so that an idle window settles too).
pub fn frame_drawn(&mut self) -> Option<Duration> {
self.frame_drawn_at(Instant::now())
}
fn frame_drawn_at(&mut self, now: Instant) -> Option<Duration> {
self.marker.as_ref()?;
let first = *self.first_frame.get_or_insert(now);
let left = SETTLE.saturating_sub(now.saturating_duration_since(first));
if left.is_zero() {
self.settled();
return None;
}
Some(left)
}
/// The app quits normally: however soon after opening, that's no crash.
pub fn exited(&mut self) {
self.settled();
}
/// The launch made it: release and remove its marker.
fn settled(&mut self) {
self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
@@ -103,12 +138,18 @@ mod tests {
fn a_launch_that_never_drew_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// A first launch that draws: nothing changes.
// A first launch that draws: nothing changes. Its first frames don't settle it (a driver
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
assert!(dir.join(MARKER).exists());
l.drawn();
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2));
assert!(dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None);
assert!(!dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled");
// A launch that crashes before drawing (it never calls `drawn`)…
drop(Launch::begin(Some(&dir), false));
// …makes the next one use OpenGL and say so, once.
@@ -119,7 +160,7 @@ mod tests {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.drawn();
l.exited();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
}
@@ -130,6 +171,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the
/// window is shown and its surface re-created) switches the next one to OpenGL too; a
/// normal quit in that time doesn't.
#[test]
fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
drop(l);
let l = Launch::begin(Some(&dir), false);
if cfg!(target_os = "macos") {
assert_eq!(l.backends, None, "no OpenGL on macOS");
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
}
drop(l);
let _ = std::fs::remove_dir_all(&dir);
// Quitting right after the window opened leaves no marker.
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
l.exited();
assert!(!dir.join(MARKER).exists());
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
let _ = std::fs::remove_dir_all(&dir);
}
/// A second copy opened while the first is still opening isn't a crash; OpenGL that can't
/// open a window either goes back to Automatic instead of failing every launch.
#[test]
+8 -3
View File
@@ -97,8 +97,8 @@ fn main() -> eframe::Result {
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's
// window never drew (a crashing graphics driver, #243).
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
// stopped as its window opened (a crashing graphics driver, #243).
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
@@ -327,12 +327,17 @@ impl eframe::App for Desktop {
self.app.ui(ui, frame);
if !self.drawn {
self.drawn = true;
self.launch.drawn();
log::info!("first frame drawn");
}
// The launch guard's marker goes once the window has kept drawing for a few seconds: a
// driver may still crash as the window is shown and its surface re-created.
if let Some(left) = self.launch.frame_drawn() {
ui.ctx().request_repaint_after(left);
}
}
fn on_exit(&mut self) {
self.launch.exited();
self.app.on_exit();
}
+30 -15
View File
@@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) {
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
if dx != 0.0 || dy != 0.0 {
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
let Some(pr) = l.props.prop_mut("transform/position") else { continue };
let shift = |v: &mut effectcraft_keyframe::Value| {
if let effectcraft_keyframe::Value::Vec2(x) = v {
x[0] += dx;
x[1] += dy;
} else if let effectcraft_keyframe::Value::Vec3(x) = v {
x[0] += dx;
x[1] += dy;
}
};
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, dx, dy);
}
}
}
@@ -76,6 +65,8 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
// A shorter composition must not leave the start past the end.
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
@@ -191,22 +182,46 @@ fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(b) = begin {
c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd);
// min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area.
c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO);
}
if let Some(e) = end {
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration);
}
// A start on its own can land after the current end. Pull it back so the interval
// still covers one frame, the same rule as `set: "begin"`.
keep_work_area_ordered(c);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
/// Keep `work_area` inside the composition and at least one frame long.
fn keep_work_area_ordered(c: &mut Comp) {
let fd = c.frame_duration();
c.work_area.1 = c.work_area.1.min(c.duration);
if c.work_area.0 < Tick::ZERO {
c.work_area.0 = Tick::ZERO;
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO);
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.1 = (c.work_area.0 + fd).min(c.duration);
}
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
let fd = c.frame_duration();
// An interval that is already inverted must not become a negative duration.
if a < Tick::ZERO || b > c.duration || a + fd > b {
return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition"));
}
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
+2 -9
View File
@@ -1,7 +1,6 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Responsive Design — Time.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -24,14 +23,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
c.width = w;
c.height = h;
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue };
let mv = |v: &KV| {
let a = v.as_vec3();
KV::Vec3([a[0] - x, a[1] - y, a[2]])
};
pos.value = mv(&pos.value);
for k in &mut pos.keys {
k.value = mv(&k.value);
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, -x, -y);
}
}
for g in &mut c.guides {
+2 -1
View File
@@ -387,7 +387,8 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(&format!("Reset {name}"), None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?;
for ps in &spec.params {
if let Some(pr) = g.get_mut(ps.id) {
// (`group/param` ids live in twirl-down groups.)
if let Some(pr) = g.prop_mut(ps.id) {
pr.set_value_at(lt, effectcraft_effects::default_value(ps, size));
}
}
+31
View File
@@ -1270,6 +1270,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well
/// as the combined property the channels were split from.
pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) {
if dx == 0.0 && dy == 0.0 {
return;
}
if tr.get("positionX").is_some() {
for (name, d) in [("positionX", dx), ("positionY", dy)] {
if let Some(pr) = tr.get_mut(name) {
pr.value = KV::Scalar(pr.value.as_f64() + d);
for k in &mut pr.keys {
k.value = KV::Scalar(k.value.as_f64() + d);
}
}
}
}
if let Some(pr) = tr.get_mut("position") {
let shift = |v: &KV| match v {
KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]),
other => {
let a = other.as_vec3();
KV::Vec3([a[0] + dx, a[1] + dy, a[2]])
}
};
pr.value = shift(&pr.value);
for k in &mut pr.keys {
k.value = shift(&k.value);
}
}
}
/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions
/// are separated.
pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) {
+26
View File
@@ -192,6 +192,32 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
// Separate Dimensions: the picture follows X/Y/Z Position, not the combined property.
if m == "position" && l.transform().is_some_and(|tr| tr.get("positionX").is_some()) {
let cur = l
.transform()
.map(|tr| {
[
tr.get("positionX").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
tr.get("positionY").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
tr.get("positionZ").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0),
]
})
.unwrap_or([0.0; 3]);
// Two numbers keep the current z.
let j = match &v {
Value::Array(a) if a.len() == 2 => json!([a[0], a[1], cur[2]]),
_ => v.clone(),
};
let nv = KV::Vec3(cur).coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?;
let xyz = nv.as_vec3();
let Some(tr) = l.transform_mut() else { continue };
super::layer::update_position(tr, lt, |_| xyz);
if let Some(pr) = tr.get_mut("position") {
pr.set_value_at(lt, KV::Vec3(xyz));
}
continue;
}
let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue };
let cur = pr.value_at(lt);
// Two numbers for a 3D value keep the current z.
+4 -5
View File
@@ -190,8 +190,8 @@ fn forget(s: &mut Session, paths: &BTreeSet<String>) {
/// time is only remembered; one whose stamp moved is reloaded: the items reading it get their
/// size and duration from it again and its pixels are read again. A file that can't be stamped
/// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be
/// read yet (still being written) keeps its old stamp so the next scan tries again; missing
/// files are left to the footage check. Returns `{files, reloaded}`.
/// read (still being written) is tried again once it changes again; missing files are left to
/// the footage check. Returns `{files, reloaded}`.
pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option<Stamp>)>) -> crate::Result<Value> {
let before = std::mem::take(&mut s.footage_stamps);
let mut changed = BTreeSet::new();
@@ -215,10 +215,9 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option<Stamp>)>) -> crate
}
let ids: Vec<ItemId> = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect();
let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? };
// Not readable (still being written, or not a file the importer reads): its new stamp is kept,
// so it is tried again once it changes again, not on every look.
for p in &failed {
if let Some(old) = before.get(p) {
s.footage_stamps.insert(p.clone(), *old);
}
changed.remove(p);
}
if changed.is_empty() {
+12 -5
View File
@@ -206,19 +206,26 @@ impl History {
self.branches.push(Branch { parent: from.clone(), steps });
}
/// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew
/// from them, and the oldest branches when branches hold more states than `levels`.
/// Apply the undo-levels limit: keep the nearest past and future states on the working
/// line, drop branches whose parents were removed, and bound branch states separately.
pub fn trim(&mut self, levels: usize, current: &Arc<Project>) {
let levels = levels.max(1);
if self.undo.len() > levels {
let extra = self.undo.len() - levels;
self.undo.drain(..extra);
}
let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum();
while total > levels && !self.branches.is_empty() {
total -= self.branches.remove(0).steps.len();
if self.redo.len() > levels {
// The next redo is last: discard the farthest future states first.
let extra = self.redo.len() - levels;
self.redo.drain(..extra);
}
// Orphans from truncated line states must not consume the reachable branch budget.
self.prune(current);
while self.branch_states() > levels && !self.branches.is_empty() {
self.branches.remove(0);
// Removing a parent branch also removes its descendants before counting again.
self.prune(current);
}
}
/// Drop branches whose parent state is no longer in the history (`current` = the current
+3 -3
View File
@@ -121,8 +121,8 @@ page!(Startup {
/// After a crash, offer to open the most recent auto-save.
offer_crash_recovery: bool = true,
/// What the desktop window draws with, from the next launch: `auto` (the platform's best
/// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose
/// window never drew switches it to `gl` (#243).
/// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that
/// stops as its window opens switches it to `gl` (#243).
window_graphics: String = "auto".into(),
});
@@ -1081,7 +1081,7 @@ pub fn pages() -> Vec<Page> {
s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true),
Note(
"Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.",
"Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.",
),
Section("Repair"),
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
+23
View File
@@ -110,6 +110,29 @@ fn reset_effect_restores_defaults_in_one_undo_step() {
assert_eq!(g.get("start").unwrap().value, KV::Vec2([10.0, 20.0]));
}
#[test]
fn reset_restores_parameters_inside_twirl_down_groups() {
let (mut s, a, _) = comp_with_two_solids();
// Fractal and Scribble keep most parameters in groups (`mandelbrot/…`, `edgeOptions/…`).
for (effect, changes) in [
("Fractal", [("setChoice", json!(1)), ("mandelbrot/mandelbrotX", json!(1.25)), ("fractalColor/hue", json!(90.0))]),
("Scribble", [("scribble", json!(2)), ("edgeOptions/edgeWidth", json!(33.0)), ("strokeOptions/spacing", json!(9.0))]),
] {
let fx = apply(&mut s, a, effect);
let group = |s: &Session| layer(s, a).effects().unwrap().groups().find(|g| g.uid == fx).unwrap().clone();
let defaults: Vec<_> = changes.iter().map(|(id, _)| group(&s).prop(id).unwrap().value.clone()).collect();
for (id, v) in &changes {
let uid = group(&s).prop(id).unwrap().uid;
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v})).unwrap();
assert_ne!(group(&s).prop(id).unwrap().value, defaults[changes.iter().position(|(i, _)| i == id).unwrap()], "{effect} {id} changed");
}
s.execute("effect.reset", json!({"layer": a, "effect": fx})).unwrap();
for ((id, _), d) in changes.iter().zip(&defaults) {
assert_eq!(&group(&s).prop(id).unwrap().value, d, "{effect} {id} back to its default");
}
}
}
#[test]
fn reset_keys_animated_params_at_the_cti() {
let (mut s, a, _) = comp_with_two_solids();
+47
View File
@@ -1,9 +1,13 @@
//! Branching history: undo then a new step keeps the undone line as a branch; the tree lists
//! every state; jumping to any state (on any branch) restores it and keeps everything else.
use std::sync::Arc;
use effectcraft_project::Project;
use serde_json::{Value, json};
use crate::Session;
use crate::history::{Branch, History};
fn names(s: &Session) -> Vec<String> {
s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default()
@@ -190,3 +194,46 @@ fn a_session_restored_with_unsaved_changes_stays_modified() {
s.execute("edit.undo", json!({})).unwrap();
assert!(s.is_dirty(), "undo doesn't reach a saved state");
}
#[test]
fn dropped_redo_parent_does_not_discard_reachable_branches() {
let state = || Arc::new(Project::default());
let current = state();
let futures: Vec<_> = (0..5).map(|_| state()).collect();
let reachable = state();
let mut history = History {
redo: futures.iter().rev().map(|p| ("future".into(), p.clone())).collect(),
branches: vec![
Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] },
Branch { parent: futures[4].clone(), steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] },
],
..Default::default()
};
history.trim(2, &current);
assert_eq!(history.redo.len(), 2);
assert_eq!(history.branches.len(), 1);
assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable));
assert_eq!(history.branch_states(), 1);
assert!(history.tree(&current).iter().any(|node| node.label == "reachable"));
}
#[test]
fn evicted_branch_parent_does_not_discard_other_reachable_branches() {
let state = || Arc::new(Project::default());
let current = state();
let parent = state();
let reachable = state();
let mut history = History {
branches: vec![
Branch { parent: current.clone(), steps: vec![("parent".into(), parent.clone())] },
Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] },
Branch { parent, steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] },
],
..Default::default()
};
history.trim(2, &current);
assert_eq!(history.branches.len(), 1);
assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable));
assert_eq!(history.branch_states(), 1);
assert_eq!(history.tree(&current).len(), 2);
}
+223
View File
@@ -3,6 +3,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::{EngineError, Event, Session};
@@ -103,6 +104,152 @@ fn transform_dialog_values_and_center_anchor() {
assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath);
}
/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count.
fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) {
let img = s.render(s.active_comp_id().unwrap(), t, Default::default());
let mut n = 0u32;
let mut x0 = i64::MAX;
let mut y0 = i64::MAX;
let mut x1 = i64::MIN;
let mut y1 = i64::MIN;
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
if img.get(x, y)[3] > 0.5 {
n += 1;
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
}
(img.width, img.height, x0, y0, x1, y1, n)
}
#[test]
fn separated_position_follows_canvas_resize_and_crop() {
let square = |separated: bool| {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap();
if separated {
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
}
(s, a)
};
let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| {
let l = layer(s, a);
let tr = l.transform().unwrap();
let at = Tick::from_seconds_f64(t);
if tr.get("positionX").is_some() {
assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6);
assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6);
}
let p = tr.get("position").unwrap().value_at(at).as_vec3();
assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}");
};
let (mut combined, a) = square(false);
let (mut separated, b) = square(true);
assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO));
combined.execute("comp.settings", json!({"width": 200})).unwrap();
separated.execute("comp.settings", json!({"width": 200})).unwrap();
placed(&separated, b, 0.0, 90.0, 50.0);
placed(&combined, a, 0.0, 90.0, 50.0);
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100));
assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO));
let (mut separated, b) = square(true);
separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
placed(&separated, b, 0.0, 30.0, 30.0);
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100));
let (mut separated, b) = square(true);
separated.execute("layer.select", json!({"layers": [b]})).unwrap();
separated.execute("comp.cropToLayerBounds", json!({})).unwrap();
let bounds = opaque_bounds(&separated, Tick::ZERO);
assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100));
assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}");
// Two Position keys keep their spacing after the same canvas offset.
let (mut separated, b) = square(true);
separated.execute("edit.undo", json!({})).unwrap();
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap();
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap();
separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
separated.execute("comp.settings", json!({"width": 200})).unwrap();
placed(&separated, b, 0.0, 90.0, 50.0);
placed(&separated, b, 1.0, 110.0, 50.0);
assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85);
assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105);
let (mut separated, b) = square(true);
separated.execute("edit.undo", json!({})).unwrap();
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap();
separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap();
separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
placed(&separated, b, 0.0, 30.0, 30.0);
placed(&separated, b, 1.0, 50.0, 30.0);
assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100));
assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100));
}
/// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22).
fn opaque_centroid(s: &Session) -> (f64, f64, u32) {
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
let mut n = 0u32;
let mut sx = 0.0;
let mut sy = 0.0;
for y in 0..img.height {
for x in 0..img.width {
if img.get(x as i64, y as i64)[3] > 0.5 {
n += 1;
sx += x as f64 + 0.5;
sy += y as f64 + 0.5;
}
}
}
assert!(n > 0, "the layer rendered nothing");
(sx / n as f64, sy / n as f64, n)
}
#[test]
fn separated_position_set_transform_moves_the_layer() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Scene", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
let (x, y, n) = opaque_centroid(&s);
assert_eq!(n, 100);
assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "starts centred, centroid ({x}, {y})");
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layers": [a], "prop": "position", "value": [11, 22]})).unwrap();
let l = layer(&s, a);
let tr = l.transform().unwrap();
assert!((tr.get("positionX").unwrap().value.as_f64() - 11.0).abs() < 1e-6);
assert!((tr.get("positionY").unwrap().value.as_f64() - 22.0).abs() < 1e-6);
assert_eq!(tr.get("position").unwrap().value, KV::Vec3([11.0, 22.0, 0.0]));
let (x, y, n) = opaque_centroid(&s);
assert_eq!(n, 100);
assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "separated position renders at ({x}, {y})");
s.execute("edit.undo", json!({})).unwrap();
let (x, y, _) = opaque_centroid(&s);
assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "undo puts it back at ({x}, {y})");
// Combined Position, the control, still moves.
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, a).transform().unwrap().get("positionX").is_none());
s.execute("layer.setTransform", json!({"prop": "position", "value": [11, 22]})).unwrap();
let (x, y, n) = opaque_centroid(&s);
assert_eq!(n, 100);
assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "combined position renders at ({x}, {y})");
}
#[test]
fn mask_menu_on_existing_masks() {
let mut s = comp();
@@ -253,6 +400,82 @@ fn lift_and_extract_work_area() {
assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}");
}
/// A project saved with an inverted work area (by an older build) is edited without a panic:
/// `clamp` with crossed bounds would abort.
#[test]
fn an_inverted_saved_work_area_takes_an_end_edit() {
let mut s = comp();
let cid = s.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().work_area = (Tick::from_seconds_f64(9.0), Tick::from_seconds_f64(2.0));
s.execute("comp.workArea", json!({"end": 1.0})).unwrap();
s.execute("comp.workArea", json!({"start": 50.0})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.work_area.0 >= Tick::ZERO && c.work_area.0 + c.frame_duration() <= c.work_area.1 && c.work_area.1 <= c.duration);
}
#[test]
fn work_area_stays_ordered_when_the_start_passes_the_end() {
let ordered = |s: &Session| {
let c = s.active_comp().unwrap();
let fd = c.frame_duration();
assert!(c.duration >= fd, "duration {}", c.duration.seconds());
assert!(c.work_area.0 >= Tick::ZERO);
assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds());
assert!(c.work_area.1 <= c.duration);
(c.duration, c.work_area)
};
let one_frame_at_two = |s: &Session| {
let end = Tick::from_seconds_f64(2.0);
(end - s.active_comp().unwrap().frame_duration(), end)
};
let mut s = comp();
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
s.execute("comp.workArea", json!({"start": 8.0})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0)));
s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap();
s.execute("comp.trimToWorkArea", json!({})).unwrap();
assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0))));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0));
let mut s = comp();
s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap();
let before = ordered(&s);
s.execute("comp.settings", json!({"duration": 2.0})).unwrap();
let (dur, wa) = ordered(&s);
assert_eq!(dur, Tick::from_seconds_f64(2.0));
assert_eq!(wa, one_frame_at_two(&s));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ordered(&s), before);
let mut s = comp();
s.execute("comp.workArea", json!({"end": 2.0})).unwrap();
s.execute("time.set", json!({"time": 8.0})).unwrap();
s.execute("comp.workArea", json!({"set": "begin"})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
let path = tmp("WorkArea.ecproj");
s.execute("file.saveAs", json!({"path": path})).unwrap();
s.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(ordered(&s).1, one_frame_at_two(&s));
// A project that already has an inverted interval must not trim into a negative duration.
let mut s = comp();
let cid = s.state.active_comp.unwrap();
{
let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0));
}
let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err();
assert!(err.to_string().contains("work area"), "{err}");
let c = s.active_comp().unwrap();
assert_eq!(c.duration, Tick::from_seconds_f64(10.0));
assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)));
}
#[test]
fn label_group_purge_and_edit_original() {
crate::tests_roto::hold_roto_cache_test_lock();
+7
View File
@@ -145,9 +145,16 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo
let n = (buf.img.width * buf.img.height) as usize;
let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. });
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
// The first mask has nothing to combine with. Intersect and Darken would multiply or
// minimise against that empty coverage and hide the layer; they cover like Add instead.
// Lighten and Difference already do. Filling the accumulator first would make Add show
// the whole layer.
let mut first = true;
for g in list {
let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue };
let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue };
let mode = if first && matches!(mode, MaskMode::Intersect | MaskMode::Darken) { MaskMode::Add } else { mode };
first = false;
let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
cov.data.par_iter_mut().for_each(|c| {
if inverted {
+39
View File
@@ -80,6 +80,45 @@ fn mask_cuts_layer() {
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn first_intersect_and_darken_keep_their_rectangle() {
let render = |modes: &[MaskMode], centers: &[[f64; 2]]| {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
for (i, (mode, center)) in modes.iter().zip(centers).enumerate() {
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::rect(*center, 40.0, 40.0), *mode, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
}
p.comp_mut(cid).unwrap().layers.push(l);
render_frame(&p, cid, Tick::ZERO, 1.0)
};
// A first Intersect or Darken has nothing to combine with, so it covers like Add.
// Lighten and Difference already do; Subtract still starts from the whole layer.
for mode in [MaskMode::Intersect, MaskMode::Darken, MaskMode::Add, MaskMode::Lighten, MaskMode::Difference] {
let img = render(&[mode], &[[50.0, 50.0]]);
assert!(img.get(50, 50)[3] > 0.99, "{mode:?} keeps the rectangle");
assert!(img.get(150, 50)[3] < 0.01, "{mode:?} stays outside it");
}
let hole = render(&[MaskMode::Subtract], &[[50.0, 50.0]]);
assert!(hole.get(50, 50)[3] < 0.01, "subtract still cuts its rectangle");
assert!(hole.get(150, 50)[3] > 0.99, "subtract leaves the rest of the layer");
// Add, then Intersect with a shifted rectangle: only the overlap remains.
let overlap = render(&[MaskMode::Add, MaskMode::Intersect], &[[50.0, 50.0], [70.0, 50.0]]);
assert!(overlap.get(50, 50)[3] > 0.99, "overlap stays");
assert!(overlap.get(35, 50)[3] < 0.01, "the first rectangle's exclusive side is cut");
assert!(overlap.get(85, 50)[3] < 0.01, "the second rectangle's exclusive side is cut");
let darkened = render(&[MaskMode::Add, MaskMode::Darken], &[[50.0, 50.0], [70.0, 50.0]]);
assert!(darkened.get(50, 50)[3] > 0.99 && darkened.get(35, 50)[3] < 0.01 && darkened.get(85, 50)[3] < 0.01);
// A later disjoint Add still contributes after a first Intersect.
let both = render(&[MaskMode::Intersect, MaskMode::Add], &[[50.0, 50.0], [150.0, 50.0]]);
assert!(both.get(50, 50)[3] > 0.99 && both.get(150, 50)[3] > 0.99);
assert!(both.get(100, 50)[3] < 0.01);
}
#[test]
fn track_matte_alpha() {
let (mut p, cid, comp) = setup();
+4
View File
@@ -320,6 +320,7 @@ const JAPANESE: &[(&str, &str)] = &[
("No device list from this host; the system default output is used.", "このホストからはデバイス一覧を取得できないため、システムの既定の出力を使用します。"),
("No layer selected", "レイヤーが選択されていません"),
("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"),
("No matching fonts", "一致するフォントはありません"),
("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"),
("No nested compositions", "ネストされたコンポジションはありません"),
("No shortcut", "ショートカットなし"),
@@ -751,6 +752,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("No device list from this host; the system default output is used.", "此主机无法获取设备列表;将使用系统默认输出。"),
("No layer selected", "未选择图层"),
("No matching effects or presets", "没有匹配的效果或预设"),
("No matching fonts", "没有匹配的字体"),
("No model credits were built into this copy.", "此副本未内置模型致谢。"),
("No nested compositions", "没有嵌套合成"),
("No shortcut", "无快捷键"),
@@ -1242,6 +1244,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("No device list from this host; the system default output is used.", "此主機無法獲取設備清單;將使用系統預設輸出。"),
("No layer selected", "未選取圖層"),
("No matching effects or presets", "沒有匹配的效果或預設集"),
("No matching fonts", "沒有符合的字體"),
("No model credits were built into this copy.", "此副本未內置模型致謝。"),
("No nested compositions", "沒有嵌套合成"),
("No shortcut", "無快速鍵"),
@@ -1729,6 +1732,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
),
("No layer selected", "Шар не виділено"),
("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"),
("No matching fonts", "Не знайдено відповідних шрифтів"),
("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."),
("No nested compositions", "Немає вкладених композицій"),
("No shortcut", "Немає клавіатурного скорочення"),
+9 -2
View File
@@ -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;
@@ -1625,8 +1626,9 @@ impl eframe::App for EffectcraftApp {
self.session.end_recovery();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
if !self.synthetic.is_empty() {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
let injected = !self.synthetic.is_empty();
if injected {
// 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 { .. });
let n = if pointer(&self.synthetic[0]) {
@@ -1639,6 +1641,9 @@ impl eframe::App for EffectcraftApp {
};
raw_input.events.extend(self.synthetic.drain(..n));
}
if pointer_wrap::prepare_input(ctx, raw_input, injected) {
self.session.history.merge_key = None;
}
}
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
@@ -1684,6 +1689,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();
+7 -7
View File
@@ -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();
});
+2 -2
View File
@@ -361,14 +361,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 {
+13 -13
View File
@@ -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();
}
});
+6 -6
View File
@@ -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));
+1 -1
View File
@@ -771,7 +771,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, .. } => {
+30 -2
View File
@@ -41,6 +41,17 @@ pub fn parse_angle(s: &str) -> Option<f64> {
s.parse().ok()
}
/// The angle after the degrees field of `v` (which shows `shown`, the remainder's size) went to
/// `new`. A drag moves the angle by the pointer's distance, so it runs through zero into negative
/// angles; a typed number is a size, so the remainder keeps its sign.
fn degrees_edit(v: f64, shown: f64, new: f64, dragging: bool) -> f64 {
if dragging {
return v + (new - shown);
}
let (rev, deg) = split_angle(v);
rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -new } else { new }
}
/// An angle value, `0x+45.0°`, at `at` (top left): the revolutions and the degrees
/// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other.
/// Returns the revolutions' rect, the degrees' rect and the new angle when either changed.
@@ -49,10 +60,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals:
let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t);
let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
let out = match (new_rev, new_deg) {
(Some(r), _) => Some(r as f64 * 360.0 + deg),
// The degrees field shows the remainder's size; its sign stays.
(None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }),
(None, Some(d)) => Some(degrees_edit(v, deg.abs(), d, dragging)),
(None, None) => None,
};
(rr, dr, out)
@@ -551,6 +562,23 @@ mod tests {
assert_eq!(split_angle(-360.0), (-1, 0.0));
}
#[test]
fn dragging_degrees_crosses_zero_into_negative_angles() {
// The field shows the size (0.3), so a 1-degree drag left lands on -0.7, and keeps going.
let mut v = 0.3;
for _ in 0..3 {
let (_, deg) = split_angle(v);
v = degrees_edit(v, deg.abs(), deg.abs() - 1.0, true);
}
assert!((v + 2.7).abs() < 1e-9, "{v}");
// And back up through zero.
let (_, deg) = split_angle(v);
assert!((degrees_edit(v, deg.abs(), deg.abs() + 3.0, true) - 0.3).abs() < 1e-9);
// Typing keeps the sign shown.
assert_eq!(degrees_edit(-30.0, 30.0, 45.0, false), -45.0);
assert_eq!(degrees_edit(405.0, 45.0, 10.0, false), 370.0);
}
#[test]
fn angle_parsing_round_trips() {
for v in [0.0, 45.0, 405.0, -30.5, -400.0, 1080.25] {
+1 -1
View File
@@ -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<f64>, 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);
}
@@ -103,10 +103,10 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
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))
}
+2 -2
View File
@@ -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:"));
+1 -7
View File
@@ -320,13 +320,7 @@ fn text_section(
// Font family and style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("props-font-pop");
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("properties.text.font", fr, "Font family");
// Every installed family (built only while the menu is open: it can be long).
let fams: Vec<String> = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] };
if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) {
if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) {
set(actions, json!({"font": f}));
}
y += 28.0;
+2 -2
View File
@@ -112,7 +112,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));
}
@@ -123,7 +123,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));
}
+154 -39
View File
@@ -96,11 +96,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Font family + style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("char-font-pop");
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("char-font")).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add("character.font", fr, "Font family");
if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) {
if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) {
actions.push(json!({"font": f}));
}
y += 28.0;
@@ -551,47 +547,166 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The font menu: recent fonts first, names in English or the fonts' own language, and a
/// "Sample" preview in each font (Settings ▸ Type).
fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option<String> {
if !widgets::popup_is_open(ui, id) {
#[derive(Clone, Default)]
struct FontSearch {
query: String,
selected: usize,
}
/// Shared font menu for Character and Properties: search, recent fonts and previews.
pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option<String> {
let state_id = id.with("search-state");
app.auto.add(auto, rect, "Font family");
if !widgets::popup_is_open(ui, id) || !enabled {
ui.data_mut(|d| d.remove::<FontSearch>(state_id));
if widgets::dropdown(ui, rect, current, &app.tokens, id.with("field")).clicked() && enabled {
widgets::open_popup(ui, id);
}
return None;
}
let rows = effectcraft_engine::font_menu(&app.session.prefs);
let saved = ui.data(|d| d.get_temp::<FontSearch>(state_id));
let fresh = saved.is_none();
let mut state = saved.unwrap_or_default();
let search_id = id.with("search");
let (up, down, enter, escape) = ui.input_mut(|i| {
(
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter),
i.consume_key(egui::Modifiers::NONE, egui::Key::Escape),
)
});
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
let seps = rows.iter().filter(|r| r.family.is_empty()).count();
widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
let mut chosen = None;
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
// The font field itself becomes editable. The popup contains only results.
let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max);
let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y));
let search = ui.put(edit_rect, egui::TextEdit::singleline(&mut state.query).id(search_id).hint_text(current).font(Tokens::ui(12.0)));
app.auto.add(&format!("{auto}.search"), search.rect, "Search fonts");
if fresh {
search.request_focus();
}
ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() }));
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() {
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<FontSearch>(state_id);
});
ui.memory_mut(|m| m.surrender_focus(search_id));
return None;
}
app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list");
let mut chosen = None;
let screen = ui.ctx().content_rect();
let below = screen.bottom() - rect.bottom() - 16.0;
let above = below < 100.0 && rect.top() - screen.top() > below;
let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0);
let anchor = if above { rect.left_top() } else { rect.left_bottom() };
let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP };
widgets::note_open_popup_list(ui.ctx());
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(rect.width().max(180.0));
let query = state.query.trim().to_lowercase();
let mut seen = std::collections::HashSet::new();
let filtered: Vec<_> = rows
.iter()
.filter(|r| {
!r.family.is_empty()
&& (query.is_empty()
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
})
.collect();
if fresh {
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
if search.changed() {
state.selected = 0;
}
let last = filtered.len().saturating_sub(1);
state.selected = state.selected.min(last);
if down {
state.selected = state.selected.saturating_add(1).min(last);
}
if up {
state.selected = state.selected.saturating_sub(1);
}
if enter {
chosen = filtered.get(state.selected).map(|r| r.family.clone());
}
// Give the result list its actual height even when the previous query had
// only one row. A cached tiny Area must not collapse a reopened long list.
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height);
ui.set_height(height);
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
if search.changed() {
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, |ui| {
if filtered.is_empty() {
let r = ui.label(tr("No matching fonts"));
app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts");
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
}
chosen
})
let mut was_recent = false;
for (index, r) in filtered.iter().enumerate() {
if query.is_empty() && was_recent && !r.recent {
ui.separator();
}
was_recent = r.recent;
let row_width = ui.available_width();
let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width };
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, ""));
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display));
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
label_rect.left_center(),
Align2::LEFT_CENTER,
&r.display,
Tokens::ui(12.0),
t.text,
);
if ui.is_rect_visible(resp.rect) {
app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display);
}
if index == state.selected && (fresh || up || down) {
resp.scroll_to_me(None);
}
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
resp.on_hover_text(&r.display);
}
});
});
});
let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
if chosen.is_some() || outside || escape {
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<FontSearch>(state_id);
});
ui.memory_mut(|m| m.surrender_focus(search_id));
} else {
ui.data_mut(|d| d.insert_temp(state_id, state));
}
chosen
}
/// Align panel: Align Layers to Selection / Composition, the six align buttons and the six
+3 -2
View File
@@ -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");
+7 -5
View File
@@ -679,11 +679,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");
+529
View File
@@ -0,0 +1,529 @@
//! 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>(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. `injected`:
/// this frame carries pointer events from the control channel (an agent's `ui.drag`), whose
/// gesture must never move the user's real cursor.
pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -> 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>(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 = injected || 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<Pos2> {
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<Pos2>) {
let state = ctx.data(|d| d.get_temp::<State>(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>(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>(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>(state_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) {
ctx.data_mut(|d| d.remove::<State>(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<Pos2>,
standard: bool,
ordinary: bool,
angle: bool,
angle_rects: Option<(Rect, Rect)>,
modifiers: Modifiers,
injected: bool,
}
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,
angle: false,
angle_rects: None,
modifiers: Modifiers::NONE,
injected: false,
}
}
fn frame(&mut self, events: Vec<Event>, 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, self.injected);
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 if self.angle {
let (rr, dr, value) = crate::panels::fx_widgets::angle_field(
ui,
pos2(100.0, 100.0),
Id::new("scrub"),
self.value,
1,
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
);
self.angle_rects = Some((rr, dr));
if let Some(v) = value {
self.value = v;
}
} 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 angle_degrees_continue_across_zero_and_repeated_monitor_wraps() {
for initial in [0.0, 359.5, -359.5, 720.0, -720.0] {
for direction in [-1.0, 1.0] {
let mut s = Scrub::new();
s.angle = true;
s.value = initial;
s.frame(vec![], true);
let p = s.angle_rects.unwrap().1.center();
s.frame(
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }],
true,
);
let mut last = p;
let mut expected = initial;
// Feed small real movements, including zero/whole-turn crossings, then
// the synthetic OS warp. Every real pixel contributes exactly once.
for step in 0..800 {
let direction = if step < 400 { direction } else { -direction };
let next = pos2((last.x + direction * 13.0).clamp(1.0, 799.0), last.y);
expected += f64::from(next.x - last.x) * 0.5;
let count = s.warps.len();
s.move_to(next);
assert!((s.value - expected).abs() < 1e-6, "initial={initial}, direction={direction}, actual={}, expected={expected}", s.value);
last = next;
if s.warps.len() > count {
last = *s.warps.last().unwrap();
s.move_to(last);
assert!((s.value - expected).abs() < 1e-6, "the teleport must not reset the angle");
}
}
assert!(s.warps.len() >= 5);
}
}
}
#[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");
}
/// An agent's `ui.drag` over the control channel scrubs the value but never moves the user's
/// real cursor, even at the window's (monitor's) edge.
#[test]
fn injected_drags_never_warp_the_real_cursor() {
let mut s = Scrub::new();
s.injected = true;
s.begin();
s.move_to(pos2(799.0, 108.0));
assert!(s.warps.is_empty());
}
#[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_angle_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 angle in [false, true] {
let mut session = effectcraft_host::session();
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();
let path = if angle { "transform/rotation" } 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 = PanelKind::Properties;
let id = if angle { format!("properties.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 direction = if gesture == 1 { -1.0 } else { 1.0 };
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(direction * 10.0, 0.0))]);
let mut last = p;
for _ in 0..3 {
let edge = if direction < 0.0 { 1.0 } else { 1599.0 };
let target = step(&mut app, vec![Event::PointerMoved(pos2(edge, 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(direction * 10.0, 0.0);
step(&mut app, vec![Event::PointerMoved(last)]);
if angle {
let value = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.as_f64();
assert!((value - before.as_f64() - f64::from(direction) * 5.0).abs() < 1e-6);
}
}
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);
}
}
}
+17 -2
View File
@@ -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,
@@ -520,8 +530,7 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui));
});
});
let pass = ui.ctx().cumulative_pass_nr();
ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass));
note_open_popup_list(ui.ctx());
let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p));
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
@@ -529,6 +538,12 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
chosen
}
/// Custom popup lists also hold the app's keyboard shortcuts while open.
pub(crate) fn note_open_popup_list(ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(popup_list_pass_id(), pass));
}
/// The pass a [`popup_list`] was last drawn in.
fn popup_list_pass_id() -> egui::Id {
egui::Id::new("popup-list-pass")
+107
View File
@@ -0,0 +1,107 @@
//! Real keyboard/pointer coverage for the font menus in both text panels.
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone();
let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(pos));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
h.run_steps(3);
}
fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(3);
}
#[test]
fn font_search_filters_selects_and_undoes_in_both_panels() {
for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() {
builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s))
} else {
builder.build_eframe(move |_| EffectcraftApp::new(s))
};
h.state_mut().show_panel(panel);
h.run_steps(3);
click(&mut h, prefix);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some());
assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists");
let field = h.state().auto.find(prefix).unwrap();
let search = h.state().auto.find(&format!("{prefix}.search")).unwrap();
assert!(
(field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0,
"font selection and search must occupy the same row"
);
// Search gets focus on open. Mixed case and surrounding whitespace are accepted.
type_text(&mut h, " jEtBrAiNs ");
let rows = h.state().auto.query(&format!("{prefix}.option."));
assert!(!rows.is_empty());
assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains")));
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap();
}
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters");
key(&mut h, Key::Enter);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
type_text(&mut h, "no_such_font_123456789");
assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty());
assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some());
key(&mut h, Key::Enter);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open");
key(&mut h, Key::Escape);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
// Reopening clears the previous query, and mouse selection still works.
h.run_steps(2);
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap();
}
assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1);
type_text(&mut h, "JetBrains");
click(&mut h, &format!("{prefix}.option.JetBrains Mono"));
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
}
}
#[test]
fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid})).unwrap();
s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
click(&mut h, "character.font");
type_text(&mut h, "noto serif");
key(&mut h, Key::ArrowDown);
key(&mut h, Key::ArrowUp);
key(&mut h, Key::Enter);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert_eq!(doc.text, "First second");
assert_eq!(doc.style_at(0).font, "Noto Serif");
assert_eq!(doc.style_at(8).font, "Inter");
}
+2 -2
View File
@@ -38,7 +38,7 @@ Commands (CLI, MCP, control channel):
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both.
- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both.
- `project.useTemplate`: New Project Loads Template
- `project.templatePath`: Template Project
- `autoSave.enabled`: Automatically Save Projects
@@ -118,7 +118,7 @@ Commands (CLI, MCP, control channel):
size and modification time with what they were, and reloads what another app saved: a
Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload
Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer
(found again by name), and a file still being written is tried again on the next check.
(found again by name), and a file that can't be read yet (still being written) is tried again once it changes again.
`footage.reloadChanged` runs the same check once (CLI, agents)
- `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage;
Guess takes premultiplied for movies, straight for stills)
+2 -2
View File
@@ -31,8 +31,8 @@
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<!-- An upgrade, repair or silent reinstall goes where the installed copy already is: the App Paths
"Path" value written below, or for releases before it (0.6.0 and earlier, which could be put
elsewhere with INSTALLFOLDER=...) the folder of the effectcraft.exe that App Paths names. A
"Path" value written below, or for releases before this installer change (no Path value; they
could be put elsewhere with INSTALLFOLDER=...) the folder of the effectcraft.exe that App Paths names. A
"file" search with no FileSearch inside yields that file's folder. A folder passed on the
command line (INSTALLFOLDER=...) wins. -->
<Property Id="INSTALLFOLDER" Secure="yes" />
+4 -3
View File
@@ -3,8 +3,8 @@
Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build).
.DESCRIPTION
Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App
Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine:
Builds the MSI around two stand-in executables as 0.0.1 the way releases before this installer
change are (no App Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine:
1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...),
2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the
installed effectcraft.exe, as there is no Path value yet),
@@ -60,7 +60,8 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null
Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe')
[IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in')
# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value.
# 0.0.1 stands in for releases before this installer change: the same package without the App
# Paths "Path" value.
$Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs'
$LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs'
$pathValue = '(?m)^.*<RegistryValue [^>]*Name="Path"[^>]*/>\r?\n'