git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitdbd71cfc533ddf1b666242ebcae0fd84be8a006c
parenta6c3cab45b
authorLucas Galante <[email protected]>
date2026-08-26 15:24
feat: the drop-target glow is an animated vignette

The three stepped halo rects become a 14-layer feather on quadratic
spacing (dense rings near the cell, sparse outward), so cumulative
coverage falls off as a smooth vignette. The glow is animated state now
(State::drop_glow, advanced in tick_frame with frame-rate-independent
exponential smoothing): alpha fades in when a node drag starts and out
after it ends — lingering at its last cell — and the position GLIDES
when the target resolves to a different cell instead of teleporting.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs    | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/render.rs | 27 +++++++++++++++++---------
 2 files changed, 79 insertions(+), 10 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index d5b1268..e89c90d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -836,6 +836,17 @@ pub struct PendingWindowDrag {
     pub action: cce_ui::engine::WindowAction,
 }
 
+/// Animated drop-target glow (see [`State::drop_glow`]).
+#[derive(Debug, Clone, Copy)]
+pub struct DropGlow {
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    /// 0..1 — scales the whole feather's alpha profile.
+    pub alpha: f32,
+}
+
 pub struct State {
     /// Window title; the engine polls `Application::settings` and applies it.
     pub title: String,
@@ -999,6 +1010,12 @@ pub struct State {
     pub last_autosave_time: std::time::Instant,
     pub active_menu_cloud_pid: Option<u32>,
     pub active_menu_cloud_idx: Option<(usize, usize)>,
+    /// The drop-target glow's animation state: position glides toward the
+    /// cell an in-flight node drag will land on, alpha fades in on drag
+    /// start and out after release (the glow lingers at its last cell while
+    /// fading). None once fully faded. Advanced in [`State::tick_frame`],
+    /// drawn by the CONTENT branch in render.rs.
+    pub drop_glow: Option<DropGlow>,
     pub uniform_background: bool,
     pub network_opacity: f32,
     /// Node-domain opacity (style.surface.graph.node.opacity) — independent of
@@ -3296,6 +3313,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             // Cells and gaps render as ONE surface (the graph's bg_color is
             // the cell tint): the checkerboard grout is off by design; the
             // drop-target glow (render.rs) carries the only cell highlight.
+            drop_glow: None,
             uniform_background: true,
             network_opacity: 0.95,
             node_opacity: 1.0,
@@ -5683,6 +5701,48 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let now = Instant::now();
         self.last_frame = now;
 
+        // Drop-target glow animation: exponential smoothing toward the live
+        // target — the position GLIDES between cells, alpha fades in while a
+        // drag is in flight and out after it ends (lingering at the last
+        // cell). Exponential rates are frame-rate independent.
+        let mut glow_animating = false;
+        {
+            let target = self.graph().drop_target_cell_rect();
+            let ease = |k: f32| 1.0 - (-k * dt.max(1e-4)).exp();
+            match (&mut self.drop_glow, target) {
+                (Some(g), Some((tx, ty, tw, th))) => {
+                    let move_f = ease(18.0);
+                    g.x += (tx - g.x) * move_f;
+                    g.y += (ty - g.y) * move_f;
+                    g.w += (tw - g.w) * move_f;
+                    g.h += (th - g.h) * move_f;
+                    g.alpha += (1.0 - g.alpha) * ease(14.0);
+                    glow_animating = (tx - g.x).abs() > 0.3
+                        || (ty - g.y).abs() > 0.3
+                        || g.alpha < 0.995;
+                    if !glow_animating {
+                        g.x = tx;
+                        g.y = ty;
+                        g.alpha = 1.0;
+                    }
+                    // A settled glow still needs redraws only while the drag
+                    // moves it — PointerMove frames cover that.
+                }
+                (Some(g), None) => {
+                    g.alpha -= g.alpha * ease(10.0);
+                    if g.alpha < 0.02 {
+                        self.drop_glow = None;
+                    }
+                    glow_animating = true;
+                }
+                (None, Some((tx, ty, tw, th))) => {
+                    self.drop_glow = Some(DropGlow { x: tx, y: ty, w: tw, h: th, alpha: 0.0 });
+                    glow_animating = true;
+                }
+                (None, None) => {}
+            }
+        }
+
         // A simnet's geometry is a function of the frame, so advancing the
         // timeline invalidates the scene the way editing a node does. Gated on
         // the graph actually containing one: without this, every frame of
@@ -5908,7 +5968,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.read_panel_offsets();
         }
 
-        tick_changed || panned || reclaimed
+        tick_changed || panned || reclaimed || glow_animating
     }
 
     /// Flush CPU-staged mesh updates to the renderer's persistent meshes.
diff --git a/src/render.rs b/src/render.rs
index 75492b0..53174c3 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -372,18 +372,27 @@ impl State {
                         pc.quad(rect(qx, qy, qw, qh), qc);
                     }
                 }
-                // Drop-target glow: while a node drag is in flight, the cell
-                // it will land on wears a faint light-pink halo — layered
-                // expansions under the node bodies, so with grid snap on
-                // (node covering the cell exactly) the glow reads as a ring
-                // emanating around the dragged body.
-                if let Some((gx, gy, gw, gh)) = self.graph().drop_target_cell_rect() {
-                    for (grow, alpha) in [(10.0f32, 0.05f32), (5.0, 0.08), (0.0, 0.11)] {
+                // Drop-target glow, from the ANIMATED state (tick_frame owns
+                // it): position glides between cells, alpha fades in/out.
+                // The feather is many thin layers on a quadratic spacing —
+                // dense rings near the cell, sparse far out — so cumulative
+                // coverage falls off smoothly, a vignette rather than steps.
+                // Drawn under the node bodies: with grid snap the glow reads
+                // as a soft aura around the dragged body.
+                if let Some(g) = self.drop_glow {
+                    const LAYERS: usize = 14;
+                    const REACH: f32 = 30.0;
+                    for i in (0..LAYERS).rev() {
+                        let t = i as f32 / (LAYERS - 1) as f32;
+                        let grow = REACH * t * t;
+                        // Inner layers carry slightly more weight so the core
+                        // stays warm while the rim dissolves.
+                        let layer_a = g.alpha * 0.020 * (1.0 - 0.5 * t);
                         pc.rounded_rect(
-                            rect(gx - grow, gy - grow, gw + 2.0 * grow, gh + 2.0 * grow),
+                            rect(g.x - grow, g.y - grow, g.w + 2.0 * grow, g.h + 2.0 * grow),
                             cell_r + grow,
                             (true, true, true, true),
-                            [1.0, 0.72, 0.80, alpha],
+                            [1.0, 0.72, 0.80, layer_a],
                         );
                     }
                 }