damascene-core 0.6.0

Damascene — backend-agnostic UI library core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Visual-state animation tick — drives state envelopes (hover / press /
//! focus ring) and app-driven prop tracks (`fill`, `text_color`, etc.).
//!
//! The animation map (`(computed_id, AnimProp) → Animation`) lives in
//! [`crate::state::UiState`]. So does the envelope side map. This module
//! owns the per-node walker that retargets, steps, and writes back —
//! state envelopes go to `UiState::envelopes` (read by `draw_ops`), app
//! props mutate the `El`'s author fields directly so the next build
//! reads the eased value.

use std::collections::HashSet;

use rustc_hash::FxHashMap;
// web_time::Instant works on wasm32 (std::time::Instant::now() panics there).
use web_time::Instant;

use crate::anim::{AnimProp, AnimValue, Animation, Timing};
use crate::palette::Palette;
use crate::state::query::target_in_subtree;
use crate::state::{AnimationMode, EnvelopeKind};
use crate::tree::{El, InteractionState, Kind};

/// Snapshot of the active hover / focus / press leaf-targets for a
/// frame. Threaded through the tick so each node can ask "is the hot
/// target equal to me, or a descendant of me?" without re-walking the
/// trackers per node.
#[derive(Copy, Clone, Default)]
pub(crate) struct HotTargets<'a> {
    pub hovered: Option<&'a str>,
    pub focused: Option<&'a str>,
    pub pressed: Option<&'a str>,
}

/// App-driven props, processed *first* on nodes with `n.animate` set.
/// They write eased build-time values back to `n.fill` etc., so the
/// state pass that follows reads the already-eased value when computing
/// hover / press deltas. State visuals therefore compose on top of
/// app-driven motion without either tracker fighting the other.
const APP_PROPS: &[AnimProp] = &[
    AnimProp::AppFill,
    AnimProp::AppStroke,
    AnimProp::AppTextColor,
    AnimProp::AppOpacity,
    AnimProp::AppScale,
    AnimProp::AppTranslateX,
    AnimProp::AppTranslateY,
];

/// Per-node state envelopes, processed *after* app props. Always tracked
/// on keyed interactive nodes — no author opt-in. Each is a 0..1 amount
/// written to `UiState::envelopes`; `apply_state` in `draw_ops` mixes
/// the build-time visual toward the state-modulated visual based on it.
/// Drives single-target visuals (hover-lighten, press-darken, focus-
/// ring fade) — exactly one node owns each at a time.
const STATE_PROPS: &[AnimProp] = &[
    AnimProp::HoverAmount,
    AnimProp::PressAmount,
    AnimProp::FocusRingAlpha,
];

/// Subtree state envelopes, processed alongside `STATE_PROPS`. Each
/// tracks "is the active hover / focus / press target this node or any
/// descendant?" — multiple nodes can be hot simultaneously (every
/// ancestor of the leaf target). Drives region-shaped affordances
/// (`hover_alpha`, future hover-driven translate / scale / tint).
///
/// Tracked on every focusable node (so the draw-time cascade can read
/// the nearest focusable ancestor's envelope) and on every node
/// carrying `hover_alpha` (so a non-focusable wrapper — the action-pill
/// case — has a self-envelope to OR-merge with the inherited one).
const SUBTREE_PROPS: &[AnimProp] = &[
    AnimProp::SubtreeHoverAmount,
    AnimProp::SubtreePressAmount,
    AnimProp::SubtreeFocusAmount,
];

