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:
@@ -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
|
||||
Reference in New Issue
Block a user