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