#[allow(clippy::too_many_arguments)]
pub(crate) fn tick_node(
    node: &mut El,
    anims: &mut FxHashMap<(std::sync::Arc<str>, AnimProp), Animation>,
    envelopes: &mut FxHashMap<(std::sync::Arc<str>, EnvelopeKind), f32>,
    node_states: &FxHashMap<std::sync::Arc<str>, InteractionState>,
    hot: HotTargets<'_>,
    focus_visible: bool,
    visited: &mut HashSet<(std::sync::Arc<str>, AnimProp)>,
    now: Instant,
    mode: AnimationMode,
    palette: &Palette,
    needs_redraw: &mut bool,
) {
    if !node.computed_id.is_empty() {
        // App-driven props: on nodes that opted in via .animate(), or
        // that carry an enter transition (which needs the same
        // trackers; its timing doubles as the retarget timing when no
        // explicit .animate() is set).
        let app_timing = node
            .animate_timing()
            .or(node.enter_spec().map(|e| e.timing));
        if let Some(timing) = app_timing {
            for &prop in APP_PROPS {
                process_prop(
                    node,
                    prop,
                    timing,
                    anims,
                    envelopes,
                    node_states,
                    hot,
                    focus_visible,
                    visited,
                    now,
                    mode,
                    palette,
                    needs_redraw,
                );
            }
        }
        // Per-node state envelopes: only on keyed interactive nodes;
        // the library always tracks these, no author opt-in. Two classes
        // of keyed-but-non-interactive node opt out, so their fill stays
        // static under the cursor instead of hover-lightening:
        //   - `Kind::Scrim` — keyed purely so click-outside routes to
        //     `{key}:dismiss` (#33: an opaque `OVERLAY_SCRIM` fill
        //     otherwise lightens on hover).
        //   - `Kind::Viewport` — keyed for `ViewportRequest` targeting +
        //     pan/zoom state; its fill is canvas chrome, and tracking the
        //     envelope made the background flicker as the pointer transits
        //     between it and keyed children (#110).
        // The same `#110` issue adds `no_hover()` as the general opt-out
        // for keyed-but-decorative nodes (graph nodes, layout anchors).
        if node.key.is_some()
            && !node.no_hover
            && !matches!(node.kind, Kind::Scrim | Kind::Viewport)
        {
            for &prop in STATE_PROPS {
                let timing = state_timing_for(prop);
                process_prop(
                    node,
                    prop,
                    timing,
                    anims,
                    envelopes,
                    node_states,
                    hot,
                    focus_visible,
                    visited,
                    now,
                    mode,
                    palette,
                    needs_redraw,
                );
            }
        }
        // Subtree envelopes: tracked on focusable nodes (so the
        // draw-time cascade can read the nearest focusable ancestor's
        // envelope) and on any node carrying `hover_alpha` (so
        // non-focusable wrappers — action pills, hover-revealed
        // badges — get a self-envelope to OR-merge with the inherited
        // one). Plain keyed-but-not-focusable nodes don't need them.
        if node.focusable || node.hover_alpha.is_some() {
            for &prop in SUBTREE_PROPS {
                let timing = state_timing_for(prop);
                process_prop(
                    node,
                    prop,
                    timing,
                    anims,
                    envelopes,
                    node_states,
                    hot,
                    focus_visible,
                    visited,
                    now,
                    mode,
                    palette,
                    needs_redraw,
                );
            }
        }
    }
    for child in &mut node.children {
        tick_node(
            child,
            anims,
            envelopes,
            node_states,
            hot,
            focus_visible,
            visited,
            now,
            mode,
            palette,
            needs_redraw,
        );
    }
}

#[allow(clippy::too_many_arguments)]
fn process_prop(
    node: &mut El,
    prop: AnimProp,
    timing: Timing,
    anims: &mut FxHashMap<(std::sync::Arc<str>, AnimProp), Animation>,
    envelopes: &mut FxHashMap<(std::sync::Arc<str>, EnvelopeKind), f32>,
    node_states: &FxHashMap<std::sync::Arc<str>, InteractionState>,
    hot: HotTargets<'_>,
    focus_visible: bool,
    visited: &mut HashSet<(std::sync::Arc<str>, AnimProp)>,
    now: Instant,
    mode: AnimationMode,
    palette: &Palette,
    needs_redraw: &mut bool,
) {
    let state = node_states
        .get(&*node.computed_id)
        .copied()
        .unwrap_or_default();
    let Some(target) = compute_target(node, prop, state, hot, focus_visible, palette) else {
        return;
    };
    let key = (node.computed_id.clone(), prop);
    visited.insert(key.clone());
    let anim = anims.entry(key).or_insert_with(|| {
        // First frame this (node, prop) is tracked — i.e. the node just
        // mounted (trackers are GC'd only when a node leaves the tree).
        // An enter transition seeds the tracker at its `from` value so
        // the prop eases in; everything else starts settled at target.
        let from = node
            .enter_spec()
            .and_then(|e| e.seed_for(prop, target))
            .unwrap_or(target);
        Animation::new(from, target, timing, now)
    });
    anim.retarget(target, now);
    let settled = match mode {
        AnimationMode::Live => anim.step(now),
        AnimationMode::Settled => {
            anim.settle();
            true
        }
    };
    write_prop(node, prop, anim.current, envelopes);
    if !settled {
        *needs_redraw = true;
    }
}

