feat(core): add timeline — DAW-style keyframe widget

Timeline widget with:
- Header: play/pause + reset + duration drag + loop checkbox
- Ruler: 0.5s ticks, scrub via click+drag
- Tracks: horizontal rows with diamond-shaped draggable keyframes
- Playhead: vertical primary_light line + ruler triangle marker

State and types:
- Keyframe { time, value, ease }
- Track { name, vector<Keyframe> }
- TimelineState { tracks, current_time, duration, playing, loop }

Pure functions:
- track_value_at(track, t): interp between keys, ease applied via the
  destination keyframe (Maya/AfterEffects convention)
- timeline_update(state, dt): advance current_time, wrap or saturate

Render with fn_tokens for visual coherence with the rest of the design
system. Keys are sorted by time on every changed frame to keep order
consistent during drag.
This commit is contained in:
2026-04-25 21:50:35 +02:00
parent b9810a88d4
commit 66f5ca1a4f
3 changed files with 468 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
---
name: timeline
kind: component
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "bool fn::timeline_widget(const char* id, fn::TimelineState&, ImVec2 size = {-1,200}) + float fn::track_value_at(const Track&, float t) + void fn::timeline_update(TimelineState&, float dt)"
description: "Widget tipo DAW: tracks horizontales con keyframes draggable, scrub, play/pause/loop, evaluacion track_value_at(time) interpolando entre keyframes con la Ease de cada keyframe destino. Estado puro (TimelineState) + render con tokens."
tags: [imgui, timeline, animation, keyframes, daw, tween]
uses_functions:
- tokens_cpp_core
- tween_curves_cpp_core
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/core/timeline.cpp"
framework: imgui
params:
- name: id
desc: "ID ImGui unico"
- name: state
desc: "TimelineState con tracks (vector<Track>), current_time, duration, playing, loop. Modificada por el widget."
- name: size
desc: "Tamaño total. size.x = -1 toma el ancho disponible. size.y >= 100 recomendado."
output: "true en el frame en que el usuario interactua (drag de keyframe, scrub, play/pause/reset, cambio de duration o loop)"
---
# timeline
Widget de timeline tipo DAW para animar valores escalares en el tiempo. Cada **Track** es un canal con keyframes (`time`, `value`, `ease`); `track_value_at(time)` interpola entre keyframes consecutivos aplicando la `Ease` de la keyframe destino.
## Tipos
```cpp
struct fn::Keyframe {
float time;
float value;
fn::tween::Ease ease = fn::tween::Ease::Linear;
};
struct fn::Track {
std::string name;
std::vector<Keyframe> keys; // ordenadas por time
};
struct fn::TimelineState {
std::vector<Track> tracks;
float current_time = 0.0f;
float duration = 5.0f;
bool playing = false;
bool loop = true;
};
```
## API
```cpp
// Pure: interp del valor del track en t
float fn::track_value_at(const Track&, float t);
// Avanza current_time si playing; loop o satura segun flag
void fn::timeline_update(TimelineState&, float dt);
// Render del widget. Devuelve true si hubo interaccion del usuario.
bool fn::timeline_widget(const char* id, TimelineState&, ImVec2 size = {-1, 200});
```
## Ejemplo
```cpp
static fn::TimelineState tl;
if (tl.tracks.empty()) {
tl.tracks.push_back({"hue", {{0, 0.0f}, {2.0f, 1.0f, fn::tween::Ease::OutCubic}, {4.0f, 0.0f}}});
tl.tracks.push_back({"amp", {{0, 0.2f}, {3.0f, 1.0f, fn::tween::Ease::InOutQuad}}});
tl.duration = 4.0f;
}
float dt = ImGui::GetIO().DeltaTime;
fn::timeline_update(tl, dt);
float hue = fn::track_value_at(tl.tracks[0], tl.current_time);
float amp = fn::track_value_at(tl.tracks[1], tl.current_time);
ImGui::Text("hue=%.3f amp=%.3f", hue, amp);
fn::timeline_widget("##my_tl", tl);
```
## Comportamiento de track_value_at
- 0 keys → devuelve 0.0
- 1 key → devuelve siempre `keys[0].value`
- t antes de la primera key → `keys.front().value` (clamp izq)
- t despues de la ultima key → `keys.back().value` (clamp der)
- Entre dos keys `a` y `b`:
- `u = (t - a.time) / (b.time - a.time)`
- `k = tween::apply(b.ease, u)` (la ease es la "curva entrante" hasta b)
- resultado = `a.value + (b.value - a.value) * k`
### Smoke tests (linear)
Track con 2 keys `{(0,0), (1,1)}` con ease=Linear:
- `track_value_at(t, 0.0) == 0.0`
- `track_value_at(t, 0.5) == 0.5`
- `track_value_at(t, 1.0) == 1.0`
## Render
- **Header**: Play/Pause + Reset + DragFloat de duration + indicador `t=...s` + checkbox loop.
- **Ruler**: ticks cada 0.5s, etiqueta cada segundo, scrub con click+drag.
- **Tracks**: filas horizontales con nombre a la izquierda (k_label_w=80px) y keyframes como diamantes draggable.
- **Playhead**: linea vertical `primary_light` con triangulo en la cabeza del ruler.
- Colores via `fn_tokens` (surface bg, border, primary keys, text labels).
## Interaccion
- **Drag horizontal de un diamante**: cambia `key.time`. Al soltar (o cualquier frame con `changed`), las keys se reordenan por tiempo.
- **Scrub**: click + drag en el ruler mueve `current_time`.
- **Play/Pause/Reset**: cambian `playing` y `current_time`.
- **Duration drag**: clampada >= 0.1s.
## Decisiones
- **Ease en la keyframe destino**: convencion comun en animation toolings (Maya, AfterEffects). La curva define como _llegamos_ a esa key.
- **Sin edicion vertical (value drag)**: para mantener el widget simple. Si hace falta editar `value` con la UI, el caller puede mostrar un campo numerico al lado o dentro de un popup.
- **Sort en cada cambio**: O(n log n) por track no es problema para timelines tipicas (<100 keys); evita estado intermedio "drag in progress".
## Limitaciones / TODO
- No hay editor de `ease` por keyframe en el MVP (queda en `Linear` salvo que el caller lo configure).
- No hay seleccion multiple ni copy/paste de keyframes.
- No hay zoom horizontal: la timeline siempre encaja `duration` completa al ancho del widget.