Skip to main content

tellur_core/timeline_component/
trigger.rs

1//! [`Event`]s and the wrappers that decorate a component transparently:
2//! [`Triggered`] (records an event time during resolve) and [`Sourced`]
3//! (stamps the authoring call site onto the arrangement).
4
5use std::hash::Hash;
6use std::ops::Range;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use crate::geometry::Vec2;
10use crate::phase::Phase;
11use crate::raster::{RasterImage, Resolution};
12use crate::render_context::RenderContext;
13use crate::time::Time;
14use crate::window::Window;
15
16use super::*;
17
18// ── Events — a structural moment shared across the tree ──────────────────────
19
20/// Process-wide counter minting [`Event`] ids. A plain monotonic counter is
21/// enough: ids only need to be distinct within a session (`.sketch/01` A.6).
22static EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
23
24/// The typed handle the user binds once (`let e = Event::new()`). It is a small
25/// `Copy` identity token — NOT a cell.
26///
27/// A clip declares the moment with a `.trigger_*` verb (see [`Triggers`]); the
28/// resolve pass records the resolved time in a side table (`id → TimelineTime`),
29/// and components read it through the [`Clock`]. Being a plain id, it is a sound
30/// cache-key term (audit B5); the trigger *time* lives outside the component.
31///
32/// An optional `name` ([`Event::named`]) is a `&'static str` so the token stays
33/// `Copy`. It is carried purely for display — it surfaces on the arrangement's
34/// [`TriggerMark`]s so the live UI can label event markers — and is NOT part of
35/// the event identity (only [`id`](Self::id) is).
36#[derive(Debug, Clone, Copy)]
37pub struct Event {
38    id: u64,
39    name: Option<&'static str>,
40}
41
42// Identity is the minted `id` ALONE: the `name` is a display annotation, never
43// part of equality/hash (so it is not a cache-key term either, audit B5). Two
44// `Event`s are equal iff their ids match — and ids are process-unique per mint.
45impl PartialEq for Event {
46    fn eq(&self, other: &Self) -> bool {
47        self.id == other.id
48    }
49}
50
51impl Eq for Event {}
52
53impl Hash for Event {
54    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
55        self.id.hash(state);
56    }
57}
58
59impl Event {
60    /// Mints a fresh, unnamed process-unique id.
61    pub fn new() -> Self {
62        Self {
63            id: EVENT_COUNTER.fetch_add(1, Ordering::Relaxed),
64            name: None,
65        }
66    }
67
68    /// Mints a fresh process-unique id carrying a display `name`. The name is
69    /// for UI labelling only; the identity is still the freshly minted id.
70    pub fn named(name: &'static str) -> Self {
71        Self {
72            id: EVENT_COUNTER.fetch_add(1, Ordering::Relaxed),
73            name: Some(name),
74        }
75    }
76
77    /// The raw id, used by the resolve pass / [`TriggerTable`] as the key.
78    pub fn id(&self) -> u64 {
79        self.id
80    }
81
82    /// The optional display name bound at construction (`None` for [`Event::new`]).
83    pub fn name(&self) -> Option<&'static str> {
84        self.name
85    }
86
87    /// `now < trigger` (unfired ⇒ trigger is `+∞`, so always true).
88    pub fn is_before(&self, clock: &Clock<'_>) -> bool {
89        clock.global().seconds() < clock.trigger_of(*self).seconds()
90    }
91
92    /// `now >= trigger` (unfired ⇒ always false).
93    pub fn is_after(&self, clock: &Clock<'_>) -> bool {
94        clock.global().seconds() >= clock.trigger_of(*self).seconds()
95    }
96
97    /// Phase over `[trigger + a, trigger + b]`; clamps 0 before, 1 after.
98    pub fn phase(&self, clock: &Clock<'_>, a: f32, b: f32) -> Phase {
99        let trigger = clock.trigger_of(*self).seconds();
100        // `.sketch/02 §11`: an unfired (+∞) trigger must short-circuit to 0,
101        // otherwise the naive `(now - ∞)/(∞ - ∞)` is `NaN`.
102        if trigger.is_infinite() {
103            return Phase::ZERO;
104        }
105        clock.global().phase(trigger + a, trigger + b)
106    }
107
108    /// Seconds elapsed since the trigger on the global timeline.
109    ///
110    /// Unfired events report `0.0`, and times before the trigger are clamped to
111    /// `0.0`. Unlike [`Event::phase`], this keeps counting after the event, so
112    /// components can run at a natural speed without choosing an end time up
113    /// front.
114    pub fn elapsed(&self, clock: &Clock<'_>) -> f32 {
115        let trigger = clock.trigger_of(*self).seconds();
116        if trigger.is_infinite() {
117            return 0.0;
118        }
119        (clock.global().seconds() - trigger).max(0.0)
120    }
121
122    /// A [`Window`] over `[trigger + range.start, trigger + range.end)`,
123    /// already [`clamped`](Window::clamped) — the event-relative twin of
124    /// [`crate::time::Time::window`], for staggering sub-events
125    /// ([`Window::sub_secs`]) or reading elapsed/remaining seconds off a
126    /// single trigger-anchored interval instead of composing them from
127    /// [`Event::phase`] / [`Event::elapsed`] by hand.
128    ///
129    /// Returning the clamped snapshot (not the live cursor) directly is what
130    /// makes this safe to store in a component field: like
131    /// [`Window::clamped`], the snapshot is constant at `(start, end, start)`
132    /// before the window opens and constant at `(start, end, end)` once it
133    /// closes, so it is a frame-stable cache-key term the same way a
134    /// saturating [`Phase`] is (`.sketch/02 §11`).
135    ///
136    /// An unfired event (trigger at `+∞`) cannot be shifted into absolute
137    /// seconds, so it reports the "before the window" snapshot directly —
138    /// `range.start` doubling as its own anchor, cursor pinned to
139    /// `range.start` (phase `0`, matching [`Event::phase`]'s `+∞` short
140    /// circuit) — rather than propagating `+∞`/`NaN` or panicking. That
141    /// snapshot does not depend on the current time, so it stays stable
142    /// across every frame the event remains unfired, exactly like the
143    /// post-close snapshot stays stable across every frame after firing.
144    pub fn window(&self, clock: &Clock<'_>, range: Range<f32>) -> Window {
145        assert!(
146            range.start.is_finite() && range.end.is_finite() && range.end > range.start,
147            "Event::window requires a finite range with end > start"
148        );
149        let trigger = clock.trigger_of(*self).seconds();
150        if trigger.is_infinite() {
151            return Window::new(range.start, range.end, range.start);
152        }
153        let start = trigger + range.start;
154        let end = trigger + range.end;
155        Window::new(start, end, clock.global().seconds()).clamped()
156    }
157}
158
159impl Default for Event {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165/// A transparent wrapper that registers an [`Event`]'s time during the resolve
166/// pass and otherwise plays its child unchanged. A [`TimelineComponent`].
167pub struct Triggered<T> {
168    child: T,
169    event: Event,
170    kind: TriggerKind,
171}
172
173// Hand-written so the `T: PartialEq + Hash` bound is attached (the `Keyable`
174// derive copies the declared generics verbatim and would not add it). Backs the
175// `DynEq` / `DynHash` super-traits a `Triggered<T>` needs as a
176// `TimelineComponent`.
177impl<T: PartialEq> PartialEq for Triggered<T> {
178    fn eq(&self, other: &Self) -> bool {
179        self.child == other.child && self.event == other.event && self.kind == other.kind
180    }
181}
182
183impl<T: Eq> Eq for Triggered<T> {}
184
185impl<T: Hash> Hash for Triggered<T> {
186    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
187        self.child.hash(state);
188        self.event.hash(state);
189        self.kind.hash(state);
190    }
191}
192
193/// Where in the child's interval a [`Triggered`] fires its [`Event`].
194#[derive(Debug, Clone, Copy, crate::Keyable)]
195enum TriggerKind {
196    /// At the child's resolved start (`abs_start`).
197    Start,
198    /// At the child's resolved end (`abs_start + len`).
199    End,
200    /// At a local offset into the child (`abs_start + local`).
201    At(f32),
202}
203
204impl<T> Triggered<T> {
205    /// The wrapped child.
206    pub fn child(&self) -> &T {
207        &self.child
208    }
209
210    /// The event this wrapper fires.
211    pub fn event(&self) -> Event {
212        self.event
213    }
214}
215
216impl<T: TimelineComponent + PartialEq + Hash + 'static> TimelineComponent for Triggered<T> {
217    fn duration(&self) -> Option<f32> {
218        self.child.duration()
219    }
220
221    fn measure(&self) -> Option<f32> {
222        self.child.measure()
223    }
224
225    fn resolve(&self, abs_start: f32, out: &mut ResolveCtx) -> f32 {
226        let len = self.child.resolve(abs_start, out);
227        // `Start` is already the absolute parent-clock start. `End` / `At` are in
228        // the child's LOCAL seconds, which run faster than the global clock by any
229        // enclosing window stretch, so scale them back via the cumulative
230        // `local_scale` (1.0 unless inside a stretched `.at(a..b)`).
231        let scale = out.local_scale;
232        let at = match self.kind {
233            TriggerKind::Start => abs_start,
234            TriggerKind::End => abs_start + len * scale,
235            TriggerKind::At(local) => abs_start + local * scale,
236        };
237        out.triggers_mut().record(self.event, at);
238        len
239    }
240
241    fn frame(
242        &self,
243        clock: Clock<'_>,
244        canvas: Vec2,
245        target: Resolution,
246        ctx: &mut dyn RenderContext,
247    ) -> Option<RasterImage> {
248        self.child.frame(clock, canvas, target, ctx)
249    }
250
251    fn samples(&self, clock: Clock<'_>, window: f32) -> Option<AudioBuffer> {
252        self.child.samples(clock, window)
253    }
254
255    fn mix_into(&self, mix: &mut crate::audio::AudioMix, start_secs: f32, speed: f32) {
256        // A trigger wrapper is transparent to the mix-down: contribute the
257        // child unchanged at the same offset / speed.
258        self.child.mix_into(mix, start_secs, speed);
259    }
260
261    fn cues(&self, offset: f32) -> Vec<Cue> {
262        self.child.cues(offset)
263    }
264
265    fn arrangement(&self, offset: f32) -> Arrangement {
266        // Transparent to the structure: build the child's node, then push this
267        // trigger's resolved time onto it (mirrors `resolve`'s trigger table).
268        // The child's resolved interval is `[offset, node.end]`, so its length
269        // is `node.end - offset` — `Start → offset`, `End → offset + len`,
270        // `At(local) → offset + local`. Multiple triggers accumulate.
271        let mut node = self.child.arrangement(offset);
272        let at = match self.kind {
273            TriggerKind::Start => offset,
274            TriggerKind::End => node.end,
275            TriggerKind::At(local) => offset + local,
276        };
277        node.triggers.push(TriggerMark {
278            time: at,
279            name: self.event().name().map(::std::string::String::from),
280        });
281        node
282    }
283}
284
285/// A `.trigger_*` result drops straight into a container's child setter, like
286/// [`Placed`] does.
287impl<T> From<Triggered<T>> for Box<dyn TimelineComponent + Send>
288where
289    T: TimelineComponent + PartialEq + Hash + Send + 'static,
290{
291    fn from(triggered: Triggered<T>) -> Self {
292        Box::new(triggered)
293    }
294}
295
296/// A transparent decorator that stamps a CALL-SITE source location onto its
297/// child's arrangement node and otherwise plays the child unchanged.
298///
299/// The generated container `.child(...)` setter (a `#[track_caller]` method)
300/// wraps every child in a `Sourced` carrying `Location::caller()`, so each node
301/// in the arrangement tree can be traced back to the authoring `.child(...)`
302/// line. Every query EXCEPT [`arrangement`](TimelineComponent::arrangement) is
303/// forwarded verbatim to `inner`; `arrangement` stamps the location onto the
304/// returned node (only if it is not already set — the innermost wrapper wins,
305/// which is the most specific call site).
306pub struct Sourced {
307    source: &'static ::core::panic::Location<'static>,
308    inner: Box<dyn TimelineComponent + Send>,
309}
310
311impl Sourced {
312    /// Wraps `inner`, recording the `source` call site for its arrangement node.
313    pub fn new(
314        source: &'static ::core::panic::Location<'static>,
315        inner: Box<dyn TimelineComponent + Send>,
316    ) -> Self {
317        Self { source, inner }
318    }
319
320    /// The captured call site as a [`SourceLoc`].
321    pub fn source_loc(&self) -> SourceLoc {
322        SourceLoc {
323            file: self.source.file().to_owned(),
324            line: self.source.line(),
325        }
326    }
327}
328
329/// Peels any wrapping [`Sourced`] decorators off a boxed child, returning the
330/// INNERMOST call site (matching [`Sourced::arrangement`], where the innermost
331/// stamp wins) and the structural inner component.
332///
333/// A container that bypasses a child's own `arrangement` (e.g.
334/// [`Timeline`](crate::timeline_container::Timeline),
335/// which builds the node from the peeled [`Placed`]) uses this to re-stamp the
336/// source the wrapper would otherwise have applied.
337pub fn peel_source(
338    child: &(dyn TimelineComponent + Send),
339) -> (Option<SourceLoc>, &(dyn TimelineComponent + Send)) {
340    let mut source = None;
341    let mut cur = child;
342    // Walk inward, keeping the LAST (innermost) source seen.
343    while let Some(sourced) = cur.as_any().downcast_ref::<Sourced>() {
344        source = Some(sourced.source_loc());
345        cur = sourced.inner.as_ref();
346    }
347    (source, cur)
348}
349
350// IDENTITY DELEGATES TO `inner`, IGNORING `source`: the call-site location is a
351// display annotation, NOT part of component identity / a cache-key term (same
352// contract as `Event::name`). These hand-written impls back the `DynEq` /
353// `DynHash` super-traits a `TimelineComponent` needs, and let `Sourced` be a
354// comparable `Box<dyn TimelineComponent + Send>` child like any other.
355impl PartialEq for Sourced {
356    fn eq(&self, other: &Self) -> bool {
357        // Compare the boxed children through `dyn TimelineComponent + Send`'s
358        // own `PartialEq` (the `DynEq` downcast), ignoring `source`.
359        *self.inner == *other.inner
360    }
361}
362
363impl Eq for Sourced {}
364
365impl Hash for Sourced {
366    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
367        // Hash through the boxed child's `dyn TimelineComponent + Send` `Hash`
368        // (the `DynHash` path), ignoring `source`.
369        self.inner.hash(state);
370    }
371}
372
373impl TimelineComponent for Sourced {
374    fn duration(&self) -> Option<f32> {
375        self.inner.duration()
376    }
377
378    fn measure(&self) -> Option<f32> {
379        self.inner.measure()
380    }
381
382    fn resolve(&self, abs_start: f32, out: &mut ResolveCtx) -> f32 {
383        self.inner.resolve(abs_start, out)
384    }
385
386    fn frame(
387        &self,
388        clock: Clock<'_>,
389        canvas: Vec2,
390        target: Resolution,
391        ctx: &mut dyn RenderContext,
392    ) -> Option<RasterImage> {
393        self.inner.frame(clock, canvas, target, ctx)
394    }
395
396    fn samples(&self, clock: Clock<'_>, window: f32) -> Option<AudioBuffer> {
397        self.inner.samples(clock, window)
398    }
399
400    fn mix_into(&self, mix: &mut crate::audio::AudioMix, start_secs: f32, speed: f32) {
401        self.inner.mix_into(mix, start_secs, speed);
402    }
403
404    fn cues(&self, offset: f32) -> Vec<Cue> {
405        self.inner.cues(offset)
406    }
407
408    fn arrangement(&self, offset: f32) -> Arrangement {
409        let mut node = self.inner.arrangement(offset);
410        // Only the innermost (most specific) call site stamps the node; an outer
411        // wrapper leaves an already-set `source` untouched.
412        if node.source.is_none() {
413            node.source = Some(SourceLoc {
414                file: self.source.file().to_owned(),
415                line: self.source.line(),
416            });
417        }
418        node
419    }
420
421    fn structural_any(&self) -> &dyn std::any::Any {
422        // Transparent to structural inspection: a container downcasting a child
423        // must see the real component, not this decorator. Recurses so nested
424        // wrappers peel fully.
425        self.inner.structural_any()
426    }
427}
428
429/// A `Sourced` drops straight into a container's child setter as a boxed
430/// `TimelineComponent`, like [`Placed`] / [`Triggered`] do.
431impl From<Sourced> for Box<dyn TimelineComponent + Send> {
432    fn from(sourced: Sourced) -> Self {
433        Box::new(sourced)
434    }
435}
436
437/// Trigger verbs on a built [`TimelineComponent`]. Blanket-implemented for every
438/// `T: TimelineComponent`. Multiple writers to one [`Event`] ⇒ EARLIEST wins
439/// (resolved in the place pass).
440///
441/// VERB ORDERING (`.sketch/01` A.6): a `.trigger_*` records its time against the
442/// wrapped node's OWN resolved interval (`abs_start` / `abs_start + len` /
443/// `abs_start + local`), where `abs_start` is whatever the parent hands the
444/// [`Triggered`] wrapper. So put the trigger OUTERMOST: either trigger a child
445/// the container positions (e.g. a `Sequence`/`Timeline` child —
446/// `Dialogue::builder()…​.trigger_at_start(e)`, the canonical usage; the
447/// container hands it its resolved start), or trigger THEN place
448/// (`x.trigger_at_start(e).at(5.0)`).
449///
450/// The inverted order `x.at(5.0).trigger_at_start(e)` wraps a `Placed` whose
451/// inner relative `5.0` is the CHILD's offset, not the wrapper's: the wrapper
452/// still receives its own `abs_start` and records the event THERE, ignoring the
453/// inner `5.0`. This is intentional — a `Triggered` fires at the interval IT is
454/// handed, and "fold a wrapped child's leading offset into my own trigger" would
455/// only handle a single immediate `Placed` and silently misbehave for any other
456/// nesting. See `triggered_over_placed_ignores_inner_offset` for the documented
457/// behaviour and `placed_over_triggered_keeps_offset` for the correct order.
458pub trait Triggers: TimelineComponent + Sized {
459    /// Records the [`Event`] at the wrapped node's resolved START (the
460    /// `abs_start` the parent hands this wrapper). See the trait-level VERB
461    /// ORDERING note: trigger outermost (or trigger a container-positioned
462    /// child), not `x.at(off).trigger_at_start(e)` — that records at the
463    /// wrapper's start, NOT accounting for the inner placement's `off`.
464    fn trigger_at_start(self, e: Event) -> Triggered<Self> {
465        Triggered {
466            child: self,
467            event: e,
468            kind: TriggerKind::Start,
469        }
470    }
471
472    fn trigger_at_end(self, e: Event) -> Triggered<Self> {
473        Triggered {
474            child: self,
475            event: e,
476            kind: TriggerKind::End,
477        }
478    }
479
480    /// At a local offset into the clip (an interior beat).
481    fn trigger_at(self, local: f32, e: Event) -> Triggered<Self> {
482        Triggered {
483            child: self,
484            event: e,
485            kind: TriggerKind::At(local),
486        }
487    }
488}
489
490impl<T: TimelineComponent + Sized> Triggers for T {}
491
492/// Buildless twin of [`Triggers`], over complete builders.
493pub trait TriggersBuilder: TimelineBuilder {
494    fn trigger_at_start(self, e: Event) -> Triggered<Self::Output> {
495        self.build_component().trigger_at_start(e)
496    }
497
498    fn trigger_at_end(self, e: Event) -> Triggered<Self::Output> {
499        self.build_component().trigger_at_end(e)
500    }
501
502    fn trigger_at(self, local: f32, e: Event) -> Triggered<Self::Output> {
503        self.build_component().trigger_at(local, e)
504    }
505}
506
507impl<B: TimelineBuilder> TriggersBuilder for B {}