/// Compute the visual target for `prop` based on the node's current
/// interaction state and its build-closure-supplied original value.
/// Returns `None` if the prop doesn't apply (e.g., a node with no fill
/// has no `AppFill` to animate).
///
/// `focus_visible` is the runtime-level `:focus-visible` flag (raised
/// by Tab / arrow nav, cleared by pointer-down). The focus-ring target
/// only goes to 1.0 when the node is focused *and* the ring is allowed
/// — either the runtime says so, or the node opts in via
/// `always_show_focus_ring`.
fn compute_target(
    n: &El,
    prop: AnimProp,
    state: InteractionState,
    hot: HotTargets<'_>,
    focus_visible: bool,
    palette: &Palette,
) -> Option<AnimValue> {
    let in_subtree = |target: Option<&str>| -> bool {
        target.is_some_and(|t| target_in_subtree(&n.computed_id, t))
    };
    match prop {
        AnimProp::HoverAmount => Some(AnimValue::Float(
            if matches!(state, InteractionState::Hover) {
                1.0
            } else {
                0.0
            },
        )),
        AnimProp::PressAmount => Some(AnimValue::Float(
            if matches!(state, InteractionState::Press) {
                1.0
            } else {
                0.0
            },
        )),
        AnimProp::FocusRingAlpha => Some(AnimValue::Float(
            // Focus ring is independent of hover / press: a focused node
            // that is also hovered keeps `state = Hover` (Hover wins
            // over Focus in `apply_to_state`), but the ring should still
            // be on. Read `focused` straight from the hot targets so
            // the ring's envelope doesn't fall off when the cursor
            // enters the focused element.
            if hot.focused == Some(n.computed_id.as_ref())
                && (focus_visible || n.always_show_focus_ring)
            {
                1.0
            } else {
                0.0
            },
        )),
        AnimProp::SubtreeHoverAmount => Some(AnimValue::Float(if in_subtree(hot.hovered) {
            1.0
        } else {
            0.0
        })),
        AnimProp::SubtreePressAmount => Some(AnimValue::Float(if in_subtree(hot.pressed) {
            1.0
        } else {
            0.0
        })),
        // Subtree focus reveals on any focused descendant — including
        // pointer-focused ones (no `focus_visible` gate). The pattern
        // is "show me the close × on my focused tab", which a pointer
        // focus path should satisfy too.
        AnimProp::SubtreeFocusAmount => Some(AnimValue::Float(if in_subtree(hot.focused) {
            1.0
        } else {
            0.0
        })),
        // Resolve through the active palette so the integration walks
        // the user's palette's rgb space (e.g., slate blue's PRIMARY
        // (0,144,255)), not the compile-time baked default-dark rgb on
        // the token constant. Without this the in-flight color reads
        // against default-dark values and only snaps to the user's
        // palette when the animation settles — visible as a brief
        // wrong-palette flash mid-transition. `palette.resolve`
        // preserves the token name on the returned color, so settled
        // values stay tokenized for downstream palette swaps.
        AnimProp::AppFill => n.fill.map(|c| AnimValue::Color(palette.resolve(c))),
        AnimProp::AppStroke => n.stroke.map(|c| AnimValue::Color(palette.resolve(c))),
        AnimProp::AppTextColor => n.text_color.map(|c| AnimValue::Color(palette.resolve(c))),
        AnimProp::AppOpacity => Some(AnimValue::Float(n.opacity)),
        AnimProp::AppScale => Some(AnimValue::Float(n.scale)),
        AnimProp::AppTranslateX => Some(AnimValue::Float(n.translate.0)),
        AnimProp::AppTranslateY => Some(AnimValue::Float(n.translate.1)),
    }
}

/// Library-default timing for state-driven envelopes. Hover, press,
/// focus transitions (and their subtree analogues) are uniformly
/// snappy — overshoot on a 0..1 envelope reads as jitter, so we stick
/// to a near-critical preset.
fn state_timing_for(prop: AnimProp) -> Timing {
    match prop {
        AnimProp::HoverAmount
        | AnimProp::PressAmount
        | AnimProp::FocusRingAlpha
        | AnimProp::SubtreeHoverAmount
        | AnimProp::SubtreePressAmount
        | AnimProp::SubtreeFocusAmount => Timing::SPRING_QUICK,
        // App props don't reach this function — they pull timing from
        // the per-node `animate` setting in `tick_node`.
        _ => Timing::SPRING_QUICK,
    }
}

fn write_prop(
    n: &mut El,
    prop: AnimProp,
    value: AnimValue,
    envelopes: &mut FxHashMap<(std::sync::Arc<str>, EnvelopeKind), f32>,
) {
    match (prop, value) {
        (AnimProp::AppFill, AnimValue::Color(c)) => n.fill = Some(c),
        (AnimProp::AppStroke, AnimValue::Color(c)) => n.stroke = Some(c),
        (AnimProp::AppTextColor, AnimValue::Color(c)) => n.text_color = Some(c),
        (AnimProp::HoverAmount, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::Hover),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::PressAmount, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::Press),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::FocusRingAlpha, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::FocusRing),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::SubtreeHoverAmount, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::SubtreeHover),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::SubtreePressAmount, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::SubtreePress),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::SubtreeFocusAmount, AnimValue::Float(v)) => {
            envelopes.insert(
                (n.computed_id.clone(), EnvelopeKind::SubtreeFocus),
                v.clamp(0.0, 1.0),
            );
        }
        (AnimProp::AppOpacity, AnimValue::Float(v)) => n.opacity = v.clamp(0.0, 1.0),
        (AnimProp::AppScale, AnimValue::Float(v)) => n.scale = v.max(0.0),
        (AnimProp::AppTranslateX, AnimValue::Float(v)) => n.translate.0 = v,
        (AnimProp::AppTranslateY, AnimValue::Float(v)) => n.translate.1 = v,
        _ => {}
    }
}

pub(crate) fn is_in_flight(anim: &Animation) -> bool {
    let cur = anim.current.channels();
    let tgt = anim.target.channels();
    if cur.n != tgt.n {
        return true;
    }
    for i in 0..cur.n {
        if (cur.v[i] - tgt.v[i]).abs() > f32::EPSILON {
            return true;
        }
        if anim.velocity.n == cur.n && anim.velocity.v[i].abs() > f32::EPSILON {
            return true;
        }
    }
    false
}