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