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
+264
View File
@@ -0,0 +1,264 @@
#include "core/timeline.h"
#include "core/tokens.h"
#include <imgui.h>
#include <imgui_internal.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace fn {
// ---------------------------------------------------------------------------
// Pure interp
// ---------------------------------------------------------------------------
float track_value_at(const Track& track, float t) {
if (track.keys.empty()) return 0.0f;
if (track.keys.size() == 1) return track.keys[0].value;
if (t <= track.keys.front().time) return track.keys.front().value;
if (t >= track.keys.back().time) return track.keys.back().value;
// Encontrar el segmento [k_i, k_{i+1}] con k_i.time <= t < k_{i+1}.time
for (size_t i = 0; i + 1 < track.keys.size(); i++) {
const Keyframe& a = track.keys[i];
const Keyframe& b = track.keys[i + 1];
if (t >= a.time && t <= b.time) {
float dt = b.time - a.time;
float u = (dt > 1e-6f) ? (t - a.time) / dt : 0.0f;
float k = tween::apply(b.ease, u); // ease de la key destino
return a.value + (b.value - a.value) * k;
}
}
return track.keys.back().value;
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
void timeline_update(TimelineState& s, float dt) {
if (!s.playing) return;
s.current_time += dt;
if (s.current_time >= s.duration) {
if (s.loop) {
// Wrap por modulo (puede saltar varios loops si dt es enorme).
if (s.duration > 1e-6f)
s.current_time = std::fmod(s.current_time, s.duration);
else
s.current_time = 0.0f;
} else {
s.current_time = s.duration;
s.playing = false;
}
}
if (s.current_time < 0.0f) s.current_time = 0.0f;
}
// ---------------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------------
namespace {
constexpr float k_label_w = 80.0f; // ancho de la columna de nombre de track
constexpr float k_track_h = 36.0f; // alto de cada track
constexpr float k_ruler_h = 22.0f; // alto del ruler superior
constexpr float k_header_h = 28.0f; // alto de la cabecera (play/pause + tiempo)
constexpr float k_diamond_r = 6.0f; // radio del diamante de keyframe
float time_to_x(float time, float duration, float track_x0, float track_w) {
if (duration <= 1e-6f) return track_x0;
return track_x0 + (time / duration) * track_w;
}
float x_to_time(float x, float duration, float track_x0, float track_w) {
if (track_w <= 1e-6f) return 0.0f;
float t = (x - track_x0) / track_w * duration;
if (t < 0.0f) t = 0.0f;
if (t > duration) t = duration;
return t;
}
void draw_diamond(ImDrawList* dl, ImVec2 c, float r, ImU32 col) {
ImVec2 pts[4] = {
ImVec2(c.x, c.y - r),
ImVec2(c.x + r, c.y),
ImVec2(c.x, c.y + r),
ImVec2(c.x - r, c.y),
};
dl->AddConvexPolyFilled(pts, 4, col);
}
} // namespace
bool timeline_widget(const char* id, TimelineState& s, ImVec2 size) {
using namespace fn_tokens;
ImGui::PushID(id);
bool changed = false;
// ---- size resolution
ImVec2 region = ImGui::GetContentRegionAvail();
if (size.x <= 0.0f) size.x = region.x;
if (size.y <= 0.0f) size.y = 200.0f;
ImVec2 origin = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
// ---- Background
ImVec2 bg_max = ImVec2(origin.x + size.x, origin.y + size.y);
dl->AddRectFilled(origin, bg_max, ImGui::GetColorU32(colors::surface), radius::sm);
dl->AddRect(origin, bg_max, ImGui::GetColorU32(colors::border), radius::sm);
// ---- Header (play/pause + tiempo)
ImGui::SetCursorScreenPos(ImVec2(origin.x + spacing::sm, origin.y + spacing::xs));
const char* play_label = s.playing ? "Pause##tl_play" : "Play##tl_play";
if (ImGui::Button(play_label, ImVec2(60.0f, k_header_h - spacing::xs))) {
s.playing = !s.playing;
changed = true;
}
ImGui::SameLine();
if (ImGui::Button("Reset##tl_reset", ImVec2(60.0f, k_header_h - spacing::xs))) {
s.current_time = 0.0f;
changed = true;
}
ImGui::SameLine();
ImGui::SetNextItemWidth(60.0f);
if (ImGui::DragFloat("##tl_dur", &s.duration, 0.1f, 0.1f, 600.0f, "dur %.2fs")) {
if (s.duration < 0.1f) s.duration = 0.1f;
changed = true;
}
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::Text("t=%.3fs", s.current_time);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::Checkbox("loop##tl_loop", &s.loop);
// ---- Layout
float track_x0 = origin.x + k_label_w;
float track_w = size.x - k_label_w - spacing::sm;
float ruler_y0 = origin.y + k_header_h;
float ruler_y1 = ruler_y0 + k_ruler_h;
// ---- Ruler
dl->AddRectFilled(ImVec2(origin.x, ruler_y0), ImVec2(bg_max.x, ruler_y1),
ImGui::GetColorU32(colors::bg));
dl->AddLine(ImVec2(origin.x, ruler_y1), ImVec2(bg_max.x, ruler_y1),
ImGui::GetColorU32(colors::border));
// Tick marks cada 0.5s
ImU32 tick_col = ImGui::GetColorU32(colors::text_dim);
int n_ticks = (int)std::floor(s.duration / 0.5f);
for (int i = 0; i <= n_ticks; i++) {
float ts = (float)i * 0.5f;
if (ts > s.duration) break;
float x = time_to_x(ts, s.duration, track_x0, track_w);
bool whole = (i % 2 == 0);
float h = whole ? 8.0f : 4.0f;
dl->AddLine(ImVec2(x, ruler_y1 - h), ImVec2(x, ruler_y1), tick_col, 1.0f);
if (whole) {
char buf[16];
std::snprintf(buf, sizeof(buf), "%.0fs", ts);
dl->AddText(ImVec2(x + 2.0f, ruler_y0 + 2.0f), tick_col, buf);
}
}
// Scrub interaction en el ruler
{
ImGui::SetCursorScreenPos(ImVec2(track_x0, ruler_y0));
ImGui::InvisibleButton("##tl_ruler_scrub", ImVec2(track_w, k_ruler_h));
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 0.0f)) {
float mx = ImGui::GetIO().MousePos.x;
s.current_time = x_to_time(mx, s.duration, track_x0, track_w);
changed = true;
} else if (ImGui::IsItemClicked()) {
float mx = ImGui::GetIO().MousePos.x;
s.current_time = x_to_time(mx, s.duration, track_x0, track_w);
changed = true;
}
}
// ---- Tracks
ImU32 col_text = ImGui::GetColorU32(colors::text);
ImU32 col_track_bg = ImGui::GetColorU32(colors::bg);
ImU32 col_key = ImGui::GetColorU32(colors::primary);
ImU32 col_key_hover = ImGui::GetColorU32(colors::primary_hover);
ImU32 col_separator = ImGui::GetColorU32(colors::border);
float track_y = ruler_y1;
float available_h = (origin.y + size.y) - ruler_y1;
int n_tracks = (int)s.tracks.size();
float row_h = (n_tracks > 0) ? std::min(k_track_h, available_h / (float)n_tracks)
: k_track_h;
if (row_h < 18.0f) row_h = 18.0f;
for (int ti = 0; ti < n_tracks; ti++) {
Track& tr = s.tracks[ti];
float y0 = track_y;
float y1 = y0 + row_h;
// bg
dl->AddRectFilled(ImVec2(track_x0, y0), ImVec2(bg_max.x, y1), col_track_bg);
dl->AddLine(ImVec2(origin.x, y1), ImVec2(bg_max.x, y1), col_separator);
// label
dl->AddText(ImVec2(origin.x + spacing::sm, y0 + (row_h - ImGui::GetTextLineHeight()) * 0.5f),
col_text, tr.name.c_str());
// keyframes
for (size_t ki = 0; ki < tr.keys.size(); ki++) {
Keyframe& k = tr.keys[ki];
float kx = time_to_x(k.time, s.duration, track_x0, track_w);
ImVec2 c = ImVec2(kx, y0 + row_h * 0.5f);
char btn_id[64];
std::snprintf(btn_id, sizeof(btn_id), "##tl_k_%d_%zu", ti, ki);
ImGui::SetCursorScreenPos(ImVec2(c.x - k_diamond_r, c.y - k_diamond_r));
ImGui::InvisibleButton(btn_id, ImVec2(k_diamond_r * 2.0f, k_diamond_r * 2.0f));
bool hovered = ImGui::IsItemHovered();
draw_diamond(dl, c, k_diamond_r, hovered ? col_key_hover : col_key);
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 0.0f)) {
float mx = ImGui::GetIO().MousePos.x;
float new_t = x_to_time(mx, s.duration, track_x0, track_w);
k.time = new_t;
changed = true;
}
}
// Reordenar keys por tiempo si el usuario solto el drag (consistencia).
// Lo hacemos en cada frame que hubo cambio: cheap, O(n log n) per track.
if (changed) {
std::sort(tr.keys.begin(), tr.keys.end(),
[](const Keyframe& a, const Keyframe& b) { return a.time < b.time; });
}
track_y = y1;
}
// ---- Playhead vertical
{
float px = time_to_x(s.current_time, s.duration, track_x0, track_w);
ImU32 ph_col = ImGui::GetColorU32(colors::primary_light);
dl->AddLine(ImVec2(px, ruler_y0), ImVec2(px, bg_max.y), ph_col, 1.5f);
// pequeno triangulo en el ruler
ImVec2 t0(px - 4.0f, ruler_y0);
ImVec2 t1(px + 4.0f, ruler_y0);
ImVec2 t2(px, ruler_y0 + 6.0f);
dl->AddTriangleFilled(t0, t1, t2, ph_col);
}
// Reservar espacio en el layout vertical
ImGui::SetCursorScreenPos(ImVec2(origin.x, origin.y + size.y + spacing::xs));
ImGui::Dummy(ImVec2(size.x, 1.0f));
ImGui::PopID();
return changed;
}
} // namespace fn
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// timeline — widget tipo DAW: tracks horizontales con keyframes interpolados,
// scrub y play/pause. Sirve para animar valores escalares (uniforms shader,
// parametros UI, etc) a lo largo del tiempo.
//
// Estado puro (TimelineState) + funciones puras de interpolacion
// (track_value_at) + render impuro (timeline_widget).
//
// Uso:
// static fn::TimelineState tl;
// tl.tracks.push_back({"hue", {{0,0}, {2,1}, {4,0}}});
// tl.duration = 4.0f;
//
// fn::timeline_update(tl, dt);
// float h = fn::track_value_at(tl.tracks[0], tl.current_time);
// fn::timeline_widget("##tl", tl);
#include "core/tween_curves.h"
#include <imgui.h>
#include <string>
#include <vector>
namespace fn {
struct Keyframe {
float time;
float value;
tween::Ease ease = tween::Ease::Linear;
};
struct Track {
std::string name;
std::vector<Keyframe> keys;
};
struct TimelineState {
std::vector<Track> tracks;
float current_time = 0.0f;
float duration = 5.0f;
bool playing = false;
bool loop = true;
};
// --- Pure -------------------------------------------------------------------
// Interpola el valor de `track` en el tiempo `t`. Asume keys ordenadas por
// time. Si `t` cae antes del primer keyframe devuelve el value del primero;
// si cae despues del ultimo, devuelve el value del ultimo. Entre keyframes
// usa el ease de la SEGUNDA key (la "curva entrante" hasta esa key).
float track_value_at(const Track& track, float t);
// --- Update -----------------------------------------------------------------
// Avanza current_time si playing. Si loop=true hace wrap; si no, satura en
// duration y pone playing=false al llegar.
void timeline_update(TimelineState& s, float dt);
// --- Render -----------------------------------------------------------------
// Widget completo: cabecera con play/pause + tiempo, ruler con scrub, y un
// panel por track con keyframes draggable. Devuelve true si el usuario hizo
// algun cambio (drag de keyframe, scrub, play/pause).
bool timeline_widget(const char* id, TimelineState& s, ImVec2 size = ImVec2(-1, 200));
} // namespace fn
+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.