diff --git a/cpp/functions/core/timeline.cpp b/cpp/functions/core/timeline.cpp new file mode 100644 index 00000000..38e057dc --- /dev/null +++ b/cpp/functions/core/timeline.cpp @@ -0,0 +1,264 @@ +#include "core/timeline.h" +#include "core/tokens.h" + +#include +#include + +#include +#include +#include + +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 diff --git a/cpp/functions/core/timeline.h b/cpp/functions/core/timeline.h new file mode 100644 index 00000000..ab06f3ff --- /dev/null +++ b/cpp/functions/core/timeline.h @@ -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 +#include +#include + +namespace fn { + +struct Keyframe { + float time; + float value; + tween::Ease ease = tween::Ease::Linear; +}; + +struct Track { + std::string name; + std::vector keys; +}; + +struct TimelineState { + std::vector 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 diff --git a/cpp/functions/core/timeline.md b/cpp/functions/core/timeline.md new file mode 100644 index 00000000..5e760020 --- /dev/null +++ b/cpp/functions/core/timeline.md @@ -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), 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 keys; // ordenadas por time +}; + +struct fn::TimelineState { + std::vector 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.