mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 03:57:44 -06:00
Merge pull request #639 from storytold/claude/community-pr-review-lwtrlr
Land community PRs, batch 2, with follow-ups
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "{}" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¤t);
|
||||
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(¤t).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, ¤t);
|
||||
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(¤t).len(), 2);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", "Немає клавіатурного скорочення"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user