This commit is contained in:
Clinv committed 2026-10-24 12:01:52 -03:00
1 parent 7f7fcd545d
commit 4d9a339421
114 files changed
+11383 -1837

No files matched your search

-2
View File
@@ -1,3 +1 @@
build/ build/
.cache/
compile_commands.json
View File
Whitespace-only changes.
+24 -88
View File
@@ -1,98 +1,34 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.21)
project(Tahgibu VERSION 2.0.0 LANGUAGES C CXX) project(Tahgibu VERSION 2.0.0 DESCRIPTION "Editor de animación 2D" LANGUAGES C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_C_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Tipo de compilación" FORCE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Tipo de compilación" FORCE)
endif() endif()
option(TAHGIBU_BUILD_APP "Compilar el editor (requiere raylib)" ON) option(TAHGIBU_BUILD_APP "Construir la aplicación gráfica (necesita raylib)" ON)
option(TAHGIBU_BUILD_TESTS "Compilar las pruebas del núcleo" ON) option(TAHGIBU_BUILD_TESTS "Construir las pruebas" ON)
option(TAHGIBU_FETCH_RAYLIB "Descargar y compilar raylib si no se encuentra (requiere red y git)" OFF) option(TAHGIBU_BUILD_EXAMPLES "Construir la extensión nativa de ejemplo" ON)
option(TAHGIBU_WITH_LUA "Incluir soporte de extensiones Lua (necesita Lua 5.4)" OFF)
option(TAHGIBU_FETCH_DEPS "Descargar raylib si no está instalada (necesita Internet)" OFF)
option(TAHGIBU_SANITIZE "Compilar con AddressSanitizer y UBSan (solo gcc/clang)" OFF)
option(TAHGIBU_WARNINGS_AS_ERRORS "Tratar los avisos como errores" OFF)
option(TAHGIBU_UI_SMOKE "Prueba de la interfaz con un raylib falso (desarrollo)" OFF)
# --------------------------------------------------------------------------- list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
# Núcleo: modelo de datos del proyecto. NO depende de raylib. include(CompilerOptions)
# --------------------------------------------------------------------------- include(PlatformConfig)
add_library(tahgibu_core STATIC include(Dependencies)
source/core/Frame.cpp include(BuildTargets)
source/core/Project.cpp
)
target_include_directories(tahgibu_core PUBLIC source)
target_compile_definitions(tahgibu_core PUBLIC TAHGIBU_VERSION="${PROJECT_VERSION}")
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(tahgibu_core PUBLIC -Wall -Wextra)
endif()
# --------------------------------------------------------------------------- message(STATUS "Tahgibu ${PROJECT_VERSION}: app=${TAHGIBU_BUILD_APP} tests=${TAHGIBU_BUILD_TESTS} lua=${TAHGIBU_WITH_LUA}")
# Lua: solo se detecta. No se enlaza hasta la etapa 10.
# ---------------------------------------------------------------------------
find_package(Lua QUIET)
if(LUA_FOUND)
message(STATUS "Lua ${LUA_VERSION_STRING} encontrado (se usará a partir de la etapa 10)")
else()
message(STATUS "Lua no encontrado. No hace falta todavía; será necesario en la etapa 10.")
endif()
# --------------------------------------------------------------------------- # Paquetes (cpack): tar.gz y zip del ejecutable instalado. Sin probar todavía: ver docs/STATUS.md.
# Editor (raylib) set(CPACK_PACKAGE_NAME "Tahgibu")
# --------------------------------------------------------------------------- set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
if(TAHGIBU_BUILD_APP) set(CPACK_PACKAGE_VENDOR "Autores de Tahgibu")
set(TAHGIBU_RAYLIB_TARGET "") set(CPACK_RESOURCE_FILE_LICENSE ${CMAKE_SOURCE_DIR}/LICENSE)
set(CPACK_GENERATOR "TGZ;ZIP")
find_package(raylib QUIET) include(CPack)
if(TARGET raylib)
set(TAHGIBU_RAYLIB_TARGET raylib)
else()
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_check_modules(RAYLIB QUIET IMPORTED_TARGET raylib)
if(RAYLIB_FOUND)
set(TAHGIBU_RAYLIB_TARGET PkgConfig::RAYLIB)
endif()
endif()
endif()
if(NOT TAHGIBU_RAYLIB_TARGET AND TAHGIBU_FETCH_RAYLIB)
include(FetchContent)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_Declare(raylib
GIT_REPOSITORY https://github.com/raysan5/raylib.git
GIT_TAG 5.0
GIT_SHALLOW ON)
FetchContent_MakeAvailable(raylib)
set(TAHGIBU_RAYLIB_TARGET raylib)
endif()
if(NOT TAHGIBU_RAYLIB_TARGET)
message(FATAL_ERROR
"No se encontró raylib.\n"
"Opciones:\n"
" 1) Instalarla con el gestor de paquetes de tu distribución (ver README.md).\n"
" 2) Reconfigurar con -DTAHGIBU_FETCH_RAYLIB=ON (descarga y compila raylib).\n"
" 3) Compilar solo el núcleo y las pruebas con -DTAHGIBU_BUILD_APP=OFF.")
endif()
message(STATUS "raylib: ${TAHGIBU_RAYLIB_TARGET}")
add_executable(tahgibu
source/main.cpp
source/ui/Application.cpp
source/ui/Layout.cpp
)
target_link_libraries(tahgibu PRIVATE tahgibu_core ${TAHGIBU_RAYLIB_TARGET})
endif()
# ---------------------------------------------------------------------------
# Pruebas del núcleo (no necesitan raylib ni pantalla)
# ---------------------------------------------------------------------------
if(TAHGIBU_BUILD_TESTS)
enable_testing()
add_executable(tahgibu_core_tests tests/core_tests.cpp)
target_link_libraries(tahgibu_core_tests PRIVATE tahgibu_core)
add_test(NAME core COMMAND tahgibu_core_tests)
endif()
+40
View File
@@ -0,0 +1,40 @@
{
"version": 3,
"cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 },
"configurePresets": [
{
"name": "release",
"displayName": "Release (aplicación + pruebas)",
"binaryDir": "${sourceDir}/build/release",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
},
{
"name": "dev",
"displayName": "Desarrollo: Debug + sanitizers + avisos como errores",
"binaryDir": "${sourceDir}/build/dev",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "TAHGIBU_SANITIZE": "ON", "TAHGIBU_WARNINGS_AS_ERRORS": "ON" }
},
{
"name": "core-only",
"displayName": "Solo núcleo y pruebas (sin raylib)",
"binaryDir": "${sourceDir}/build/core-only",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "TAHGIBU_BUILD_APP": "OFF" }
},
{
"name": "release-fetch",
"displayName": "Release descargando raylib",
"inherits": "release",
"binaryDir": "${sourceDir}/build/release-fetch",
"cacheVariables": { "TAHGIBU_FETCH_DEPS": "ON" }
}
],
"buildPresets": [
{ "name": "release", "configurePreset": "release" },
{ "name": "dev", "configurePreset": "dev" },
{ "name": "core-only", "configurePreset": "core-only" }
],
"testPresets": [
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
{ "name": "core-only", "configurePreset": "core-only", "output": { "outputOnFailure": true } }
]
}
View File
Whitespace-only changes.
-92
View File
@@ -1,92 +0,0 @@
# Tahgibu 2.0.0
Editor de animación 2D ligero y nativo para Linux.
C++ · C · Lua · raylib · CMake. **Sin Python.**
## Estado actual: Etapa 1 (base)
Hecho y verificado:
- Sistema de compilación con CMake (C, C++, raylib; Lua solo se detecta).
- Núcleo sin dependencias gráficas (`source/core`): `Frame`, `Project`, límite de 60 FPS,
duración real por frame. Con pruebas automáticas.
- Ventana de raylib con el reparto de zonas: menús, herramientas, canvas, propiedades, timeline.
- Registro de herramientas (`source/editor/Tool.hpp`): la barra sale de una tabla.
Todavía **no** hace nada más que eso:
- Los menús se dibujan pero no abren nada.
- Las herramientas se pueden seleccionar (queda resaltada), pero no editan nada.
- La timeline es un esqueleto vacío (etapa 3).
## Dependencias
Necesarias hoy: compilador C++17, CMake >= 3.16 y raylib >= 4.0.
| Distro | raylib |
|---|---|
| Arch | `sudo pacman -S raylib` |
| Fedora | `sudo dnf install raylib-devel` |
| Debian/Ubuntu | `libraylib-dev` si tu versión lo trae |
Si tu distro no la trae, dos opciones:
1. Compilarla desde https://github.com/raysan5/raylib (en Debian/Ubuntu necesita
`libasound2-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libglu1-mesa-dev
libxcursor-dev libxinerama-dev libwayland-dev libxkbcommon-dev`).
2. Dejar que CMake la descargue y compile: `cmake -S . -B build -DTAHGIBU_FETCH_RAYLIB=ON`
(requiere red y `git`).
Lua (`liblua5.4-dev` en Debian/Ubuntu, `lua` en Arch, `lua-devel` en Fedora) no hace falta
hasta la etapa 10.
## Compilar y ejecutar
```bash
cmake -S . -B build
cmake --build build
./build/tahgibu
```
Solo núcleo + pruebas, sin raylib:
```bash
cmake -S . -B build -DTAHGIBU_BUILD_APP=OFF
cmake --build build
ctest --test-dir build --output-on-failure
```
## Estructura
```text
source/
├── main.cpp
├── core/ modelo de datos (sin raylib): Frame, Project, constantes
├── editor/ estado del editor y registro de herramientas
└── ui/ ventana, layout y dibujo con raylib
tests/ pruebas del núcleo
```
Los `.hpp` viven junto a su `.cpp`; `source/` es la raíz de includes
(`#include "core/Project.hpp"`). Las carpetas `canvas/`, `timeline/`, `animation/`,
`project/`, `media/`, `export/` y `lua/` se crean cuando llega su etapa.
## Decisiones de diseño
- **FPS por frame = override opcional.** Un frame usa el FPS base del proyecto salvo que
tenga FPS propios. Cambiar el FPS base afecta a los frames sin override.
- **Límite 60 FPS** aplicado en un único punto (`Frame::clampFps`) para el base y para cada frame.
- **Mínimo 0.1 FPS** (un frame puede durar hasta 10 s) para hacer pausas largas sin duplicar frames.
Se cambia en `core/Constants.hpp`.
- **El FPS de la interfaz (60) es independiente** del FPS de la animación.
## Limitaciones conocidas
- El texto usa la fuente por defecto de raylib. Si las tildes salen como `?`, hará falta
cargar una fuente propia.
- Esc no cierra la ventana (a propósito, para no perder trabajo); se cierra con el botón de la ventana.
## Hoja de ruta
1 Base · 2 Editor · 3 Frames · 4 Sprites · 5 Fondos · 6 FPS · 7 Audio · 8 Vértices ·
9 Proyecto · 10 Lua · 11 Exportación
-2
View File
@@ -1,2 +0,0 @@
python -m pip install pyinstaller
python pyinstaller .\tahgibu_animator.py
-2
View File
@@ -1,2 +0,0 @@
python -m pip install pyinstaller
python pyinstaller .\tahgibu_animator.py
+78
View File
@@ -0,0 +1,78 @@
# Destinos: biblioteca del núcleo, aplicación, pruebas y ejemplos.
set(TAHGIBU_CORE_SOURCES
src/core/error.c src/core/context.c
src/document/document.c src/document/doc_ops.c
src/history/history.c
src/editor/edit.c src/editor/tools/tool_raster.c src/editor/tools/tool_fill.c src/editor/tools/tool_region.c
src/animation/render.c src/animation/player.c
src/rendering/canvas.c
src/io/io_util.c src/io/json.c src/io/png.c src/io/tgb_format.c src/io/image_io.c
src/recovery/recovery_points.c src/recovery/autosave.c
src/extensions/plugin_manager.c src/extensions/lua_bridge.c
${TAHGIBU_PLATFORM_SOURCES})
add_library(tahgibu_core STATIC ${TAHGIBU_CORE_SOURCES})
target_include_directories(tahgibu_core PUBLIC include)
target_link_libraries(tahgibu_core PUBLIC ZLIB::ZLIB ${TAHGIBU_PLATFORM_LIBS})
if(UNIX)
target_compile_definitions(tahgibu_core PUBLIC _GNU_SOURCE)
endif()
tahgibu_apply_warnings(tahgibu_core)
if(TAHGIBU_WITH_LUA)
target_compile_definitions(tahgibu_core PRIVATE TAHGIBU_WITH_LUA)
target_include_directories(tahgibu_core PRIVATE ${LUA_INCLUDE_DIR})
target_link_libraries(tahgibu_core PUBLIC ${LUA_LIBRARIES})
endif()
if(TAHGIBU_BUILD_APP)
add_executable(tahgibu src/main.c src/ui/ui_main.c src/ui/ui_canvas.c src/ui/ui_panel.c src/ui/ui_timeline.c src/ui/ui_widgets.c)
target_include_directories(tahgibu PRIVATE src)
target_link_libraries(tahgibu PRIVATE tahgibu_core raylib)
tahgibu_apply_warnings(tahgibu)
if(WIN32)
set_target_properties(tahgibu PROPERTIES WIN32_EXECUTABLE ON)
endif()
install(TARGETS tahgibu RUNTIME DESTINATION bin)
install(DIRECTORY resources/palettes DESTINATION share/tahgibu)
install(FILES README.md LICENSE CHANGELOG.md DESTINATION share/doc/tahgibu)
install(DIRECTORY docs/ DESTINATION share/doc/tahgibu/docs)
endif()
install(DIRECTORY include/tahgibu DESTINATION include)
install(TARGETS tahgibu_core ARCHIVE DESTINATION lib)
if(TAHGIBU_BUILD_EXAMPLES)
add_library(ejemplo_cruz MODULE extensions/native/ejemplo_cruz.c)
target_include_directories(ejemplo_cruz PRIVATE include)
set_target_properties(ejemplo_cruz PROPERTIES PREFIX "")
tahgibu_apply_warnings(ejemplo_cruz)
endif()
if(TAHGIBU_BUILD_TESTS)
enable_testing()
if(UNIX) # las pruebas usan POSIX (fork, mkdtemp, rlimit): aún no son portables a Windows
set(TAHGIBU_PLUGIN_TEST_DIR ${CMAKE_BINARY_DIR}/tests/plugins)
set(TAHGIBU_TEST_PLUGINS plug_good plug_readonly plug_needs_edit plug_init_fails plug_no_entry plug_old_api plug_no_version plug_no_name plug_events)
foreach(p ${TAHGIBU_TEST_PLUGINS})
add_library(${p} MODULE tests/plugins/${p}.c)
target_include_directories(${p} PRIVATE include)
set_target_properties(${p} PROPERTIES PREFIX "" LIBRARY_OUTPUT_DIRECTORY ${TAHGIBU_PLUGIN_TEST_DIR})
tahgibu_apply_warnings(${p})
endforeach()
foreach(t test_canvas_draw test_document_history test_animation test_recovery_points test_io_formats test_io_files test_recovery test_plugins)
add_executable(${t} tests/${t}.c)
target_include_directories(${t} PRIVATE tests)
target_link_libraries(${t} PRIVATE tahgibu_core)
tahgibu_apply_warnings(${t})
add_test(NAME ${t} COMMAND ${t})
endforeach()
target_compile_definitions(test_plugins PRIVATE TGB_TEST_PLUGIN_DIR="${TAHGIBU_PLUGIN_TEST_DIR}")
add_dependencies(test_plugins ${TAHGIBU_TEST_PLUGINS})
if(TAHGIBU_UI_SMOKE) # prueba de la interfaz con un raylib FALSO (desarrollo)
add_executable(ui_smoke tests/ui_smoke/ui_smoke.c tests/ui_smoke/mock_raylib.c
src/ui/ui_main.c src/ui/ui_canvas.c src/ui/ui_panel.c src/ui/ui_timeline.c src/ui/ui_widgets.c)
target_include_directories(ui_smoke PRIVATE tests tools/dev/raylib_stub src)
target_link_libraries(ui_smoke PRIVATE tahgibu_core)
add_test(NAME ui_smoke COMMAND ui_smoke)
endif()
endif()
endif()
+16
View File
@@ -0,0 +1,16 @@
# Opciones del compilador comunes a todos los destinos.
function(tahgibu_apply_warnings target)
if(MSVC)
target_compile_options(${target} PRIVATE /W4 /utf-8)
target_compile_definitions(${target} PRIVATE _CRT_SECURE_NO_WARNINGS)
else()
target_compile_options(${target} PRIVATE -Wall -Wextra -Wshadow -Wpedantic)
if(TAHGIBU_WARNINGS_AS_ERRORS)
target_compile_options(${target} PRIVATE -Werror)
endif()
if(TAHGIBU_SANITIZE)
target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all)
target_link_options(${target} PRIVATE -fsanitize=address,undefined)
endif()
endif()
endfunction()
+28
View File
@@ -0,0 +1,28 @@
# Dependencias. Licencias y justificación: docs/THIRD_PARTY.md
# - zlib (obligatoria): DEFLATE y CRC32 para leer/escribir PNG. Licencia zlib.
# - raylib (solo la aplicación gráfica): ventana, dibujo y entrada. Licencia zlib/libpng.
# - Lua 5.4 (opcional): extensiones Lua. Licencia MIT.
find_package(ZLIB REQUIRED)
if(TAHGIBU_BUILD_APP)
find_package(raylib 5.0 QUIET)
if(NOT raylib_FOUND)
if(TAHGIBU_FETCH_DEPS)
include(FetchContent)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_Declare(raylib GIT_REPOSITORY https://github.com/raysan5/raylib.git GIT_TAG 5.5 GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(raylib)
else()
message(FATAL_ERROR
"No se encontró raylib 5.x. Opciones:\n"
" - Linux (Arch/CachyOS): sudo pacman -S raylib\n"
" - Linux (Debian/Ubuntu): sudo apt install libraylib-dev (si es la versión 5)\n"
" - o configurar con -DTAHGIBU_FETCH_DEPS=ON para que CMake la descargue (necesita Internet)\n"
" - o construir solo el núcleo y las pruebas con -DTAHGIBU_BUILD_APP=OFF")
endif()
endif()
endif()
if(TAHGIBU_WITH_LUA)
find_package(Lua 5.4 REQUIRED)
endif()
+13
View File
@@ -0,0 +1,13 @@
# Diferencias entre sistemas operativos.
if(WIN32)
set(TAHGIBU_PLATFORM_SOURCES src/platform/windows/platform_windows.c)
set(TAHGIBU_PLATFORM_LIBS "")
elseif(UNIX)
set(TAHGIBU_PLATFORM_SOURCES src/platform/linux/platform_linux.c)
set(TAHGIBU_PLATFORM_LIBS ${CMAKE_DL_LIBS} m)
else()
message(FATAL_ERROR "Sistema operativo no soportado: Tahgibu 2.0.0 apunta a Linux y Windows de 64 bits.")
endif()
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
message(WARNING "Tahgibu se ha probado solo en 64 bits.")
endif()
+17
View File
@@ -0,0 +1,17 @@
-- Extensión LUA de ejemplo: dibuja una diagonal azul en el fotograma 1, capa 1.
-- NOTA: el soporte de Lua se compila con -DTAHGIBU_WITH_LUA=ON y este script NO se ha podido
-- probar todavía (ver docs/STATUS.md). Instalar: panel Extensiones > "Instalar extension" > ruta del .lua.
--
-- Entorno: solo base (sin dofile/load/require), table, string, math y utf8. Las funciones tgb.*
-- que necesitan permisos solo existen si el usuario los concede al habilitar la extensión.
info = { name = "Diagonal de ejemplo", version = "1.0.0", caps = { "read", "edit" } }
tgb.register_command("diagonal-azul", function()
local w, h = tgb.doc_width(), tgb.doc_height()
tgb.edit_begin(0, 0) -- fotograma 0, capa 0
for i = 0, math.min(w, h) - 1 do
tgb.fill_rect(i, i, 3, 3, 0, 0, 255, 255) -- x, y, ancho, alto, r, g, b, a
end
tgb.edit_end() -- una sola operación deshacible
end)
+34
View File
@@ -0,0 +1,34 @@
/* Extensión NATIVA de ejemplo: dibuja una cruz roja en el centro del fotograma 1, capa 1.
*
* Compilar (Linux): gcc -shared -fPIC -Iinclude extensions/native/ejemplo_cruz.c -o ejemplo_cruz.so
* (o activar -DTAHGIBU_BUILD_EXAMPLES=ON en CMake). Luego en Tahgibu: panel Extensiones >
* "Instalar extension" > ruta del .so > "Habilitar" y conceder "Leer" y "Modificar el documento".
* Recuerda: una extensión nativa corre DENTRO de Tahgibu con tus permisos. */
#include "tahgibu/plugin.h"
TGB_PLUGIN_DECLARE_API_VERSION;
static const tgb_host_api *host;
static tgb_status dibujar_cruz(tgb_plugin_handle *self, void *user) {
(void)user;
tgb_status s = host->edit_begin(self, 0, 0); /* fotograma 0, capa 0 */
if (s < 0) return s;
const tgb_color rojo = {255, 0, 0, 255};
int cx = host->doc_width(self) / 2, cy = host->doc_height(self) / 2;
for (int i = -20; i <= 20; i++) {
host->fill_rect(self, (tgb_rect){cx + i - 1, cy - 1, 3, 3}, rojo);
host->fill_rect(self, (tgb_rect){cx - 1, cy + i - 1, 3, 3}, rojo);
}
return host->edit_end(self); /* una sola operación deshacible */
}
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *h, tgb_plugin_handle *self, tgb_plugin_info *info) {
host = h;
tgb_status s = h->register_command(self, "dibujar-cruz", dibujar_cruz, NULL);
if (s < 0) return s;
info->name = "Cruz de ejemplo";
info->version = "1.0.0";
info->required_caps = TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT;
return TGB_OK;
}
+59
View File
@@ -0,0 +1,59 @@
/* Composición, Onion Skin y reproducción. */
#ifndef TAHGIBU_ANIMATION_H
#define TAHGIBU_ANIMATION_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct tgb_onion_settings {
bool enabled;
int frames_before; /* 0..8 */
int frames_after; /* 0..8 */
float opacity_before; /* 0..1 opacidad del fotograma más cercano */
float opacity_after;
float falloff; /* 0..1: cuánto se atenúa cada fotograma más lejano */
bool tint; /* teñir anteriores/posteriores */
tgb_color tint_before; /* por defecto rojizo */
tgb_color tint_after; /* por defecto azulado */
} tgb_onion_settings;
void tgb_onion_defaults(tgb_onion_settings *s);
tgb_status tgb_onion_validate(tgb_onion_settings *s); /* recorta a rangos válidos */
/* Aplana las capas visibles (con su opacidad) del fotograma en 'out'
* (mismas dimensiones que el documento; se sobrescribe). */
tgb_status tgb_render_flatten(const tgb_document *doc, size_t frame_index, tgb_canvas *out);
/* Vista de edición: fondo + Onion Skin + fotograma actual.
* background.a == 0 deja el fondo transparente. */
tgb_status tgb_render_view(const tgb_document *doc, size_t frame_index,
const tgb_onion_settings *onion, tgb_color background,
tgb_canvas *out);
typedef enum tgb_play_mode { TGB_PLAY_ONCE = 0, TGB_PLAY_LOOP, TGB_PLAY_PINGPONG } tgb_play_mode;
/* Reproductor: lógica pura de tiempo, respeta la duración de cada fotograma.
* El documento debe sobrevivir al reproductor. */
typedef struct tgb_player tgb_player;
tgb_player *tgb_player_create(const tgb_document *doc);
void tgb_player_destroy(tgb_player *p);
void tgb_player_play(tgb_player *p);
void tgb_player_pause(tgb_player *p);
void tgb_player_stop(tgb_player *p); /* pausa y vuelve al inicio del rango */
bool tgb_player_is_playing(const tgb_player *p);
void tgb_player_set_mode(tgb_player *p, tgb_play_mode mode);
void tgb_player_set_speed(tgb_player *p, float speed); /* 0.1 .. 8 */
tgb_status tgb_player_set_range(tgb_player *p, size_t first, size_t last);
void tgb_player_clear_range(tgb_player *p);
tgb_status tgb_player_seek(tgb_player *p, size_t frame_index);
size_t tgb_player_frame(const tgb_player *p);
/* Avanza dt_ms milisegundos; devuelve el fotograma actual. */
size_t tgb_player_update(tgb_player *p, double dt_ms);
#ifdef __cplusplus
}
#endif
#endif
+62
View File
@@ -0,0 +1,62 @@
/* Lienzo de píxeles RGBA de 8 bits (NO premultiplicado), sin dependencias
* de raylib ni del sistema operativo.
* Propiedad: tgb_canvas_create/clone devuelven un objeto con 1 referencia;
* quien lo recibe debe llamar a tgb_canvas_release una vez. */
#ifndef TAHGIBU_CANVAS_H
#define TAHGIBU_CANVAS_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "tahgibu/error.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TGB_MAX_DIMENSION 4096 /* ancho/alto máximos de un lienzo */
typedef struct tgb_color { uint8_t r, g, b, a; } tgb_color;
typedef struct tgb_rect { int x, y, w, h; } tgb_rect;
typedef struct tgb_canvas tgb_canvas;
tgb_canvas *tgb_canvas_create(int width, int height); /* transparente; NULL si falla */
tgb_canvas *tgb_canvas_clone(const tgb_canvas *src); /* copia profunda */
tgb_canvas *tgb_canvas_retain(tgb_canvas *c); /* +1 referencia */
void tgb_canvas_release(tgb_canvas *c); /* -1; libera al llegar a 0; acepta NULL */
int tgb_canvas_refs(const tgb_canvas *c);
int tgb_canvas_width(const tgb_canvas *c);
int tgb_canvas_height(const tgb_canvas *c);
size_t tgb_canvas_bytes(const tgb_canvas *c);
/* Lectura directa (filas contiguas, 4 bytes por píxel: R,G,B,A). */
const uint8_t *tgb_canvas_pixels(const tgb_canvas *c);
tgb_color tgb_canvas_get(const tgb_canvas *c, int x, int y); /* transparente fuera del lienzo */
void tgb_canvas_set(tgb_canvas *c, int x, int y, tgb_color col); /* reemplaza */
void tgb_canvas_blend(tgb_canvas *c, int x, int y, tgb_color col); /* "source-over" */
void tgb_canvas_erase_px(tgb_canvas *c, int x, int y, uint8_t amount);/* resta opacidad */
void tgb_canvas_clear(tgb_canvas *c);
void tgb_canvas_fill_rect(tgb_canvas *c, tgb_rect r, tgb_color col, bool blend);
/* Copia src sobre dst en (dx,dy). opacity 0..255. blend=false reemplaza. */
void tgb_canvas_blit(tgb_canvas *dst, const tgb_canvas *src, int dx, int dy,
uint8_t opacity, bool blend);
tgb_status tgb_canvas_crop(const tgb_canvas *src, tgb_rect r, tgb_canvas **out);
bool tgb_canvas_is_empty(const tgb_canvas *c); /* todo alfa = 0 */
bool tgb_canvas_equal(const tgb_canvas *a, const tgb_canvas *b);
tgb_status tgb_canvas_copy_pixels(tgb_canvas *dst, const tgb_canvas *src); /* mismas dimensiones */
/* Seguimiento de la zona modificada (lo usan las sesiones de edición). */
tgb_rect tgb_canvas_dirty(const tgb_canvas *c); /* w==0 si no hay cambios */
void tgb_canvas_reset_dirty(tgb_canvas *c);
void tgb_canvas_mark_dirty(tgb_canvas *c, tgb_rect r);
tgb_rect tgb_rect_intersect(tgb_rect a, tgb_rect b);
tgb_rect tgb_rect_union(tgb_rect a, tgb_rect b);
#ifdef __cplusplus
}
#endif
#endif
+35
View File
@@ -0,0 +1,35 @@
/* Contexto de la aplicación: dueño de la configuración, rutas de usuario,
* bus de eventos y portapapeles de fotogramas.
* Orden de destrucción: destruye primero todos los tgb_document y
* gestores (recuperación, extensiones) y al final el contexto. */
#ifndef TAHGIBU_CONTEXT_H
#define TAHGIBU_CONTEXT_H
#include "tahgibu/error.h"
#include "tahgibu/events.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct tgb_context_config {
/* NULL = ubicación estándar del sistema (ver docs/ARCHITECTURE.md). */
const char *config_dir; /* configuración y registro de extensiones */
const char *data_dir; /* datos del usuario (extensiones instaladas, paletas) */
const char *cache_dir; /* caché y temporales de historial */
const char *state_dir; /* registros y archivos de recuperación */
} tgb_context_config;
/* config puede ser NULL. Crea las carpetas si no existen. */
tgb_status tgb_context_create(const tgb_context_config *config, tgb_context **out);
void tgb_context_destroy(tgb_context *ctx); /* acepta NULL */
const char *tgb_context_config_dir(const tgb_context *ctx);
const char *tgb_context_data_dir(const tgb_context *ctx);
const char *tgb_context_cache_dir(const tgb_context *ctx);
const char *tgb_context_state_dir(const tgb_context *ctx);
#ifdef __cplusplus
}
#endif
#endif
+60
View File
@@ -0,0 +1,60 @@
/* Documento de animación: dimensiones, fps, capas y fotogramas.
* Modelo: las CAPAS son del documento; cada FOTOGRAMA tiene una "celda"
* (lienzo) por capa. Las celdas vacías no ocupan memoria. Los índices
* empiezan en 0; la capa 0 es la de más abajo.
* Propiedad: tgb_document_create devuelve un objeto que debes destruir
* con tgb_document_destroy ANTES de destruir el contexto. */
#ifndef TAHGIBU_DOCUMENT_H
#define TAHGIBU_DOCUMENT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "tahgibu/canvas.h"
#include "tahgibu/context.h"
#include "tahgibu/error.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TGB_MAX_FRAMES 5000
#define TGB_MAX_LAYERS 64
#define TGB_NAME_MAX 64 /* bytes, incluye el NUL; UTF-8 */
#define TGB_MIN_FPS 1
#define TGB_MAX_FPS 60
#define TGB_MIN_FRAME_MS 16
#define TGB_MAX_FRAME_MS 60000
/* Crea un documento con 1 capa ("Capa 1") y 1 fotograma vacío. */
tgb_status tgb_document_create(tgb_context *ctx, int width, int height, int fps,
tgb_document **out);
void tgb_document_destroy(tgb_document *doc); /* acepta NULL */
tgb_context *tgb_document_context(const tgb_document *doc);
int tgb_document_width(const tgb_document *doc);
int tgb_document_height(const tgb_document *doc);
int tgb_document_fps(const tgb_document *doc);
/* Cambia el fps por defecto (y, si apply_to_all, la duración de todos los
* fotogramas). Se puede deshacer. */
tgb_status tgb_document_set_fps(tgb_document *doc, int fps, bool apply_to_all);
/* Cada cambio incrementa la revisión. "Sucio" = revisión distinta de la guardada. */
uint64_t tgb_document_revision(const tgb_document *doc);
bool tgb_document_dirty(const tgb_document *doc);
void tgb_document_mark_saved(tgb_document *doc);
/* Ruta asociada (de la última carga/guardado). Puede ser NULL. */
const char *tgb_document_path(const tgb_document *doc);
tgb_status tgb_document_set_path(tgb_document *doc, const char *path);
/* false si una operación falló a medias y no se pudo restaurar el estado. */
bool tgb_document_is_consistent(const tgb_document *doc);
/* Campo desconocido conservado de un archivo .tgb (JSON crudo) o NULL. */
const char *tgb_document_extra_field(const tgb_document *doc, const char *key);
#ifdef __cplusplus
}
#endif
#endif
+68
View File
@@ -0,0 +1,68 @@
/* Herramientas de dibujo.
* Capa 1 (primitivas): funciones sobre un tgb_canvas, sin historial.
* Capa 2 (sesión de edición): tgb_edit_* conecta una celda del documento
* con el historial: todo lo dibujado entre begin y end es UNA operación
* que se puede deshacer. Solo puede haber una sesión abierta por documento. */
#ifndef TAHGIBU_DRAWING_H
#define TAHGIBU_DRAWING_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum tgb_brush_mode { TGB_BRUSH_PAINT = 0, TGB_BRUSH_ERASE = 1 } tgb_brush_mode;
typedef struct tgb_brush {
float size; /* diámetro en píxeles (1..512) */
float hardness; /* 0 (muy suave) .. 1 (borde duro) */
bool antialias; /* false = lápiz de píxeles exactos */
tgb_color color;
tgb_brush_mode mode;
} tgb_brush;
tgb_brush tgb_brush_pencil(tgb_color color, int size); /* sin suavizado */
tgb_brush tgb_brush_soft(tgb_color color, float size, float hardness);
tgb_brush tgb_brush_eraser(float size, float hardness);
/* ---- Primitivas (marcan la zona modificada del lienzo) ---- */
void tgb_draw_dab(tgb_canvas *c, const tgb_brush *b, float x, float y);
void tgb_draw_line(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1);
void tgb_draw_rect(tgb_canvas *c, const tgb_brush *b, tgb_rect r, bool filled);
void tgb_draw_ellipse(tgb_canvas *c, const tgb_brush *b, tgb_rect bounds, bool filled);
/* Curva de Bézier cúbica con puntos de control p1 y p2. */
void tgb_draw_bezier(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1,
float x2, float y2, float x3, float y3);
/* Relleno por inundación; tolerance 0..255 (por canal). Devuelve píxeles cambiados. */
size_t tgb_flood_fill(tgb_canvas *c, int x, int y, tgb_color fill, int tolerance);
tgb_color tgb_eyedropper(const tgb_canvas *c, int x, int y);
/* Transformaciones de una región (selección rectangular). */
tgb_status tgb_region_move(tgb_canvas *c, tgb_rect src, int dx, int dy); /* mueve y deja hueco */
tgb_status tgb_region_flip(tgb_canvas *c, tgb_rect r, bool horizontal);
tgb_status tgb_region_scale(tgb_canvas *c, tgb_rect r, int new_w, int new_h); /* vecino más cercano, ancla arriba-izquierda */
tgb_status tgb_region_clear(tgb_canvas *c, tgb_rect r);
/* ---- Sesión de edición ligada al documento ---- */
/* Abre la sesión sobre la celda (frame_id, layer_id). Crea la celda si estaba
* vacía. Errores: TGB_ERR_LOCKED (capa bloqueada), TGB_ERR_STATE (ya hay
* sesión), TGB_ERR_NOT_FOUND. */
tgb_status tgb_edit_begin(tgb_document *doc, uint32_t frame_id, uint32_t layer_id,
const char *label);
/* Lienzo editable de la sesión (NULL si no hay sesión). NO lo liberes. */
tgb_canvas *tgb_edit_canvas(tgb_document *doc);
/* Vuelve al estado del principio de la sesión (útil para previsualizar formas). */
tgb_status tgb_edit_reset(tgb_document *doc);
/* Cierra y registra UNA operación en el historial si hubo cambios reales.
* Devuelve TGB_WARN_HISTORY_LOST si el cambio se aplicó pero no cabe en el
* historial. Si falla por memoria, deja la celda como estaba. */
tgb_status tgb_edit_end(tgb_document *doc);
tgb_status tgb_edit_cancel(tgb_document *doc); /* descarta los cambios */
bool tgb_edit_active(const tgb_document *doc);
#ifdef __cplusplus
}
#endif
#endif
+44
View File
@@ -0,0 +1,44 @@
/* Códigos de error comunes de toda la API pública.
* Regla: valor < 0 = fallo, 0 = éxito, > 0 = éxito con aviso. */
#ifndef TAHGIBU_ERROR_H
#define TAHGIBU_ERROR_H
#ifdef __cplusplus
extern "C" {
#endif
typedef enum tgb_status {
TGB_WARN_HISTORY_LOST = 1, /* la operación se hizo pero no se pudo guardar para deshacer */
TGB_OK = 0,
TGB_ERR_INVALID_ARG = -1, /* argumento NULL o inválido */
TGB_ERR_NOMEM = -2, /* sin memoria */
TGB_ERR_RANGE = -3, /* índice o valor fuera de rango */
TGB_ERR_NOT_FOUND = -4, /* id/archivo/elemento inexistente */
TGB_ERR_EXISTS = -5, /* ya existe (p. ej. nombre repetido) */
TGB_ERR_IO = -6, /* error de lectura/escritura del sistema */
TGB_ERR_FORMAT = -7, /* el archivo no tiene el formato esperado */
TGB_ERR_CORRUPT = -8, /* datos dañados (Base64, PNG, CRC...) */
TGB_ERR_UNSUPPORTED = -9, /* válido pero no soportado todavía */
TGB_ERR_LIMIT = -10, /* supera un límite de seguridad */
TGB_ERR_STATE = -11, /* operación no permitida en el estado actual */
TGB_ERR_LOCKED = -12, /* capa bloqueada */
TGB_ERR_NOTHING = -13, /* nada que deshacer/rehacer */
TGB_ERR_PLUGIN = -14, /* fallo de una extensión */
TGB_ERR_INCOMPATIBLE = -15 /* versión de formato más nueva o desconocida */
} tgb_status;
#define TGB_FAILED(s) ((s) < 0)
#define TGB_SUCCEEDED(s) ((s) >= 0)
/* Nombre corto del código (para registros), p. ej. "TGB_ERR_IO". */
const char *tgb_status_name(tgb_status status);
/* Mensaje detallado en español del último fallo en ESTE hilo.
* Nunca devuelve NULL. El puntero es válido hasta la siguiente llamada
* a la API desde el mismo hilo; copia el texto si lo necesitas más tiempo. */
const char *tgb_last_error(void);
#ifdef __cplusplus
}
#endif
#endif
+50
View File
@@ -0,0 +1,50 @@
/* Bus de eventos sencillo. Las llamadas a los suscriptores son SÍNCRONAS,
* en el mismo hilo que provocó el cambio. Un suscriptor no debe
* suscribirse/desuscribirse ni modificar el documento dentro de su
* callback. */
#ifndef TAHGIBU_EVENTS_H
#define TAHGIBU_EVENTS_H
#include <stdint.h>
#include "tahgibu/canvas.h"
#include "tahgibu/error.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct tgb_context tgb_context;
typedef struct tgb_document tgb_document;
typedef enum tgb_event_type {
TGB_EVENT_DOCUMENT_CHANGED = 1, /* cualquier cambio del documento */
TGB_EVENT_PIXELS_CHANGED, /* cambian píxeles (rect, frame_id, layer_id) */
TGB_EVENT_FRAMES_CHANGED, /* altas/bajas/orden/duración de fotogramas */
TGB_EVENT_LAYERS_CHANGED, /* altas/bajas/propiedades de capas */
TGB_EVENT_HISTORY_CHANGED, /* undo/redo disponible o modificado */
TGB_EVENT_HISTORY_TRUNCATED, /* se descartó historial por límites */
TGB_EVENT_RECOVERY_POINTS_CHANGED,
TGB_EVENT_DOCUMENT_SAVED,
TGB_EVENT_AUTOSAVED,
TGB_EVENT_PLUGIN_LOADED,
TGB_EVENT_PLUGIN_UNLOADED
} tgb_event_type;
typedef struct tgb_event {
tgb_event_type type;
tgb_document *document; /* puede ser NULL (eventos globales) */
uint32_t frame_id, layer_id;
tgb_rect rect;
const char *text; /* nombre/etiqueta; válido solo durante el callback */
} tgb_event;
typedef void (*tgb_event_fn)(const tgb_event *event, void *user);
/* Devuelve un identificador >0 en *out_id. Máximo 64 suscriptores. */
tgb_status tgb_events_subscribe(tgb_context *ctx, tgb_event_fn fn, void *user, uint32_t *out_id);
tgb_status tgb_events_unsubscribe(tgb_context *ctx, uint32_t id);
#ifdef __cplusplus
}
#endif
#endif
+36
View File
@@ -0,0 +1,36 @@
/* Fotogramas. Todas las operaciones que modifican se pueden deshacer y
* fallan con TGB_ERR_STATE si hay una sesión de edición abierta. */
#ifndef TAHGIBU_FRAME_H
#define TAHGIBU_FRAME_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
size_t tgb_frame_count(const tgb_document *doc);
uint32_t tgb_frame_id(const tgb_document *doc, size_t index); /* 0 si fuera de rango */
tgb_status tgb_frame_index_of(const tgb_document *doc, uint32_t id, size_t *out_index);
uint32_t tgb_frame_duration_ms(const tgb_document *doc, size_t index);
/* index == count añade al final. Devuelve el id del nuevo fotograma. */
tgb_status tgb_frame_add(tgb_document *doc, size_t index, uint32_t *out_id);
/* Duplica y coloca la copia justo después (comparte memoria hasta editarla). */
tgb_status tgb_frame_duplicate(tgb_document *doc, size_t index, uint32_t *out_id);
tgb_status tgb_frame_delete(tgb_document *doc, size_t index); /* nunca el último */
tgb_status tgb_frame_move(tgb_document *doc, size_t from, size_t to);
tgb_status tgb_frame_set_duration(tgb_document *doc, size_t index, uint32_t ms);
/* Portapapeles de fotogramas (vive en el contexto). Pegar exige mismas
* dimensiones; las capas se emparejan por posición (las sobrantes se ignoran). */
tgb_status tgb_frame_copy(tgb_document *doc, size_t index);
tgb_status tgb_frame_paste(tgb_document *doc, size_t index, uint32_t *out_id);
/* Celda de un fotograma/capa. NULL = celda vacía (transparente). Solo lectura. */
const tgb_canvas *tgb_frame_cel(const tgb_document *doc, size_t frame_index, size_t layer_index);
#ifdef __cplusplus
}
#endif
#endif
+55
View File
@@ -0,0 +1,55 @@
/* Historial unificado (deshacer/rehacer) de un documento.
* - Edición de píxeles, fotogramas, capas y propiedades usan el mismo historial.
* - Operaciones agrupadas: begin_group ... end_group = UNA entrada.
* - Límites adaptativos: memoria, nº de operaciones y almacenamiento temporal.
* Al superarlos, las operaciones más antiguas de píxeles se pasan a disco
* (si hay carpeta temporal y espacio) o, si no, se descartan (la más
* antigua primero). Nunca se descarta lo ya aplicado al documento. */
#ifndef TAHGIBU_HISTORY_H
#define TAHGIBU_HISTORY_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
tgb_status tgb_history_undo(tgb_document *doc); /* TGB_ERR_NOTHING si no hay nada */
tgb_status tgb_history_redo(tgb_document *doc);
bool tgb_history_can_undo(const tgb_document *doc);
bool tgb_history_can_redo(const tgb_document *doc);
const char *tgb_history_undo_label(const tgb_document *doc); /* NULL si no hay */
const char *tgb_history_redo_label(const tgb_document *doc);
/* Grupos anidables (hasta 8 niveles). Mientras hay un grupo abierto no se
* puede deshacer/rehacer. abort_group deshace lo hecho dentro del grupo y
* lo descarta (lo usan las extensiones al fallar). */
tgb_status tgb_history_begin_group(tgb_document *doc, const char *label);
tgb_status tgb_history_end_group(tgb_document *doc);
tgb_status tgb_history_abort_group(tgb_document *doc);
typedef struct tgb_history_limits {
size_t max_memory_bytes; /* memoria para datos del historial */
uint32_t max_operations; /* nº máximo de operaciones deshacibles */
size_t max_temp_bytes; /* disco temporal permitido (0 = no usar disco) */
char temp_dir[512]; /* carpeta temporal ("" = no usar disco) */
} tgb_history_limits;
tgb_status tgb_history_set_limits(tgb_document *doc, const tgb_history_limits *limits);
void tgb_history_get_limits(const tgb_document *doc, tgb_history_limits *out);
/* Recalcula los límites según la RAM libre, el tamaño del documento y el
* espacio en disco. Llámalo de vez en cuando (p. ej. cada minuto). */
tgb_status tgb_history_adapt(tgb_document *doc);
typedef struct tgb_history_stats {
size_t undo_count, redo_count;
size_t memory_bytes, temp_bytes;
uint64_t dropped_ops, spilled_ops;
} tgb_history_stats;
void tgb_history_get_stats(const tgb_document *doc, tgb_history_stats *out);
void tgb_history_clear(tgb_document *doc);
#ifdef __cplusplus
}
#endif
#endif
+56
View File
@@ -0,0 +1,56 @@
/* Archivos: proyectos .tgb, importación de imágenes y exportación.
* Toda lectura valida el contenido y devuelve errores con mensaje claro
* (tgb_last_error). Todo guardado es atómico: se escribe un temporal en la
* misma carpeta y se reemplaza el destino solo si todo salió bien. */
#ifndef TAHGIBU_IO_H
#define TAHGIBU_IO_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TGB_MAX_FILE_BYTES (512u * 1024u * 1024u) /* tamaño máximo de un .tgb */
#define TGB_MAX_PIXEL_MEMORY ((size_t)2048u * 1024u * 1024u) /* memoria máxima de píxeles al cargar */
tgb_status tgb_document_load(tgb_context *ctx, const char *path, tgb_document **out);
tgb_status tgb_document_load_memory(tgb_context *ctx, const uint8_t *data, size_t len,
tgb_document **out);
typedef struct tgb_save_options {
bool include_flat_frames; /* true: añade "frames" aplanados para lectores del formato 2 */
bool legacy_v2; /* true: escribe formato 2 (una imagen por fotograma, SIN capas
ni puntos de recuperación: se pierde información) */
} tgb_save_options;
void tgb_save_options_defaults(tgb_save_options *opts); /* include_flat_frames=true */
/* path NULL = usar la ruta asociada al documento. Tras guardar bien, asocia
* la ruta y marca el documento como guardado (salvo en modo legacy_v2). */
tgb_status tgb_document_save(tgb_document *doc, const char *path, const tgb_save_options *opts);
/* Serializa a memoria; libera *out_data con tgb_free. No cambia el estado del documento. */
tgb_status tgb_document_save_memory(const tgb_document *doc, const tgb_save_options *opts,
uint8_t **out_data, size_t *out_len);
void tgb_free(void *p);
/* PNG (8 bits RGBA al escribir; al leer: 8/16 bits, gris/RGB/paleta/+alfa,
* sin entrelazado). */
tgb_status tgb_png_decode_memory(const uint8_t *data, size_t len, tgb_canvas **out);
tgb_status tgb_png_encode_memory(const tgb_canvas *canvas, uint8_t **out_data, size_t *out_len);
/* Importa un PNG como NUEVO fotograma en frame_index, dibujado en la capa
* layer_index (centrado; se recorta si es mayor que el documento). Una sola
* operación deshacible. */
tgb_status tgb_import_png(tgb_document *doc, const char *path, size_t frame_index,
size_t layer_index, uint32_t *out_frame_id);
/* Exporta un fotograma aplanado a PNG. */
tgb_status tgb_export_png(const tgb_document *doc, size_t frame_index, const char *path);
/* Exporta todos los fotogramas: <dir>/<prefix>_0001.png ... */
tgb_status tgb_export_png_sequence(const tgb_document *doc, const char *dir, const char *prefix);
/* Exporta una animación APNG (PNG animado) respetando la duración de cada fotograma. */
tgb_status tgb_export_apng(const tgb_document *doc, const char *path);
#ifdef __cplusplus
}
#endif
#endif
+31
View File
@@ -0,0 +1,31 @@
/* Capas. Opacidad 0..255. Una capa bloqueada rechaza ediciones de píxeles. */
#ifndef TAHGIBU_LAYER_H
#define TAHGIBU_LAYER_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
size_t tgb_layer_count(const tgb_document *doc);
uint32_t tgb_layer_id(const tgb_document *doc, size_t index); /* 0 si fuera de rango */
tgb_status tgb_layer_index_of(const tgb_document *doc, uint32_t id, size_t *out_index);
/* El puntero devuelto es válido hasta el siguiente cambio de capas. */
const char *tgb_layer_name(const tgb_document *doc, size_t index);
bool tgb_layer_visible(const tgb_document *doc, size_t index);
bool tgb_layer_locked(const tgb_document *doc, size_t index);
uint8_t tgb_layer_opacity(const tgb_document *doc, size_t index);
tgb_status tgb_layer_add(tgb_document *doc, size_t index, const char *name, uint32_t *out_id);
tgb_status tgb_layer_delete(tgb_document *doc, size_t index); /* nunca la última */
tgb_status tgb_layer_move(tgb_document *doc, size_t from, size_t to);
tgb_status tgb_layer_rename(tgb_document *doc, size_t index, const char *name);
tgb_status tgb_layer_set_visible(tgb_document *doc, size_t index, bool visible);
tgb_status tgb_layer_set_locked(tgb_document *doc, size_t index, bool locked);
tgb_status tgb_layer_set_opacity(tgb_document *doc, size_t index, uint8_t opacity);
#ifdef __cplusplus
}
#endif
#endif
+121
View File
@@ -0,0 +1,121 @@
/* Extensiones nativas en C (y base de las de Lua).
*
* ADVERTENCIA DE SEGURIDAD: una extensión nativa es código que se ejecuta
* DENTRO del proceso de Tahgibu con sus mismos permisos. Los permisos
* (capabilities) de abajo controlan qué funciones del host se le ofrecen,
* pero NO pueden impedir que el código nativo lea/escriba memoria o
* archivos por su cuenta. Instala solo extensiones de origen confiable.
*
* Una extensión nativa es una biblioteca compartida (.so/.dll) que exporta:
* const uint32_t tgb_plugin_api_version; (usa TGB_PLUGIN_DECLARE_API_VERSION)
* tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self,
* tgb_plugin_info *info);
* void tgb_plugin_shutdown(tgb_plugin_handle *self); (opcional)
*
* Las funciones de lectura/edición del host solo funcionan DENTRO de un
* comando (mientras Tahgibu ejecuta tgb_plugin_command_fn), nunca en init. */
#ifndef TAHGIBU_PLUGIN_H
#define TAHGIBU_PLUGIN_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
#define TGB_PLUGIN_API_VERSION 1
#if defined(_WIN32)
#define TGB_PLUGIN_EXPORT __declspec(dllexport)
#else
#define TGB_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
/* Declara la versión de API con la que se compiló la extensión. Sin ella (o con otra) no se carga. */
#define TGB_PLUGIN_DECLARE_API_VERSION TGB_PLUGIN_EXPORT const uint32_t tgb_plugin_api_version = TGB_PLUGIN_API_VERSION
typedef enum tgb_capability {
TGB_CAP_READ_DOCUMENT = 1u << 0, /* leer dimensiones, fotogramas, píxeles */
TGB_CAP_EDIT_DOCUMENT = 1u << 1, /* modificar el documento (siempre con historial) */
TGB_CAP_EVENTS = 1u << 2 /* recibir eventos */
} tgb_capability;
typedef struct tgb_plugin_handle tgb_plugin_handle; /* identidad de la extensión (opaco) */
typedef struct tgb_plugin_info {
const char *name; /* nombre legible */
const char *version; /* p. ej. "1.0.0" */
uint32_t required_caps; /* combinación de tgb_capability */
} tgb_plugin_info;
/* Un comando recibe el documento activo a través del host (no directamente). */
typedef tgb_status (*tgb_plugin_command_fn)(tgb_plugin_handle *self, void *user);
typedef struct tgb_host_api {
uint32_t api_version; /* = TGB_PLUGIN_API_VERSION */
uint32_t struct_size; /* sizeof(tgb_host_api) del host */
uint32_t (*granted_caps)(tgb_plugin_handle *self);
void (*log)(tgb_plugin_handle *self, const char *message);
tgb_status (*register_command)(tgb_plugin_handle *self, const char *name,
tgb_plugin_command_fn fn, void *user);
/* TGB_CAP_EVENTS */
tgb_status (*subscribe)(tgb_plugin_handle *self, tgb_event_fn fn, void *user);
/* TGB_CAP_READ_DOCUMENT */
int (*doc_width)(tgb_plugin_handle *self);
int (*doc_height)(tgb_plugin_handle *self);
size_t (*doc_frame_count)(tgb_plugin_handle *self);
size_t (*doc_layer_count)(tgb_plugin_handle *self);
tgb_color (*get_pixel)(tgb_plugin_handle *self, size_t frame, size_t layer, int x, int y);
/* TGB_CAP_EDIT_DOCUMENT: todo lo editado entre begin y end es una operación deshacible */
tgb_status (*edit_begin)(tgb_plugin_handle *self, size_t frame, size_t layer);
tgb_status (*put_pixel)(tgb_plugin_handle *self, int x, int y, tgb_color color);
tgb_status (*fill_rect)(tgb_plugin_handle *self, tgb_rect r, tgb_color color);
tgb_status (*edit_end)(tgb_plugin_handle *self);
tgb_status (*add_frame)(tgb_plugin_handle *self, size_t index);
} tgb_host_api;
/* ---------------- Gestor de extensiones (lado de la aplicación) ---------------- */
typedef struct tgb_plugin_manager tgb_plugin_manager;
typedef struct tgb_plugin_entry_info {
char id[64];
char kind[8]; /* "native" o "lua" */
char file[256];
char name[64];
char version[32];
bool enabled;
bool loaded;
uint32_t granted_caps;
uint32_t required_caps;
char last_error[160];
} tgb_plugin_entry_info;
/* El registro vive en <config_dir>/extensions.json; los archivos instalados
* en <data_dir>/extensions/. NUNCA se carga nada que no esté en el registro
* y habilitado explícitamente. */
tgb_status tgb_plugin_manager_create(tgb_context *ctx, tgb_plugin_manager **out);
void tgb_plugin_manager_destroy(tgb_plugin_manager *mgr); /* descarga todo */
/* Copia el archivo a la carpeta de extensiones y lo registra DESHABILITADO. */
tgb_status tgb_plugin_install(tgb_plugin_manager *mgr, const char *source_path,
const char *id, size_t *out_index); /* id: [a-z0-9_-], 1..48; .lua = Lua */
/* granted_caps = permisos que el usuario concede. Falla si no cubren los requeridos. */
tgb_status tgb_plugin_enable(tgb_plugin_manager *mgr, size_t index, uint32_t granted_caps);
tgb_status tgb_plugin_disable(tgb_plugin_manager *mgr, size_t index);
tgb_status tgb_plugin_remove(tgb_plugin_manager *mgr, size_t index); /* borra archivo y registro */
size_t tgb_plugin_count(const tgb_plugin_manager *mgr);
tgb_status tgb_plugin_info_at(const tgb_plugin_manager *mgr, size_t index, tgb_plugin_entry_info *out);
/* OJO: los índices de comandos CAMBIAN al habilitar, deshabilitar o eliminar extensiones.
* Una interfaz debe guardar el NOMBRE ("id:comando") y ejecutar con tgb_plugin_run_command_named. */
size_t tgb_plugin_command_count(const tgb_plugin_manager *mgr);
tgb_status tgb_plugin_command_name(const tgb_plugin_manager *mgr, size_t cmd_index,
char *buf, size_t buf_size);
/* Ejecuta un comando sobre un documento como UNA operación deshacible.
* Si el comando falla, se deshace todo lo que hizo (el documento queda como estaba). */
tgb_status tgb_plugin_run_command(tgb_plugin_manager *mgr, size_t cmd_index, tgb_document *doc);
/* Igual, pero identificando el comando por su nombre estable "id:comando". TGB_ERR_NOT_FOUND si no existe. */
tgb_status tgb_plugin_run_command_named(tgb_plugin_manager *mgr, const char *full_name, tgb_document *doc);
#ifdef __cplusplus
}
#endif
#endif
+78
View File
@@ -0,0 +1,78 @@
/* Recuperación. Hay CUATRO cosas distintas (ver docs/RECOVERY.md):
* 1. El proyecto guardado por el usuario (.tgb).
* 2. El autoguardado: copia temporal en la carpeta de estado.
* 3. La sesión: marcador que permite detectar un cierre inesperado.
* 4. Los puntos de recuperación NOMBRADOS: viven dentro del proyecto. */
#ifndef TAHGIBU_RECOVERY_H
#define TAHGIBU_RECOVERY_H
#include "tahgibu/document.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---------- Puntos de recuperación nombrados ---------- */
#define TGB_MAX_RECOVERY_POINTS 64
#define TGB_RECOVERY_POINT_NAME_MAX 128
typedef struct tgb_recovery_point_info {
uint32_t id;
char name[TGB_RECOVERY_POINT_NAME_MAX];
int64_t created_unix;
bool compatible; /* false: guardado con un formato que no se puede restaurar */
} tgb_recovery_point_info;
size_t tgb_recovery_point_count(const tgb_document *doc);
tgb_status tgb_recovery_point_info_at(const tgb_document *doc, size_t index,
tgb_recovery_point_info *out);
/* Nombre único, no vacío, UTF-8 válido. Comparte memoria con el documento
* (copy-on-write): solo cuesta lo que luego se edite. */
tgb_status tgb_recovery_point_create(tgb_document *doc, const char *name, uint32_t *out_id);
/* Antes de restaurar crea el punto "Antes de restaurar «nombre»" y vacía el
* historial ordinario (queda el punto automático para volver). */
tgb_status tgb_recovery_point_restore(tgb_document *doc, uint32_t id);
tgb_status tgb_recovery_point_delete(tgb_document *doc, uint32_t id);
/* Memoria extra retenida por los puntos (celdas que ya no están en el documento). */
size_t tgb_recovery_points_extra_bytes(const tgb_document *doc);
/* ---------- Autoguardado y recuperación de sesión ---------- */
typedef struct tgb_recovery tgb_recovery;
typedef struct tgb_recovery_candidate {
char autosave_path[512];
char original_path[512]; /* "" si el documento nunca se guardó */
char title[128];
int64_t saved_unix;
} tgb_recovery_candidate;
/* Abre la gestión de sesión: busca restos de sesiones que terminaron mal y
* registra la sesión actual. Los archivos originales nunca se tocan. */
tgb_status tgb_recovery_open(tgb_context *ctx, tgb_recovery **out);
/* clean_exit=true: borra marcador y autoguardados de esta sesión.
* clean_exit=false: libera memoria pero deja los archivos (simula un cierre inesperado en pruebas). */
void tgb_recovery_close(tgb_recovery *rec, bool clean_exit);
size_t tgb_recovery_candidate_count(const tgb_recovery *rec);
tgb_status tgb_recovery_candidate_at(const tgb_recovery *rec, size_t index,
tgb_recovery_candidate *out);
/* Carga el autoguardado en un documento NUEVO (el original no se modifica). */
tgb_status tgb_recovery_candidate_load(tgb_recovery *rec, size_t index, tgb_document **out);
/* Borra el autoguardado (solo si el usuario lo decide). */
tgb_status tgb_recovery_candidate_discard(tgb_recovery *rec, size_t index);
tgb_status tgb_recovery_attach(tgb_recovery *rec, tgb_document *doc, const char *title);
tgb_status tgb_recovery_detach(tgb_recovery *rec, tgb_document *doc); /* borra su autoguardado */
void tgb_recovery_set_interval(tgb_recovery *rec, double seconds); /* por defecto 300 (5 min) */
/* Llamar a menudo con un reloj monotónico en segundos. Autoguarda los
* documentos con cambios cuando toca. Si falla (p. ej. disco lleno) devuelve
* el error y CONSERVA el autoguardado anterior. */
tgb_status tgb_recovery_tick(tgb_recovery *rec, double now_seconds);
tgb_status tgb_recovery_save_now(tgb_recovery *rec, tgb_document *doc);
/* Avisa de que el usuario guardó el proyecto: el autoguardado ya no hace falta. */
void tgb_recovery_notify_saved(tgb_recovery *rec, tgb_document *doc);
#ifdef __cplusplus
}
#endif
#endif
+20
View File
@@ -0,0 +1,20 @@
/* Cabecera "paraguas" de la API pública de Tahgibu. */
#ifndef TAHGIBU_H
#define TAHGIBU_H
#include "tahgibu/version.h"
#include "tahgibu/error.h"
#include "tahgibu/canvas.h"
#include "tahgibu/events.h"
#include "tahgibu/context.h"
#include "tahgibu/document.h"
#include "tahgibu/frame.h"
#include "tahgibu/layer.h"
#include "tahgibu/drawing.h"
#include "tahgibu/animation.h"
#include "tahgibu/history.h"
#include "tahgibu/recovery.h"
#include "tahgibu/io.h"
#include "tahgibu/plugin.h"
#endif
+23
View File
@@ -0,0 +1,23 @@
/* Versiones de Tahgibu. Son TRES cosas distintas, no las confundas:
* - versión de la aplicación (2.0.0)
* - versión de la API pública en C (TGB_API_VERSION)
* - versión del formato de archivo .tgb (TGB_FORMAT_VERSION_*) */
#ifndef TAHGIBU_VERSION_H
#define TAHGIBU_VERSION_H
#define TGB_VERSION_MAJOR 2
#define TGB_VERSION_MINOR 0
#define TGB_VERSION_PATCH 0
#define TGB_VERSION_STRING "2.0.0"
#define TGB_APP_NAME "Tahgibu"
/* API pública: sube cuando cambian los contratos. NO es estable todavía
* (ver docs/API.md): no hay política de compatibilidad de ABI. */
#define TGB_API_VERSION 1
/* Formato .tgb: 2 = formato histórico (un PNG por fotograma, sin capas),
* 3 = formato de Tahgibu 2.0.0 (capas, duraciones, puntos). */
#define TGB_FORMAT_VERSION_LEGACY 2
#define TGB_FORMAT_VERSION_CURRENT 3
#endif
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -e
APP_DIR="$HOME/.local/share/tahgibu-animator"
mkdir -p "$APP_DIR" "$HOME/.local/share/applications"
cp tahgibu_animator.py "$APP_DIR/tahgibu_animator.py"
chmod +x "$APP_DIR/tahgibu_animator.py"
cp tahgibu-animator.desktop "$HOME/.local/share/applications/tahgibu-animator.desktop"
echo "Tahgibu Animator instalado."
echo "Dependencias necesarias: python3 y python-pillow."
echo "Para pegar imágenes con Ctrl+V en KDE/Wayland: sudo pacman -S wl-clipboard"
echo "Abrilo desde el menú de aplicaciones como: Tahgibu Animator"
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
; Paleta por defecto de Tahgibu: un color RRGGBB por línea (las líneas que empiezan por ; o # se ignoran).
; Se busca en resources/palettes/default.hex, ../share/tahgibu/palettes/default.hex y ../resources/palettes/default.hex.
000000
FFFFFF
7F7F7F
C3C3C3
880015
ED1C24
FF7F27
FFF200
22B14C
B5E61D
00A2E8
99D9EA
3F48CC
7092BE
A349A4
C8BFE7
B97A57
FFAEC9
FFC90E
EFE4B0
-25
View File
@@ -1,25 +0,0 @@
#pragma once
// Constantes globales de Tahgibu. Sin dependencias (ni raylib).
// CMake define TAHGIBU_VERSION a partir de project(VERSION ...).
#ifndef TAHGIBU_VERSION
#define TAHGIBU_VERSION "2.0.0"
#endif
namespace tahgibu {
inline constexpr const char* kAppName = "Tahgibu";
inline constexpr const char* kAppVersion = TAHGIBU_VERSION;
// Límite duro: ni el proyecto ni ningún frame individual pueden superarlo.
inline constexpr float kMaxFps = 60.0f;
// Mínimo permitido. 0.1 FPS = un frame visible hasta 10 s, para poder hacer
// pausas largas sin duplicar frames.
inline constexpr float kMinFps = 0.1f;
// FPS base de un proyecto nuevo.
inline constexpr float kDefaultFps = 24.0f;
} // namespace tahgibu
-28
View File
@@ -1,28 +0,0 @@
#include "core/Frame.hpp"
#include <algorithm>
#include <cmath>
namespace tahgibu {
float Frame::clampFps(float fps) noexcept {
if (!std::isfinite(fps)) {
return kDefaultFps;
}
return std::clamp(fps, kMinFps, kMaxFps);
}
void Frame::setFpsOverride(float fps) noexcept {
fpsOverride_ = clampFps(fps);
}
float Frame::effectiveFps(float baseFps) const noexcept {
return fpsOverride_ ? *fpsOverride_ : clampFps(baseFps);
}
double Frame::durationSeconds(float baseFps) const noexcept {
// effectiveFps() >= kMinFps, así que nunca se divide por cero.
return 1.0 / static_cast<double>(effectiveFps(baseFps));
}
} // namespace tahgibu
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include <optional>
#include "core/Constants.hpp"
namespace tahgibu {
// Un frame de la animación. Solo guarda cuánto dura: usa los FPS base del
// proyecto, salvo que tenga FPS propios (override).
class Frame {
public:
// Fuerza el valor al rango [kMinFps, kMaxFps]. Valores no finitos (NaN, inf)
// devuelven kDefaultFps.
static float clampFps(float fps) noexcept;
bool hasFpsOverride() const noexcept { return fpsOverride_.has_value(); }
void setFpsOverride(float fps) noexcept; // aplica clampFps
void clearFpsOverride() noexcept { fpsOverride_.reset(); }
float effectiveFps(float baseFps) const noexcept;
double durationSeconds(float baseFps) const noexcept;
private:
std::optional<float> fpsOverride_;
};
} // namespace tahgibu
-30
View File
@@ -1,30 +0,0 @@
#include "core/Project.hpp"
namespace tahgibu {
Project::Project() : name_("Sin título") {
frames_.emplace_back();
}
void Project::setBaseFps(float fps) noexcept {
baseFps_ = Frame::clampFps(fps);
}
std::size_t Project::addFrame() {
frames_.emplace_back();
return frames_.size() - 1;
}
double Project::frameDurationSeconds(std::size_t index) const {
return frames_.at(index).durationSeconds(baseFps_);
}
double Project::totalDurationSeconds() const {
double total = 0.0;
for (const Frame& frame : frames_) {
total += frame.durationSeconds(baseFps_);
}
return total;
}
} // namespace tahgibu
-39
View File
@@ -1,39 +0,0 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
#include "core/Frame.hpp"
namespace tahgibu {
// Modelo de datos del proyecto. No conoce raylib ni la interfaz.
class Project {
public:
Project(); // 1280x720, FPS base kDefaultFps, un frame inicial
const std::string& name() const noexcept { return name_; }
int width() const noexcept { return width_; }
int height() const noexcept { return height_; }
float baseFps() const noexcept { return baseFps_; }
void setBaseFps(float fps) noexcept; // aplica Frame::clampFps
std::size_t frameCount() const noexcept { return frames_.size(); }
const Frame& frame(std::size_t index) const { return frames_.at(index); }
Frame& frame(std::size_t index) { return frames_.at(index); }
std::size_t addFrame(); // devuelve el índice del frame nuevo
double frameDurationSeconds(std::size_t index) const;
double totalDurationSeconds() const;
private:
std::string name_;
int width_ = 1280;
int height_ = 720;
float baseFps_ = kDefaultFps;
std::vector<Frame> frames_;
};
} // namespace tahgibu
-15
View File
@@ -1,15 +0,0 @@
#pragma once
#include <cstddef>
#include "editor/Tool.hpp"
namespace tahgibu {
// Estado del editor que NO forma parte del proyecto guardado.
struct EditorState {
Tool activeTool = Tool::Select;
std::size_t currentFrame = 0;
};
} // namespace tahgibu
-55
View File
@@ -1,55 +0,0 @@
#pragma once
#include <cstddef>
namespace tahgibu {
enum class Tool {
Select,
Move,
Scale,
Rotate,
Transform,
Vertices,
Draw,
Eraser,
Sprite,
ColorPicker,
Zoom,
Pan,
};
struct ToolInfo {
Tool id;
const char* label;
};
// Registro de herramientas. La barra superior se genera a partir de esta
// tabla: agregar una herramienta nueva = agregar un valor al enum y una línea aquí.
inline constexpr ToolInfo kToolInfos[] = {
{Tool::Select, "Seleccionar"},
{Tool::Move, "Mover"},
{Tool::Scale, "Escalar"},
{Tool::Rotate, "Rotar"},
{Tool::Transform, "Transformar"},
{Tool::Vertices, "Vértices"},
{Tool::Draw, "Dibujar"},
{Tool::Eraser, "Borrador"},
{Tool::Sprite, "Sprite"},
{Tool::ColorPicker, "Color"},
{Tool::Zoom, "Zoom"},
{Tool::Pan, "Mano"},
};
inline constexpr std::size_t kToolCount = sizeof(kToolInfos) / sizeof(kToolInfos[0]);
inline const char* toolLabel(Tool tool) noexcept {
for (const ToolInfo& info : kToolInfos) {
if (info.id == tool) {
return info.label;
}
}
return "?";
}
} // namespace tahgibu
-14
View File
@@ -1,14 +0,0 @@
#include <cstdio>
#include <exception>
#include "ui/Application.hpp"
int main() {
try {
tahgibu::Application app;
return app.run();
} catch (const std::exception& error) {
std::fprintf(stderr, "Error fatal: %s\n", error.what());
return 1;
}
}
-242
View File
@@ -1,242 +0,0 @@
#include "ui/Application.hpp"
#include <raylib.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdio>
#include <string>
#include "core/Constants.hpp"
#include "editor/Tool.hpp"
#include "ui/Theme.hpp"
namespace tahgibu {
namespace {
constexpr int kInitialWidth = 1280;
constexpr int kInitialHeight = 800;
constexpr int kMinWidth = 1000;
constexpr int kMinHeight = 640;
constexpr int kUiTargetFps = 60; // FPS de la interfaz; no tiene relación con los FPS de la animación
constexpr int kFontSize = 16;
constexpr int kSmallFontSize = 14;
constexpr int kButtonPadding = 10;
constexpr float kButtonGap = 4.0f;
constexpr float kPanelHeaderHeight = 24.0f;
constexpr std::array<const char*, 7> kMenus = {
"Archivo", "Editar", "Animación", "Sprite", "Audio", "Ver", "Lua"};
int px(float value) {
return static_cast<int>(value);
}
void drawTextVCentered(const char* text, float x, const Rectangle& area, int size, Color color) {
DrawText(text, px(x), px(area.y + (area.height - static_cast<float>(size)) / 2.0f), size, color);
}
void drawPanelHeader(const Rectangle& panel, const char* title) {
DrawRectangle(px(panel.x), px(panel.y), px(panel.width), px(kPanelHeaderHeight), theme::kPanelHeader);
DrawText(title, px(panel.x + 12.0f), px(panel.y + 4.0f), kFontSize, theme::kText);
}
std::array<Rectangle, kToolCount> computeToolButtons(const Rectangle& bar) {
std::array<Rectangle, kToolCount> rects{};
float x = bar.x + 8.0f;
for (std::size_t i = 0; i < kToolCount; ++i) {
const float w = static_cast<float>(MeasureText(kToolInfos[i].label, kFontSize) + 2 * kButtonPadding);
rects[i] = {x, bar.y + 6.0f, w, bar.height - 12.0f};
x += w + kButtonGap;
}
return rects;
}
std::array<Rectangle, kMenus.size()> computeMenuRects(const Rectangle& bar) {
std::array<Rectangle, kMenus.size()> rects{};
float x = bar.x + 6.0f;
for (std::size_t i = 0; i < kMenus.size(); ++i) {
const float w = static_cast<float>(MeasureText(kMenus[i], kFontSize) + 2 * kButtonPadding);
rects[i] = {x, bar.y + 2.0f, w, bar.height - 4.0f};
x += w + 2.0f;
}
return rects;
}
} // namespace
Application::Application() {
SetConfigFlags(FLAG_WINDOW_RESIZABLE);
const std::string title = std::string(kAppName) + " " + kAppVersion;
InitWindow(kInitialWidth, kInitialHeight, title.c_str());
if (!IsWindowReady()) {
return;
}
SetWindowMinSize(kMinWidth, kMinHeight);
SetExitKey(KEY_NULL); // Esc no debe cerrar el editor
SetTargetFPS(kUiTargetFps);
layout_ = Layout::compute(GetScreenWidth(), GetScreenHeight());
}
Application::~Application() {
if (IsWindowReady()) {
CloseWindow();
}
}
int Application::run() {
if (!IsWindowReady()) {
std::fprintf(stderr, "No se pudo crear la ventana (¿hay entorno gráfico y OpenGL disponible?).\n");
return 1;
}
while (!WindowShouldClose()) {
update();
draw();
}
return 0;
}
void Application::update() {
layout_ = Layout::compute(GetScreenWidth(), GetScreenHeight());
if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) {
const Vector2 mouse = GetMousePosition();
const auto rects = computeToolButtons(layout_.toolBar);
for (std::size_t i = 0; i < kToolCount; ++i) {
if (CheckCollisionPointRec(mouse, rects[i])) {
state_.activeTool = kToolInfos[i].id;
break;
}
}
}
}
void Application::draw() const {
BeginDrawing();
ClearBackground(theme::kWindowBg);
drawCanvas();
drawProperties();
drawTimeline();
drawToolBar();
drawMenuBar();
EndDrawing();
}
void Application::drawMenuBar() const {
const Rectangle& bar = layout_.menuBar;
DrawRectangleRec(bar, theme::kPanelHeader);
const Vector2 mouse = GetMousePosition();
const auto rects = computeMenuRects(bar);
for (std::size_t i = 0; i < kMenus.size(); ++i) {
if (CheckCollisionPointRec(mouse, rects[i])) {
DrawRectangleRec(rects[i], theme::kHover);
}
drawTextVCentered(kMenus[i], rects[i].x + static_cast<float>(kButtonPadding), rects[i], kFontSize,
theme::kText);
}
}
void Application::drawToolBar() const {
const Rectangle& bar = layout_.toolBar;
DrawRectangleRec(bar, theme::kPanelBg);
DrawRectangle(px(bar.x), px(bar.y + bar.height - 1.0f), px(bar.width), 1, theme::kBorder);
const Vector2 mouse = GetMousePosition();
const auto rects = computeToolButtons(bar);
for (std::size_t i = 0; i < kToolCount; ++i) {
const bool active = state_.activeTool == kToolInfos[i].id;
const bool hover = CheckCollisionPointRec(mouse, rects[i]);
const Color background = active ? theme::kAccent : (hover ? theme::kHover : theme::kPanelHeader);
DrawRectangleRec(rects[i], background);
drawTextVCentered(kToolInfos[i].label, rects[i].x + static_cast<float>(kButtonPadding), rects[i],
kFontSize, active ? theme::kWindowBg : theme::kText);
}
}
void Application::drawCanvas() const {
const Rectangle& area = layout_.canvas;
DrawRectangleRec(area, theme::kCanvasBg);
// Escenario del tamaño del proyecto, ajustado al área disponible.
constexpr float kMargin = 32.0f;
const float projectW = static_cast<float>(project_.width());
const float projectH = static_cast<float>(project_.height());
const float availableW = std::max(1.0f, area.width - 2.0f * kMargin);
const float availableH = std::max(1.0f, area.height - 2.0f * kMargin);
const float scale = std::min(availableW / projectW, availableH / projectH);
const float w = projectW * scale;
const float h = projectH * scale;
const Rectangle stage{area.x + (area.width - w) / 2.0f, area.y + (area.height - h) / 2.0f, w, h};
DrawRectangleRec(stage, theme::kStage);
DrawRectangleLinesEx(stage, 1.0f, theme::kBorder);
char caption[96];
std::snprintf(caption, sizeof caption, "Escenario %d x %d", project_.width(), project_.height());
DrawText(caption, px(area.x + 10.0f), px(area.y + 8.0f), kSmallFontSize, theme::kTextDim);
}
void Application::drawProperties() const {
const Rectangle& panel = layout_.properties;
DrawRectangleRec(panel, theme::kPanelBg);
drawPanelHeader(panel, "Propiedades");
DrawRectangle(px(panel.x), px(panel.y), 1, px(panel.height), theme::kBorder);
const std::size_t index = std::min(state_.currentFrame, project_.frameCount() - 1);
const Frame& frame = project_.frame(index);
float y = panel.y + kPanelHeaderHeight + 12.0f;
const float x = panel.x + 12.0f;
char line[128];
auto put = [&](Color color) {
DrawText(line, px(x), px(y), kFontSize, color);
y += 24.0f;
};
std::snprintf(line, sizeof line, "Proyecto: %s", project_.name().c_str());
put(theme::kText);
std::snprintf(line, sizeof line, "Resolución: %d x %d", project_.width(), project_.height());
put(theme::kText);
std::snprintf(line, sizeof line, "FPS base: %.1f (máx. %.0f)", static_cast<double>(project_.baseFps()),
static_cast<double>(kMaxFps));
put(theme::kText);
std::snprintf(line, sizeof line, "Frame: %zu / %zu", index + 1, project_.frameCount());
put(theme::kText);
std::snprintf(line, sizeof line, "FPS del frame: %.1f %s",
static_cast<double>(frame.effectiveFps(project_.baseFps())),
frame.hasFpsOverride() ? "(propio)" : "(base)");
put(theme::kText);
std::snprintf(line, sizeof line, "Herramienta: %s", toolLabel(state_.activeTool));
put(theme::kText);
y += 8.0f;
std::snprintf(line, sizeof line, "Sprites y vértices: etapas 4 y 8");
put(theme::kTextDim);
}
void Application::drawTimeline() const {
const Rectangle& panel = layout_.timeline;
DrawRectangleRec(panel, theme::kPanelBg);
drawPanelHeader(panel, "Timeline");
DrawRectangle(px(panel.x), px(panel.y), px(panel.width), 1, theme::kBorder);
constexpr std::array<const char*, 3> kTracks = {"Fondo", "Sprite", "Audio"};
constexpr float kLaneHeight = 32.0f;
constexpr float kLabelWidth = 90.0f;
float y = panel.y + kPanelHeaderHeight + 12.0f;
for (const char* name : kTracks) {
DrawText(name, px(panel.x + 12.0f), px(y + (kLaneHeight - static_cast<float>(kFontSize)) / 2.0f),
kFontSize, theme::kText);
const Rectangle lane{panel.x + kLabelWidth, y, panel.width - kLabelWidth - 12.0f, kLaneHeight - 4.0f};
DrawRectangleRec(lane, theme::kPanelHeader);
y += kLaneHeight;
}
DrawText("La timeline se implementa en la etapa 3", px(panel.x + 12.0f), px(y + 8.0f), kSmallFontSize,
theme::kTextDim);
}
} // namespace tahgibu
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include "core/Project.hpp"
#include "editor/EditorState.hpp"
#include "ui/Layout.hpp"
namespace tahgibu {
// Ventana principal: crea la ventana de raylib, ejecuta el bucle y dibuja las zonas.
// La lógica del programa vive en core/ y editor/, no aquí.
class Application {
public:
Application();
~Application();
Application(const Application&) = delete;
Application& operator=(const Application&) = delete;
// Devuelve el código de salida del proceso.
int run();
private:
void update();
void draw() const;
void drawMenuBar() const;
void drawToolBar() const;
void drawCanvas() const;
void drawProperties() const;
void drawTimeline() const;
Project project_;
EditorState state_;
Layout layout_;
};
} // namespace tahgibu
-35
View File
@@ -1,35 +0,0 @@
#include "ui/Layout.hpp"
#include <algorithm>
namespace tahgibu {
namespace {
constexpr float kMenuBarHeight = 26.0f;
constexpr float kToolBarHeight = 40.0f;
constexpr float kPropertiesWidth = 280.0f;
constexpr float kTimelineHeight = 200.0f;
constexpr float kMinCanvasWidth = 400.0f;
constexpr float kMinCanvasHeight = 200.0f;
} // namespace
Layout Layout::compute(int windowWidth, int windowHeight) {
const float w = static_cast<float>(windowWidth);
const float h = static_cast<float>(windowHeight);
const float top = kMenuBarHeight + kToolBarHeight;
const float timelineH = std::min(kTimelineHeight, std::max(0.0f, h - top - kMinCanvasHeight));
const float propertiesW = std::min(kPropertiesWidth, std::max(0.0f, w - kMinCanvasWidth));
const float middleH = std::max(0.0f, h - top - timelineH);
Layout layout;
layout.menuBar = {0.0f, 0.0f, w, kMenuBarHeight};
layout.toolBar = {0.0f, kMenuBarHeight, w, kToolBarHeight};
layout.canvas = {0.0f, top, w - propertiesW, middleH};
layout.properties = {w - propertiesW, top, propertiesW, middleH};
layout.timeline = {0.0f, top + middleH, w, timelineH};
return layout;
}
} // namespace tahgibu
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include <raylib.h>
namespace tahgibu {
// Reparto de la ventana en zonas. Función pura del tamaño de la ventana.
struct Layout {
Rectangle menuBar{};
Rectangle toolBar{};
Rectangle canvas{};
Rectangle properties{};
Rectangle timeline{};
static Layout compute(int windowWidth, int windowHeight);
};
} // namespace tahgibu
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include <raylib.h>
namespace tahgibu::theme {
inline constexpr Color kWindowBg{24, 24, 28, 255};
inline constexpr Color kPanelBg{34, 34, 40, 255};
inline constexpr Color kPanelHeader{44, 44, 52, 255};
inline constexpr Color kBorder{14, 14, 18, 255};
inline constexpr Color kHover{62, 62, 74, 255};
inline constexpr Color kText{225, 225, 230, 255};
inline constexpr Color kTextDim{140, 140, 152, 255};
inline constexpr Color kAccent{255, 140, 60, 255};
inline constexpr Color kCanvasBg{18, 18, 22, 255};
inline constexpr Color kStage{245, 245, 245, 255};
} // namespace tahgibu::theme
+109
View File
@@ -0,0 +1,109 @@
/* Reproductor: lógica pura de tiempo. No dibuja nada. */
#include "../document/doc_internal.h"
struct tgb_player {
const tgb_document *doc;
bool playing;
tgb_play_mode mode;
float speed;
bool has_range;
size_t first, last;
size_t frame;
double elapsed_ms; /* tiempo transcurrido dentro del fotograma actual */
int dir; /* +1 / -1 (ping-pong) */
};
tgb_player *tgb_player_create(const tgb_document *doc) {
if (!doc) { tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo"); return NULL; }
tgb_player *p = calloc(1, sizeof *p);
if (!p) { tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); return NULL; }
p->doc = doc;
p->mode = TGB_PLAY_LOOP;
p->speed = 1.0f;
p->dir = 1;
return p;
}
void tgb_player_destroy(tgb_player *p) { free(p); }
static void bounds(const tgb_player *p, size_t *lo, size_t *hi) {
size_t n = p->doc->nframes;
*lo = 0; *hi = n ? n - 1 : 0;
if (p->has_range) {
if (p->first < n) *lo = p->first;
if (p->last < n) *hi = p->last;
if (*hi < *lo) *hi = *lo;
}
}
void tgb_player_play(tgb_player *p) {
if (!p) return;
size_t lo, hi;
bounds(p, &lo, &hi);
if (p->frame < lo || p->frame > hi) { p->frame = lo; p->elapsed_ms = 0; }
else if (p->mode == TGB_PLAY_ONCE && p->frame == hi) { p->frame = lo; p->elapsed_ms = 0; }
p->playing = true;
}
void tgb_player_pause(tgb_player *p) { if (p) p->playing = false; }
void tgb_player_stop(tgb_player *p) {
if (!p) return;
size_t lo, hi;
bounds(p, &lo, &hi);
p->playing = false;
p->frame = lo;
p->elapsed_ms = 0;
p->dir = 1;
}
bool tgb_player_is_playing(const tgb_player *p) { return p && p->playing; }
void tgb_player_set_mode(tgb_player *p, tgb_play_mode m) { if (p) { p->mode = m; p->dir = 1; } }
void tgb_player_set_speed(tgb_player *p, float s) {
if (!p) return;
if (!(s >= 0.1f)) s = 0.1f;
if (s > 8.0f) s = 8.0f;
p->speed = s;
}
tgb_status tgb_player_set_range(tgb_player *p, size_t first, size_t last) {
if (!p) return tgb_fail(TGB_ERR_INVALID_ARG, "Reproductor nulo");
if (first > last || last >= p->doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Rango de reproducción no válido");
p->has_range = true; p->first = first; p->last = last;
return TGB_OK;
}
void tgb_player_clear_range(tgb_player *p) { if (p) p->has_range = false; }
tgb_status tgb_player_seek(tgb_player *p, size_t frame) {
if (!p) return tgb_fail(TGB_ERR_INVALID_ARG, "Reproductor nulo");
if (frame >= p->doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Fotograma %zu fuera de rango", frame);
p->frame = frame; p->elapsed_ms = 0;
return TGB_OK;
}
size_t tgb_player_frame(const tgb_player *p) { return p ? p->frame : 0; }
size_t tgb_player_update(tgb_player *p, double dt_ms) {
if (!p) return 0;
size_t lo, hi;
bounds(p, &lo, &hi);
if (p->frame >= p->doc->nframes) p->frame = lo; /* el documento cambió */
if (!p->playing || !(dt_ms > 0)) return p->frame;
p->elapsed_ms += dt_ms * (double)p->speed;
/* tiempo total de un ciclo: evita bucles largos si dt es enorme */
double cycle = 0;
for (size_t i = lo; i <= hi; i++) cycle += (double)p->doc->frames[i].duration_ms;
if (p->mode == TGB_PLAY_PINGPONG && hi > lo) cycle *= 2;
if (p->mode != TGB_PLAY_ONCE && cycle > 0 && p->elapsed_ms > cycle * 2) {
double extra = p->elapsed_ms - cycle;
p->elapsed_ms -= cycle * (double)(long long)(extra / cycle);
}
for (;;) {
double dur = (double)p->doc->frames[p->frame].duration_ms;
if (p->elapsed_ms < dur) break;
p->elapsed_ms -= dur;
if (p->dir > 0) {
if (p->frame < hi) p->frame++;
else if (p->mode == TGB_PLAY_LOOP) p->frame = lo;
else if (p->mode == TGB_PLAY_PINGPONG && hi > lo) { p->dir = -1; p->frame--; }
else { p->playing = false; p->elapsed_ms = 0; break; } /* ONCE: se queda en el último */
} else {
if (p->frame > lo) p->frame--;
else { p->dir = 1; if (hi > lo) p->frame++; }
}
}
return p->frame;
}
+104
View File
@@ -0,0 +1,104 @@
/* Composición de capas y Onion Skin. */
#include <math.h>
#include "../document/doc_internal.h"
void tgb_onion_defaults(tgb_onion_settings *s) {
if (!s) return;
s->enabled = false;
s->frames_before = 1;
s->frames_after = 1;
s->opacity_before = 0.4f;
s->opacity_after = 0.3f;
s->falloff = 0.5f;
s->tint = true;
tgb_color b = {255, 60, 60, 255}, a = {60, 120, 255, 255};
s->tint_before = b;
s->tint_after = a;
}
static float clampf(float v, float lo, float hi) { return !(v >= lo) ? lo : (v > hi ? hi : v); }
tgb_status tgb_onion_validate(tgb_onion_settings *s) {
if (!s) return tgb_fail(TGB_ERR_INVALID_ARG, "Ajustes de Onion Skin nulos");
if (s->frames_before < 0) s->frames_before = 0;
if (s->frames_before > 8) s->frames_before = 8;
if (s->frames_after < 0) s->frames_after = 0;
if (s->frames_after > 8) s->frames_after = 8;
s->opacity_before = clampf(s->opacity_before, 0.0f, 1.0f);
s->opacity_after = clampf(s->opacity_after, 0.0f, 1.0f);
s->falloff = clampf(s->falloff, 0.0f, 1.0f);
return TGB_OK;
}
tgb_status tgb_render_flatten(const tgb_document *doc, size_t frame_index, tgb_canvas *out) {
if (!doc || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_render_flatten: argumento nulo");
if (frame_index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", frame_index);
if (tgb_canvas_width(out) != doc->width || tgb_canvas_height(out) != doc->height)
return tgb_fail(TGB_ERR_RANGE, "El lienzo de salida debe medir %dx%d", doc->width, doc->height);
tgb_canvas_clear(out);
for (size_t l = 0; l < doc->nlayers; l++) {
const tgb_layer *ly = &doc->layers[l];
const tgb_canvas *cel = doc->frames[frame_index].cels[l];
if (!ly->visible || !cel || ly->opacity == 0) continue;
tgb_canvas_blit(out, cel, 0, 0, ly->opacity, true);
}
tgb_canvas_reset_dirty(out);
return TGB_OK;
}
/* Dibuja 'src' sobre 'out' con opacidad global y tinte opcional. */
static void blit_onion(tgb_canvas *out, const tgb_canvas *src, float opacity, bool tint, tgb_color tc) {
int w = tgb_canvas_width(src), h = tgb_canvas_height(src);
const uint8_t *s = tgb_canvas_pixels(src);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++, s += 4) {
if (s[3] == 0) continue;
tgb_color p = {s[0], s[1], s[2], (uint8_t)((float)s[3] * opacity + 0.5f)};
if (p.a == 0) continue;
if (tint) { /* mezcla el color original con el tinte (60 % tinte) */
p.r = (uint8_t)((p.r * 40 + tc.r * 60) / 100);
p.g = (uint8_t)((p.g * 40 + tc.g * 60) / 100);
p.b = (uint8_t)((p.b * 40 + tc.b * 60) / 100);
}
tgb_canvas_blend(out, x, y, p);
}
}
tgb_status tgb_render_view(const tgb_document *doc, size_t frame_index, const tgb_onion_settings *onion,
tgb_color background, tgb_canvas *out) {
if (!doc || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_render_view: argumento nulo");
if (frame_index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", frame_index);
if (tgb_canvas_width(out) != doc->width || tgb_canvas_height(out) != doc->height)
return tgb_fail(TGB_ERR_RANGE, "El lienzo de salida debe medir %dx%d", doc->width, doc->height);
tgb_canvas_clear(out);
if (background.a) {
tgb_rect all = {0, 0, doc->width, doc->height};
tgb_canvas_fill_rect(out, all, background, false);
}
if (onion && onion->enabled) {
tgb_onion_settings s = *onion;
tgb_onion_validate(&s);
tgb_canvas *tmp = tgb_canvas_create(doc->width, doc->height);
if (!tmp) return TGB_ERR_NOMEM;
/* de los más lejanos a los más cercanos, para que lo cercano quede encima */
for (int k = s.frames_before; k >= 1; k--) {
if ((size_t)k > frame_index) continue;
if (TGB_SUCCEEDED(tgb_render_flatten(doc, frame_index - (size_t)k, tmp)))
blit_onion(out, tmp, s.opacity_before * powf(1.0f - s.falloff, (float)(k - 1)), s.tint, s.tint_before);
}
for (int k = s.frames_after; k >= 1; k--) {
if (frame_index + (size_t)k >= doc->nframes) continue;
if (TGB_SUCCEEDED(tgb_render_flatten(doc, frame_index + (size_t)k, tmp)))
blit_onion(out, tmp, s.opacity_after * powf(1.0f - s.falloff, (float)(k - 1)), s.tint, s.tint_after);
}
tgb_canvas_release(tmp);
}
for (size_t l = 0; l < doc->nlayers; l++) {
const tgb_layer *ly = &doc->layers[l];
const tgb_canvas *cel = doc->frames[frame_index].cels[l];
if (!ly->visible || !cel || ly->opacity == 0) continue;
tgb_canvas_blit(out, cel, 0, 0, ly->opacity, true);
}
tgb_canvas_reset_dirty(out);
return TGB_OK;
}
+79
View File
@@ -0,0 +1,79 @@
/* Contexto de la aplicación y bus de eventos. */
#include "internal.h"
#include "../platform/platform.h"
tgb_status tgb_context_create(const tgb_context_config *config, tgb_context **out) {
if (!out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_context_create: 'out' es nulo");
*out = NULL;
tgb_context *ctx = calloc(1, sizeof *ctx);
if (!ctx) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el contexto");
char dc[512], dd[512], dk[512], ds[512];
tgb_status s = tgb_plat_default_dirs(dc, dd, dk, ds, sizeof dc);
const char *cfg[4] = {config ? config->config_dir : NULL, config ? config->data_dir : NULL,
config ? config->cache_dir : NULL, config ? config->state_dir : NULL};
const char *def[4] = {dc, dd, dk, ds};
char *dst[4] = {ctx->config_dir, ctx->data_dir, ctx->cache_dir, ctx->state_dir};
for (int i = 0; i < 4; i++) {
if (cfg[i] && cfg[i][0]) {
if (strlen(cfg[i]) >= 512) { free(ctx); return tgb_fail(TGB_ERR_LIMIT, "Ruta de carpeta demasiado larga"); }
tgb_strlcpy(dst[i], cfg[i], 512);
} else {
if (TGB_FAILED(s)) { free(ctx); return s; }
tgb_strlcpy(dst[i], def[i], 512);
}
tgb_status m = tgb_plat_mkdirs(dst[i]);
if (TGB_FAILED(m)) { free(ctx); return m; }
}
ctx->next_sub_id = 1;
*out = ctx;
return TGB_OK;
}
void tgb_context_clear_clipboard(tgb_context *ctx) {
if (!ctx || !ctx->clip.valid) return;
for (size_t i = 0; i < ctx->clip.nlayers; i++) tgb_canvas_release(ctx->clip.cels[i]);
free(ctx->clip.cels);
memset(&ctx->clip, 0, sizeof ctx->clip);
}
void tgb_context_destroy(tgb_context *ctx) {
if (!ctx) return;
tgb_context_clear_clipboard(ctx);
free(ctx);
}
const char *tgb_context_config_dir(const tgb_context *ctx) { return ctx ? ctx->config_dir : ""; }
const char *tgb_context_data_dir(const tgb_context *ctx) { return ctx ? ctx->data_dir : ""; }
const char *tgb_context_cache_dir(const tgb_context *ctx) { return ctx ? ctx->cache_dir : ""; }
const char *tgb_context_state_dir(const tgb_context *ctx) { return ctx ? ctx->state_dir : ""; }
/* ---- eventos ---- */
tgb_status tgb_events_subscribe(tgb_context *ctx, tgb_event_fn fn, void *user, uint32_t *out_id) {
if (!ctx || !fn) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_events_subscribe: argumento nulo");
if (ctx->nsubs >= TGB_MAX_SUBSCRIBERS) return tgb_fail(TGB_ERR_LIMIT, "Demasiados suscriptores de eventos (máximo %d)", TGB_MAX_SUBSCRIBERS);
uint32_t id = ctx->next_sub_id++;
ctx->subs[ctx->nsubs].id = id;
ctx->subs[ctx->nsubs].fn = fn;
ctx->subs[ctx->nsubs].user = user;
ctx->nsubs++;
if (out_id) *out_id = id;
return TGB_OK;
}
tgb_status tgb_events_unsubscribe(tgb_context *ctx, uint32_t id) {
if (!ctx) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_events_unsubscribe: contexto nulo");
for (size_t i = 0; i < ctx->nsubs; i++) {
if (ctx->subs[i].id == id) {
memmove(&ctx->subs[i], &ctx->subs[i + 1], (ctx->nsubs - i - 1) * sizeof ctx->subs[0]);
ctx->nsubs--;
return TGB_OK;
}
}
return tgb_fail(TGB_ERR_NOT_FOUND, "Suscripción %u inexistente", (unsigned)id);
}
void tgb_emit(tgb_context *ctx, const tgb_event *ev) {
if (!ctx || !ev) return;
size_t n = ctx->nsubs; /* copia: los suscriptores no deben modificar la lista */
for (size_t i = 0; i < n && i < ctx->nsubs; i++) ctx->subs[i].fn(ev, ctx->subs[i].user);
}
+83
View File
@@ -0,0 +1,83 @@
#include <stdio.h>
#include "internal.h"
#if defined(_MSC_VER)
#define TGB_THREAD_LOCAL __declspec(thread)
#else
#define TGB_THREAD_LOCAL _Thread_local
#endif
static TGB_THREAD_LOCAL char g_message[512];
tgb_status tgb_fail(tgb_status status, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(g_message, sizeof g_message, fmt, ap);
va_end(ap);
return status;
}
const char *tgb_last_error(void) { return g_message[0] ? g_message : "sin errores registrados"; }
const char *tgb_status_name(tgb_status s) {
switch (s) {
case TGB_WARN_HISTORY_LOST: return "TGB_WARN_HISTORY_LOST";
case TGB_OK: return "TGB_OK";
case TGB_ERR_INVALID_ARG: return "TGB_ERR_INVALID_ARG";
case TGB_ERR_NOMEM: return "TGB_ERR_NOMEM";
case TGB_ERR_RANGE: return "TGB_ERR_RANGE";
case TGB_ERR_NOT_FOUND: return "TGB_ERR_NOT_FOUND";
case TGB_ERR_EXISTS: return "TGB_ERR_EXISTS";
case TGB_ERR_IO: return "TGB_ERR_IO";
case TGB_ERR_FORMAT: return "TGB_ERR_FORMAT";
case TGB_ERR_CORRUPT: return "TGB_ERR_CORRUPT";
case TGB_ERR_UNSUPPORTED: return "TGB_ERR_UNSUPPORTED";
case TGB_ERR_LIMIT: return "TGB_ERR_LIMIT";
case TGB_ERR_STATE: return "TGB_ERR_STATE";
case TGB_ERR_LOCKED: return "TGB_ERR_LOCKED";
case TGB_ERR_NOTHING: return "TGB_ERR_NOTHING";
case TGB_ERR_PLUGIN: return "TGB_ERR_PLUGIN";
case TGB_ERR_INCOMPATIBLE: return "TGB_ERR_INCOMPATIBLE";
}
return "TGB_ERR_UNKNOWN";
}
char *tgb_strdup(const char *s) {
size_t n = strlen(s) + 1;
char *p = malloc(n);
if (p) memcpy(p, s, n);
return p;
}
void tgb_strlcpy(char *dst, const char *src, size_t cap) {
if (cap == 0) return;
size_t n = strlen(src);
if (n >= cap) n = cap - 1;
memcpy(dst, src, n);
dst[n] = '\0';
}
bool tgb_utf8_valid(const char *s, size_t len) {
const unsigned char *p = (const unsigned char *)s;
size_t i = 0;
while (i < len) {
unsigned char c = p[i];
size_t extra;
uint32_t cp;
if (c < 0x80) { i++; continue; }
if ((c & 0xE0) == 0xC0) { extra = 1; cp = c & 0x1Fu; }
else if ((c & 0xF0) == 0xE0) { extra = 2; cp = c & 0x0Fu; }
else if ((c & 0xF8) == 0xF0) { extra = 3; cp = c & 0x07u; }
else return false;
if (i + extra >= len) return false; /* faltan bytes de continuación */
for (size_t k = 1; k <= extra; k++) {
if ((p[i + k] & 0xC0) != 0x80) return false;
cp = (cp << 6) | (p[i + k] & 0x3Fu);
}
if (extra == 1 && cp < 0x80) return false; /* sobrelargo */
if (extra == 2 && cp < 0x800) return false;
if (extra == 3 && (cp < 0x10000 || cp > 0x10FFFF)) return false;
if (cp >= 0xD800 && cp <= 0xDFFF) return false; /* sustitutos */
i += extra + 1;
}
return true;
}
+66
View File
@@ -0,0 +1,66 @@
/* Cabecera INTERNA (no es API pública). Utilidades comunes y estructuras
* compartidas entre módulos. */
#ifndef TAHGIBU_INTERNAL_H
#define TAHGIBU_INTERNAL_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "tahgibu/tahgibu.h"
#if defined(__GNUC__)
#define TGB_PRINTF(a, b) __attribute__((format(printf, a, b)))
#else
#define TGB_PRINTF(a, b)
#endif
/* Registra el mensaje del último error (por hilo) y devuelve 'status'. */
tgb_status tgb_fail(tgb_status status, const char *fmt, ...) TGB_PRINTF(2, 3);
/* Operaciones aritméticas con detección de desbordamiento. */
static inline bool tgb_mul_ok(size_t a, size_t b, size_t *out) {
if (a != 0 && b > SIZE_MAX / a) return false;
*out = a * b;
return true;
}
static inline bool tgb_add_ok(size_t a, size_t b, size_t *out) {
if (b > SIZE_MAX - a) return false;
*out = a + b;
return true;
}
char *tgb_strdup(const char *s);
/* Acceso de escritura a los píxeles (solo para módulos internos; NO marca zona modificada). */
uint8_t *tgb_canvas_pixels_mut(tgb_canvas *c);
/* Copia segura a un buffer fijo (siempre termina en NUL). */
void tgb_strlcpy(char *dst, const char *src, size_t cap);
/* true si s[0..len) es UTF-8 válido. */
bool tgb_utf8_valid(const char *s, size_t len);
#define TGB_MAX_SUBSCRIBERS 64
struct tgb_context {
char config_dir[512], data_dir[512], cache_dir[512], state_dir[512];
struct {
uint32_t id;
tgb_event_fn fn;
void *user;
} subs[TGB_MAX_SUBSCRIBERS];
size_t nsubs;
uint32_t next_sub_id;
/* portapapeles de fotogramas */
struct {
bool valid;
int w, h;
uint32_t duration_ms;
size_t nlayers;
tgb_canvas **cels;
} clip;
};
void tgb_emit(tgb_context *ctx, const tgb_event *ev);
void tgb_emit_simple(tgb_document *doc, tgb_event_type type);
void tgb_context_clear_clipboard(tgb_context *ctx);
#endif
+104
View File
@@ -0,0 +1,104 @@
/* INTERNO: estructura del documento y mutadores de bajo nivel (sin historial).
* Solo los módulos document/, history/, editor/, io/ y recovery/ la usan. */
#ifndef TAHGIBU_DOC_INTERNAL_H
#define TAHGIBU_DOC_INTERNAL_H
#include "../core/internal.h"
#define NPOS ((size_t)-1)
typedef struct tgb_layer {
uint32_t id;
char name[TGB_NAME_MAX];
bool visible, locked;
uint8_t opacity;
} tgb_layer;
typedef struct tgb_frame {
uint32_t id;
uint32_t duration_ms;
tgb_canvas **cels; /* un puntero por capa (NULL = vacía); longitud = nº de capas */
} tgb_frame;
/* Instantánea de la estructura completa (comparte las celdas por referencia). */
typedef struct tgb_state {
tgb_layer *layers;
size_t nlayers;
tgb_frame *frames;
size_t nframes;
int fps;
} tgb_state;
typedef struct tgb_rpoint {
uint32_t id;
char name[TGB_RECOVERY_POINT_NAME_MAX];
int64_t created;
bool compatible;
tgb_state state; /* válido si compatible */
char *raw_json; /* si !compatible: JSON original, se reescribe tal cual */
} tgb_rpoint;
struct tgb_history;
struct tgb_document {
tgb_context *ctx;
int width, height, fps;
tgb_layer *layers;
size_t nlayers, caplayers;
tgb_frame *frames;
size_t nframes, capframes;
uint32_t next_id;
uint64_t revision, saved_revision;
char *path;
bool inconsistent;
struct tgb_history *history;
struct {
bool active;
uint32_t frame_id, layer_id;
tgb_canvas *canvas; /* celda editada (propiedad del documento) */
tgb_canvas *base; /* copia del estado inicial (propiedad de la sesión) */
char label[64];
} edit;
tgb_rpoint *rpoints;
size_t nrpoints, caprpoints;
uint32_t next_rpoint_id;
struct { char *key; char *raw; } *extra; /* campos desconocidos de .tgb */
size_t nextra;
};
/* ---- búsqueda ---- */
size_t doc_find_frame(const tgb_document *doc, uint32_t id);
size_t doc_find_layer(const tgb_document *doc, uint32_t id);
uint32_t doc_new_id(tgb_document *doc);
uint32_t doc_default_duration_ms(int fps);
void doc_touch(tgb_document *doc); /* revision++ y evento DOCUMENT_CHANGED */
/* ---- mutadores de bajo nivel: NO registran historial ---- */
void doc_frame_free_cels(tgb_canvas **cels, size_t n);
/* Toma posesión de f->cels (debe tener doc->nlayers entradas). */
tgb_status doc_frame_insert(tgb_document *doc, size_t index, tgb_frame *f);
void doc_frame_remove(tgb_document *doc, size_t index, tgb_frame *out);
void doc_frame_move(tgb_document *doc, size_t from, size_t to);
/* 'cels' tiene doc->nframes entradas; las referencias pasan al documento. */
tgb_status doc_layer_insert(tgb_document *doc, size_t index, const tgb_layer *layer, tgb_canvas **cels);
/* 'cels_out' debe tener sitio para doc->nframes punteros; recibe las referencias. */
void doc_layer_remove(tgb_document *doc, size_t index, tgb_layer *out, tgb_canvas **cels_out);
void doc_layer_move(tgb_document *doc, size_t from, size_t to);
/* Celda editable: la crea si no existe y create=true; si está compartida
* (copy-on-write con instantáneas/historial) la copia antes. */
tgb_canvas *doc_cel_for_write(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, bool create,
bool *created);
/* ---- instantáneas ---- */
tgb_status state_capture(const tgb_document *doc, tgb_state *out);
void state_free(tgb_state *st);
tgb_status state_apply(tgb_document *doc, const tgb_state *st); /* sustituye la estructura */
/* Bytes de celdas de 'st' que NO están en el documento actual (memoria incremental). */
size_t state_extra_bytes(const tgb_state *st, const tgb_document *doc);
/* Comprobación común: ¿se puede modificar el documento ahora? */
tgb_status doc_check_mutable(const tgb_document *doc);
void doc_clear_extra(tgb_document *doc);
#endif
+372
View File
@@ -0,0 +1,372 @@
#include <stdio.h>
/* Operaciones deshacibles sobre la estructura: fotogramas, capas y propiedades.
* Cada operación se escribe como un objeto con 'redo' (aplicar) y 'undo'. */
#include "../history/history_internal.h"
/* ================= presencia de un fotograma (alta/baja) ================= */
typedef struct frame_op {
tgb_op base;
size_t index;
tgb_frame saved; /* fotograma fuera del documento (si saved_valid) */
size_t saved_nlayers;
bool saved_valid;
bool insert_on_redo;
} frame_op;
static tgb_status frame_insert_now(frame_op *o, tgb_document *doc) {
if (!o->saved_valid) return TGB_ERR_STATE;
if (o->saved_nlayers != doc->nlayers) return tgb_fail(TGB_ERR_STATE, "El número de capas cambió: no se puede restaurar el fotograma");
tgb_status s = doc_frame_insert(doc, o->index, &o->saved);
if (TGB_FAILED(s)) return s;
o->saved_valid = false;
return TGB_OK;
}
static tgb_status frame_remove_now(frame_op *o, tgb_document *doc) {
if (o->index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma fuera de rango");
doc_frame_remove(doc, o->index, &o->saved);
o->saved_nlayers = doc->nlayers;
o->saved_valid = true;
return TGB_OK;
}
static tgb_status frame_redo(tgb_op *op, tgb_document *doc) {
frame_op *o = (frame_op *)op;
return o->insert_on_redo ? frame_insert_now(o, doc) : frame_remove_now(o, doc);
}
static tgb_status frame_undo(tgb_op *op, tgb_document *doc) {
frame_op *o = (frame_op *)op;
return o->insert_on_redo ? frame_remove_now(o, doc) : frame_insert_now(o, doc);
}
static void frame_destroy(tgb_op *op) {
frame_op *o = (frame_op *)op;
if (o->saved_valid) doc_frame_free_cels(o->saved.cels, o->saved_nlayers);
free(o);
}
static const tgb_op_vt frame_vt = {frame_undo, frame_redo, frame_destroy, NULL};
/* Construye la operación con un fotograma ya preparado (toma posesión de 'f'). */
static tgb_status run_frame_op(tgb_document *doc, tgb_frame *f, size_t index, bool insert, const char *label) {
(void)insert; /* hoy solo se usa para altas; las bajas se construyen en tgb_frame_delete */
frame_op *o = calloc(1, sizeof *o);
if (!o) { doc_frame_free_cels(f->cels, doc->nlayers); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
op_init(&o->base, &frame_vt, label, TGB_NOTIFY_FRAMES | TGB_NOTIFY_PIXELS);
o->index = index;
o->insert_on_redo = true;
o->saved_nlayers = doc->nlayers;
o->saved = *f; /* el fotograma nuevo espera fuera del documento hasta 'redo' */
o->saved_valid = true;
o->base.mem_bytes = sizeof *o;
return hist_run(doc, &o->base);
}
static tgb_canvas **new_cels(size_t n) { return calloc(n ? n : 1, sizeof(tgb_canvas *)); }
tgb_status tgb_frame_add(tgb_document *doc, size_t index, uint32_t *out_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index > doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango (0..%zu)", index, doc->nframes);
if (doc->nframes >= TGB_MAX_FRAMES) return tgb_fail(TGB_ERR_LIMIT, "Máximo de %d fotogramas alcanzado", TGB_MAX_FRAMES);
tgb_frame f = {0, 0, new_cels(doc->nlayers)};
if (!f.cels) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
f.id = doc->next_id;
f.duration_ms = doc_default_duration_ms(doc->fps);
doc->next_id++;
uint32_t id = f.id;
s = run_frame_op(doc, &f, index, true, "Añadir fotograma");
if (TGB_SUCCEEDED(s) && out_id) *out_id = id;
return s;
}
tgb_status tgb_frame_duplicate(tgb_document *doc, size_t index, uint32_t *out_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
if (doc->nframes >= TGB_MAX_FRAMES) return tgb_fail(TGB_ERR_LIMIT, "Máximo de %d fotogramas alcanzado", TGB_MAX_FRAMES);
tgb_frame f = {0, doc->frames[index].duration_ms, new_cels(doc->nlayers)};
if (!f.cels) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
for (size_t j = 0; j < doc->nlayers; j++) f.cels[j] = tgb_canvas_retain(doc->frames[index].cels[j]);
f.id = doc->next_id++;
uint32_t id = f.id;
s = run_frame_op(doc, &f, index + 1, true, "Duplicar fotograma");
if (TGB_SUCCEEDED(s) && out_id) *out_id = id;
return s;
}
tgb_status tgb_frame_delete(tgb_document *doc, size_t index) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
if (doc->nframes == 1) return tgb_fail(TGB_ERR_STATE, "No se puede eliminar el último fotograma");
frame_op *o = calloc(1, sizeof *o);
if (!o) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
op_init(&o->base, &frame_vt, "Eliminar fotograma", TGB_NOTIFY_FRAMES | TGB_NOTIFY_PIXELS);
o->index = index;
o->insert_on_redo = false;
o->saved_nlayers = doc->nlayers;
/* memoria exclusiva de las celdas que pasarán a ser propiedad solo de la operación */
for (size_t j = 0; j < doc->nlayers; j++) o->base.mem_bytes += cel_exclusive_bytes(doc->frames[index].cels[j]);
o->base.mem_bytes += sizeof *o;
return hist_run(doc, &o->base);
}
/* ================= mover (fotograma o capa) ================= */
typedef struct move_op {
tgb_op base;
size_t from, to;
bool is_layer;
} move_op;
static tgb_status move_apply(move_op *o, tgb_document *doc, size_t from, size_t to) {
size_t n = o->is_layer ? doc->nlayers : doc->nframes;
if (from >= n || to >= n) return tgb_fail(TGB_ERR_RANGE, "Índice fuera de rango al mover");
if (o->is_layer) doc_layer_move(doc, from, to); else doc_frame_move(doc, from, to);
return TGB_OK;
}
static tgb_status move_redo(tgb_op *op, tgb_document *doc) { move_op *o = (move_op *)op; return move_apply(o, doc, o->from, o->to); }
static tgb_status move_undo(tgb_op *op, tgb_document *doc) { move_op *o = (move_op *)op; return move_apply(o, doc, o->to, o->from); }
static void plain_destroy(tgb_op *op) { free(op); }
static const tgb_op_vt move_vt = {move_undo, move_redo, plain_destroy, NULL};
static tgb_status run_move(tgb_document *doc, size_t from, size_t to, bool layer) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
size_t n = layer ? doc->nlayers : doc->nframes;
if (from >= n || to >= n) return tgb_fail(TGB_ERR_RANGE, "Índice fuera de rango (0..%zu)", n - 1);
if (from == to) return TGB_OK;
move_op *o = calloc(1, sizeof *o);
if (!o) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
op_init(&o->base, &move_vt, layer ? "Mover capa" : "Mover fotograma", layer ? (TGB_NOTIFY_LAYERS | TGB_NOTIFY_PIXELS) : TGB_NOTIFY_FRAMES);
o->base.mem_bytes = sizeof *o;
o->from = from; o->to = to; o->is_layer = layer;
return hist_run(doc, &o->base);
}
tgb_status tgb_frame_move(tgb_document *doc, size_t from, size_t to) { return run_move(doc, from, to, false); }
tgb_status tgb_layer_move(tgb_document *doc, size_t from, size_t to) { return run_move(doc, from, to, true); }
/* ================= presencia de una capa ================= */
typedef struct layer_op {
tgb_op base;
size_t index;
tgb_layer saved;
tgb_canvas **saved_cels; /* una por fotograma, cuando la capa está fuera del documento */
size_t saved_nframes;
bool saved_valid;
bool insert_on_redo;
} layer_op;
static tgb_status layer_insert_now(layer_op *o, tgb_document *doc) {
if (!o->saved_valid) return TGB_ERR_STATE;
if (o->saved_nframes != doc->nframes) return tgb_fail(TGB_ERR_STATE, "El número de fotogramas cambió: no se puede restaurar la capa");
tgb_status s = doc_layer_insert(doc, o->index, &o->saved, o->saved_cels);
if (TGB_FAILED(s)) return s;
o->saved_valid = false; /* las referencias pasaron al documento */
free(o->saved_cels);
o->saved_cels = NULL;
return TGB_OK;
}
static tgb_status layer_remove_now(layer_op *o, tgb_document *doc) {
if (o->index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa fuera de rango");
tgb_canvas **cels = new_cels(doc->nframes);
if (!cels) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
doc_layer_remove(doc, o->index, &o->saved, cels);
o->saved_cels = cels;
o->saved_nframes = doc->nframes;
o->saved_valid = true;
return TGB_OK;
}
static tgb_status layer_redo(tgb_op *op, tgb_document *doc) { layer_op *o = (layer_op *)op; return o->insert_on_redo ? layer_insert_now(o, doc) : layer_remove_now(o, doc); }
static tgb_status layer_undo(tgb_op *op, tgb_document *doc) { layer_op *o = (layer_op *)op; return o->insert_on_redo ? layer_remove_now(o, doc) : layer_insert_now(o, doc); }
static void layer_destroy(tgb_op *op) {
layer_op *o = (layer_op *)op;
if (o->saved_cels) doc_frame_free_cels(o->saved_cels, o->saved_nframes);
free(o);
}
static const tgb_op_vt layer_vt = {layer_undo, layer_redo, layer_destroy, NULL};
static tgb_status check_name(const char *name) {
if (!name || !name[0]) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre no puede estar vacío");
size_t n = strlen(name);
if (n >= TGB_NAME_MAX) return tgb_fail(TGB_ERR_LIMIT, "Nombre demasiado largo (máximo %d bytes)", TGB_NAME_MAX - 1);
if (!tgb_utf8_valid(name, n)) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre no es UTF-8 válido");
return TGB_OK;
}
tgb_status tgb_layer_add(tgb_document *doc, size_t index, const char *name, uint32_t *out_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index > doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango (0..%zu)", index, doc->nlayers);
if (doc->nlayers >= TGB_MAX_LAYERS) return tgb_fail(TGB_ERR_LIMIT, "Máximo de %d capas alcanzado", TGB_MAX_LAYERS);
char auto_name[TGB_NAME_MAX];
if (!name) { snprintf(auto_name, sizeof auto_name, "Capa %zu", doc->nlayers + 1); name = auto_name; }
s = check_name(name);
if (TGB_FAILED(s)) return s;
layer_op *o = calloc(1, sizeof *o);
tgb_canvas **cels = new_cels(doc->nframes);
if (!o || !cels) { free(o); free(cels); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
op_init(&o->base, &layer_vt, "Añadir capa", TGB_NOTIFY_LAYERS | TGB_NOTIFY_PIXELS);
o->base.mem_bytes = sizeof *o + doc->nframes * sizeof(tgb_canvas *);
o->index = index;
o->insert_on_redo = true;
o->saved.id = doc->next_id;
tgb_strlcpy(o->saved.name, name, TGB_NAME_MAX);
o->saved.visible = true;
o->saved.opacity = 255;
o->saved_cels = cels;
o->saved_nframes = doc->nframes;
o->saved_valid = true;
doc->next_id++;
uint32_t id = o->saved.id;
s = hist_run(doc, &o->base);
if (TGB_SUCCEEDED(s) && out_id) *out_id = id;
return s;
}
tgb_status tgb_layer_delete(tgb_document *doc, size_t index) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
if (doc->nlayers == 1) return tgb_fail(TGB_ERR_STATE, "No se puede eliminar la última capa");
layer_op *o = calloc(1, sizeof *o);
if (!o) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
op_init(&o->base, &layer_vt, "Eliminar capa", TGB_NOTIFY_LAYERS | TGB_NOTIFY_PIXELS);
o->index = index;
o->insert_on_redo = false;
o->base.mem_bytes = sizeof *o + doc->nframes * sizeof(tgb_canvas *);
for (size_t i = 0; i < doc->nframes; i++) o->base.mem_bytes += cel_exclusive_bytes(doc->frames[i].cels[index]);
return hist_run(doc, &o->base);
}
/* ================= propiedades (valores pequeños) ================= */
typedef enum { P_FRAME_MS, P_LAYER_NAME, P_LAYER_VISIBLE, P_LAYER_LOCKED, P_LAYER_OPACITY, P_DOC_FPS } prop_kind;
typedef struct prop_op {
tgb_op base;
prop_kind kind;
uint32_t id;
char old_s[TGB_NAME_MAX], new_s[TGB_NAME_MAX];
int old_i, new_i;
} prop_op;
static tgb_status prop_apply(tgb_document *doc, prop_kind k, uint32_t id, const char *s, int v) {
if (k == P_FRAME_MS) {
size_t i = doc_find_frame(doc, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "Fotograma inexistente");
doc->frames[i].duration_ms = (uint32_t)v;
} else if (k == P_DOC_FPS) {
doc->fps = v;
} else {
size_t i = doc_find_layer(doc, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "Capa inexistente");
if (k == P_LAYER_NAME) tgb_strlcpy(doc->layers[i].name, s, TGB_NAME_MAX);
else if (k == P_LAYER_VISIBLE) doc->layers[i].visible = v != 0;
else if (k == P_LAYER_LOCKED) doc->layers[i].locked = v != 0;
else doc->layers[i].opacity = (uint8_t)v;
}
return TGB_OK;
}
static tgb_status prop_redo(tgb_op *op, tgb_document *doc) { prop_op *p = (prop_op *)op; return prop_apply(doc, p->kind, p->id, p->new_s, p->new_i); }
static tgb_status prop_undo(tgb_op *op, tgb_document *doc) { prop_op *p = (prop_op *)op; return prop_apply(doc, p->kind, p->id, p->old_s, p->old_i); }
static const tgb_op_vt prop_vt = {prop_undo, prop_redo, plain_destroy, NULL};
static tgb_status run_prop(tgb_document *doc, prop_kind k, uint32_t id, const char *label, const char *old_s,
const char *new_s, int old_i, int new_i) {
prop_op *p = calloc(1, sizeof *p);
if (!p) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
unsigned notify = (k == P_FRAME_MS || k == P_DOC_FPS) ? TGB_NOTIFY_FRAMES : (TGB_NOTIFY_LAYERS | TGB_NOTIFY_PIXELS);
op_init(&p->base, &prop_vt, label, notify);
p->base.mem_bytes = sizeof *p;
p->kind = k; p->id = id; p->old_i = old_i; p->new_i = new_i;
if (old_s) tgb_strlcpy(p->old_s, old_s, sizeof p->old_s);
if (new_s) tgb_strlcpy(p->new_s, new_s, sizeof p->new_s);
return hist_run(doc, &p->base);
}
tgb_status tgb_frame_set_duration(tgb_document *doc, size_t index, uint32_t ms) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
if (ms < TGB_MIN_FRAME_MS || ms > TGB_MAX_FRAME_MS) return tgb_fail(TGB_ERR_RANGE, "Duración %u ms fuera de rango (%d..%d)", (unsigned)ms, TGB_MIN_FRAME_MS, TGB_MAX_FRAME_MS);
if (doc->frames[index].duration_ms == ms) return TGB_OK;
return run_prop(doc, P_FRAME_MS, doc->frames[index].id, "Duración del fotograma", NULL, NULL, (int)doc->frames[index].duration_ms, (int)ms);
}
tgb_status tgb_document_set_fps(tgb_document *doc, int fps, bool apply_to_all) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (fps < TGB_MIN_FPS || fps > TGB_MAX_FPS) return tgb_fail(TGB_ERR_RANGE, "fps %d fuera de rango (%d..%d)", fps, TGB_MIN_FPS, TGB_MAX_FPS);
if (!apply_to_all) return doc->fps == fps ? TGB_OK : run_prop(doc, P_DOC_FPS, 0, "Cambiar fps", NULL, NULL, doc->fps, fps);
s = tgb_history_begin_group(doc, "Cambiar fps");
if (TGB_FAILED(s)) return s;
uint32_t ms = doc_default_duration_ms(fps);
if (doc->fps != fps) s = run_prop(doc, P_DOC_FPS, 0, "fps", NULL, NULL, doc->fps, fps);
for (size_t i = 0; TGB_SUCCEEDED(s) && i < doc->nframes; i++)
if (doc->frames[i].duration_ms != ms)
s = run_prop(doc, P_FRAME_MS, doc->frames[i].id, "duración", NULL, NULL, (int)doc->frames[i].duration_ms, (int)ms);
if (TGB_FAILED(s)) { tgb_history_abort_group(doc); return s; }
return tgb_history_end_group(doc);
}
tgb_status tgb_layer_rename(tgb_document *doc, size_t index, const char *name) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
s = check_name(name);
if (TGB_FAILED(s)) return s;
if (!strcmp(doc->layers[index].name, name)) return TGB_OK;
return run_prop(doc, P_LAYER_NAME, doc->layers[index].id, "Renombrar capa", doc->layers[index].name, name, 0, 0);
}
tgb_status tgb_layer_set_visible(tgb_document *doc, size_t index, bool v) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
if (doc->layers[index].visible == v) return TGB_OK;
return run_prop(doc, P_LAYER_VISIBLE, doc->layers[index].id, v ? "Mostrar capa" : "Ocultar capa", NULL, NULL, !v, v);
}
tgb_status tgb_layer_set_locked(tgb_document *doc, size_t index, bool v) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
if (doc->layers[index].locked == v) return TGB_OK;
return run_prop(doc, P_LAYER_LOCKED, doc->layers[index].id, v ? "Bloquear capa" : "Desbloquear capa", NULL, NULL, !v, v);
}
tgb_status tgb_layer_set_opacity(tgb_document *doc, size_t index, uint8_t o) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
if (doc->layers[index].opacity == o) return TGB_OK;
return run_prop(doc, P_LAYER_OPACITY, doc->layers[index].id, "Opacidad de capa", NULL, NULL, doc->layers[index].opacity, o);
}
/* ================= portapapeles de fotogramas ================= */
tgb_status tgb_frame_copy(tgb_document *doc, size_t index) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
if (index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
tgb_context *ctx = doc->ctx;
tgb_canvas **cels = new_cels(doc->nlayers);
if (!cels) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
for (size_t j = 0; j < doc->nlayers; j++) cels[j] = tgb_canvas_retain(doc->frames[index].cels[j]);
tgb_context_clear_clipboard(ctx);
ctx->clip.valid = true;
ctx->clip.w = doc->width;
ctx->clip.h = doc->height;
ctx->clip.duration_ms = doc->frames[index].duration_ms;
ctx->clip.nlayers = doc->nlayers;
ctx->clip.cels = cels;
return TGB_OK;
}
tgb_status tgb_frame_paste(tgb_document *doc, size_t index, uint32_t *out_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
tgb_context *ctx = doc->ctx;
if (!ctx->clip.valid) return tgb_fail(TGB_ERR_STATE, "El portapapeles de fotogramas está vacío");
if (ctx->clip.w != doc->width || ctx->clip.h != doc->height)
return tgb_fail(TGB_ERR_UNSUPPORTED, "El fotograma copiado es de %dx%d y el documento de %dx%d", ctx->clip.w, ctx->clip.h, doc->width, doc->height);
if (index > doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
if (doc->nframes >= TGB_MAX_FRAMES) return tgb_fail(TGB_ERR_LIMIT, "Máximo de %d fotogramas alcanzado", TGB_MAX_FRAMES);
tgb_frame f = {0, ctx->clip.duration_ms, new_cels(doc->nlayers)};
if (!f.cels) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
for (size_t j = 0; j < doc->nlayers && j < ctx->clip.nlayers; j++) f.cels[j] = tgb_canvas_retain(ctx->clip.cels[j]);
f.id = doc->next_id++;
uint32_t id = f.id;
s = run_frame_op(doc, &f, index, true, "Pegar fotograma");
if (TGB_SUCCEEDED(s) && out_id) *out_id = id;
return s;
}
+343
View File
@@ -0,0 +1,343 @@
/* Modelo de documento: creación, destrucción, consultas y mutadores de bajo nivel. */
#include "doc_internal.h"
#include "../history/history_internal.h"
uint32_t doc_default_duration_ms(int fps) {
uint32_t ms = (uint32_t)((1000 + fps / 2) / fps);
if (ms < TGB_MIN_FRAME_MS) ms = TGB_MIN_FRAME_MS;
return ms;
}
uint32_t doc_new_id(tgb_document *doc) { return doc->next_id++; }
void tgb_emit_simple(tgb_document *doc, tgb_event_type type) {
if (!doc || !doc->ctx) return;
tgb_event ev = {type, doc, 0, 0, {0, 0, 0, 0}, NULL};
tgb_emit(doc->ctx, &ev);
}
void doc_touch(tgb_document *doc) {
doc->revision++;
tgb_emit_simple(doc, TGB_EVENT_DOCUMENT_CHANGED);
}
tgb_status tgb_document_create(tgb_context *ctx, int width, int height, int fps, tgb_document **out) {
if (!ctx || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_document_create: argumento nulo");
*out = NULL;
if (width < 1 || height < 1 || width > TGB_MAX_DIMENSION || height > TGB_MAX_DIMENSION)
return tgb_fail(TGB_ERR_RANGE, "Dimensiones no válidas %dx%d (1..%d)", width, height, TGB_MAX_DIMENSION);
if (fps < TGB_MIN_FPS || fps > TGB_MAX_FPS)
return tgb_fail(TGB_ERR_RANGE, "fps %d fuera de rango (%d..%d)", fps, TGB_MIN_FPS, TGB_MAX_FPS);
tgb_document *doc = calloc(1, sizeof *doc);
if (!doc) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el documento");
doc->ctx = ctx;
doc->width = width;
doc->height = height;
doc->fps = fps;
doc->next_id = 1;
doc->next_rpoint_id = 1;
doc->history = hist_create();
doc->layers = calloc(4, sizeof *doc->layers);
doc->frames = calloc(4, sizeof *doc->frames);
tgb_canvas **cels = calloc(1, sizeof *cels);
if (!doc->history || !doc->layers || !doc->frames || !cels) {
free(cels);
tgb_document_destroy(doc);
return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el documento");
}
doc->caplayers = 4;
doc->capframes = 4;
doc->layers[0].id = doc_new_id(doc);
tgb_strlcpy(doc->layers[0].name, "Capa 1", TGB_NAME_MAX);
doc->layers[0].visible = true;
doc->layers[0].opacity = 255;
doc->nlayers = 1;
doc->frames[0].id = doc_new_id(doc);
doc->frames[0].duration_ms = doc_default_duration_ms(fps);
doc->frames[0].cels = cels;
doc->nframes = 1;
tgb_history_adapt(doc); /* límites iniciales según la máquina (no es fatal si falla) */
*out = doc;
return TGB_OK;
}
void doc_clear_extra(tgb_document *doc) {
for (size_t i = 0; i < doc->nextra; i++) { free(doc->extra[i].key); free(doc->extra[i].raw); }
free(doc->extra);
doc->extra = NULL;
doc->nextra = 0;
}
void doc_frame_free_cels(tgb_canvas **cels, size_t n) {
if (!cels) return;
for (size_t i = 0; i < n; i++) tgb_canvas_release(cels[i]);
free(cels);
}
void tgb_document_destroy(tgb_document *doc) {
if (!doc) return;
if (doc->edit.active) { tgb_canvas_release(doc->edit.base); }
hist_destroy(doc->history); /* antes que las celdas: las operaciones pueden retener celdas */
for (size_t i = 0; i < doc->nframes; i++) doc_frame_free_cels(doc->frames[i].cels, doc->nlayers);
free(doc->frames);
free(doc->layers);
for (size_t i = 0; i < doc->nrpoints; i++) {
if (doc->rpoints[i].compatible) state_free(&doc->rpoints[i].state);
free(doc->rpoints[i].raw_json);
}
free(doc->rpoints);
doc_clear_extra(doc);
free(doc->path);
free(doc);
}
/* ---- consultas ---- */
tgb_context *tgb_document_context(const tgb_document *d) { return d ? d->ctx : NULL; }
int tgb_document_width(const tgb_document *d) { return d ? d->width : 0; }
int tgb_document_height(const tgb_document *d) { return d ? d->height : 0; }
int tgb_document_fps(const tgb_document *d) { return d ? d->fps : 0; }
uint64_t tgb_document_revision(const tgb_document *d) { return d ? d->revision : 0; }
bool tgb_document_dirty(const tgb_document *d) { return d && d->revision != d->saved_revision; }
void tgb_document_mark_saved(tgb_document *d) { if (d) d->saved_revision = d->revision; }
const char *tgb_document_path(const tgb_document *d) { return d ? d->path : NULL; }
bool tgb_document_is_consistent(const tgb_document *d) { return d && !d->inconsistent; }
tgb_status tgb_document_set_path(tgb_document *doc, const char *path) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
char *copy = NULL;
if (path) { copy = tgb_strdup(path); if (!copy) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
free(doc->path);
doc->path = copy;
return TGB_OK;
}
const char *tgb_document_extra_field(const tgb_document *doc, const char *key) {
if (!doc || !key) return NULL;
for (size_t i = 0; i < doc->nextra; i++) if (!strcmp(doc->extra[i].key, key)) return doc->extra[i].raw;
return NULL;
}
size_t tgb_frame_count(const tgb_document *d) { return d ? d->nframes : 0; }
uint32_t tgb_frame_id(const tgb_document *d, size_t i) { return (d && i < d->nframes) ? d->frames[i].id : 0; }
uint32_t tgb_frame_duration_ms(const tgb_document *d, size_t i) { return (d && i < d->nframes) ? d->frames[i].duration_ms : 0; }
size_t tgb_layer_count(const tgb_document *d) { return d ? d->nlayers : 0; }
uint32_t tgb_layer_id(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].id : 0; }
const char *tgb_layer_name(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].name : ""; }
bool tgb_layer_visible(const tgb_document *d, size_t i) { return d && i < d->nlayers && d->layers[i].visible; }
bool tgb_layer_locked(const tgb_document *d, size_t i) { return d && i < d->nlayers && d->layers[i].locked; }
uint8_t tgb_layer_opacity(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].opacity : 0; }
size_t doc_find_frame(const tgb_document *d, uint32_t id) {
for (size_t i = 0; i < d->nframes; i++) if (d->frames[i].id == id) return i;
return NPOS;
}
size_t doc_find_layer(const tgb_document *d, uint32_t id) {
for (size_t i = 0; i < d->nlayers; i++) if (d->layers[i].id == id) return i;
return NPOS;
}
tgb_status tgb_frame_index_of(const tgb_document *d, uint32_t id, size_t *out) {
if (!d || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
size_t i = doc_find_frame(d, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el fotograma con id %u", (unsigned)id);
*out = i;
return TGB_OK;
}
tgb_status tgb_layer_index_of(const tgb_document *d, uint32_t id, size_t *out) {
if (!d || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
size_t i = doc_find_layer(d, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe la capa con id %u", (unsigned)id);
*out = i;
return TGB_OK;
}
const tgb_canvas *tgb_frame_cel(const tgb_document *d, size_t f, size_t l) {
if (!d || f >= d->nframes || l >= d->nlayers) return NULL;
return d->frames[f].cels[l];
}
tgb_status doc_check_mutable(const tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "Hay una sesión de edición abierta: ciérrala antes de cambiar la estructura");
return TGB_OK;
}
/* ---- mutadores de bajo nivel ---- */
tgb_status doc_frame_insert(tgb_document *doc, size_t index, tgb_frame *f) {
if (index > doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index);
if (doc->nframes == doc->capframes) {
size_t nc = doc->capframes ? doc->capframes * 2 : 4;
tgb_frame *nf = realloc(doc->frames, nc * sizeof *nf);
if (!nf) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para más fotogramas");
doc->frames = nf;
doc->capframes = nc;
}
memmove(&doc->frames[index + 1], &doc->frames[index], (doc->nframes - index) * sizeof doc->frames[0]);
doc->frames[index] = *f;
doc->nframes++;
f->cels = NULL;
return TGB_OK;
}
void doc_frame_remove(tgb_document *doc, size_t index, tgb_frame *out) {
*out = doc->frames[index];
memmove(&doc->frames[index], &doc->frames[index + 1], (doc->nframes - index - 1) * sizeof doc->frames[0]);
doc->nframes--;
}
void doc_frame_move(tgb_document *doc, size_t from, size_t to) {
if (from == to) return;
tgb_frame tmp = doc->frames[from];
if (from < to) memmove(&doc->frames[from], &doc->frames[from + 1], (to - from) * sizeof tmp);
else memmove(&doc->frames[to + 1], &doc->frames[to], (from - to) * sizeof tmp);
doc->frames[to] = tmp;
}
tgb_status doc_layer_insert(tgb_document *doc, size_t index, const tgb_layer *layer, tgb_canvas **cels) {
if (index > doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index);
/* 1) reservar todo antes de tocar nada */
if (doc->nlayers == doc->caplayers) {
size_t nc = doc->caplayers ? doc->caplayers * 2 : 4;
tgb_layer *nl = realloc(doc->layers, nc * sizeof *nl);
if (!nl) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para más capas");
doc->layers = nl;
doc->caplayers = nc;
}
for (size_t i = 0; i < doc->nframes; i++) {
tgb_canvas **na = realloc(doc->frames[i].cels, (doc->nlayers + 1) * sizeof *na);
if (!na) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para añadir la capa");
doc->frames[i].cels = na; /* hueco extra inofensivo si luego falla otro fotograma */
}
/* 2) confirmar */
for (size_t i = 0; i < doc->nframes; i++) {
tgb_canvas **a = doc->frames[i].cels;
memmove(&a[index + 1], &a[index], (doc->nlayers - index) * sizeof *a);
a[index] = cels ? cels[i] : NULL;
}
memmove(&doc->layers[index + 1], &doc->layers[index], (doc->nlayers - index) * sizeof doc->layers[0]);
doc->layers[index] = *layer;
doc->nlayers++;
return TGB_OK;
}
void doc_layer_remove(tgb_document *doc, size_t index, tgb_layer *out, tgb_canvas **cels_out) {
for (size_t i = 0; i < doc->nframes; i++) {
tgb_canvas **a = doc->frames[i].cels;
if (cels_out) cels_out[i] = a[index]; else tgb_canvas_release(a[index]);
memmove(&a[index], &a[index + 1], (doc->nlayers - index - 1) * sizeof *a);
}
*out = doc->layers[index];
memmove(&doc->layers[index], &doc->layers[index + 1], (doc->nlayers - index - 1) * sizeof doc->layers[0]);
doc->nlayers--;
}
void doc_layer_move(tgb_document *doc, size_t from, size_t to) {
if (from == to) return;
for (size_t i = 0; i < doc->nframes; i++) {
tgb_canvas **a = doc->frames[i].cels;
tgb_canvas *tmp = a[from];
if (from < to) memmove(&a[from], &a[from + 1], (to - from) * sizeof tmp);
else memmove(&a[to + 1], &a[to], (from - to) * sizeof tmp);
a[to] = tmp;
}
tgb_layer tl = doc->layers[from];
if (from < to) memmove(&doc->layers[from], &doc->layers[from + 1], (to - from) * sizeof tl);
else memmove(&doc->layers[to + 1], &doc->layers[to], (from - to) * sizeof tl);
doc->layers[to] = tl;
}
tgb_canvas *doc_cel_for_write(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, bool create, bool *created) {
size_t fi = doc_find_frame(doc, frame_id), li = doc_find_layer(doc, layer_id);
if (created) *created = false;
if (fi == NPOS || li == NPOS) { tgb_fail(TGB_ERR_NOT_FOUND, "Celda inexistente (fotograma %u, capa %u)", (unsigned)frame_id, (unsigned)layer_id); return NULL; }
tgb_canvas **slot = &doc->frames[fi].cels[li];
if (!*slot) {
if (!create) return NULL;
*slot = tgb_canvas_create(doc->width, doc->height);
if (!*slot) return NULL;
if (created) *created = true;
} else if (tgb_canvas_refs(*slot) > 1) { /* copy-on-write */
tgb_canvas *copy = tgb_canvas_clone(*slot);
if (!copy) return NULL;
tgb_canvas_release(*slot);
*slot = copy;
}
return *slot;
}
/* ---- instantáneas ---- */
void state_free(tgb_state *st) {
if (!st) return;
for (size_t i = 0; i < st->nframes; i++) doc_frame_free_cels(st->frames[i].cels, st->nlayers);
free(st->frames);
free(st->layers);
memset(st, 0, sizeof *st);
}
static tgb_status copy_structure(const tgb_layer *layers, size_t nl, const tgb_frame *frames, size_t nf,
tgb_layer **out_l, tgb_frame **out_f) {
tgb_layer *l = malloc((nl ? nl : 1) * sizeof *l);
tgb_frame *f = calloc(nf ? nf : 1, sizeof *f);
if (!l || !f) { free(l); free(f); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para la instantánea"); }
memcpy(l, layers, nl * sizeof *l);
for (size_t i = 0; i < nf; i++) {
f[i].id = frames[i].id;
f[i].duration_ms = frames[i].duration_ms;
f[i].cels = calloc(nl ? nl : 1, sizeof *f[i].cels);
if (!f[i].cels) {
for (size_t k = 0; k < i; k++) doc_frame_free_cels(f[k].cels, nl);
free(l); free(f);
return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para la instantánea");
}
for (size_t j = 0; j < nl; j++) f[i].cels[j] = tgb_canvas_retain(frames[i].cels[j]);
}
*out_l = l;
*out_f = f;
return TGB_OK;
}
tgb_status state_capture(const tgb_document *doc, tgb_state *out) {
memset(out, 0, sizeof *out);
tgb_status s = copy_structure(doc->layers, doc->nlayers, doc->frames, doc->nframes, &out->layers, &out->frames);
if (TGB_FAILED(s)) return s;
out->nlayers = doc->nlayers;
out->nframes = doc->nframes;
out->fps = doc->fps;
return TGB_OK;
}
tgb_status state_apply(tgb_document *doc, const tgb_state *st) {
tgb_layer *nl; tgb_frame *nf;
tgb_status s = copy_structure(st->layers, st->nlayers, st->frames, st->nframes, &nl, &nf);
if (TGB_FAILED(s)) return s;
for (size_t i = 0; i < doc->nframes; i++) doc_frame_free_cels(doc->frames[i].cels, doc->nlayers);
free(doc->frames);
free(doc->layers);
doc->layers = nl; doc->nlayers = st->nlayers; doc->caplayers = st->nlayers ? st->nlayers : 1;
doc->frames = nf; doc->nframes = st->nframes; doc->capframes = st->nframes ? st->nframes : 1;
doc->fps = st->fps;
return TGB_OK;
}
static int cmp_ptr(const void *a, const void *b) {
const void *x = *(const void *const *)a, *y = *(const void *const *)b;
return (x > y) - (x < y);
}
size_t state_extra_bytes(const tgb_state *st, const tgb_document *doc) {
size_t n = 0;
for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j]) n++;
const void **cur = malloc((n ? n : 1) * sizeof *cur);
if (!cur) return 0;
size_t k = 0;
for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j]) cur[k++] = doc->frames[i].cels[j];
qsort(cur, n, sizeof *cur, cmp_ptr);
size_t total = 0;
for (size_t i = 0; i < st->nframes; i++) for (size_t j = 0; j < st->nlayers; j++) {
tgb_canvas *c = st->frames[i].cels[j];
if (c && !bsearch(&c, cur, n, sizeof *cur, cmp_ptr)) total += tgb_canvas_bytes(c);
}
free(cur);
return total;
}
size_t cel_exclusive_bytes(tgb_canvas *c) { return (c && tgb_canvas_refs(c) == 1) ? tgb_canvas_bytes(c) : 0; }
+193
View File
@@ -0,0 +1,193 @@
/* Sesión de edición: conecta una celda del documento con el historial.
* Mientras dura la sesión se dibuja directamente sobre la celda; al cerrar
* se calcula el rectángulo realmente modificado y se guarda UNA operación
* con los píxeles antes/después de ese rectángulo. */
#include <errno.h>
#include <stdio.h>
#include "../history/history_internal.h"
#include "../platform/platform.h"
typedef struct patch_op {
tgb_op base;
uint32_t frame_id, layer_id;
tgb_rect r;
size_t len; /* bytes de cada búfer */
uint8_t *before, *after; /* NULL si están en disco */
char *spill_path;
} patch_op;
static tgb_status load_spill(const patch_op *p, uint8_t **out) {
FILE *f = fopen(p->spill_path, "rb");
if (!f) return tgb_fail(TGB_ERR_IO, "No se pudo leer el archivo temporal del historial: %s", strerror(errno));
uint8_t *buf = malloc(p->len * 2);
if (!buf) { fclose(f); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para recuperar el historial"); }
size_t got = fread(buf, 1, p->len * 2, f);
fclose(f);
if (got != p->len * 2) { free(buf); return tgb_fail(TGB_ERR_IO, "El archivo temporal del historial está incompleto"); }
*out = buf;
return TGB_OK;
}
static tgb_status patch_apply(patch_op *p, tgb_document *doc, bool use_before) {
uint8_t *tmp = NULL;
const uint8_t *data;
if (p->before) data = use_before ? p->before : p->after;
else {
tgb_status s = load_spill(p, &tmp);
if (TGB_FAILED(s)) return s;
data = tmp + (use_before ? 0 : p->len);
}
tgb_canvas *c = doc_cel_for_write(doc, p->frame_id, p->layer_id, true, NULL);
if (!c) { free(tmp); return TGB_ERR_NOMEM; }
if (p->r.x < 0 || p->r.y < 0 || p->r.x + p->r.w > tgb_canvas_width(c) || p->r.y + p->r.h > tgb_canvas_height(c)) {
free(tmp);
return tgb_fail(TGB_ERR_CORRUPT, "La operación del historial no encaja en el lienzo");
}
uint8_t *px = tgb_canvas_pixels_mut(c);
size_t stride = (size_t)tgb_canvas_width(c) * 4u, row = (size_t)p->r.w * 4u;
for (int y = 0; y < p->r.h; y++)
memcpy(px + (size_t)(p->r.y + y) * stride + (size_t)p->r.x * 4u, data + (size_t)y * row, row);
free(tmp);
return TGB_OK;
}
static tgb_status patch_redo(tgb_op *op, tgb_document *doc) { return patch_apply((patch_op *)op, doc, false); }
static tgb_status patch_undo(tgb_op *op, tgb_document *doc) { return patch_apply((patch_op *)op, doc, true); }
static void patch_destroy(tgb_op *op) {
patch_op *p = (patch_op *)op;
if (p->spill_path) { tgb_plat_remove(p->spill_path); free(p->spill_path); }
free(p->before);
free(p->after);
free(p);
}
static size_t patch_spill(tgb_op *op, tgb_spill_ctx *ctx) {
patch_op *p = (patch_op *)op;
if (!p->before || p->len * 2 > ctx->room) return 0;
char path[640];
int n = snprintf(path, sizeof path, "%s/tgb-hist-%ld-%llu.bin", ctx->dir, tgb_plat_pid(), (unsigned long long)ctx->serial++);
if (n < 0 || (size_t)n >= sizeof path) return 0;
FILE *f = fopen(path, "wb");
if (!f) return 0;
bool ok = fwrite(p->before, 1, p->len, f) == p->len && fwrite(p->after, 1, p->len, f) == p->len;
ok = (fclose(f) == 0) && ok;
if (!ok) { remove(path); return 0; }
p->spill_path = tgb_strdup(path);
if (!p->spill_path) { remove(path); return 0; }
free(p->before); free(p->after);
p->before = p->after = NULL;
size_t freed = p->len * 2;
op->mem_bytes = op->mem_bytes > freed ? op->mem_bytes - freed : 0;
op->disk_bytes = freed;
ctx->room -= freed;
return freed;
}
static const tgb_op_vt patch_vt = {patch_undo, patch_redo, patch_destroy, patch_spill};
/* ---------------- API pública de la sesión ---------------- */
bool tgb_edit_active(const tgb_document *doc) { return doc && doc->edit.active; }
tgb_canvas *tgb_edit_canvas(tgb_document *doc) { return (doc && doc->edit.active) ? doc->edit.canvas : NULL; }
static void finish_session(tgb_document *doc) {
tgb_canvas_reset_dirty(doc->edit.canvas);
tgb_canvas_release(doc->edit.base);
memset(&doc->edit, 0, sizeof doc->edit);
}
tgb_status tgb_edit_begin(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, const char *label) {
tgb_status s = doc_check_mutable(doc); /* falla si ya hay sesión */
if (TGB_FAILED(s)) return s;
size_t li = doc_find_layer(doc, layer_id);
if (doc_find_frame(doc, frame_id) == NPOS || li == NPOS)
return tgb_fail(TGB_ERR_NOT_FOUND, "Celda inexistente (fotograma %u, capa %u)", (unsigned)frame_id, (unsigned)layer_id);
if (doc->layers[li].locked) return tgb_fail(TGB_ERR_LOCKED, "La capa '%s' está bloqueada", doc->layers[li].name);
tgb_canvas *c = doc_cel_for_write(doc, frame_id, layer_id, true, NULL);
if (!c) return TGB_ERR_NOMEM;
tgb_canvas *base = tgb_canvas_clone(c);
if (!base) return TGB_ERR_NOMEM;
tgb_canvas_reset_dirty(c);
doc->edit.active = true;
doc->edit.frame_id = frame_id;
doc->edit.layer_id = layer_id;
doc->edit.canvas = c;
doc->edit.base = base;
tgb_strlcpy(doc->edit.label, (label && label[0]) ? label : "Dibujar", sizeof doc->edit.label);
return TGB_OK;
}
tgb_status tgb_edit_reset(tgb_document *doc) {
if (!doc || !doc->edit.active) return tgb_fail(TGB_ERR_STATE, "No hay sesión de edición abierta");
return tgb_canvas_copy_pixels(doc->edit.canvas, doc->edit.base);
}
tgb_status tgb_edit_cancel(tgb_document *doc) {
if (!doc || !doc->edit.active) return tgb_fail(TGB_ERR_STATE, "No hay sesión de edición abierta");
tgb_canvas_copy_pixels(doc->edit.canvas, doc->edit.base);
finish_session(doc);
return TGB_OK;
}
/* Rectángulo mínimo que contiene las diferencias entre base y actual dentro de 'area'. */
static bool diff_bounds(const tgb_canvas *a, const tgb_canvas *b, tgb_rect area, tgb_rect *out) {
int w = tgb_canvas_width(a);
const uint8_t *pa = tgb_canvas_pixels(a), *pb = tgb_canvas_pixels(b);
int minx = INT32_MAX, miny = INT32_MAX, maxx = -1, maxy = -1;
for (int y = area.y; y < area.y + area.h; y++) {
const uint8_t *ra = pa + ((size_t)y * (size_t)w + (size_t)area.x) * 4u;
const uint8_t *rb = pb + ((size_t)y * (size_t)w + (size_t)area.x) * 4u;
if (memcmp(ra, rb, (size_t)area.w * 4u) == 0) continue;
int first = 0, last = area.w - 1;
while (memcmp(ra + (size_t)first * 4u, rb + (size_t)first * 4u, 4) == 0) first++;
while (memcmp(ra + (size_t)last * 4u, rb + (size_t)last * 4u, 4) == 0) last--;
if (area.x + first < minx) minx = area.x + first;
if (area.x + last > maxx) maxx = area.x + last;
if (y < miny) miny = y;
maxy = y;
}
if (maxx < 0) return false;
out->x = minx; out->y = miny; out->w = maxx - minx + 1; out->h = maxy - miny + 1;
return true;
}
static void crop_into(const tgb_canvas *c, tgb_rect r, uint8_t *dst) {
size_t stride = (size_t)tgb_canvas_width(c) * 4u, row = (size_t)r.w * 4u;
const uint8_t *px = tgb_canvas_pixels(c);
for (int y = 0; y < r.h; y++) memcpy(dst + (size_t)y * row, px + (size_t)(r.y + y) * stride + (size_t)r.x * 4u, row);
}
tgb_status tgb_edit_end(tgb_document *doc) {
if (!doc || !doc->edit.active) return tgb_fail(TGB_ERR_STATE, "No hay sesión de edición abierta");
tgb_canvas *c = doc->edit.canvas, *base = doc->edit.base;
tgb_rect bounds = {0, 0, tgb_canvas_width(c), tgb_canvas_height(c)};
tgb_rect area = tgb_rect_intersect(tgb_canvas_dirty(c), bounds), r;
if (area.w <= 0 || !diff_bounds(base, c, area, &r)) { finish_session(doc); return TGB_OK; } /* sin cambios reales */
tgb_status s = hist_reserve(doc);
patch_op *p = NULL;
if (TGB_SUCCEEDED(s)) {
p = calloc(1, sizeof *p);
size_t len = (size_t)r.w * (size_t)r.h * 4u;
if (p) { p->before = malloc(len); p->after = malloc(len); p->len = len; }
if (!p || !p->before || !p->after) { if (p) { free(p->before); free(p->after); free(p); } p = NULL; s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria para registrar el dibujo"); }
}
if (!p) { /* no hay sitio para el historial: la celda vuelve a como estaba */
tgb_canvas_copy_pixels(c, base);
finish_session(doc);
return s;
}
crop_into(base, r, p->before);
crop_into(c, r, p->after);
op_init(&p->base, &patch_vt, doc->edit.label, TGB_NOTIFY_PIXELS);
p->base.mem_bytes = sizeof *p + p->len * 2;
p->frame_id = doc->edit.frame_id;
p->layer_id = doc->edit.layer_id;
p->r = r;
uint32_t fid = p->frame_id, lid = p->layer_id;
finish_session(doc);
tgb_status result = hist_push(doc, &p->base); /* 'p' puede destruirse aquí */
doc_touch(doc);
tgb_event ev = {TGB_EVENT_PIXELS_CHANGED, doc, fid, lid, r, NULL};
tgb_emit(doc->ctx, &ev);
tgb_emit_simple(doc, TGB_EVENT_HISTORY_CHANGED);
return result;
}
+62
View File
@@ -0,0 +1,62 @@
/* Relleno por inundación (por líneas de barrido) y cuentagotas. */
#include "../../core/internal.h"
typedef struct { int x, y; } pt;
static bool matches(const tgb_canvas *c, int x, int y, tgb_color seed, int tol) {
tgb_color p = tgb_canvas_get(c, x, y);
return abs((int)p.r - seed.r) <= tol && abs((int)p.g - seed.g) <= tol && abs((int)p.b - seed.b) <= tol &&
abs((int)p.a - seed.a) <= tol;
}
tgb_color tgb_eyedropper(const tgb_canvas *c, int x, int y) { return tgb_canvas_get(c, x, y); }
size_t tgb_flood_fill(tgb_canvas *c, int x, int y, tgb_color fill, int tolerance) {
if (!c) return 0;
int w = tgb_canvas_width(c), h = tgb_canvas_height(c);
if (x < 0 || y < 0 || x >= w || y >= h) return 0;
if (tolerance < 0) tolerance = 0;
if (tolerance > 255) tolerance = 255;
tgb_color seed = tgb_canvas_get(c, x, y);
if (tolerance == 0 && seed.r == fill.r && seed.g == fill.g && seed.b == fill.b && seed.a == fill.a)
return 0; /* ya es de ese color: nada que hacer */
uint8_t *seen = calloc((size_t)w * (size_t)h, 1);
size_t cap = 256, n = 0, changed = 0;
pt *stack = malloc(cap * sizeof *stack);
if (!seen || !stack) { free(seen); free(stack); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el relleno"); return 0; }
stack[n++] = (pt){x, y};
while (n > 0) {
pt p = stack[--n];
if (seen[(size_t)p.y * (size_t)w + (size_t)p.x] || !matches(c, p.x, p.y, seed, tolerance)) continue;
int lx = p.x, rx = p.x;
while (lx > 0 && !seen[(size_t)p.y * (size_t)w + (size_t)(lx - 1)] && matches(c, lx - 1, p.y, seed, tolerance)) lx--;
while (rx < w - 1 && !seen[(size_t)p.y * (size_t)w + (size_t)(rx + 1)] && matches(c, rx + 1, p.y, seed, tolerance)) rx++;
for (int i = lx; i <= rx; i++) seen[(size_t)p.y * (size_t)w + (size_t)i] = 1;
for (int dy = -1; dy <= 1; dy += 2) {
int ny = p.y + dy;
if (ny < 0 || ny >= h) continue;
bool in_run = false;
for (int i = lx; i <= rx; i++) {
bool ok = !seen[(size_t)ny * (size_t)w + (size_t)i] && matches(c, i, ny, seed, tolerance);
if (ok && !in_run) {
if (n == cap) {
cap *= 2;
pt *ns = realloc(stack, cap * sizeof *stack);
if (!ns) { free(seen); free(stack); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el relleno"); return changed; }
stack = ns;
}
stack[n++] = (pt){i, ny};
}
in_run = ok;
}
}
for (int i = lx; i <= rx; i++) {
tgb_color old = tgb_canvas_get(c, i, p.y);
if (old.r != fill.r || old.g != fill.g || old.b != fill.b || old.a != fill.a) changed++; /* cuenta solo cambios reales */
tgb_canvas_set(c, i, p.y, fill);
}
}
free(seen);
free(stack);
return changed;
}
+174
View File
@@ -0,0 +1,174 @@
/* Primitivas de dibujo sobre un lienzo: pincel, línea, formas, curvas. */
#include <math.h>
#include "../../core/internal.h"
#define COORD_LIMIT 20000.0f
tgb_brush tgb_brush_pencil(tgb_color color, int size) {
tgb_brush b = {(float)(size < 1 ? 1 : size), 1.0f, false, color, TGB_BRUSH_PAINT};
return b;
}
tgb_brush tgb_brush_soft(tgb_color color, float size, float hardness) {
tgb_brush b = {size < 1.0f ? 1.0f : size, hardness, true, color, TGB_BRUSH_PAINT};
return b;
}
tgb_brush tgb_brush_eraser(float size, float hardness) {
tgb_color k = {0, 0, 0, 255};
tgb_brush b = {size < 1.0f ? 1.0f : size, hardness, true, k, TGB_BRUSH_ERASE};
return b;
}
static float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
static float sanitize(float v) {
if (!isfinite(v)) return 0.0f;
return clampf(v, -COORD_LIMIT, COORD_LIMIT);
}
static void plot(tgb_canvas *c, const tgb_brush *b, int x, int y, float cov) {
if (cov <= 0.0f) return;
if (cov > 1.0f) cov = 1.0f;
if (b->mode == TGB_BRUSH_ERASE) {
float a = cov * (b->color.a ? (float)b->color.a / 255.0f : 1.0f);
tgb_canvas_erase_px(c, x, y, (uint8_t)(a * 255.0f + 0.5f));
} else {
tgb_color col = b->color;
col.a = (uint8_t)((float)col.a * cov + 0.5f);
if (col.a == 255 && !b->antialias) tgb_canvas_set(c, x, y, col);
else tgb_canvas_blend(c, x, y, col);
}
}
void tgb_draw_dab(tgb_canvas *c, const tgb_brush *b, float x, float y) {
if (!c || !b) return;
float size = clampf(b->size, 1.0f, 512.0f);
float r = size * 0.5f;
x = sanitize(x); y = sanitize(y);
if (!b->antialias) { /* lápiz: píxeles exactos, sin suavizado */
float cx = floorf(x) + 0.5f, cy = floorf(y) + 0.5f;
int x0 = (int)floorf(cx - r), x1 = (int)ceilf(cx + r), y0 = (int)floorf(cy - r), y1 = (int)ceilf(cy + r);
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > tgb_canvas_width(c)) x1 = tgb_canvas_width(c);
if (y1 > tgb_canvas_height(c)) y1 = tgb_canvas_height(c);
for (int py = y0; py < y1; py++)
for (int px = x0; px < x1; px++) {
float dx = (float)px + 0.5f - cx, dy = (float)py + 0.5f - cy;
if (dx * dx + dy * dy <= r * r) plot(c, b, px, py, 1.0f);
}
return;
}
float h = clampf(b->hardness, 0.0f, 1.0f);
int x0 = (int)floorf(x - r - 1), x1 = (int)ceilf(x + r + 1), y0 = (int)floorf(y - r - 1), y1 = (int)ceilf(y + r + 1);
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > tgb_canvas_width(c)) x1 = tgb_canvas_width(c);
if (y1 > tgb_canvas_height(c)) y1 = tgb_canvas_height(c);
for (int py = y0; py < y1; py++)
for (int px = x0; px < x1; px++) {
float dx = (float)px + 0.5f - x, dy = (float)py + 0.5f - y;
float d = sqrtf(dx * dx + dy * dy);
float edge = clampf(r + 0.5f - d, 0.0f, 1.0f);
float soft = h >= 1.0f ? 1.0f : clampf((r - d) / (r * (1.0f - h)), 0.0f, 1.0f);
plot(c, b, px, py, edge < soft ? edge : soft);
}
}
void tgb_draw_line(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1) {
if (!c || !b) return;
x0 = sanitize(x0); y0 = sanitize(y0); x1 = sanitize(x1); y1 = sanitize(y1);
if (!b->antialias) { /* Bresenham sobre píxeles */
int ix0 = (int)floorf(x0), iy0 = (int)floorf(y0), ix1 = (int)floorf(x1), iy1 = (int)floorf(y1);
int dx = abs(ix1 - ix0), dy = -abs(iy1 - iy0), sx = ix0 < ix1 ? 1 : -1, sy = iy0 < iy1 ? 1 : -1;
int err = dx + dy;
for (;;) {
tgb_draw_dab(c, b, (float)ix0 + 0.5f, (float)iy0 + 0.5f);
if (ix0 == ix1 && iy0 == iy1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; ix0 += sx; }
if (e2 <= dx) { err += dx; iy0 += sy; }
}
return;
}
float dx = x1 - x0, dy = y1 - y0, len = sqrtf(dx * dx + dy * dy);
float spacing = clampf(b->size * 0.1f, 0.5f, 8.0f);
int steps = (int)ceilf(len / spacing);
if (steps < 1) steps = 1;
for (int i = 0; i <= steps; i++) {
float t = (float)i / (float)steps;
tgb_draw_dab(c, b, x0 + dx * t, y0 + dy * t);
}
}
static void fill_cov(tgb_canvas *c, const tgb_brush *b, int x, int y) { plot(c, b, x, y, 1.0f); }
void tgb_draw_rect(tgb_canvas *c, const tgb_brush *b, tgb_rect r, bool filled) {
if (!c || !b || r.w < 1 || r.h < 1) return;
if (filled) {
tgb_rect bounds = {0, 0, tgb_canvas_width(c), tgb_canvas_height(c)};
tgb_rect a = tgb_rect_intersect(r, bounds);
for (int y = a.y; y < a.y + a.h; y++)
for (int x = a.x; x < a.x + a.w; x++) fill_cov(c, b, x, y);
return;
}
float inset = clampf(b->size, 1.0f, 512.0f) * 0.5f;
float l = (float)r.x + inset, t = (float)r.y + inset, rr = (float)(r.x + r.w) - inset, bb = (float)(r.y + r.h) - inset;
if (rr < l) rr = l = ((float)r.x + (float)(r.x + r.w)) * 0.5f;
if (bb < t) bb = t = ((float)r.y + (float)(r.y + r.h)) * 0.5f;
tgb_draw_line(c, b, l, t, rr, t);
tgb_draw_line(c, b, rr, t, rr, bb);
tgb_draw_line(c, b, rr, bb, l, bb);
tgb_draw_line(c, b, l, bb, l, t);
}
void tgb_draw_ellipse(tgb_canvas *c, const tgb_brush *b, tgb_rect bd, bool filled) {
if (!c || !b || bd.w < 1 || bd.h < 1) return;
float cx = (float)bd.x + (float)bd.w * 0.5f, cy = (float)bd.y + (float)bd.h * 0.5f;
float rx = (float)bd.w * 0.5f, ry = (float)bd.h * 0.5f;
if (filled) {
int ymin = bd.y < 0 ? 0 : bd.y, ymax = bd.y + bd.h > tgb_canvas_height(c) ? tgb_canvas_height(c) : bd.y + bd.h;
for (int y = ymin; y < ymax; y++) {
float ny = ((float)y + 0.5f - cy) / ry;
if (fabsf(ny) > 1.0f) continue;
float hw = rx * sqrtf(1.0f - ny * ny);
int xa = (int)ceilf(cx - hw - 0.5f), xb = (int)floorf(cx + hw - 0.5f);
if (xa < 0) xa = 0;
if (xb >= tgb_canvas_width(c)) xb = tgb_canvas_width(c) - 1;
for (int x = xa; x <= xb; x++) fill_cov(c, b, x, y);
}
return;
}
float inset = clampf(b->size, 1.0f, 512.0f) * 0.5f;
float arx = rx - inset, ary = ry - inset;
if (arx < 0.5f) arx = 0.5f;
if (ary < 0.5f) ary = 0.5f;
int n = (int)ceilf(6.2831853f * (arx > ary ? arx : ary) / 2.0f);
if (n < 24) n = 24;
if (n > 4096) n = 4096;
float px = cx + arx, py = cy;
for (int i = 1; i <= n; i++) {
float t = 6.2831853f * (float)i / (float)n;
float qx = cx + arx * cosf(t), qy = cy + ary * sinf(t);
tgb_draw_line(c, b, px, py, qx, qy);
px = qx; py = qy;
}
}
void tgb_draw_bezier(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1, float x2,
float y2, float x3, float y3) {
if (!c || !b) return;
x0 = sanitize(x0); y0 = sanitize(y0); x1 = sanitize(x1); y1 = sanitize(y1);
x2 = sanitize(x2); y2 = sanitize(y2); x3 = sanitize(x3); y3 = sanitize(y3);
float poly = hypotf(x1 - x0, y1 - y0) + hypotf(x2 - x1, y2 - y1) + hypotf(x3 - x2, y3 - y2);
int n = (int)ceilf(poly / 2.0f);
if (n < 8) n = 8;
if (n > 2048) n = 2048;
float px = x0, py = y0;
for (int i = 1; i <= n; i++) {
float t = (float)i / (float)n, u = 1.0f - t;
float w0 = u * u * u, w1 = 3 * u * u * t, w2 = 3 * u * t * t, w3 = t * t * t;
float qx = w0 * x0 + w1 * x1 + w2 * x2 + w3 * x3, qy = w0 * y0 + w1 * y1 + w2 * y2 + w3 * y3;
tgb_draw_line(c, b, px, py, qx, qy);
px = qx; py = qy;
}
}
+68
View File
@@ -0,0 +1,68 @@
/* Selección rectangular y transformaciones de una región. */
#include "../../core/internal.h"
static tgb_status clip_region(const tgb_canvas *c, tgb_rect *r) {
tgb_rect bounds = {0, 0, tgb_canvas_width(c), tgb_canvas_height(c)};
*r = tgb_rect_intersect(*r, bounds);
if (r->w <= 0 || r->h <= 0) return tgb_fail(TGB_ERR_RANGE, "La selección queda fuera del lienzo");
return TGB_OK;
}
tgb_status tgb_region_clear(tgb_canvas *c, tgb_rect r) {
if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo");
tgb_status s = clip_region(c, &r);
if (TGB_FAILED(s)) return s;
tgb_color none = {0, 0, 0, 0};
tgb_canvas_fill_rect(c, r, none, false);
return TGB_OK;
}
tgb_status tgb_region_move(tgb_canvas *c, tgb_rect src, int dx, int dy) {
if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo");
tgb_status s = clip_region(c, &src);
if (TGB_FAILED(s)) return s;
if (dx == 0 && dy == 0) return TGB_OK;
tgb_canvas *tmp;
s = tgb_canvas_crop(c, src, &tmp);
if (TGB_FAILED(s)) return s;
tgb_region_clear(c, src);
tgb_canvas_blit(c, tmp, src.x + dx, src.y + dy, 255, true); /* lo que sale del lienzo se pierde */
tgb_canvas_release(tmp);
return TGB_OK;
}
tgb_status tgb_region_flip(tgb_canvas *c, tgb_rect r, bool horizontal) {
if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo");
tgb_status s = clip_region(c, &r);
if (TGB_FAILED(s)) return s;
tgb_canvas *tmp;
s = tgb_canvas_crop(c, r, &tmp);
if (TGB_FAILED(s)) return s;
for (int y = 0; y < r.h; y++)
for (int x = 0; x < r.w; x++) {
int sx = horizontal ? r.w - 1 - x : x, sy = horizontal ? y : r.h - 1 - y;
tgb_canvas_set(c, r.x + x, r.y + y, tgb_canvas_get(tmp, sx, sy));
}
tgb_canvas_release(tmp);
return TGB_OK;
}
tgb_status tgb_region_scale(tgb_canvas *c, tgb_rect r, int new_w, int new_h) {
if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo");
if (new_w < 1 || new_h < 1 || new_w > TGB_MAX_DIMENSION || new_h > TGB_MAX_DIMENSION)
return tgb_fail(TGB_ERR_RANGE, "Tamaño de escala no válido %dx%d", new_w, new_h);
tgb_status s = clip_region(c, &r);
if (TGB_FAILED(s)) return s;
tgb_canvas *src, *dst = tgb_canvas_create(new_w, new_h);
if (!dst) return TGB_ERR_NOMEM;
s = tgb_canvas_crop(c, r, &src);
if (TGB_FAILED(s)) { tgb_canvas_release(dst); return s; }
for (int y = 0; y < new_h; y++)
for (int x = 0; x < new_w; x++)
tgb_canvas_set(dst, x, y, tgb_canvas_get(src, (int)((int64_t)x * r.w / new_w), (int)((int64_t)y * r.h / new_h)));
tgb_region_clear(c, r);
tgb_canvas_blit(c, dst, r.x, r.y, 255, true);
tgb_canvas_release(src);
tgb_canvas_release(dst);
return TGB_OK;
}
+17
View File
@@ -0,0 +1,17 @@
/* INTERNO: puente entre el gestor de extensiones y el intérprete de Lua. */
#ifndef TAHGIBU_EXT_INTERNAL_H
#define TAHGIBU_EXT_INTERNAL_H
#include "../core/internal.h"
typedef struct tgb_lua_ext tgb_lua_ext;
/* true si esta compilación incluye Lua (-DTAHGIBU_WITH_LUA). */
bool tgb_lua_available(void);
/* Carga y ejecuta el script en un intérprete aislado. Los nombres de info se copian a los búferes. */
tgb_status tgb_lua_ext_load(const char *path, tgb_plugin_handle *self, const tgb_host_api *api,
tgb_lua_ext **out, char *name, size_t name_cap, char *version, size_t ver_cap,
uint32_t *required_caps);
void tgb_lua_ext_unload(tgb_lua_ext *ext);
#endif
+248
View File
@@ -0,0 +1,248 @@
/* Extensiones Lua. SOLO se compila con -DTAHGIBU_WITH_LUA (necesita Lua 5.4).
*
* ESTADO: escrito, NO compilado ni probado en el entorno de desarrollo del
* proyecto (no había cabeceras de Lua). Ver docs/STATUS.md.
*
* Seguridad (esto SÍ se puede garantizar en Lua, a diferencia del código nativo):
* - el intérprete solo abre base (sin dofile/loadfile/load), table, string, math y utf8:
* no hay io, os, package ni debug;
* - las funciones tgb.* que necesitan un permiso NO EXISTEN si no se concedió;
* - hay un límite de instrucciones por llamada y un límite de memoria. */
#include "ext_internal.h"
#ifndef TAHGIBU_WITH_LUA
bool tgb_lua_available(void) { return false; }
tgb_status tgb_lua_ext_load(const char *path, tgb_plugin_handle *self, const tgb_host_api *api, tgb_lua_ext **out,
char *name, size_t name_cap, char *version, size_t ver_cap, uint32_t *required_caps) {
(void)path; (void)self; (void)api; (void)out; (void)name; (void)name_cap; (void)version; (void)ver_cap; (void)required_caps;
return tgb_fail(TGB_ERR_UNSUPPORTED, "Esta compilación de Tahgibu no incluye soporte de Lua (compila con -DTAHGIBU_WITH_LUA=ON)");
}
void tgb_lua_ext_unload(tgb_lua_ext *ext) { (void)ext; }
#else /* TAHGIBU_WITH_LUA */
#include <lauxlib.h>
#include <lua.h>
#include <lualib.h>
#define LUA_MEM_LIMIT ((size_t)64 * 1024 * 1024)
#define LUA_INSN_LIMIT 50000000
#define LUA_MAX_COMMANDS 32
typedef struct lua_cmd {
struct tgb_lua_ext *ext;
int ref; /* referencia en el registro de Lua a la función */
char name[64];
} lua_cmd;
struct tgb_lua_ext {
lua_State *L;
const tgb_host_api *api;
tgb_plugin_handle *self;
size_t mem_used;
lua_cmd cmds[LUA_MAX_COMMANDS];
size_t ncmds;
};
/* Asignador con tope de memoria. */
static void *limited_alloc(void *ud, void *ptr, size_t osize, size_t nsize) {
tgb_lua_ext *e = ud;
if (nsize == 0) { if (ptr) e->mem_used -= osize; free(ptr); return NULL; }
size_t old = ptr ? osize : 0;
if (e->mem_used - old + nsize > LUA_MEM_LIMIT) return NULL;
void *np = realloc(ptr, nsize);
if (np) e->mem_used = e->mem_used - old + nsize;
return np;
}
static void insn_hook(lua_State *L, lua_Debug *ar) {
(void)ar;
luaL_error(L, "el script superó el límite de instrucciones");
}
static tgb_lua_ext *ext_of(lua_State *L) {
lua_getfield(L, LUA_REGISTRYINDEX, "tgb_ext");
tgb_lua_ext *e = lua_touserdata(L, -1);
lua_pop(L, 1);
return e;
}
static int push_status(lua_State *L, tgb_status s) {
if (TGB_FAILED(s)) return luaL_error(L, "%s", tgb_last_error());
return 0;
}
static int l_log(lua_State *L) { tgb_lua_ext *e = ext_of(L); e->api->log(e->self, luaL_checkstring(L, 1)); return 0; }
static int l_doc_width(lua_State *L) { tgb_lua_ext *e = ext_of(L); lua_pushinteger(L, e->api->doc_width(e->self)); return 1; }
static int l_doc_height(lua_State *L) { tgb_lua_ext *e = ext_of(L); lua_pushinteger(L, e->api->doc_height(e->self)); return 1; }
static int l_frame_count(lua_State *L) { tgb_lua_ext *e = ext_of(L); lua_pushinteger(L, (lua_Integer)e->api->doc_frame_count(e->self)); return 1; }
static int l_layer_count(lua_State *L) { tgb_lua_ext *e = ext_of(L); lua_pushinteger(L, (lua_Integer)e->api->doc_layer_count(e->self)); return 1; }
static int l_get_pixel(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
lua_Integer f = luaL_checkinteger(L, 1), l = luaL_checkinteger(L, 2), x = luaL_checkinteger(L, 3), y = luaL_checkinteger(L, 4);
if (f < 0 || l < 0) return luaL_error(L, "índice negativo");
tgb_color c = e->api->get_pixel(e->self, (size_t)f, (size_t)l, (int)x, (int)y);
lua_pushinteger(L, c.r); lua_pushinteger(L, c.g); lua_pushinteger(L, c.b); lua_pushinteger(L, c.a);
return 4;
}
static tgb_color check_color(lua_State *L, int i) {
tgb_color c;
lua_Integer v[4];
for (int k = 0; k < 4; k++) { v[k] = luaL_optinteger(L, i + k, k == 3 ? 255 : 0); if (v[k] < 0 || v[k] > 255) luaL_error(L, "componente de color fuera de 0..255"); }
c.r = (uint8_t)v[0]; c.g = (uint8_t)v[1]; c.b = (uint8_t)v[2]; c.a = (uint8_t)v[3];
return c;
}
static int l_edit_begin(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
lua_Integer f = luaL_checkinteger(L, 1), l = luaL_checkinteger(L, 2);
if (f < 0 || l < 0) return luaL_error(L, "índice negativo");
return push_status(L, e->api->edit_begin(e->self, (size_t)f, (size_t)l));
}
static int l_put_pixel(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
return push_status(L, e->api->put_pixel(e->self, (int)luaL_checkinteger(L, 1), (int)luaL_checkinteger(L, 2), check_color(L, 3)));
}
static int l_fill_rect(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
tgb_rect r = {(int)luaL_checkinteger(L, 1), (int)luaL_checkinteger(L, 2), (int)luaL_checkinteger(L, 3), (int)luaL_checkinteger(L, 4)};
return push_status(L, e->api->fill_rect(e->self, r, check_color(L, 5)));
}
static int l_edit_end(lua_State *L) { tgb_lua_ext *e = ext_of(L); return push_status(L, e->api->edit_end(e->self)); }
static int l_add_frame(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
lua_Integer i = luaL_checkinteger(L, 1);
if (i < 0) return luaL_error(L, "índice negativo");
return push_status(L, e->api->add_frame(e->self, (size_t)i));
}
/* Ejecuta el comando Lua cuando el host lo pide. */
static tgb_status lua_trampoline(tgb_plugin_handle *self, void *user) {
(void)self;
lua_cmd *c = user;
lua_State *L = c->ext->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, c->ref);
lua_sethook(L, insn_hook, LUA_MASKCOUNT, LUA_INSN_LIMIT);
int rc = lua_pcall(L, 0, 0, 0);
lua_sethook(L, NULL, 0, 0);
if (rc != LUA_OK) {
tgb_status s = tgb_fail(TGB_ERR_PLUGIN, "Error en el script Lua: %s", lua_tostring(L, -1) ? lua_tostring(L, -1) : "desconocido");
lua_pop(L, 1);
return s;
}
return TGB_OK;
}
static int l_register_command(lua_State *L) {
tgb_lua_ext *e = ext_of(L);
const char *name = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
if (e->ncmds >= LUA_MAX_COMMANDS) return luaL_error(L, "demasiados comandos");
if (strlen(name) >= sizeof e->cmds[0].name) return luaL_error(L, "nombre de comando demasiado largo");
lua_cmd *c = &e->cmds[e->ncmds];
c->ext = e;
tgb_strlcpy(c->name, name, sizeof c->name);
lua_pushvalue(L, 2);
c->ref = luaL_ref(L, LUA_REGISTRYINDEX);
tgb_status s = e->api->register_command(e->self, c->name, lua_trampoline, c);
if (TGB_FAILED(s)) { luaL_unref(L, LUA_REGISTRYINDEX, c->ref); return luaL_error(L, "%s", tgb_last_error()); }
e->ncmds++;
return 0;
}
bool tgb_lua_available(void) { return true; }
void tgb_lua_ext_unload(tgb_lua_ext *e) {
if (!e) return;
if (e->L) lua_close(e->L);
free(e);
}
static uint32_t parse_caps(lua_State *L, int idx) {
uint32_t caps = 0;
if (!lua_istable(L, idx)) return 0;
lua_Integer n = (lua_Integer)luaL_len(L, idx);
for (lua_Integer i = 1; i <= n; i++) {
lua_rawgeti(L, idx, i);
const char *s = lua_tostring(L, -1);
if (s && !strcmp(s, "read")) caps |= TGB_CAP_READ_DOCUMENT;
else if (s && !strcmp(s, "edit")) caps |= TGB_CAP_EDIT_DOCUMENT;
else if (s && !strcmp(s, "events")) caps |= TGB_CAP_EVENTS;
lua_pop(L, 1);
}
return caps;
}
tgb_status tgb_lua_ext_load(const char *path, tgb_plugin_handle *self, const tgb_host_api *api, tgb_lua_ext **out,
char *name, size_t name_cap, char *version, size_t ver_cap, uint32_t *required_caps) {
tgb_lua_ext *e = calloc(1, sizeof *e);
if (!e) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
e->api = api;
e->self = self;
e->L = lua_newstate(limited_alloc, e);
if (!e->L) { free(e); return tgb_fail(TGB_ERR_NOMEM, "No se pudo crear el intérprete de Lua"); }
lua_State *L = e->L;
/* solo bibliotecas seguras */
luaL_requiref(L, "_G", luaopen_base, 1); lua_pop(L, 1);
luaL_requiref(L, "table", luaopen_table, 1); lua_pop(L, 1);
luaL_requiref(L, "string", luaopen_string, 1); lua_pop(L, 1);
luaL_requiref(L, "math", luaopen_math, 1); lua_pop(L, 1);
luaL_requiref(L, "utf8", luaopen_utf8, 1); lua_pop(L, 1);
const char *banned[] = {"dofile", "loadfile", "load", "loadstring", "require", "collectgarbage"};
for (size_t i = 0; i < sizeof banned / sizeof banned[0]; i++) { lua_pushnil(L); lua_setglobal(L, banned[i]); }
lua_pushlightuserdata(L, e);
lua_setfield(L, LUA_REGISTRYINDEX, "tgb_ext");
uint32_t granted = api->granted_caps(self);
lua_newtable(L);
#define REG(fn, cname, cap) do { if ((cap) == 0 || (granted & (cap))) { lua_pushcfunction(L, fn); lua_setfield(L, -2, cname); } } while (0)
REG(l_log, "log", 0);
REG(l_register_command, "register_command", 0);
REG(l_doc_width, "doc_width", TGB_CAP_READ_DOCUMENT);
REG(l_doc_height, "doc_height", TGB_CAP_READ_DOCUMENT);
REG(l_frame_count, "frame_count", TGB_CAP_READ_DOCUMENT);
REG(l_layer_count, "layer_count", TGB_CAP_READ_DOCUMENT);
REG(l_get_pixel, "get_pixel", TGB_CAP_READ_DOCUMENT);
REG(l_edit_begin, "edit_begin", TGB_CAP_EDIT_DOCUMENT);
REG(l_put_pixel, "put_pixel", TGB_CAP_EDIT_DOCUMENT);
REG(l_fill_rect, "fill_rect", TGB_CAP_EDIT_DOCUMENT);
REG(l_edit_end, "edit_end", TGB_CAP_EDIT_DOCUMENT);
REG(l_add_frame, "add_frame", TGB_CAP_EDIT_DOCUMENT);
#undef REG
lua_setglobal(L, "tgb");
if (luaL_loadfile(L, path) != LUA_OK) {
tgb_status s = tgb_fail(TGB_ERR_PLUGIN, "No se pudo cargar el script Lua: %s", lua_tostring(L, -1));
tgb_lua_ext_unload(e);
return s;
}
lua_sethook(L, insn_hook, LUA_MASKCOUNT, LUA_INSN_LIMIT);
int rc = lua_pcall(L, 0, 0, 0);
lua_sethook(L, NULL, 0, 0);
if (rc != LUA_OK) {
tgb_status s = tgb_fail(TGB_ERR_PLUGIN, "Error al ejecutar el script Lua: %s", lua_tostring(L, -1));
tgb_lua_ext_unload(e);
return s;
}
/* el script debe definir la tabla global 'info' = {name=, version=, caps={"read","edit"}} */
lua_getglobal(L, "info");
if (!lua_istable(L, -1)) { tgb_lua_ext_unload(e); return tgb_fail(TGB_ERR_PLUGIN, "El script Lua no define la tabla global 'info'"); }
lua_getfield(L, -1, "name");
const char *n = lua_tostring(L, -1);
if (!n || !n[0]) { tgb_lua_ext_unload(e); return tgb_fail(TGB_ERR_PLUGIN, "info.name ausente"); }
tgb_strlcpy(name, n, name_cap);
lua_pop(L, 1);
lua_getfield(L, -1, "version");
tgb_strlcpy(version, lua_tostring(L, -1) ? lua_tostring(L, -1) : "?", ver_cap);
lua_pop(L, 1);
lua_getfield(L, -1, "caps");
*required_caps = parse_caps(L, lua_gettop(L));
lua_pop(L, 2);
*out = e;
return TGB_OK;
}
#endif /* TAHGIBU_WITH_LUA */
+501
View File
@@ -0,0 +1,501 @@
/* Gestor de extensiones: instalar, habilitar, deshabilitar, eliminar y ejecutar comandos.
*
* Reglas de seguridad de diseño:
* - NUNCA se carga un archivo solo por estar en la carpeta: debe estar en el registro
* (extensions.json) y habilitado explícitamente por el usuario;
* - los permisos concedidos limitan qué funciones del host se ofrecen (en Lua es una
* garantía real; en código nativo es solo una convención: ver plugin.h);
* - un comando de extensión se ejecuta dentro de un grupo del historial: se deshace de
* una vez, y si falla se revierte todo lo que hizo;
* - TGB_SAFE_MODE=1 en el entorno arranca sin cargar ninguna extensión. */
#include <stdio.h>
#include "ext_internal.h"
#include "../document/doc_internal.h"
#include "../io/io_internal.h"
#include "../platform/platform.h"
#define MAX_COMMANDS 128
#define MAX_HANDLE_SUBS 8
struct tgb_plugin_handle {
tgb_plugin_manager *mgr;
size_t index;
uint32_t granted;
tgb_document *doc; /* solo durante un comando */
bool edit_open;
uint32_t subs[MAX_HANDLE_SUBS];
size_t nsubs;
};
typedef struct pentry {
char id[64];
char kind[8]; /* "native" | "lua" */
char file[256];
char name[64], version[32], err[160];
bool enabled, loaded;
uint32_t granted, required;
void *lib;
void (*shutdown)(tgb_plugin_handle *);
tgb_plugin_handle *handle;
tgb_lua_ext *lua;
} pentry;
typedef struct command {
size_t plugin;
char name[64];
tgb_plugin_command_fn fn;
void *user;
} command;
struct tgb_plugin_manager {
tgb_context *ctx;
char dir[600], registry[600];
pentry *p;
size_t n, cap;
command cmds[MAX_COMMANDS];
size_t ncmds;
tgb_host_api api;
};
typedef tgb_status (*init_fn)(const tgb_host_api *, tgb_plugin_handle *, tgb_plugin_info *);
static bool valid_id(const char *id) {
size_t n = id ? strlen(id) : 0;
if (n < 1 || n > 48) return false;
for (size_t i = 0; i < n; i++) {
char c = id[i];
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || ((c == '_' || c == '-') && i > 0);
if (!ok) return false;
}
return true;
}
static void set_err(pentry *e, const char *msg) { tgb_strlcpy(e->err, msg, sizeof e->err); }
/* =================== API que se ofrece a las extensiones =================== */
static pentry *E(tgb_plugin_handle *h) { return &h->mgr->p[h->index]; }
static tgb_status need_cap(tgb_plugin_handle *h, uint32_t cap, const char *what) {
if (!h || !h->mgr) return tgb_fail(TGB_ERR_INVALID_ARG, "Manejador de extensión nulo");
if (!(h->granted & cap)) return tgb_fail(TGB_ERR_PLUGIN, "La extensión '%s' no tiene permiso para %s", E(h)->id, what);
return TGB_OK;
}
static tgb_status need_doc(tgb_plugin_handle *h) {
if (!h->doc) return tgb_fail(TGB_ERR_STATE, "Las funciones de documento solo se pueden usar dentro de un comando");
return TGB_OK;
}
static uint32_t h_granted(tgb_plugin_handle *h) { return h ? h->granted : 0; }
static void h_log(tgb_plugin_handle *h, const char *msg) {
if (h && h->mgr) fprintf(stderr, "[extensión %s] %.300s\n", E(h)->id, msg ? msg : "");
}
static tgb_status h_register_command(tgb_plugin_handle *h, const char *name, tgb_plugin_command_fn fn, void *user) {
if (!h || !h->mgr || !name || !fn) return tgb_fail(TGB_ERR_INVALID_ARG, "register_command: argumento nulo");
if (!name[0] || strlen(name) >= sizeof h->mgr->cmds[0].name) return tgb_fail(TGB_ERR_INVALID_ARG, "Nombre de comando no válido");
tgb_plugin_manager *m = h->mgr;
if (m->ncmds >= MAX_COMMANDS) return tgb_fail(TGB_ERR_LIMIT, "Demasiados comandos de extensiones (máximo %d)", MAX_COMMANDS);
for (size_t i = 0; i < m->ncmds; i++)
if (m->cmds[i].plugin == h->index && !strcmp(m->cmds[i].name, name)) return tgb_fail(TGB_ERR_EXISTS, "Comando '%s' repetido", name);
command *c = &m->cmds[m->ncmds++];
c->plugin = h->index;
tgb_strlcpy(c->name, name, sizeof c->name);
c->fn = fn;
c->user = user;
return TGB_OK;
}
static tgb_status h_subscribe(tgb_plugin_handle *h, tgb_event_fn fn, void *user) {
tgb_status s = need_cap(h, TGB_CAP_EVENTS, "recibir eventos");
if (TGB_FAILED(s)) return s;
if (!fn) return tgb_fail(TGB_ERR_INVALID_ARG, "subscribe: función nula");
if (h->nsubs >= MAX_HANDLE_SUBS) return tgb_fail(TGB_ERR_LIMIT, "Demasiadas suscripciones de la extensión");
uint32_t id;
s = tgb_events_subscribe(h->mgr->ctx, fn, user, &id);
if (TGB_FAILED(s)) return s;
h->subs[h->nsubs++] = id;
return TGB_OK;
}
static int h_doc_width(tgb_plugin_handle *h) { return (TGB_SUCCEEDED(need_cap(h, TGB_CAP_READ_DOCUMENT, "leer el documento")) && h->doc) ? h->doc->width : 0; }
static int h_doc_height(tgb_plugin_handle *h) { return (TGB_SUCCEEDED(need_cap(h, TGB_CAP_READ_DOCUMENT, "leer el documento")) && h->doc) ? h->doc->height : 0; }
static size_t h_doc_frames(tgb_plugin_handle *h) { return (TGB_SUCCEEDED(need_cap(h, TGB_CAP_READ_DOCUMENT, "leer el documento")) && h->doc) ? h->doc->nframes : 0; }
static size_t h_doc_layers(tgb_plugin_handle *h) { return (TGB_SUCCEEDED(need_cap(h, TGB_CAP_READ_DOCUMENT, "leer el documento")) && h->doc) ? h->doc->nlayers : 0; }
static tgb_color h_get_pixel(tgb_plugin_handle *h, size_t f, size_t l, int x, int y) {
tgb_color none = {0, 0, 0, 0};
if (TGB_FAILED(need_cap(h, TGB_CAP_READ_DOCUMENT, "leer el documento")) || !h->doc) return none;
const tgb_canvas *c = tgb_frame_cel(h->doc, f, l);
return c ? tgb_canvas_get(c, x, y) : none;
}
static tgb_status h_edit_begin(tgb_plugin_handle *h, size_t f, size_t l) {
tgb_status s = need_cap(h, TGB_CAP_EDIT_DOCUMENT, "modificar el documento");
if (TGB_SUCCEEDED(s)) s = need_doc(h);
if (TGB_FAILED(s)) return s;
if (h->edit_open) return tgb_fail(TGB_ERR_STATE, "Ya hay una edición abierta");
if (f >= h->doc->nframes || l >= h->doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Fotograma o capa fuera de rango");
char label[64];
snprintf(label, sizeof label, "Extensión %.40s", E(h)->id);
s = tgb_edit_begin(h->doc, h->doc->frames[f].id, h->doc->layers[l].id, label);
if (TGB_SUCCEEDED(s)) h->edit_open = true;
return s;
}
static tgb_status h_put_pixel(tgb_plugin_handle *h, int x, int y, tgb_color c) {
tgb_status s = need_cap(h, TGB_CAP_EDIT_DOCUMENT, "modificar el documento");
if (TGB_FAILED(s)) return s;
if (!h->doc || !h->edit_open) return tgb_fail(TGB_ERR_STATE, "No hay una edición abierta (usa edit_begin)");
tgb_canvas_set(tgb_edit_canvas(h->doc), x, y, c);
return TGB_OK;
}
static tgb_status h_fill_rect(tgb_plugin_handle *h, tgb_rect r, tgb_color c) {
tgb_status s = need_cap(h, TGB_CAP_EDIT_DOCUMENT, "modificar el documento");
if (TGB_FAILED(s)) return s;
if (!h->doc || !h->edit_open) return tgb_fail(TGB_ERR_STATE, "No hay una edición abierta (usa edit_begin)");
tgb_canvas_fill_rect(tgb_edit_canvas(h->doc), r, c, false);
return TGB_OK;
}
static tgb_status h_edit_end(tgb_plugin_handle *h) {
tgb_status s = need_cap(h, TGB_CAP_EDIT_DOCUMENT, "modificar el documento");
if (TGB_FAILED(s)) return s;
if (!h->doc || !h->edit_open) return tgb_fail(TGB_ERR_STATE, "No hay una edición abierta");
h->edit_open = false;
return tgb_edit_end(h->doc);
}
static tgb_status h_add_frame(tgb_plugin_handle *h, size_t index) {
tgb_status s = need_cap(h, TGB_CAP_EDIT_DOCUMENT, "modificar el documento");
if (TGB_SUCCEEDED(s)) s = need_doc(h);
if (TGB_FAILED(s)) return s;
return tgb_frame_add(h->doc, index, NULL);
}
static void fill_api(tgb_host_api *a) {
memset(a, 0, sizeof *a);
a->api_version = TGB_PLUGIN_API_VERSION;
a->struct_size = (uint32_t)sizeof *a;
a->granted_caps = h_granted;
a->log = h_log;
a->register_command = h_register_command;
a->subscribe = h_subscribe;
a->doc_width = h_doc_width;
a->doc_height = h_doc_height;
a->doc_frame_count = h_doc_frames;
a->doc_layer_count = h_doc_layers;
a->get_pixel = h_get_pixel;
a->edit_begin = h_edit_begin;
a->put_pixel = h_put_pixel;
a->fill_rect = h_fill_rect;
a->edit_end = h_edit_end;
a->add_frame = h_add_frame;
}
/* =================== registro (extensions.json) =================== */
static tgb_status save_registry(tgb_plugin_manager *m) {
tgb_sb sb; sb_init(&sb);
sb_puts(&sb, "{\"version\":1,\"plugins\":[");
for (size_t i = 0; i < m->n; i++) {
pentry *e = &m->p[i];
if (i) sb_puts(&sb, ",");
sb_puts(&sb, "{\"id\":");
sb_json_string(&sb, e->id, strlen(e->id));
sb_puts(&sb, ",\"kind\":");
sb_json_string(&sb, e->kind, strlen(e->kind));
sb_printf(&sb, ",\"enabled\":%s,\"granted_caps\":%u}", e->enabled ? "true" : "false", (unsigned)e->granted);
}
sb_puts(&sb, "]}\n");
tgb_status s = sb.oom ? tgb_fail(TGB_ERR_NOMEM, "Sin memoria") : tgb_plat_write_atomic(m->registry, sb.data, sb.len);
sb_free(&sb);
return s;
}
static void entry_path(const tgb_plugin_manager *m, const pentry *e, char *out, size_t cap) { snprintf(out, cap, "%s/%s", m->dir, e->file); }
static void make_file_name(pentry *e) {
snprintf(e->file, sizeof e->file, "%s%s", e->id, strcmp(e->kind, "lua") == 0 ? ".lua" : tgb_plat_dll_extension());
}
static tgb_status push_entry(tgb_plugin_manager *m, const char *id, const char *kind, bool enabled, uint32_t granted, size_t *out_index) {
if (m->n == m->cap) {
size_t nc = m->cap ? m->cap * 2 : 8;
pentry *np = realloc(m->p, nc * sizeof *np);
if (!np) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
m->p = np; m->cap = nc;
}
pentry *e = &m->p[m->n];
memset(e, 0, sizeof *e);
tgb_strlcpy(e->id, id, sizeof e->id);
tgb_strlcpy(e->kind, kind, sizeof e->kind);
e->enabled = enabled;
e->granted = granted;
make_file_name(e);
if (out_index) *out_index = m->n;
m->n++;
return TGB_OK;
}
static tgb_status load_registry(tgb_plugin_manager *m) {
if (!tgb_plat_exists(m->registry)) return TGB_OK;
uint8_t *buf; size_t len;
tgb_status s = tgb_plat_read_file(m->registry, &buf, &len, 1u << 20);
if (TGB_FAILED(s)) return s;
jnode *root = NULL;
s = json_parse((const char *)buf, len, &root); /* el árbol apunta al texto: 'buf' debe vivir hasta json_free */
if (TGB_FAILED(s)) { char t[300]; snprintf(t, sizeof t, "%s", tgb_last_error()); free(buf); return tgb_fail(TGB_ERR_FORMAT, "El registro de extensiones '%s' está dañado (%s). No se ha modificado: corrígelo o bórralo.", m->registry, t); }
const jnode *arr = json_get(root, "plugins");
if (!arr || arr->t != J_ARR) { json_free(root); free(buf); return tgb_fail(TGB_ERR_FORMAT, "El registro de extensiones '%s' no tiene la lista 'plugins'", m->registry); }
for (size_t i = 0; i < arr->n; i++) {
const jnode *o = &arr->items[i], *idn = json_get(o, "id"), *kn = json_get(o, "kind"), *en = json_get(o, "enabled");
long long caps = 0;
char id[64] = "", kind[8] = "native";
if (idn && idn->t == J_STR && idn->slen < sizeof id) { memcpy(id, idn->s, idn->slen); id[idn->slen] = '\0'; }
if (kn && kn->t == J_STR && json_str_eq(kn, "lua")) strcpy(kind, "lua");
json_is_int(json_get(o, "granted_caps"), 0, 7, &caps);
if (!valid_id(id)) continue; /* entrada no válida: se ignora (no se ejecuta nada de ella) */
bool dup = false;
for (size_t k = 0; k < m->n; k++) if (!strcmp(m->p[k].id, id)) dup = true;
if (dup) continue;
s = push_entry(m, id, kind, en && en->t == J_BOOL && en->b, (uint32_t)caps, NULL);
if (TGB_FAILED(s)) { json_free(root); free(buf); return s; }
}
json_free(root);
free(buf);
return TGB_OK;
}
/* =================== carga y descarga =================== */
static void remove_commands_of(tgb_plugin_manager *m, size_t plugin) {
size_t w = 0;
for (size_t i = 0; i < m->ncmds; i++) if (m->cmds[i].plugin != plugin) m->cmds[w++] = m->cmds[i];
m->ncmds = w;
}
static void unload_entry(tgb_plugin_manager *m, size_t idx, bool announce) {
pentry *e = &m->p[idx];
if (!e->loaded && !e->lib && !e->lua && !e->handle) return;
if (e->shutdown && e->handle) e->shutdown(e->handle);
if (e->handle) for (size_t i = 0; i < e->handle->nsubs; i++) tgb_events_unsubscribe(m->ctx, e->handle->subs[i]);
remove_commands_of(m, idx);
if (e->lua) tgb_lua_ext_unload(e->lua);
if (e->lib) tgb_plat_dlclose(e->lib);
free(e->handle);
e->lua = NULL; e->lib = NULL; e->shutdown = NULL; e->handle = NULL;
bool was = e->loaded;
e->loaded = false;
if (was && announce) {
tgb_event ev = {TGB_EVENT_PLUGIN_UNLOADED, NULL, 0, 0, {0, 0, 0, 0}, e->id};
tgb_emit(m->ctx, &ev);
}
}
static tgb_status load_entry(tgb_plugin_manager *m, size_t idx) {
pentry *e = &m->p[idx];
char path[1000];
entry_path(m, e, path, sizeof path);
e->err[0] = '\0';
if (!tgb_plat_exists(path)) { set_err(e, "el archivo de la extensión no existe"); return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el archivo de la extensión '%s' (%s)", e->id, path); }
tgb_plugin_handle *h = calloc(1, sizeof *h);
if (!h) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
h->mgr = m; h->index = idx; h->granted = e->granted;
e->handle = h;
tgb_plugin_info info = {NULL, NULL, 0};
tgb_status s = TGB_OK;
if (!strcmp(e->kind, "lua")) {
char name[64] = "", ver[32] = "";
uint32_t req = 0;
s = tgb_lua_ext_load(path, h, &m->api, &e->lua, name, sizeof name, ver, sizeof ver, &req);
if (TGB_SUCCEEDED(s)) { tgb_strlcpy(e->name, name, sizeof e->name); tgb_strlcpy(e->version, ver, sizeof e->version); e->required = req; }
} else {
e->lib = tgb_plat_dlopen(path);
if (!e->lib) { char t[200]; snprintf(t, sizeof t, "%s", tgb_plat_dlerror()); s = tgb_fail(TGB_ERR_PLUGIN, "No se pudo cargar la biblioteca de '%s': %s", e->id, t); }
else {
const uint32_t *ver = tgb_plat_dlsym(e->lib, "tgb_plugin_api_version");
init_fn init = (init_fn)(uintptr_t)tgb_plat_dlsym(e->lib, "tgb_plugin_init");
if (!ver) s = tgb_fail(TGB_ERR_INCOMPATIBLE, "'%s' no declara tgb_plugin_api_version (¿compilada para otra versión de Tahgibu?)", e->id);
else if (*ver != TGB_PLUGIN_API_VERSION) s = tgb_fail(TGB_ERR_INCOMPATIBLE, "'%s' usa la API de extensiones %u y esta versión de Tahgibu usa la %d", e->id, (unsigned)*ver, TGB_PLUGIN_API_VERSION);
else if (!init) s = tgb_fail(TGB_ERR_PLUGIN, "'%s' no exporta tgb_plugin_init", e->id);
else {
e->shutdown = (void (*)(tgb_plugin_handle *))(uintptr_t)tgb_plat_dlsym(e->lib, "tgb_plugin_shutdown");
s = init(&m->api, h, &info);
if (TGB_FAILED(s)) { char t[300]; snprintf(t, sizeof t, "%s", tgb_last_error()); s = tgb_fail(TGB_ERR_PLUGIN, "La extensión '%s' falló al iniciarse: %s", e->id, t); e->shutdown = NULL; }
else if (!info.name || !info.name[0]) s = tgb_fail(TGB_ERR_PLUGIN, "La extensión '%s' no declara un nombre", e->id);
else { tgb_strlcpy(e->name, info.name, sizeof e->name); tgb_strlcpy(e->version, info.version ? info.version : "?", sizeof e->version); e->required = info.required_caps; }
}
}
}
if (TGB_SUCCEEDED(s) && (e->required & ~e->granted)) {
s = tgb_fail(TGB_ERR_PLUGIN, "La extensión '%s' necesita permisos que no se le han concedido (pide 0x%x, concedidos 0x%x)", e->id, (unsigned)e->required, (unsigned)e->granted);
}
if (TGB_FAILED(s)) {
char t[300]; snprintf(t, sizeof t, "%s", tgb_last_error());
set_err(e, t);
e->loaded = false;
unload_entry(m, idx, false);
tgb_fail(s, "%s", t);
return s;
}
e->loaded = true;
tgb_event ev = {TGB_EVENT_PLUGIN_LOADED, NULL, 0, 0, {0, 0, 0, 0}, e->id};
tgb_emit(m->ctx, &ev);
return TGB_OK;
}
/* =================== API pública del gestor =================== */
tgb_status tgb_plugin_manager_create(tgb_context *ctx, tgb_plugin_manager **out) {
if (!ctx || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_plugin_manager_create: argumento nulo");
*out = NULL;
tgb_plugin_manager *m = calloc(1, sizeof *m);
if (!m) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
m->ctx = ctx;
fill_api(&m->api);
int a = snprintf(m->dir, sizeof m->dir, "%s/extensions", tgb_context_data_dir(ctx));
int b = snprintf(m->registry, sizeof m->registry, "%s/extensions.json", tgb_context_config_dir(ctx));
if (a < 0 || b < 0 || (size_t)a >= sizeof m->dir || (size_t)b >= sizeof m->registry) { free(m); return tgb_fail(TGB_ERR_LIMIT, "Ruta demasiado larga"); }
tgb_status s = tgb_plat_mkdirs(m->dir);
if (TGB_SUCCEEDED(s)) s = load_registry(m);
if (TGB_FAILED(s)) { tgb_plugin_manager_destroy(m); return s; }
const char *safe = getenv("TGB_SAFE_MODE");
if (!(safe && safe[0] && strcmp(safe, "0") != 0)) {
for (size_t i = 0; i < m->n; i++) if (m->p[i].enabled) load_entry(m, i); /* solo las que el usuario habilitó; el error queda en last_error */
}
*out = m;
return TGB_OK;
}
void tgb_plugin_manager_destroy(tgb_plugin_manager *m) {
if (!m) return;
for (size_t i = 0; i < m->n; i++) unload_entry(m, i, false);
free(m->p);
free(m);
}
tgb_status tgb_plugin_install(tgb_plugin_manager *m, const char *source, const char *id, size_t *out_index) {
if (!m || !source || !id) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_plugin_install: argumento nulo");
if (!valid_id(id)) return tgb_fail(TGB_ERR_INVALID_ARG, "Identificador no válido: usa 1 a 48 caracteres a-z, 0-9, '_' o '-' (sin empezar por _ o -)");
for (size_t i = 0; i < m->n; i++) if (!strcmp(m->p[i].id, id)) return tgb_fail(TGB_ERR_EXISTS, "Ya hay una extensión instalada con el identificador '%s'", id);
if (!tgb_plat_exists(source)) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el archivo '%s'", source);
size_t sl = strlen(source);
const char *kind = (sl > 4 && !strcmp(source + sl - 4, ".lua")) ? "lua" : "native";
size_t idx;
tgb_status s = push_entry(m, id, kind, false, 0, &idx);
if (TGB_FAILED(s)) return s;
char dest[1000];
entry_path(m, &m->p[idx], dest, sizeof dest);
s = tgb_plat_copy_file(source, dest);
if (TGB_SUCCEEDED(s)) s = save_registry(m);
if (TGB_FAILED(s)) { char t[300]; snprintf(t, sizeof t, "%s", tgb_last_error()); tgb_plat_remove(dest); m->n--; return tgb_fail(s, "No se pudo instalar: %s", t); }
if (out_index) *out_index = idx;
return TGB_OK;
}
tgb_status tgb_plugin_enable(tgb_plugin_manager *m, size_t index, uint32_t granted_caps) {
if (!m) return tgb_fail(TGB_ERR_INVALID_ARG, "Gestor nulo");
if (index >= m->n) return tgb_fail(TGB_ERR_RANGE, "Extensión %zu inexistente", index);
pentry *e = &m->p[index];
if (e->loaded) return tgb_fail(TGB_ERR_STATE, "La extensión '%s' ya está habilitada", e->id);
if (granted_caps & ~(uint32_t)(TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT | TGB_CAP_EVENTS)) return tgb_fail(TGB_ERR_INVALID_ARG, "Permisos desconocidos");
e->granted = granted_caps;
tgb_status s = load_entry(m, index);
if (TGB_FAILED(s)) return s;
e->enabled = true;
s = save_registry(m);
if (TGB_FAILED(s)) { unload_entry(m, index, true); e->enabled = false; return s; }
return TGB_OK;
}
tgb_status tgb_plugin_disable(tgb_plugin_manager *m, size_t index) {
if (!m) return tgb_fail(TGB_ERR_INVALID_ARG, "Gestor nulo");
if (index >= m->n) return tgb_fail(TGB_ERR_RANGE, "Extensión %zu inexistente", index);
unload_entry(m, index, true);
m->p[index].enabled = false;
return save_registry(m);
}
tgb_status tgb_plugin_remove(tgb_plugin_manager *m, size_t index) {
if (!m) return tgb_fail(TGB_ERR_INVALID_ARG, "Gestor nulo");
if (index >= m->n) return tgb_fail(TGB_ERR_RANGE, "Extensión %zu inexistente", index);
unload_entry(m, index, true);
char path[1000];
entry_path(m, &m->p[index], path, sizeof path);
tgb_status s = tgb_plat_remove(path);
if (TGB_FAILED(s)) return s;
memmove(&m->p[index], &m->p[index + 1], (m->n - index - 1) * sizeof m->p[0]);
m->n--;
for (size_t i = index; i < m->n; i++) { /* los índices posteriores bajan una posición */
if (m->p[i].handle) m->p[i].handle->index = i;
}
for (size_t i = 0; i < m->ncmds; i++) if (m->cmds[i].plugin > index) m->cmds[i].plugin--;
return save_registry(m);
}
size_t tgb_plugin_count(const tgb_plugin_manager *m) { return m ? m->n : 0; }
tgb_status tgb_plugin_info_at(const tgb_plugin_manager *m, size_t i, tgb_plugin_entry_info *out) {
if (!m || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (i >= m->n) return tgb_fail(TGB_ERR_RANGE, "Extensión %zu inexistente", i);
const pentry *e = &m->p[i];
memset(out, 0, sizeof *out);
tgb_strlcpy(out->id, e->id, sizeof out->id);
tgb_strlcpy(out->kind, e->kind, sizeof out->kind);
tgb_strlcpy(out->file, e->file, sizeof out->file);
tgb_strlcpy(out->name, e->name[0] ? e->name : e->id, sizeof out->name);
tgb_strlcpy(out->version, e->version, sizeof out->version);
out->enabled = e->enabled; out->loaded = e->loaded;
out->granted_caps = e->granted; out->required_caps = e->required;
tgb_strlcpy(out->last_error, e->err, sizeof out->last_error);
return TGB_OK;
}
size_t tgb_plugin_command_count(const tgb_plugin_manager *m) { return m ? m->ncmds : 0; }
tgb_status tgb_plugin_command_name(const tgb_plugin_manager *m, size_t i, char *buf, size_t size) {
if (!m || !buf || size == 0) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (i >= m->ncmds) return tgb_fail(TGB_ERR_RANGE, "Comando %zu inexistente", i);
snprintf(buf, size, "%s:%s", m->p[m->cmds[i].plugin].id, m->cmds[i].name);
return TGB_OK;
}
tgb_status tgb_plugin_run_command(tgb_plugin_manager *m, size_t ci, tgb_document *doc) {
if (!m || !doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (ci >= m->ncmds) return tgb_fail(TGB_ERR_RANGE, "Comando %zu inexistente", ci);
command cmd = m->cmds[ci]; /* copia: la extensión no puede alterar la tabla mientras se ejecuta */
pentry *e = &m->p[cmd.plugin];
tgb_plugin_handle *h = e->handle;
if (!e->loaded || !h) return tgb_fail(TGB_ERR_STATE, "La extensión '%s' no está habilitada", e->id);
if (h->doc) return tgb_fail(TGB_ERR_STATE, "La extensión '%s' ya está ejecutando un comando", e->id);
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
char label[64];
snprintf(label, sizeof label, "Extensión: %.30s", cmd.name);
s = tgb_history_begin_group(doc, label);
if (TGB_FAILED(s)) return s;
h->doc = doc;
h->edit_open = false;
tgb_fail(TGB_OK, "%s", ""); /* limpiar el mensaje: si el comando falla sin mensaje propio, lo sabremos */
tgb_status r = cmd.fn(h, cmd.user);
char msg[400] = "";
if (TGB_SUCCEEDED(r) && h->edit_open) r = tgb_fail(TGB_ERR_PLUGIN, "la extensión dejó una edición sin cerrar (falta edit_end)");
if (TGB_FAILED(r)) {
const char *lm = tgb_last_error();
if (strcmp(lm, "sin errores registrados") == 0) snprintf(msg, sizeof msg, "devolvió %s", tgb_status_name(r));
else snprintf(msg, sizeof msg, "%s", lm);
}
h->doc = NULL;
h->edit_open = false;
if (TGB_FAILED(r)) {
tgb_status ab = tgb_history_abort_group(doc); /* vuelve al estado anterior al comando */
set_err(e, msg);
if (TGB_FAILED(ab)) return tgb_fail(TGB_ERR_PLUGIN, "El comando '%s' falló (%s) y NO se pudo restaurar el documento: guarda con otro nombre y reinicia", cmd.name, msg);
return tgb_fail(TGB_ERR_PLUGIN, "El comando '%s' de '%s' falló: %s. Se deshicieron todos sus cambios.", cmd.name, e->id, msg);
}
return tgb_history_end_group(doc);
}
tgb_status tgb_plugin_run_command_named(tgb_plugin_manager *m, const char *full_name, tgb_document *doc) {
if (!m || !full_name || !doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
for (size_t i = 0; i < m->ncmds; i++) {
char name[160];
snprintf(name, sizeof name, "%s:%s", m->p[m->cmds[i].plugin].id, m->cmds[i].name);
if (!strcmp(name, full_name)) return tgb_plugin_run_command(m, i, doc);
}
return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el comando '%s' (¿la extensión está habilitada?)", full_name);
}
+406
View File
@@ -0,0 +1,406 @@
/* Historial unificado: pilas de deshacer/rehacer, grupos y límites adaptativos. */
#include <stdio.h>
#include "history_internal.h"
#include "../platform/platform.h"
typedef struct group {
tgb_op **kids;
size_t n, cap;
char label[64];
} group;
struct tgb_history {
tgb_op **undo; size_t nundo, capundo;
tgb_op **redo; size_t nredo, capredo;
group g[TGB_MAX_GROUP_DEPTH];
int depth;
tgb_history_limits lim;
size_t mem, disk;
uint64_t dropped, spilled, serial;
};
#define MiB ((size_t)1024 * 1024)
void op_init(tgb_op *op, const tgb_op_vt *vt, const char *label, unsigned notify) {
memset(op, 0, sizeof *op);
op->vt = vt;
tgb_strlcpy(op->label, label ? label : "", sizeof op->label);
op->notify = notify;
}
struct tgb_history *hist_create(void) {
struct tgb_history *h = calloc(1, sizeof *h);
if (!h) return NULL;
h->lim.max_memory_bytes = 256 * MiB;
h->lim.max_operations = 10000;
return h;
}
static void destroy_op(tgb_op *op) { if (op && op->vt->destroy) op->vt->destroy(op); }
static void clear_stack(tgb_op **a, size_t *n) {
for (size_t i = 0; i < *n; i++) destroy_op(a[i]);
*n = 0;
}
void hist_destroy(struct tgb_history *h) {
if (!h) return;
clear_stack(h->undo, &h->nundo);
clear_stack(h->redo, &h->nredo);
for (int i = 0; i < h->depth; i++) { clear_stack(h->g[i].kids, &h->g[i].n); }
for (int i = 0; i < TGB_MAX_GROUP_DEPTH; i++) free(h->g[i].kids);
free(h->undo);
free(h->redo);
free(h);
}
static bool grow(tgb_op ***arr, size_t *cap, size_t need) {
if (need <= *cap) return true;
size_t nc = *cap ? *cap * 2 : 16;
while (nc < need) nc *= 2;
tgb_op **na = realloc(*arr, nc * sizeof **arr);
if (!na) return false;
*arr = na;
*cap = nc;
return true;
}
tgb_status hist_reserve(tgb_document *doc) {
struct tgb_history *h = doc->history;
bool ok = h->depth > 0 ? grow(&h->g[h->depth - 1].kids, &h->g[h->depth - 1].cap, h->g[h->depth - 1].n + 1)
: grow(&h->undo, &h->capundo, h->nundo + 1);
return ok ? TGB_OK : tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el historial");
}
static void account_remove(struct tgb_history *h, const tgb_op *op) {
h->mem = h->mem >= op->mem_bytes ? h->mem - op->mem_bytes : 0;
h->disk = h->disk >= op->disk_bytes ? h->disk - op->disk_bytes : 0;
}
static void clear_redo(struct tgb_history *h) {
for (size_t i = 0; i < h->nredo; i++) { account_remove(h, h->redo[i]); destroy_op(h->redo[i]); }
h->nredo = 0;
}
static void drop_oldest(struct tgb_history *h) {
tgb_op *op = h->undo[0];
memmove(&h->undo[0], &h->undo[1], (h->nundo - 1) * sizeof h->undo[0]);
h->nundo--;
account_remove(h, op);
destroy_op(op);
h->dropped++;
}
static void spill_pass(struct tgb_history *h, tgb_op **arr, size_t n, tgb_spill_ctx *ctx) {
for (size_t i = 0; i < n && h->mem > h->lim.max_memory_bytes; i++) {
tgb_op *op = arr[i];
if (!op->vt->spill || op->mem_bytes == 0) continue;
size_t before_disk = op->disk_bytes;
size_t freed = op->vt->spill(op, ctx);
if (freed) {
h->mem = h->mem >= freed ? h->mem - freed : 0;
h->disk += op->disk_bytes - before_disk;
h->spilled++;
}
}
}
/* Aplica los límites. Devuelve true si se descartó algo. */
static bool enforce(tgb_document *doc) {
struct tgb_history *h = doc->history;
uint64_t dropped_before = h->dropped;
if (h->mem > h->lim.max_memory_bytes && h->lim.max_temp_bytes > 0 && h->lim.temp_dir[0]) {
if (TGB_SUCCEEDED(tgb_plat_mkdirs(h->lim.temp_dir))) {
tgb_spill_ctx ctx = {h->lim.temp_dir, h->serial,
h->lim.max_temp_bytes > h->disk ? h->lim.max_temp_bytes - h->disk : 0};
spill_pass(h, h->undo, h->nundo, &ctx);
spill_pass(h, h->redo, h->nredo, &ctx);
h->serial = ctx.serial;
}
}
while (h->nundo > 0 && (h->mem > h->lim.max_memory_bytes || h->nundo > h->lim.max_operations ||
h->disk > h->lim.max_temp_bytes))
drop_oldest(h);
if (h->nundo == 0 && (h->mem > h->lim.max_memory_bytes)) clear_redo(h);
bool lost = h->dropped != dropped_before;
if (lost) {
tgb_event ev = {TGB_EVENT_HISTORY_TRUNCATED, doc, 0, 0, {0, 0, 0, 0}, NULL};
tgb_emit(doc->ctx, &ev);
}
return lost;
}
tgb_status hist_push(tgb_document *doc, tgb_op *op) {
struct tgb_history *h = doc->history;
tgb_status result = TGB_OK;
if (h->depth > 0) {
group *g = &h->g[h->depth - 1];
g->kids[g->n++] = op;
h->mem += op->mem_bytes;
h->disk += op->disk_bytes;
return TGB_OK;
}
clear_redo(h);
h->undo[h->nundo++] = op;
h->mem += op->mem_bytes;
h->disk += op->disk_bytes;
if (enforce(doc) && (h->nundo == 0 || h->undo[h->nundo - 1] != op)) result = TGB_WARN_HISTORY_LOST;
return result;
}
static void notify_op(tgb_document *doc, unsigned mask) {
doc_touch(doc);
if (mask & TGB_NOTIFY_PIXELS) {
tgb_event ev = {TGB_EVENT_PIXELS_CHANGED, doc, 0, 0, {0, 0, doc->width, doc->height}, NULL};
tgb_emit(doc->ctx, &ev);
}
if (mask & TGB_NOTIFY_FRAMES) tgb_emit_simple(doc, TGB_EVENT_FRAMES_CHANGED);
if (mask & TGB_NOTIFY_LAYERS) tgb_emit_simple(doc, TGB_EVENT_LAYERS_CHANGED);
tgb_emit_simple(doc, TGB_EVENT_HISTORY_CHANGED);
}
tgb_status hist_run(tgb_document *doc, tgb_op *op) {
tgb_status s = hist_reserve(doc);
if (TGB_SUCCEEDED(s)) s = op->vt->redo(op, doc);
if (TGB_FAILED(s)) { destroy_op(op); return s; }
unsigned mask = op->notify;
tgb_status r = hist_push(doc, op); /* 'op' puede haberse destruido aquí: no usarlo más */
notify_op(doc, mask);
return r;
}
/* ---------- deshacer / rehacer ---------- */
tgb_status tgb_history_undo(tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "No se puede deshacer con una sesión de edición abierta");
if (h->depth > 0) return tgb_fail(TGB_ERR_STATE, "No se puede deshacer con un grupo abierto");
if (h->nundo == 0) return tgb_fail(TGB_ERR_NOTHING, "No hay nada que deshacer");
if (!grow(&h->redo, &h->capredo, h->nredo + 1)) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el historial");
tgb_op *op = h->undo[h->nundo - 1];
tgb_status s = op->vt->undo(op, doc);
if (TGB_FAILED(s)) return s;
h->nundo--;
h->redo[h->nredo++] = op;
notify_op(doc, op->notify);
return TGB_OK;
}
tgb_status tgb_history_redo(tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "No se puede rehacer con una sesión de edición abierta");
if (h->depth > 0) return tgb_fail(TGB_ERR_STATE, "No se puede rehacer con un grupo abierto");
if (h->nredo == 0) return tgb_fail(TGB_ERR_NOTHING, "No hay nada que rehacer");
if (!grow(&h->undo, &h->capundo, h->nundo + 1)) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el historial");
tgb_op *op = h->redo[h->nredo - 1];
tgb_status s = op->vt->redo(op, doc);
if (TGB_FAILED(s)) return s;
h->nredo--;
h->undo[h->nundo++] = op;
notify_op(doc, op->notify);
return TGB_OK;
}
bool tgb_history_can_undo(const tgb_document *d) { return d && d->history->nundo > 0 && d->history->depth == 0; }
bool tgb_history_can_redo(const tgb_document *d) { return d && d->history->nredo > 0 && d->history->depth == 0; }
const char *tgb_history_undo_label(const tgb_document *d) { return (d && d->history->nundo) ? d->history->undo[d->history->nundo - 1]->label : NULL; }
const char *tgb_history_redo_label(const tgb_document *d) { return (d && d->history->nredo) ? d->history->redo[d->history->nredo - 1]->label : NULL; }
void tgb_history_clear(tgb_document *doc) {
if (!doc) return;
struct tgb_history *h = doc->history;
clear_stack(h->undo, &h->nundo);
clear_stack(h->redo, &h->nredo);
h->mem = h->disk = 0;
for (int i = 0; i < h->depth; i++) /* lo que haya en grupos abiertos sigue contando */
for (size_t k = 0; k < h->g[i].n; k++) { h->mem += h->g[i].kids[k]->mem_bytes; h->disk += h->g[i].kids[k]->disk_bytes; }
tgb_emit_simple(doc, TGB_EVENT_HISTORY_CHANGED);
}
/* ---------- grupos ---------- */
typedef struct group_op {
tgb_op base;
tgb_op **kids;
size_t n;
} group_op;
static tgb_status group_undo(tgb_op *op, tgb_document *doc) {
group_op *g = (group_op *)op;
for (size_t i = g->n; i-- > 0;) {
tgb_status s = g->kids[i]->vt->undo(g->kids[i], doc);
if (TGB_FAILED(s)) { /* restaurar lo ya deshecho */
for (size_t k = i + 1; k < g->n; k++)
if (TGB_FAILED(g->kids[k]->vt->redo(g->kids[k], doc))) { doc->inconsistent = true; break; }
return s;
}
}
return TGB_OK;
}
static tgb_status group_redo(tgb_op *op, tgb_document *doc) {
group_op *g = (group_op *)op;
for (size_t i = 0; i < g->n; i++) {
tgb_status s = g->kids[i]->vt->redo(g->kids[i], doc);
if (TGB_FAILED(s)) {
for (size_t k = i; k-- > 0;)
if (TGB_FAILED(g->kids[k]->vt->undo(g->kids[k], doc))) { doc->inconsistent = true; break; }
return s;
}
}
return TGB_OK;
}
static void group_destroy(tgb_op *op) {
group_op *g = (group_op *)op;
for (size_t i = 0; i < g->n; i++) destroy_op(g->kids[i]);
free(g->kids);
free(g);
}
static size_t group_spill(tgb_op *op, tgb_spill_ctx *ctx) {
group_op *g = (group_op *)op;
size_t total = 0;
for (size_t i = 0; i < g->n; i++) {
tgb_op *k = g->kids[i];
if (!k->vt->spill || k->mem_bytes == 0) continue;
size_t before = k->disk_bytes;
size_t f = k->vt->spill(k, ctx);
if (f) { total += f; op->mem_bytes -= f; op->disk_bytes += k->disk_bytes - before; }
}
return total;
}
static const tgb_op_vt group_vt = {group_undo, group_redo, group_destroy, group_spill};
tgb_status tgb_history_begin_group(tgb_document *doc, const char *label) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
if (h->depth >= TGB_MAX_GROUP_DEPTH) return tgb_fail(TGB_ERR_LIMIT, "Demasiados grupos anidados (máximo %d)", TGB_MAX_GROUP_DEPTH);
if (h->depth == 0) clear_redo(h);
group *g = &h->g[h->depth];
g->n = 0;
tgb_strlcpy(g->label, (label && label[0]) ? label : "Grupo", sizeof g->label);
h->depth++;
return TGB_OK;
}
tgb_status tgb_history_end_group(tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
if (h->depth == 0) return tgb_fail(TGB_ERR_STATE, "No hay ningún grupo abierto");
if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "Cierra la sesión de edición antes de cerrar el grupo");
group *g = &h->g[h->depth - 1];
if (g->n == 0) { h->depth--; return TGB_OK; }
/* reservar sitio en el destino ANTES de cerrar (si falla, el grupo sigue abierto) */
bool ok = h->depth > 1 ? grow(&h->g[h->depth - 2].kids, &h->g[h->depth - 2].cap, h->g[h->depth - 2].n + 1)
: grow(&h->undo, &h->capundo, h->nundo + 1);
if (!ok) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para cerrar el grupo");
tgb_op *top;
size_t mem = 0, disk = 0;
unsigned notify = 0;
for (size_t i = 0; i < g->n; i++) { mem += g->kids[i]->mem_bytes; disk += g->kids[i]->disk_bytes; notify |= g->kids[i]->notify; }
if (g->n == 1) {
top = g->kids[0];
tgb_strlcpy(top->label, g->label, sizeof top->label);
} else {
group_op *go = calloc(1, sizeof *go);
if (!go) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para cerrar el grupo");
go->kids = malloc(g->n * sizeof *go->kids);
if (!go->kids) { free(go); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para cerrar el grupo"); }
memcpy(go->kids, g->kids, g->n * sizeof *go->kids);
go->n = g->n;
op_init(&go->base, &group_vt, g->label, notify);
go->base.mem_bytes = mem;
go->base.disk_bytes = disk;
top = &go->base;
}
g->n = 0;
h->depth--;
/* los hijos ya estaban contabilizados en h->mem/h->disk: no volver a sumar */
if (h->depth > 0) {
group *p = &h->g[h->depth - 1];
p->kids[p->n++] = top;
return TGB_OK;
}
h->undo[h->nundo++] = top;
tgb_status r = TGB_OK;
if (enforce(doc) && (h->nundo == 0 || h->undo[h->nundo - 1] != top)) r = TGB_WARN_HISTORY_LOST;
tgb_emit_simple(doc, TGB_EVENT_HISTORY_CHANGED);
return r;
}
tgb_status tgb_history_abort_group(tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
if (h->depth == 0) return tgb_fail(TGB_ERR_STATE, "No hay ningún grupo abierto");
if (doc->edit.active) tgb_edit_cancel(doc);
group *g = &h->g[h->depth - 1];
tgb_status result = TGB_OK;
unsigned notify = 0;
for (size_t i = g->n; i-- > 0;) {
tgb_op *k = g->kids[i];
notify |= k->notify;
tgb_status s = k->vt->undo(k, doc);
if (TGB_FAILED(s)) { doc->inconsistent = true; result = s; }
}
for (size_t i = 0; i < g->n; i++) { account_remove(h, g->kids[i]); destroy_op(g->kids[i]); }
g->n = 0;
h->depth--;
notify_op(doc, notify);
return result;
}
/* ---------- límites ---------- */
tgb_status tgb_history_set_limits(tgb_document *doc, const tgb_history_limits *lim) {
if (!doc || !lim) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (lim->max_operations == 0) return tgb_fail(TGB_ERR_RANGE, "max_operations debe ser al menos 1");
if (memchr(lim->temp_dir, '\0', sizeof lim->temp_dir) == NULL) return tgb_fail(TGB_ERR_INVALID_ARG, "temp_dir sin terminador");
struct tgb_history *h = doc->history;
h->lim = *lim;
if (h->depth == 0) enforce(doc);
return TGB_OK;
}
void tgb_history_get_limits(const tgb_document *doc, tgb_history_limits *out) {
if (doc && out) *out = doc->history->lim;
}
static size_t clamp_sz(size_t v, size_t lo, size_t hi) { return v < lo ? lo : (v > hi ? hi : v); }
tgb_status tgb_history_adapt(tgb_document *doc) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
struct tgb_history *h = doc->history;
size_t avail = tgb_plat_available_memory();
if (avail == 0) avail = 1024 * MiB; /* desconocido: suposición prudente */
size_t cel = (size_t)doc->width * (size_t)doc->height * 4u;
size_t mem = clamp_sz(avail / 10, 64 * MiB, 1024 * MiB);
if (mem < cel * 8) mem = cel * 8; /* al menos 8 celdas completas */
if (mem > avail / 2 && avail / 2 > cel * 2) mem = avail / 2; /* nunca más de la mitad de lo libre */
h->lim.max_memory_bytes = mem;
h->lim.max_operations = 10000;
h->lim.max_temp_bytes = 0;
h->lim.temp_dir[0] = '\0';
if (doc->ctx && doc->ctx->cache_dir[0]) {
char dir[512];
int n = snprintf(dir, sizeof dir, "%s/history", doc->ctx->cache_dir);
if (n > 0 && (size_t)n < sizeof dir) {
size_t freeb = tgb_plat_free_disk(doc->ctx->cache_dir);
size_t temp = clamp_sz(freeb / 20, 0, 2048 * MiB);
if (temp >= 64 * MiB) { tgb_strlcpy(h->lim.temp_dir, dir, sizeof h->lim.temp_dir); h->lim.max_temp_bytes = temp; }
}
}
if (h->depth == 0) enforce(doc);
return TGB_OK;
}
void tgb_history_get_stats(const tgb_document *doc, tgb_history_stats *out) {
if (!doc || !out) return;
const struct tgb_history *h = doc->history;
out->undo_count = h->nundo;
out->redo_count = h->nredo;
out->memory_bytes = h->mem;
out->temp_bytes = h->disk;
out->dropped_ops = h->dropped;
out->spilled_ops = h->spilled;
}
+46
View File
@@ -0,0 +1,46 @@
/* INTERNO: operaciones del historial y funciones para los demás módulos. */
#ifndef TAHGIBU_HISTORY_INTERNAL_H
#define TAHGIBU_HISTORY_INTERNAL_H
#include "../document/doc_internal.h"
#define TGB_NOTIFY_PIXELS 1u
#define TGB_NOTIFY_FRAMES 2u
#define TGB_NOTIFY_LAYERS 4u
#define TGB_MAX_GROUP_DEPTH 8
typedef struct tgb_op tgb_op;
typedef struct tgb_spill_ctx {
const char *dir;
uint64_t serial;
size_t room; /* disco aún disponible */
} tgb_spill_ctx;
typedef struct tgb_op_vt {
tgb_status (*undo)(tgb_op *op, tgb_document *doc); /* si falla, el documento no cambia */
tgb_status (*redo)(tgb_op *op, tgb_document *doc); /* aplica (también la 1ª vez) */
void (*destroy)(tgb_op *op);
/* Pasa datos a disco; devuelve la memoria liberada (0 si no puede). Opcional. */
size_t (*spill)(tgb_op *op, tgb_spill_ctx *ctx);
} tgb_op_vt;
struct tgb_op {
const tgb_op_vt *vt;
char label[64];
size_t mem_bytes; /* memoria estimada en uso */
size_t disk_bytes; /* disco temporal en uso */
unsigned notify; /* qué eventos emitir al deshacer/rehacer */
};
struct tgb_history *hist_create(void);
void hist_destroy(struct tgb_history *h);
tgb_status hist_reserve(tgb_document *doc); /* garantiza sitio para 1 operación más */
tgb_status hist_push(tgb_document *doc, tgb_op *op); /* toma posesión; solo tras hist_reserve */
/* Reserva, aplica (vt->redo) y registra. Si falla destruye 'op'. */
tgb_status hist_run(tgb_document *doc, tgb_op *op);
/* Utilidad: inicializa campos comunes de una operación. */
void op_init(tgb_op *op, const tgb_op_vt *vt, const char *label, unsigned notify);
/* Bytes de una celda solo si está retenida únicamente por quien la consulta. */
size_t cel_exclusive_bytes(tgb_canvas *c);
#endif
+122
View File
@@ -0,0 +1,122 @@
/* Importación de imágenes y exportación (PNG, secuencia PNG, APNG). */
#include <stdio.h>
#include "io_internal.h"
#include "../document/doc_internal.h"
#include "../history/history_internal.h"
#include "../platform/platform.h"
tgb_status tgb_import_png(tgb_document *doc, const char *path, size_t frame_index, size_t layer_index, uint32_t *out_frame_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
if (!path) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta de imagen nula");
if (frame_index > doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", frame_index);
if (layer_index >= doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", layer_index);
if (doc->layers[layer_index].locked) return tgb_fail(TGB_ERR_LOCKED, "La capa '%s' está bloqueada", doc->layers[layer_index].name);
uint8_t *file; size_t flen;
s = tgb_plat_read_file(path, &file, &flen, TGB_MAX_FILE_BYTES); /* todo se valida ANTES de tocar el documento */
if (TGB_FAILED(s)) return s;
tgb_canvas *img = NULL;
s = tgb_png_decode_memory(file, flen, &img);
free(file);
if (TGB_FAILED(s)) { char m[400]; snprintf(m, sizeof m, "%s", tgb_last_error()); return tgb_fail(s, "No se pudo importar '%s': %s", path, m); }
uint32_t layer_id = doc->layers[layer_index].id, fid = 0;
s = tgb_history_begin_group(doc, "Importar imagen");
if (TGB_FAILED(s)) { tgb_canvas_release(img); return s; }
s = tgb_frame_add(doc, frame_index, &fid);
if (TGB_SUCCEEDED(s)) s = tgb_edit_begin(doc, fid, layer_id, "Importar imagen");
if (TGB_SUCCEEDED(s)) {
int dx = (doc->width - tgb_canvas_width(img)) / 2, dy = (doc->height - tgb_canvas_height(img)) / 2;
tgb_canvas_blit(tgb_edit_canvas(doc), img, dx, dy, 255, false); /* centrada; recorte si es mayor */
s = tgb_edit_end(doc);
}
tgb_canvas_release(img);
if (TGB_FAILED(s)) { tgb_history_abort_group(doc); return s; }
tgb_status e = tgb_history_end_group(doc);
if (TGB_FAILED(e)) { tgb_history_abort_group(doc); return e; }
if (out_frame_id) *out_frame_id = fid;
return s;
}
tgb_status tgb_export_png(const tgb_document *doc, size_t frame_index, const char *path) {
if (!doc || !path) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_export_png: argumento nulo");
if (frame_index >= doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Fotograma %zu fuera de rango", frame_index);
tgb_canvas *c = tgb_canvas_create(doc->width, doc->height);
if (!c) return TGB_ERR_NOMEM;
tgb_status s = tgb_render_flatten(doc, frame_index, c);
uint8_t *png = NULL; size_t pl = 0;
if (TGB_SUCCEEDED(s)) s = tgb_png_encode_memory(c, &png, &pl);
tgb_canvas_release(c);
if (TGB_FAILED(s)) return s;
s = tgb_plat_write_atomic(path, png, pl);
free(png);
return s;
}
tgb_status tgb_export_png_sequence(const tgb_document *doc, const char *dir, const char *prefix) {
if (!doc || !dir || !prefix) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_export_png_sequence: argumento nulo");
if (!prefix[0] || strpbrk(prefix, "/\\")) return tgb_fail(TGB_ERR_INVALID_ARG, "El prefijo no puede estar vacío ni contener '/' o '\\'");
if (!tgb_plat_is_dir(dir)) return tgb_fail(TGB_ERR_NOT_FOUND, "La carpeta '%s' no existe", dir);
for (size_t i = 0; i < doc->nframes; i++) {
char path[1100];
int n = snprintf(path, sizeof path, "%s/%s_%04zu.png", dir, prefix, i + 1);
if (n < 0 || (size_t)n >= sizeof path) return tgb_fail(TGB_ERR_LIMIT, "Ruta de exportación demasiado larga");
tgb_status s = tgb_export_png(doc, i, path);
if (TGB_FAILED(s)) {
char m[400]; snprintf(m, sizeof m, "%s", tgb_last_error());
return tgb_fail(s, "Falló al exportar el fotograma %zu (los anteriores ya se escribieron): %s", i + 1, m);
}
}
return TGB_OK;
}
tgb_status tgb_export_apng(const tgb_document *doc, const char *path) {
if (!doc || !path) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_export_apng: argumento nulo");
tgb_canvas *c = tgb_canvas_create(doc->width, doc->height);
if (!c) return TGB_ERR_NOMEM;
tgb_sb sb; sb_init(&sb);
uint8_t ihdr[13], actl[8], fctl[26];
png_put_u32(ihdr, (uint32_t)doc->width); png_put_u32(ihdr + 4, (uint32_t)doc->height);
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = ihdr[11] = ihdr[12] = 0;
png_put_u32(actl, (uint32_t)doc->nframes); png_put_u32(actl + 4, 0); /* 0 = repetir sin fin */
png_put_signature(&sb);
png_put_chunk(&sb, "IHDR", ihdr, 13);
png_put_chunk(&sb, "acTL", actl, 8);
uint32_t seq = 0;
tgb_status s = TGB_OK;
for (size_t i = 0; i < doc->nframes && TGB_SUCCEEDED(s); i++) {
uint8_t *z = NULL; size_t zl = 0;
s = tgb_render_flatten(doc, i, c);
if (TGB_SUCCEEDED(s)) s = png_compress_image(c, &z, &zl);
if (TGB_FAILED(s)) break;
png_put_u32(fctl, seq++);
png_put_u32(fctl + 4, (uint32_t)doc->width); png_put_u32(fctl + 8, (uint32_t)doc->height);
png_put_u32(fctl + 12, 0); png_put_u32(fctl + 16, 0);
uint32_t ms = doc->frames[i].duration_ms;
fctl[20] = (uint8_t)(ms >> 8); fctl[21] = (uint8_t)ms; /* delay_num: milisegundos */
fctl[22] = (uint8_t)(1000 >> 8); fctl[23] = (uint8_t)(1000 & 0xFF); /* delay_den: 1000 */
fctl[24] = 0; /* dispose: ninguno */
fctl[25] = 0; /* blend: reemplazar (cada fotograma ya está compuesto entero) */
png_put_chunk(&sb, "fcTL", fctl, 26);
if (i == 0) png_put_chunk(&sb, "IDAT", z, zl);
else {
uint8_t *fd = malloc(zl + 4);
if (!fd) { free(z); s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); break; }
png_put_u32(fd, seq++);
memcpy(fd + 4, z, zl);
png_put_chunk(&sb, "fdAT", fd, zl + 4);
free(fd);
}
free(z);
if (sb.oom) { s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); break; }
if (sb.len > TGB_MAX_FILE_BYTES) { s = tgb_fail(TGB_ERR_LIMIT, "La animación es demasiado grande para exportarse"); break; }
}
tgb_canvas_release(c);
if (TGB_SUCCEEDED(s)) {
png_put_chunk(&sb, "IEND", NULL, 0);
if (sb.oom) s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
else s = tgb_plat_write_atomic(path, sb.data, sb.len);
}
sb_free(&sb);
return s;
}
+59
View File
@@ -0,0 +1,59 @@
/* INTERNO: utilidades de entrada/salida (búfer de texto, Base64, JSON, PNG). */
#ifndef TAHGIBU_IO_INTERNAL_H
#define TAHGIBU_IO_INTERNAL_H
#include "../core/internal.h"
/* ---------- búfer de texto/bytes creciente ---------- */
typedef struct tgb_sb {
char *data;
size_t len, cap;
bool oom; /* true si alguna operación falló por memoria */
} tgb_sb;
void sb_init(tgb_sb *sb);
void sb_free(tgb_sb *sb);
bool sb_append(tgb_sb *sb, const void *data, size_t n);
bool sb_puts(tgb_sb *sb, const char *s);
bool sb_printf(tgb_sb *sb, const char *fmt, ...) TGB_PRINTF(2, 3);
bool sb_json_string(tgb_sb *sb, const char *s, size_t n); /* con comillas y escapes */
/* Entrega la propiedad del contenido (liberar con free). */
char *sb_take(tgb_sb *sb, size_t *len);
/* ---------- Base64 estándar con relleno ---------- */
size_t tgb_b64_encoded_len(size_t n);
void tgb_b64_encode(const uint8_t *in, size_t n, char *out); /* escribe tgb_b64_encoded_len + NUL */
/* Estricto: solo el alfabeto estándar, longitud múltiplo de 4, relleno solo al final.
* Acepta el prefijo exacto "data:image/png;base64,". */
tgb_status tgb_b64_decode(const char *s, size_t len, size_t max_out, uint8_t **out, size_t *out_len);
/* ---------- JSON ---------- */
typedef enum { J_NULL = 0, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ } jtype;
typedef struct jnode {
jtype t;
bool b;
double num;
const char *s; /* J_STR: NO terminado en NUL; apunta al texto original o a una copia */
size_t slen;
bool s_owned;
struct jnode *items; /* J_ARR / J_OBJ */
char **keys; /* J_OBJ: claves terminadas en NUL */
size_t n;
size_t start, end; /* posición del valor en el texto original */
} jnode;
#define JSON_MAX_DEPTH 24
#define JSON_MAX_NODES 4000000u
/* El texto debe seguir vivo mientras se use el árbol. Devuelve TGB_ERR_FORMAT con mensaje si no es JSON válido. */
tgb_status json_parse(const char *src, size_t len, jnode **root);
void json_free(jnode *root);
const jnode *json_get(const jnode *obj, const char *key);
bool json_is_int(const jnode *n, long long lo, long long hi, long long *out);
bool json_str_eq(const jnode *n, const char *lit);
/* ---------- PNG (interno) ---------- */
/* Filtra y comprime (zlib) un lienzo RGBA 8 bits: datos listos para IDAT. */
tgb_status png_compress_image(const tgb_canvas *c, uint8_t **out, size_t *len);
void png_put_chunk(tgb_sb *sb, const char type[4], const uint8_t *data, size_t len);
void png_put_signature(tgb_sb *sb);
void png_put_u32(uint8_t *p, uint32_t v);
#endif
+136
View File
@@ -0,0 +1,136 @@
/* Búfer de texto y Base64. */
#include <stdio.h>
#include "io_internal.h"
void sb_init(tgb_sb *sb) { memset(sb, 0, sizeof *sb); }
void sb_free(tgb_sb *sb) { free(sb->data); memset(sb, 0, sizeof *sb); }
bool sb_append(tgb_sb *sb, const void *data, size_t n) {
if (sb->oom) return false;
size_t need;
if (!tgb_add_ok(sb->len, n, &need) || !tgb_add_ok(need, 1, &need)) { sb->oom = true; return false; }
if (need > sb->cap) {
size_t nc = sb->cap ? sb->cap : 256;
while (nc < need) {
if (nc > SIZE_MAX / 2) { sb->oom = true; return false; }
nc *= 2;
}
char *nd = realloc(sb->data, nc);
if (!nd) { sb->oom = true; return false; }
sb->data = nd;
sb->cap = nc;
}
if (n) memcpy(sb->data + sb->len, data, n);
sb->len += n;
sb->data[sb->len] = '\0';
return true;
}
bool sb_puts(tgb_sb *sb, const char *s) { return sb_append(sb, s, strlen(s)); }
bool sb_printf(tgb_sb *sb, const char *fmt, ...) {
char buf[512];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
if (n < 0 || (size_t)n >= sizeof buf) { sb->oom = true; return false; } /* nuestros usos son siempre cortos */
return sb_append(sb, buf, (size_t)n);
}
bool sb_json_string(tgb_sb *sb, const char *s, size_t n) {
if (!sb_append(sb, "\"", 1)) return false;
size_t run = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c >= 0x20 && c != '"' && c != '\\') continue;
if (i > run && !sb_append(sb, s + run, i - run)) return false;
run = i + 1;
char esc[8];
switch (c) {
case '"': strcpy(esc, "\\\""); break;
case '\\': strcpy(esc, "\\\\"); break;
case '\n': strcpy(esc, "\\n"); break;
case '\r': strcpy(esc, "\\r"); break;
case '\t': strcpy(esc, "\\t"); break;
default: snprintf(esc, sizeof esc, "\\u%04x", c); break;
}
if (!sb_puts(sb, esc)) return false;
}
if (n > run && !sb_append(sb, s + run, n - run)) return false;
return sb_append(sb, "\"", 1);
}
char *sb_take(tgb_sb *sb, size_t *len) {
if (!sb->data && !sb_append(sb, "", 0)) return NULL;
char *p = sb->data;
if (len) *len = sb->len;
memset(sb, 0, sizeof *sb);
return p;
}
/* ---------- Base64 ---------- */
static const char B64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t tgb_b64_encoded_len(size_t n) { return ((n + 2) / 3) * 4; }
void tgb_b64_encode(const uint8_t *in, size_t n, char *out) {
size_t i = 0, o = 0;
for (; i + 2 < n; i += 3) {
uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8) | in[i + 2];
out[o++] = B64[v >> 18]; out[o++] = B64[(v >> 12) & 63]; out[o++] = B64[(v >> 6) & 63]; out[o++] = B64[v & 63];
}
if (n - i == 1) {
uint32_t v = (uint32_t)in[i] << 16;
out[o++] = B64[v >> 18]; out[o++] = B64[(v >> 12) & 63]; out[o++] = '='; out[o++] = '=';
} else if (n - i == 2) {
uint32_t v = ((uint32_t)in[i] << 16) | ((uint32_t)in[i + 1] << 8);
out[o++] = B64[v >> 18]; out[o++] = B64[(v >> 12) & 63]; out[o++] = B64[(v >> 6) & 63]; out[o++] = '=';
}
out[o] = '\0';
}
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
tgb_status tgb_b64_decode(const char *s, size_t len, size_t max_out, uint8_t **out, size_t *out_len) {
static const char prefix[] = "data:image/png;base64,";
if (len >= sizeof prefix - 1 && memcmp(s, prefix, sizeof prefix - 1) == 0) { s += sizeof prefix - 1; len -= sizeof prefix - 1; }
if (len == 0) return tgb_fail(TGB_ERR_CORRUPT, "Base64 vacío");
if (len % 4 != 0) return tgb_fail(TGB_ERR_CORRUPT, "Base64 inválido: la longitud (%zu) no es múltiplo de 4", len);
size_t pad = 0;
if (s[len - 1] == '=') pad++;
if (s[len - 2] == '=') pad++;
size_t outn = len / 4 * 3 - pad;
if (outn > max_out) return tgb_fail(TGB_ERR_LIMIT, "Los datos Base64 decodificados (%zu bytes) superan el límite (%zu)", outn, max_out);
uint8_t *buf = malloc(outn ? outn : 1);
if (!buf) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para decodificar Base64");
size_t o = 0;
for (size_t i = 0; i < len; i += 4) {
int v[4];
for (int k = 0; k < 4; k++) {
unsigned char c = (unsigned char)s[i + (size_t)k];
if (c == '=') {
bool last_group = i + 4 == len;
if (!last_group || k < 2 || (k == 2 && s[i + 3] != '=')) { free(buf); return tgb_fail(TGB_ERR_CORRUPT, "Base64 inválido: relleno '=' mal colocado (posición %zu)", i + (size_t)k); }
v[k] = 0;
} else {
v[k] = b64_val(c);
if (v[k] < 0) { free(buf); return tgb_fail(TGB_ERR_CORRUPT, "Base64 inválido: carácter no permitido (0x%02x) en la posición %zu", c, i + (size_t)k); }
}
}
uint32_t w = ((uint32_t)v[0] << 18) | ((uint32_t)v[1] << 12) | ((uint32_t)v[2] << 6) | (uint32_t)v[3];
if (o < outn) buf[o++] = (uint8_t)(w >> 16);
if (o < outn) buf[o++] = (uint8_t)(w >> 8);
if (o < outn) buf[o++] = (uint8_t)w;
}
*out = buf;
*out_len = outn;
return TGB_OK;
}
+272
View File
@@ -0,0 +1,272 @@
/* Analizador JSON estricto (RFC 8259) con límites de profundidad y de nodos.
* Cada valor recuerda su posición en el texto para poder conservar campos
* desconocidos tal cual. */
#include <stdio.h>
#include "io_internal.h"
typedef struct {
const char *src;
size_t len, pos, nodes;
int depth;
char err[128];
} parser;
static bool perr(parser *p, const char *msg) {
snprintf(p->err, sizeof p->err, "%s", msg);
return false;
}
static void free_node(jnode *n) {
if (n->t == J_STR) { if (n->s_owned) free((void *)n->s); }
else if (n->t == J_ARR || n->t == J_OBJ) {
for (size_t i = 0; i < n->n; i++) { free_node(&n->items[i]); if (n->keys) free(n->keys[i]); }
free(n->items);
free(n->keys);
}
memset(n, 0, sizeof *n);
}
void json_free(jnode *root) {
if (!root) return;
free_node(root);
free(root);
}
static void skip_ws(parser *p) {
while (p->pos < p->len) {
char c = p->src[p->pos];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p->pos++; else break;
}
}
static int hex4(const char *s) {
int v = 0;
for (int i = 0; i < 4; i++) {
char c = s[i];
v <<= 4;
if (c >= '0' && c <= '9') v |= c - '0';
else if (c >= 'a' && c <= 'f') v |= c - 'a' + 10;
else if (c >= 'A' && c <= 'F') v |= c - 'A' + 10;
else return -1;
}
return v;
}
static size_t utf8_put(char *o, uint32_t cp) {
if (cp < 0x80) { o[0] = (char)cp; return 1; }
if (cp < 0x800) { o[0] = (char)(0xC0 | (cp >> 6)); o[1] = (char)(0x80 | (cp & 0x3F)); return 2; }
if (cp < 0x10000) { o[0] = (char)(0xE0 | (cp >> 12)); o[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[2] = (char)(0x80 | (cp & 0x3F)); return 3; }
o[0] = (char)(0xF0 | (cp >> 18)); o[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); o[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}
/* Lee una cadena (la posición está en la comilla de apertura). */
static bool parse_string(parser *p, const char **out, size_t *outlen, bool *owned) {
size_t i = p->pos + 1, start = i;
bool escapes = false;
while (i < p->len) {
unsigned char c = (unsigned char)p->src[i];
if (c == '"') break;
if (c < 0x20) return perr(p, "carácter de control sin escapar dentro de una cadena");
if (c == '\\') { escapes = true; i += 2; continue; }
i++;
}
if (i >= p->len) return perr(p, "cadena sin cerrar");
if (!escapes) {
if (!tgb_utf8_valid(p->src + start, i - start)) return perr(p, "cadena con UTF-8 no válido");
*out = p->src + start; *outlen = i - start; *owned = false;
p->pos = i + 1;
return true;
}
char *buf = malloc(i - start + 1);
if (!buf) return perr(p, "sin memoria");
size_t o = 0;
for (size_t k = start; k < i;) {
char c = p->src[k];
if (c != '\\') { buf[o++] = c; k++; continue; }
if (k + 1 >= i) { free(buf); return perr(p, "escape incompleto"); }
char e = p->src[k + 1];
k += 2;
switch (e) {
case '"': buf[o++] = '"'; break;
case '\\': buf[o++] = '\\'; break;
case '/': buf[o++] = '/'; break;
case 'b': buf[o++] = '\b'; break;
case 'f': buf[o++] = '\f'; break;
case 'n': buf[o++] = '\n'; break;
case 'r': buf[o++] = '\r'; break;
case 't': buf[o++] = '\t'; break;
case 'u': {
if (k + 4 > i) { free(buf); return perr(p, "escape \\u incompleto"); }
int hi = hex4(p->src + k);
if (hi < 0) { free(buf); return perr(p, "escape \\u no válido"); }
k += 4;
uint32_t cp = (uint32_t)hi;
if (cp >= 0xD800 && cp <= 0xDBFF) {
if (k + 6 > i || p->src[k] != '\\' || p->src[k + 1] != 'u') { free(buf); return perr(p, "sustituto UTF-16 sin pareja"); }
int lo = hex4(p->src + k + 2);
if (lo < 0xDC00 || lo > 0xDFFF) { free(buf); return perr(p, "sustituto UTF-16 no válido"); }
cp = 0x10000 + (((uint32_t)hi - 0xD800) << 10) + ((uint32_t)lo - 0xDC00);
k += 6;
} else if (cp >= 0xDC00 && cp <= 0xDFFF) { free(buf); return perr(p, "sustituto UTF-16 suelto"); }
o += utf8_put(buf + o, cp);
break;
}
default: free(buf); return perr(p, "secuencia de escape desconocida");
}
}
buf[o] = '\0';
*out = buf; *outlen = o; *owned = true;
p->pos = i + 1;
return true;
}
static bool parse_number(parser *p, double *out) {
size_t i = p->pos, s = i;
if (i < p->len && p->src[i] == '-') i++;
if (i >= p->len) return perr(p, "número incompleto");
if (p->src[i] == '0') i++;
else if (p->src[i] >= '1' && p->src[i] <= '9') while (i < p->len && p->src[i] >= '0' && p->src[i] <= '9') i++;
else return perr(p, "número no válido");
bool simple = true;
if (i < p->len && p->src[i] == '.') {
simple = false; i++;
if (i >= p->len || p->src[i] < '0' || p->src[i] > '9') return perr(p, "número no válido tras el punto decimal");
while (i < p->len && p->src[i] >= '0' && p->src[i] <= '9') i++;
}
if (i < p->len && (p->src[i] == 'e' || p->src[i] == 'E')) {
simple = false; i++;
if (i < p->len && (p->src[i] == '+' || p->src[i] == '-')) i++;
if (i >= p->len || p->src[i] < '0' || p->src[i] > '9') return perr(p, "exponente no válido");
while (i < p->len && p->src[i] >= '0' && p->src[i] <= '9') i++;
}
size_t n = i - s;
if (n > 60) return perr(p, "número demasiado largo");
if (simple && n <= 17) { /* enteros: sin depender de la configuración regional */
bool neg = p->src[s] == '-';
double v = 0;
for (size_t k = s + (neg ? 1 : 0); k < i; k++) v = v * 10 + (double)(p->src[k] - '0');
*out = neg ? -v : v;
} else {
char buf[64];
memcpy(buf, p->src + s, n); buf[n] = '\0';
*out = strtod(buf, NULL);
}
p->pos = i;
return true;
}
static bool parse_value(parser *p, jnode *out);
static bool grow_node(jnode *n, size_t *cap, bool is_obj) {
if (n->n < *cap) return true;
size_t nc = *cap ? *cap * 2 : 4;
jnode *ni = realloc(n->items, nc * sizeof *ni);
if (!ni) return false;
n->items = ni;
if (is_obj) {
char **nk = realloc(n->keys, nc * sizeof *nk);
if (!nk) return false;
n->keys = nk;
}
*cap = nc;
return true;
}
static bool parse_container(parser *p, jnode *out, bool is_obj) {
char close = is_obj ? '}' : ']';
out->t = is_obj ? J_OBJ : J_ARR;
if (++p->depth > JSON_MAX_DEPTH) return perr(p, "anidamiento demasiado profundo");
p->pos++;
skip_ws(p);
size_t cap = 0;
if (p->pos < p->len && p->src[p->pos] == close) { p->pos++; p->depth--; return true; }
for (;;) {
if (!grow_node(out, &cap, is_obj)) return perr(p, "sin memoria");
char *key = NULL;
if (is_obj) {
skip_ws(p);
if (p->pos >= p->len || p->src[p->pos] != '"') return perr(p, "se esperaba el nombre de un campo entre comillas");
const char *ks; size_t kl; bool ko;
if (!parse_string(p, &ks, &kl, &ko)) return false;
key = malloc(kl + 1);
if (!key) { if (ko) free((void *)ks); return perr(p, "sin memoria"); }
memcpy(key, ks, kl); key[kl] = '\0';
if (ko) free((void *)ks);
if (strlen(key) != kl) { free(key); return perr(p, "el nombre de un campo contiene un carácter nulo"); }
skip_ws(p);
if (p->pos >= p->len || p->src[p->pos] != ':') { free(key); return perr(p, "se esperaba ':'"); }
p->pos++;
}
memset(&out->items[out->n], 0, sizeof out->items[0]);
if (is_obj) out->keys[out->n] = key;
out->n++; /* contar antes: si falla, free_node lo libera */
if (!parse_value(p, &out->items[out->n - 1])) return false;
skip_ws(p);
if (p->pos >= p->len) return perr(p, is_obj ? "objeto sin cerrar" : "lista sin cerrar");
char c = p->src[p->pos];
if (c == ',') { p->pos++; continue; }
if (c == close) { p->pos++; break; }
return perr(p, "se esperaba ',' o el cierre del contenedor");
}
p->depth--;
return true;
}
static bool parse_value(parser *p, jnode *out) {
skip_ws(p);
if (p->pos >= p->len) return perr(p, "fin de texto inesperado");
if (++p->nodes > JSON_MAX_NODES) return perr(p, "demasiados elementos");
out->start = p->pos;
char c = p->src[p->pos];
bool ok;
if (c == '{') ok = parse_container(p, out, true);
else if (c == '[') ok = parse_container(p, out, false);
else if (c == '"') { out->t = J_STR; ok = parse_string(p, &out->s, &out->slen, &out->s_owned); }
else if (c == 't' && p->len - p->pos >= 4 && !memcmp(p->src + p->pos, "true", 4)) { out->t = J_BOOL; out->b = true; p->pos += 4; ok = true; }
else if (c == 'f' && p->len - p->pos >= 5 && !memcmp(p->src + p->pos, "false", 5)) { out->t = J_BOOL; out->b = false; p->pos += 5; ok = true; }
else if (c == 'n' && p->len - p->pos >= 4 && !memcmp(p->src + p->pos, "null", 4)) { out->t = J_NULL; p->pos += 4; ok = true; }
else if (c == '-' || (c >= '0' && c <= '9')) { out->t = J_NUM; ok = parse_number(p, &out->num); }
else ok = perr(p, "carácter inesperado");
out->end = p->pos;
return ok;
}
tgb_status json_parse(const char *src, size_t len, jnode **root) {
parser p = {src, len, 0, 0, 0, {0}};
jnode *r = calloc(1, sizeof *r);
if (!r) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para analizar JSON");
if (!parse_value(&p, r)) {
size_t where = p.pos;
free_node(r);
free(r);
return tgb_fail(TGB_ERR_FORMAT, "JSON no válido (posición %zu): %s", where, p.err[0] ? p.err : "error desconocido");
}
skip_ws(&p);
if (p.pos != len) {
free_node(r); free(r);
return tgb_fail(TGB_ERR_FORMAT, "JSON no válido (posición %zu): hay texto después del documento", p.pos);
}
*root = r;
return TGB_OK;
}
const jnode *json_get(const jnode *obj, const char *key) {
if (!obj || obj->t != J_OBJ) return NULL;
for (size_t i = 0; i < obj->n; i++) if (!strcmp(obj->keys[i], key)) return &obj->items[i];
return NULL;
}
bool json_is_int(const jnode *n, long long lo, long long hi, long long *out) {
if (!n || n->t != J_NUM) return false;
double v = n->num;
if (!(v >= -9007199254740992.0 && v <= 9007199254740992.0)) return false;
long long iv = (long long)v;
if ((double)iv != v || iv < lo || iv > hi) return false;
if (out) *out = iv;
return true;
}
bool json_str_eq(const jnode *n, const char *lit) {
return n && n->t == J_STR && n->slen == strlen(lit) && memcmp(n->s, lit, n->slen) == 0;
}
+250
View File
@@ -0,0 +1,250 @@
/* Lector y escritor de PNG (usa zlib solo para DEFLATE/CRC). */
#include <zlib.h>
#include "io_internal.h"
static uint32_t get_u32(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; }
void png_put_u32(uint8_t *p, uint32_t v) { p[0] = (uint8_t)(v >> 24); p[1] = (uint8_t)(v >> 16); p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; }
static const uint8_t SIG[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
void png_put_signature(tgb_sb *sb) { sb_append(sb, SIG, 8); }
void png_put_chunk(tgb_sb *sb, const char type[4], const uint8_t *data, size_t len) {
uint8_t hdr[8];
png_put_u32(hdr, (uint32_t)len);
memcpy(hdr + 4, type, 4);
sb_append(sb, hdr, 8);
if (len) sb_append(sb, data, len);
uLong crc = crc32(0, (const Bytef *)type, 4);
if (len) crc = crc32(crc, data, (uInt)len);
uint8_t c[4];
png_put_u32(c, (uint32_t)crc);
sb_append(sb, c, 4);
}
/* ================= decodificación ================= */
static int paeth(int a, int b, int c) {
int p = a + b - c, pa = abs(p - a), pb = abs(p - b), pc = abs(p - c);
return (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c);
}
static tgb_status bad(const char *msg) { return tgb_fail(TGB_ERR_CORRUPT, "PNG dañado: %s", msg); }
tgb_status tgb_png_decode_memory(const uint8_t *data, size_t len, tgb_canvas **out) {
if (!data || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_png_decode_memory: argumento nulo");
*out = NULL;
if (len < 8 || memcmp(data, SIG, 8) != 0) return tgb_fail(TGB_ERR_FORMAT, "No es una imagen PNG (firma incorrecta)");
size_t pos = 8;
uint32_t w = 0, h = 0;
int depth = 0, ctype = 0;
bool have_ihdr = false, have_iend = false;
uint8_t palette[256 * 3];
uint8_t pal_alpha[256];
size_t pal_n = 0;
bool have_trns = false;
uint16_t key[3] = {0, 0, 0};
uint8_t *idat = NULL;
size_t idat_len = 0, idat_cap = 0;
memset(pal_alpha, 255, sizeof pal_alpha);
tgb_status s = TGB_OK;
while (pos < len && !have_iend) {
if (len - pos < 12) { s = bad("chunk incompleto al final del archivo"); goto done; }
uint32_t clen = get_u32(data + pos);
const uint8_t *type = data + pos + 4;
if (clen > 0x7FFFFFFFu || (size_t)clen > len - pos - 12) { s = bad("longitud de chunk mayor que el archivo (¿truncado?)"); goto done; }
const uint8_t *cdata = data + pos + 8;
uLong crc = crc32(crc32(0, type, 4), cdata, clen);
if ((uint32_t)crc != get_u32(cdata + clen)) { s = bad("CRC incorrecto"); goto done; }
if (!have_ihdr && memcmp(type, "IHDR", 4) != 0) { s = bad("el primer chunk no es IHDR"); goto done; }
if (!memcmp(type, "IHDR", 4)) {
if (have_ihdr || clen != 13) { s = bad("IHDR no válido"); goto done; }
w = get_u32(cdata); h = get_u32(cdata + 4);
depth = cdata[8]; ctype = cdata[9];
if (cdata[10] != 0 || cdata[11] != 0) { s = tgb_fail(TGB_ERR_UNSUPPORTED, "PNG con método de compresión o filtro desconocido"); goto done; }
if (cdata[12] != 0) { s = tgb_fail(TGB_ERR_UNSUPPORTED, "PNG entrelazado (Adam7) no soportado todavía"); goto done; }
if (w == 0 || h == 0 || w > TGB_MAX_DIMENSION || h > TGB_MAX_DIMENSION) {
s = tgb_fail(TGB_ERR_LIMIT, "Imagen PNG de %ux%u fuera de los límites (máximo %d)", (unsigned)w, (unsigned)h, TGB_MAX_DIMENSION);
goto done;
}
bool ok = (ctype == 0 && (depth == 1 || depth == 2 || depth == 4 || depth == 8 || depth == 16)) ||
(ctype == 2 && (depth == 8 || depth == 16)) || (ctype == 3 && (depth == 1 || depth == 2 || depth == 4 || depth == 8)) ||
(ctype == 4 && (depth == 8 || depth == 16)) || (ctype == 6 && (depth == 8 || depth == 16));
if (!ok) { s = bad("combinación de tipo de color y profundidad no válida"); goto done; }
have_ihdr = true;
} else if (!memcmp(type, "PLTE", 4)) {
if (clen % 3 != 0 || clen == 0 || clen > 768) { s = bad("paleta no válida"); goto done; }
pal_n = clen / 3;
memcpy(palette, cdata, clen);
} else if (!memcmp(type, "tRNS", 4)) {
have_trns = true;
if (ctype == 3) { if (clen > 256) { s = bad("tRNS demasiado largo"); goto done; } memcpy(pal_alpha, cdata, clen); }
else if (ctype == 0 && clen == 2) key[0] = (uint16_t)((cdata[0] << 8) | cdata[1]);
else if (ctype == 2 && clen == 6) for (int i = 0; i < 3; i++) key[i] = (uint16_t)((cdata[i * 2] << 8) | cdata[i * 2 + 1]);
else if (ctype == 0 || ctype == 2) { s = bad("tRNS no válido"); goto done; }
} else if (!memcmp(type, "IDAT", 4)) {
size_t need = idat_len + clen;
if (need > TGB_MAX_FILE_BYTES) { s = tgb_fail(TGB_ERR_LIMIT, "Datos de imagen PNG demasiado grandes"); goto done; }
if (need > idat_cap) {
size_t nc = idat_cap ? idat_cap * 2 : 65536;
while (nc < need) nc *= 2;
uint8_t *ni = realloc(idat, nc);
if (!ni) { s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria para decodificar PNG"); goto done; }
idat = ni; idat_cap = nc;
}
memcpy(idat + idat_len, cdata, clen);
idat_len += clen;
} else if (!memcmp(type, "IEND", 4)) {
have_iend = true;
} else if ((type[0] & 0x20) == 0) { /* chunk crítico desconocido */
s = tgb_fail(TGB_ERR_UNSUPPORTED, "PNG con un chunk crítico desconocido '%.4s'", (const char *)type);
goto done;
}
pos += 12 + clen;
}
if (!have_ihdr) { s = bad("falta IHDR"); goto done; }
if (!have_iend) { s = bad("falta IEND: el archivo está truncado"); goto done; }
if (idat_len == 0) { s = bad("no contiene datos de imagen"); goto done; }
if (ctype == 3 && pal_n == 0) { s = bad("falta la paleta (PLTE)"); goto done; }
{
int samples = ctype == 0 ? 1 : ctype == 2 ? 3 : ctype == 3 ? 1 : ctype == 4 ? 2 : 4;
size_t bits_pp = (size_t)samples * (size_t)depth;
size_t rowb = ((size_t)w * bits_pp + 7) / 8;
size_t bpp = bits_pp < 8 ? 1 : bits_pp / 8;
size_t rawsz;
if (!tgb_mul_ok(rowb + 1, h, &rawsz)) { s = tgb_fail(TGB_ERR_LIMIT, "Imagen PNG demasiado grande"); goto done; }
uint8_t *raw = malloc(rawsz);
if (!raw) { s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria para decodificar PNG"); goto done; }
uLongf dest = (uLongf)rawsz;
int zr = uncompress(raw, &dest, idat, (uLong)idat_len);
if (zr == Z_BUF_ERROR) { free(raw); s = bad("datos comprimidos incompletos o con más contenido del esperado"); goto done; }
if (zr != Z_OK) { free(raw); s = bad("datos comprimidos (zlib) corruptos"); goto done; }
if (dest != rawsz) { free(raw); s = bad("los datos de imagen son más cortos de lo esperado"); goto done; }
for (uint32_t y = 0; y < h; y++) {
uint8_t *row = raw + (size_t)y * (rowb + 1) + 1;
uint8_t ft = row[-1];
const uint8_t *up = y ? row - (rowb + 1) : NULL;
if (ft > 4) { free(raw); s = bad("tipo de filtro de fila desconocido"); goto done; }
for (size_t i = 0; i < rowb; i++) {
int a = i >= bpp ? row[i - bpp] : 0, b = up ? up[i] : 0, c = (up && i >= bpp) ? up[i - bpp] : 0;
switch (ft) {
case 1: row[i] = (uint8_t)(row[i] + a); break;
case 2: row[i] = (uint8_t)(row[i] + b); break;
case 3: row[i] = (uint8_t)(row[i] + (a + b) / 2); break;
case 4: row[i] = (uint8_t)(row[i] + paeth(a, b, c)); break;
default: break;
}
}
}
tgb_canvas *cv = tgb_canvas_create((int)w, (int)h);
if (!cv) { free(raw); s = TGB_ERR_NOMEM; goto done; }
uint8_t *px = tgb_canvas_pixels_mut(cv);
unsigned maxv = (1u << (depth > 8 ? 8 : depth)) - 1u;
for (uint32_t y = 0; y < h; y++) {
const uint8_t *row = raw + (size_t)y * (rowb + 1) + 1;
uint8_t *o = px + (size_t)y * w * 4;
for (uint32_t x = 0; x < w; x++, o += 4) {
uint8_t r, g, b, a = 255;
if (ctype == 6) {
size_t k = (size_t)x * 4 * (depth / 8) ;
if (depth == 8) { r = row[k]; g = row[k + 1]; b = row[k + 2]; a = row[k + 3]; }
else { r = row[k]; g = row[k + 2]; b = row[k + 4]; a = row[k + 6]; }
} else if (ctype == 2) {
size_t k = (size_t)x * 3 * (depth / 8);
uint16_t vr, vg, vb;
if (depth == 8) { vr = row[k]; vg = row[k + 1]; vb = row[k + 2]; r = row[k]; g = row[k + 1]; b = row[k + 2]; }
else { vr = (uint16_t)((row[k] << 8) | row[k + 1]); vg = (uint16_t)((row[k + 2] << 8) | row[k + 3]); vb = (uint16_t)((row[k + 4] << 8) | row[k + 5]); r = row[k]; g = row[k + 2]; b = row[k + 4]; }
if (have_trns && vr == key[0] && vg == key[1] && vb == key[2]) a = 0;
} else if (ctype == 4) {
size_t k = (size_t)x * 2 * (depth / 8);
if (depth == 8) { r = g = b = row[k]; a = row[k + 1]; }
else { r = g = b = row[k]; a = row[k + 2]; }
} else { /* tipos 0 (gris) y 3 (paleta), cualquier profundidad */
unsigned v;
if (depth == 16) v = (unsigned)((row[x * 2] << 8) | row[x * 2 + 1]);
else if (depth == 8) v = row[x];
else { size_t bit = (size_t)x * (size_t)depth; v = (row[bit / 8] >> (8 - depth - (int)(bit % 8))) & maxv; }
if (ctype == 3) {
if (v >= pal_n) { tgb_canvas_release(cv); free(raw); s = bad("índice de paleta fuera de rango"); goto done; }
r = palette[v * 3]; g = palette[v * 3 + 1]; b = palette[v * 3 + 2]; a = pal_alpha[v];
} else {
unsigned g8 = depth == 16 ? (v >> 8) : (v * 255u / maxv);
r = g = b = (uint8_t)g8;
if (have_trns && v == key[0]) a = 0;
}
}
if (a == 0) { o[0] = o[1] = o[2] = o[3] = 0; } /* forma canónica */
else { o[0] = r; o[1] = g; o[2] = b; o[3] = a; }
}
}
free(raw);
*out = cv;
}
done:
free(idat);
return s;
}
/* ================= codificación ================= */
static unsigned filter_cost(const uint8_t *row, size_t n) {
unsigned sum = 0;
for (size_t i = 0; i < n; i++) { int v = (int8_t)row[i]; sum += (unsigned)(v < 0 ? -v : v); }
return sum;
}
tgb_status png_compress_image(const tgb_canvas *c, uint8_t **out, size_t *len) {
size_t w = (size_t)tgb_canvas_width(c), h = (size_t)tgb_canvas_height(c), rowb = w * 4;
const uint8_t *px = tgb_canvas_pixels(c);
uint8_t *raw = malloc((rowb + 1) * h);
uint8_t *cand = malloc(rowb * 5);
if (!raw || !cand) { free(raw); free(cand); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para codificar PNG"); }
for (size_t y = 0; y < h; y++) {
const uint8_t *cur = px + y * rowb, *up = y ? px + (y - 1) * rowb : NULL;
for (size_t i = 0; i < rowb; i++) {
int a = i >= 4 ? cur[i - 4] : 0, b = up ? up[i] : 0, cc = (up && i >= 4) ? up[i - 4] : 0;
cand[i] = cur[i];
cand[rowb + i] = (uint8_t)(cur[i] - a);
cand[2 * rowb + i] = (uint8_t)(cur[i] - b);
cand[3 * rowb + i] = (uint8_t)(cur[i] - (a + b) / 2);
cand[4 * rowb + i] = (uint8_t)(cur[i] - paeth(a, b, cc));
}
int best = 0;
unsigned best_cost = filter_cost(cand, rowb);
for (int f = 1; f < 5; f++) { unsigned cost = filter_cost(cand + (size_t)f * rowb, rowb); if (cost < best_cost) { best_cost = cost; best = f; } }
raw[y * (rowb + 1)] = (uint8_t)best;
memcpy(raw + y * (rowb + 1) + 1, cand + (size_t)best * rowb, rowb);
}
free(cand);
uLongf zlen = compressBound((uLong)((rowb + 1) * h));
uint8_t *z = malloc(zlen);
if (!z) { free(raw); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para codificar PNG"); }
int r = compress2(z, &zlen, raw, (uLong)((rowb + 1) * h), 6);
free(raw);
if (r != Z_OK) { free(z); return tgb_fail(TGB_ERR_IO, "zlib no pudo comprimir la imagen (código %d)", r); }
*out = z;
*len = zlen;
return TGB_OK;
}
tgb_status tgb_png_encode_memory(const tgb_canvas *canvas, uint8_t **out_data, size_t *out_len) {
if (!canvas || !out_data || !out_len) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_png_encode_memory: argumento nulo");
uint8_t *z; size_t zl;
tgb_status s = png_compress_image(canvas, &z, &zl);
if (TGB_FAILED(s)) return s;
tgb_sb sb; sb_init(&sb);
uint8_t ihdr[13];
png_put_u32(ihdr, (uint32_t)tgb_canvas_width(canvas));
png_put_u32(ihdr + 4, (uint32_t)tgb_canvas_height(canvas));
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
png_put_signature(&sb);
png_put_chunk(&sb, "IHDR", ihdr, 13);
png_put_chunk(&sb, "IDAT", z, zl);
png_put_chunk(&sb, "IEND", NULL, 0);
free(z);
if (sb.oom) { sb_free(&sb); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para codificar PNG"); }
*out_data = (uint8_t *)sb_take(&sb, out_len);
return TGB_OK;
}
void tgb_free(void *p) { free(p); }
+464
View File
@@ -0,0 +1,464 @@
/* Lectura y escritura de proyectos .tgb (formatos 2 y 3). Ver docs/FORMAT.md. */
#include <stdio.h>
#include "io_internal.h"
#include "../document/doc_internal.h"
#include "../history/history_internal.h"
#include "../platform/platform.h"
static const char *KNOWN_KEYS[] = {"format", "version", "app", "fps", "width", "height", "frames", "tgb3"};
static bool is_known_key(const char *k) {
for (size_t i = 0; i < sizeof KNOWN_KEYS / sizeof KNOWN_KEYS[0]; i++) if (!strcmp(k, KNOWN_KEYS[i])) return true;
return false;
}
/* ====================================================================== */
/* LECTURA */
/* ====================================================================== */
typedef struct {
tgb_canvas **imgs;
size_t n;
} pool_t;
static void pool_free(pool_t *p) {
for (size_t i = 0; i < p->n; i++) tgb_canvas_release(p->imgs[i]);
free(p->imgs);
memset(p, 0, sizeof *p);
}
static tgb_status decode_image(const jnode *s, int w, int h, tgb_canvas **out, const char *what, size_t index) {
if (!s || s->t != J_STR) return tgb_fail(TGB_ERR_FORMAT, "%s %zu: se esperaba una cadena Base64", what, index);
uint8_t *png = NULL; size_t pl = 0;
tgb_status st = tgb_b64_decode(s->s, s->slen, TGB_MAX_FILE_BYTES, &png, &pl);
if (TGB_FAILED(st)) { char m[400]; snprintf(m, sizeof m, "%s", tgb_last_error()); return tgb_fail(st, "%s %zu: %s", what, index, m); }
tgb_canvas *c = NULL;
st = tgb_png_decode_memory(png, pl, &c);
free(png);
if (TGB_FAILED(st)) { char m[400]; snprintf(m, sizeof m, "%s", tgb_last_error()); return tgb_fail(st, "%s %zu: %s", what, index, m); }
if (tgb_canvas_width(c) != w || tgb_canvas_height(c) != h) {
int cw = tgb_canvas_width(c), ch = tgb_canvas_height(c);
tgb_canvas_release(c);
return tgb_fail(TGB_ERR_FORMAT, "%s %zu mide %dx%d pero el documento declara %dx%d", what, index, cw, ch, w, h);
}
*out = c;
return TGB_OK;
}
static bool valid_name(const jnode *n, size_t maxlen) {
return n && n->t == J_STR && n->slen > 0 && n->slen < maxlen && memchr(n->s, '\0', n->slen) == NULL && tgb_utf8_valid(n->s, n->slen);
}
static tgb_status parse_layers(const jnode *arr, tgb_layer **out, size_t *count) {
if (!arr || arr->t != J_ARR || arr->n < 1 || arr->n > TGB_MAX_LAYERS)
return tgb_fail(TGB_ERR_FORMAT, "'layers' debe ser una lista de 1 a %d capas", TGB_MAX_LAYERS);
tgb_layer *l = calloc(arr->n, sizeof *l);
if (!l) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
for (size_t i = 0; i < arr->n; i++) {
const jnode *o = &arr->items[i];
long long id, op = 255;
if (o->t != J_OBJ) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capa %zu: se esperaba un objeto", i); }
if (!json_is_int(json_get(o, "id"), 1, 0xFFFFFFF0LL, &id)) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capa %zu: 'id' no válido", i); }
const jnode *nm = json_get(o, "name"), *vis = json_get(o, "visible"), *lk = json_get(o, "locked"), *opn = json_get(o, "opacity");
if (!valid_name(nm, TGB_NAME_MAX)) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capa %zu: 'name' ausente o no válido", i); }
if ((vis && vis->t != J_BOOL) || (lk && lk->t != J_BOOL)) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capa %zu: 'visible'/'locked' deben ser booleanos", i); }
if (opn && !json_is_int(opn, 0, 255, &op)) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capa %zu: 'opacity' debe ser un entero de 0 a 255", i); }
for (size_t k = 0; k < i; k++) if (l[k].id == (uint32_t)id) { free(l); return tgb_fail(TGB_ERR_FORMAT, "Capas con id repetido (%lld)", id); }
l[i].id = (uint32_t)id;
memcpy(l[i].name, nm->s, nm->slen);
l[i].visible = vis ? vis->b : true;
l[i].locked = lk ? lk->b : false;
l[i].opacity = (uint8_t)op;
}
*out = l;
*count = arr->n;
return TGB_OK;
}
static tgb_status parse_frames(const jnode *arr, size_t nlayers, const pool_t *pool, tgb_frame **out, size_t *count) {
if (!arr || arr->t != J_ARR || arr->n < 1 || arr->n > TGB_MAX_FRAMES)
return tgb_fail(TGB_ERR_FORMAT, "'frames' debe ser una lista de 1 a %d fotogramas", TGB_MAX_FRAMES);
tgb_frame *f = calloc(arr->n, sizeof *f);
if (!f) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
tgb_status s = TGB_OK;
size_t built = 0;
for (size_t i = 0; i < arr->n && s == TGB_OK; i++) {
const jnode *o = &arr->items[i], *cels;
long long id, ms;
if (o->t != J_OBJ) { s = tgb_fail(TGB_ERR_FORMAT, "Fotograma %zu: se esperaba un objeto", i); break; }
if (!json_is_int(json_get(o, "id"), 1, 0xFFFFFFF0LL, &id)) { s = tgb_fail(TGB_ERR_FORMAT, "Fotograma %zu: 'id' no válido", i); break; }
if (!json_is_int(json_get(o, "duration_ms"), TGB_MIN_FRAME_MS, TGB_MAX_FRAME_MS, &ms)) { s = tgb_fail(TGB_ERR_FORMAT, "Fotograma %zu: 'duration_ms' debe estar entre %d y %d", i, TGB_MIN_FRAME_MS, TGB_MAX_FRAME_MS); break; }
cels = json_get(o, "cels");
if (!cels || cels->t != J_ARR || cels->n != nlayers) { s = tgb_fail(TGB_ERR_FORMAT, "Fotograma %zu: 'cels' debe tener una entrada por capa (%zu)", i, nlayers); break; }
for (size_t k = 0; k < i; k++) if (f[k].id == (uint32_t)id) { s = tgb_fail(TGB_ERR_FORMAT, "Fotogramas con id repetido (%lld)", id); break; }
if (s != TGB_OK) break;
f[i].cels = calloc(nlayers, sizeof *f[i].cels);
if (!f[i].cels) { s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); break; }
built = i + 1;
f[i].id = (uint32_t)id;
f[i].duration_ms = (uint32_t)ms;
for (size_t j = 0; j < nlayers; j++) {
const jnode *c = &cels->items[j];
long long idx;
if (c->t == J_NULL) continue;
if (!json_is_int(c, 0, (long long)pool->n - 1, &idx)) { s = tgb_fail(TGB_ERR_FORMAT, "Fotograma %zu, capa %zu: referencia a imagen inexistente", i, j); break; }
f[i].cels[j] = tgb_canvas_retain(pool->imgs[idx]);
}
}
if (s != TGB_OK) {
for (size_t i = 0; i < built; i++) doc_frame_free_cels(f[i].cels, nlayers);
free(f);
return s;
}
*out = f;
*count = arr->n;
return TGB_OK;
}
static tgb_status parse_state(const jnode *obj, const pool_t *pool, int default_fps, tgb_state *st) {
memset(st, 0, sizeof *st);
long long fps = default_fps;
const jnode *fp = json_get(obj, "fps");
if (fp && !json_is_int(fp, TGB_MIN_FPS, TGB_MAX_FPS, &fps)) return tgb_fail(TGB_ERR_FORMAT, "'fps' de una instantánea fuera de rango");
tgb_status s = parse_layers(json_get(obj, "layers"), &st->layers, &st->nlayers);
if (TGB_FAILED(s)) return s;
s = parse_frames(json_get(obj, "frames"), st->nlayers, pool, &st->frames, &st->nframes);
if (TGB_FAILED(s)) { free(st->layers); memset(st, 0, sizeof *st); return s; }
st->fps = (int)fps;
return TGB_OK;
}
static void track_max(uint32_t *mx, const tgb_state *st) {
for (size_t i = 0; i < st->nlayers; i++) if (st->layers[i].id > *mx) *mx = st->layers[i].id;
for (size_t i = 0; i < st->nframes; i++) if (st->frames[i].id > *mx) *mx = st->frames[i].id;
}
static tgb_status add_extra(tgb_document *doc, const char *key, const char *text, size_t start, size_t end) {
void *ne = realloc(doc->extra, (doc->nextra + 1) * sizeof doc->extra[0]);
if (!ne) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
doc->extra = ne;
char *k = tgb_strdup(key), *raw = malloc(end - start + 1);
if (!k || !raw) { free(k); free(raw); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
memcpy(raw, text + start, end - start);
raw[end - start] = '\0';
doc->extra[doc->nextra].key = k;
doc->extra[doc->nextra].raw = raw;
doc->nextra++;
return TGB_OK;
}
static tgb_status load_points(tgb_document *doc, const jnode *arr, const char *text, const pool_t *pool, uint32_t *max_id) {
if (!arr) return TGB_OK;
if (arr->t != J_ARR) return tgb_fail(TGB_ERR_FORMAT, "'recovery_points' debe ser una lista");
if (arr->n > TGB_MAX_RECOVERY_POINTS) return tgb_fail(TGB_ERR_LIMIT, "El archivo tiene %zu puntos de recuperación (máximo %d)", arr->n, TGB_MAX_RECOVERY_POINTS);
if (arr->n == 0) return TGB_OK;
doc->rpoints = calloc(arr->n, sizeof *doc->rpoints);
if (!doc->rpoints) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
doc->caprpoints = arr->n;
uint32_t max_pid = 0;
for (size_t i = 0; i < arr->n; i++) {
const jnode *e = &arr->items[i];
tgb_rpoint *p = &doc->rpoints[doc->nrpoints];
long long id = 0, created = 0, ver = 0;
bool idok = e->t == J_OBJ && json_is_int(json_get(e, "id"), 1, 0xFFFFFFF0LL, &id);
for (size_t k = 0; idok && k < doc->nrpoints; k++) if (doc->rpoints[k].id == (uint32_t)id) idok = false;
p->id = idok ? (uint32_t)id : 0;
const jnode *nm = e->t == J_OBJ ? json_get(e, "name") : NULL;
if (nm && nm->t == J_STR && nm->slen > 0 && nm->slen < TGB_RECOVERY_POINT_NAME_MAX && tgb_utf8_valid(nm->s, nm->slen)) { memcpy(p->name, nm->s, nm->slen); p->name[nm->slen] = '\0'; }
else tgb_strlcpy(p->name, "(sin nombre)", sizeof p->name);
if (e->t == J_OBJ) json_is_int(json_get(e, "created"), 0, 4102444800LL, &created);
p->created = created;
bool compatible = false;
if (e->t == J_OBJ && json_is_int(json_get(e, "state_version"), 0, 1000000, &ver) && ver == 1) {
const jnode *stn = json_get(e, "state");
if (stn && stn->t == J_OBJ && TGB_SUCCEEDED(parse_state(stn, pool, doc->fps, &p->state))) compatible = true;
}
p->compatible = compatible;
if (compatible) track_max(max_id, &p->state);
else { /* formato desconocido o dañado: se conserva el texto original sin tocarlo */
size_t n = e->end - e->start;
p->raw_json = malloc(n + 1);
if (!p->raw_json) { doc->nrpoints++; return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
memcpy(p->raw_json, text + e->start, n);
p->raw_json[n] = '\0';
}
if (p->id > max_pid) max_pid = p->id;
doc->nrpoints++;
}
for (size_t i = 0; i < doc->nrpoints; i++) /* asignar ids a los que no tenían uno válido */
if (doc->rpoints[i].id == 0) doc->rpoints[i].id = ++max_pid;
doc->next_rpoint_id = max_pid + 1;
return TGB_OK;
}
tgb_status tgb_document_load_memory(tgb_context *ctx, const uint8_t *data, size_t len, tgb_document **out) {
if (!ctx || !data || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_document_load_memory: argumento nulo");
*out = NULL;
if (len > TGB_MAX_FILE_BYTES) return tgb_fail(TGB_ERR_LIMIT, "El archivo es demasiado grande (%zu bytes)", len);
const char *text = (const char *)data;
if (len >= 3 && !memcmp(data, "\xEF\xBB\xBF", 3)) { text += 3; len -= 3; } /* BOM UTF-8 */
if (len == 0) return tgb_fail(TGB_ERR_FORMAT, "El archivo está vacío");
jnode *root = NULL;
tgb_status s = json_parse(text, len, &root);
if (TGB_FAILED(s)) return s;
pool_t pool = {0};
tgb_state st;
memset(&st, 0, sizeof st);
tgb_document *doc = NULL;
long long ver, w, h, fps;
uint32_t max_id = 0;
if (root->t != J_OBJ) { s = tgb_fail(TGB_ERR_FORMAT, "La raíz del archivo debe ser un objeto JSON"); goto fail; }
const jnode *fmt = json_get(root, "format");
if (!fmt || fmt->t != J_STR) { s = tgb_fail(TGB_ERR_FORMAT, "No es un archivo de Tahgibu: falta el campo 'format'"); goto fail; }
if (!json_str_eq(fmt, "TAHGIBU")) { s = tgb_fail(TGB_ERR_FORMAT, "No es un archivo de Tahgibu: 'format' vale '%.*s'", (int)(fmt->slen > 40 ? 40 : fmt->slen), fmt->s); goto fail; }
if (!json_is_int(json_get(root, "version"), -1000000, 1000000, &ver)) { s = tgb_fail(TGB_ERR_FORMAT, "Falta el campo 'version' o no es un entero"); goto fail; }
if (ver > TGB_FORMAT_VERSION_CURRENT) { s = tgb_fail(TGB_ERR_INCOMPATIBLE, "El archivo usa la versión de formato %lld, más nueva que la que entiende esta versión de Tahgibu (%d). Actualiza Tahgibu.", ver, TGB_FORMAT_VERSION_CURRENT); goto fail; }
if (ver < TGB_FORMAT_VERSION_LEGACY) { s = tgb_fail(TGB_ERR_UNSUPPORTED, "Versión de formato %lld desconocida (se soportan %d y %d)", ver, TGB_FORMAT_VERSION_LEGACY, TGB_FORMAT_VERSION_CURRENT); goto fail; }
if (!json_is_int(json_get(root, "width"), 1, TGB_MAX_DIMENSION, &w) || !json_is_int(json_get(root, "height"), 1, TGB_MAX_DIMENSION, &h)) {
s = tgb_fail(TGB_ERR_RANGE, "'width' y 'height' deben ser enteros entre 1 y %d", TGB_MAX_DIMENSION); goto fail; }
if (!json_is_int(json_get(root, "fps"), TGB_MIN_FPS, TGB_MAX_FPS, &fps)) { s = tgb_fail(TGB_ERR_RANGE, "'fps' debe ser un entero entre %d y %d", TGB_MIN_FPS, TGB_MAX_FPS); goto fail; }
const jnode *flat = json_get(root, "frames");
const jnode *t3 = json_get(root, "tgb3");
size_t cel_bytes = (size_t)w * (size_t)h * 4u;
if (ver == TGB_FORMAT_VERSION_LEGACY) {
if (!flat || flat->t != J_ARR || flat->n < 1 || flat->n > TGB_MAX_FRAMES) { s = tgb_fail(TGB_ERR_FORMAT, "'frames' debe ser una lista de 1 a %d imágenes", TGB_MAX_FRAMES); goto fail; }
size_t total;
if (!tgb_mul_ok(cel_bytes, flat->n, &total) || total > TGB_MAX_PIXEL_MEMORY) { s = tgb_fail(TGB_ERR_LIMIT, "El proyecto necesitaría demasiada memoria (%zu fotogramas de %lldx%lld)", flat->n, w, h); goto fail; }
st.layers = calloc(1, sizeof *st.layers);
st.frames = calloc(flat->n, sizeof *st.frames);
if (!st.layers || !st.frames) { free(st.layers); free(st.frames); memset(&st, 0, sizeof st); s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); goto fail; }
st.nlayers = 1; st.nframes = 0; st.fps = (int)fps;
st.layers[0].id = 1; tgb_strlcpy(st.layers[0].name, "Capa 1", TGB_NAME_MAX); st.layers[0].visible = true; st.layers[0].opacity = 255;
for (size_t i = 0; i < flat->n; i++) {
tgb_canvas *c = NULL;
s = decode_image(&flat->items[i], (int)w, (int)h, &c, "Fotograma", i);
if (TGB_FAILED(s)) goto fail;
st.frames[i].cels = calloc(1, sizeof(tgb_canvas *));
if (!st.frames[i].cels) { tgb_canvas_release(c); s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); goto fail; }
st.nframes = i + 1;
st.frames[i].id = (uint32_t)i + 2;
st.frames[i].duration_ms = doc_default_duration_ms((int)fps);
if (tgb_canvas_is_empty(c)) tgb_canvas_release(c); else st.frames[i].cels[0] = c;
}
max_id = (uint32_t)flat->n + 1;
} else {
if (!t3 || t3->t != J_OBJ) { s = tgb_fail(TGB_ERR_FORMAT, "Falta el bloque 'tgb3' del formato %d", TGB_FORMAT_VERSION_CURRENT); goto fail; }
if (flat && flat->t != J_ARR) { s = tgb_fail(TGB_ERR_FORMAT, "'frames' debe ser una lista"); goto fail; }
const jnode *imgs = json_get(t3, "images");
if (!imgs || imgs->t != J_ARR) { s = tgb_fail(TGB_ERR_FORMAT, "'tgb3.images' debe ser una lista"); goto fail; }
size_t total;
if (!tgb_mul_ok(cel_bytes, imgs->n, &total) || total > TGB_MAX_PIXEL_MEMORY) { s = tgb_fail(TGB_ERR_LIMIT, "El proyecto necesitaría demasiada memoria (%zu imágenes de %lldx%lld)", imgs->n, w, h); goto fail; }
pool.imgs = calloc(imgs->n ? imgs->n : 1, sizeof *pool.imgs);
if (!pool.imgs) { s = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); goto fail; }
for (size_t i = 0; i < imgs->n; i++) {
s = decode_image(&imgs->items[i], (int)w, (int)h, &pool.imgs[i], "Imagen", i);
if (TGB_FAILED(s)) goto fail;
pool.n = i + 1;
}
s = parse_state(t3, &pool, (int)fps, &st);
if (TGB_FAILED(s)) goto fail;
track_max(&max_id, &st);
}
s = tgb_document_create(ctx, (int)w, (int)h, (int)fps, &doc);
if (TGB_FAILED(s)) goto fail;
s = state_apply(doc, &st);
if (TGB_FAILED(s)) goto fail;
state_free(&st);
if (ver == TGB_FORMAT_VERSION_CURRENT) {
s = load_points(doc, json_get(t3, "recovery_points"), text, &pool, &max_id);
if (TGB_FAILED(s)) goto fail;
long long nid;
if (json_is_int(json_get(t3, "next_id"), 1, 0xFFFFFFF0LL, &nid) && (uint32_t)nid > max_id) max_id = (uint32_t)nid - 1;
}
doc->next_id = max_id + 1;
/* campos desconocidos: se conservan tal cual para no perder datos de otras herramientas */
for (size_t i = 0; i < root->n; i++)
if (!is_known_key(root->keys[i])) {
s = add_extra(doc, root->keys[i], text, root->items[i].start, root->items[i].end);
if (TGB_FAILED(s)) goto fail;
}
pool_free(&pool);
json_free(root);
*out = doc;
return TGB_OK;
fail:
state_free(&st);
pool_free(&pool);
json_free(root);
tgb_document_destroy(doc);
return s;
}
tgb_status tgb_document_load(tgb_context *ctx, const char *path, tgb_document **out) {
if (!ctx || !path || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_document_load: argumento nulo");
*out = NULL;
uint8_t *buf; size_t len;
tgb_status s = tgb_plat_read_file(path, &buf, &len, TGB_MAX_FILE_BYTES);
if (TGB_FAILED(s)) return s;
tgb_document *doc = NULL;
s = tgb_document_load_memory(ctx, buf, len, &doc);
free(buf);
if (TGB_FAILED(s)) {
char m[400]; snprintf(m, sizeof m, "%s", tgb_last_error());
return tgb_fail(s, "No se pudo abrir '%s': %s", path, m);
}
s = tgb_document_set_path(doc, path);
if (TGB_FAILED(s)) { tgb_document_destroy(doc); return s; }
*out = doc;
return TGB_OK;
}
/* ====================================================================== */
/* ESCRITURA */
/* ====================================================================== */
void tgb_save_options_defaults(tgb_save_options *o) {
if (!o) return;
o->include_flat_frames = true;
o->legacy_v2 = false;
}
static int cmp_cptr(const void *a, const void *b) {
const void *x = *(const void *const *)a, *y = *(const void *const *)b;
return (x > y) - (x < y);
}
static bool put_b64_png(tgb_sb *sb, const tgb_canvas *c, tgb_status *st) {
uint8_t *png; size_t pl;
*st = tgb_png_encode_memory(c, &png, &pl);
if (TGB_FAILED(*st)) return false;
size_t el = tgb_b64_encoded_len(pl);
char *b = malloc(el + 1);
if (!b) { free(png); *st = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); return false; }
tgb_b64_encode(png, pl, b);
free(png);
sb_append(sb, "\"", 1);
sb_append(sb, b, el);
sb_append(sb, "\"", 1);
free(b);
return true;
}
static void write_state_json(tgb_sb *sb, const tgb_layer *layers, size_t nl, const tgb_frame *frames, size_t nf,
const void *const *pool, size_t npool) {
sb_puts(sb, "\"layers\":[");
for (size_t i = 0; i < nl; i++) {
if (i) sb_puts(sb, ",");
sb_printf(sb, "{\"id\":%u,\"name\":", (unsigned)layers[i].id);
sb_json_string(sb, layers[i].name, strlen(layers[i].name));
sb_printf(sb, ",\"visible\":%s,\"locked\":%s,\"opacity\":%u}", layers[i].visible ? "true" : "false", layers[i].locked ? "true" : "false", (unsigned)layers[i].opacity);
}
sb_puts(sb, "],\"frames\":[");
for (size_t i = 0; i < nf; i++) {
if (i) sb_puts(sb, ",");
sb_printf(sb, "{\"id\":%u,\"duration_ms\":%u,\"cels\":[", (unsigned)frames[i].id, (unsigned)frames[i].duration_ms);
for (size_t j = 0; j < nl; j++) {
if (j) sb_puts(sb, ",");
const void *c = frames[i].cels[j];
void **hit = c ? bsearch(&c, pool, npool, sizeof *pool, cmp_cptr) : NULL;
if (hit) sb_printf(sb, "%zu", (size_t)((const void *const *)hit - pool)); else sb_puts(sb, "null");
}
sb_puts(sb, "]}");
}
sb_puts(sb, "]");
}
tgb_status tgb_document_save_memory(const tgb_document *doc, const tgb_save_options *opts_in, uint8_t **out_data, size_t *out_len) {
if (!doc || !out_data || !out_len) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_document_save_memory: argumento nulo");
tgb_save_options opts;
if (opts_in) opts = *opts_in; else tgb_save_options_defaults(&opts);
if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "Cierra la sesión de edición antes de guardar");
tgb_status st = TGB_OK;
tgb_sb sb; sb_init(&sb);
const void **pool = NULL;
size_t npool = 0;
tgb_canvas *tmp = NULL;
sb_puts(&sb, "{\"format\":\"TAHGIBU\",");
sb_printf(&sb, "\"version\":%d,\"app\":\"%s %s\",\"fps\":%d,\"width\":%d,\"height\":%d", opts.legacy_v2 ? TGB_FORMAT_VERSION_LEGACY : TGB_FORMAT_VERSION_CURRENT,
TGB_APP_NAME, TGB_VERSION_STRING, doc->fps, doc->width, doc->height);
for (size_t i = 0; i < doc->nextra; i++) { /* campos desconocidos conservados */
sb_puts(&sb, ",");
sb_json_string(&sb, doc->extra[i].key, strlen(doc->extra[i].key));
sb_puts(&sb, ":");
sb_puts(&sb, doc->extra[i].raw);
}
bool want_flat = opts.legacy_v2 || opts.include_flat_frames;
if (want_flat) {
tmp = tgb_canvas_create(doc->width, doc->height);
if (!tmp) { st = TGB_ERR_NOMEM; goto done; }
sb_puts(&sb, ",\"frames\":[");
for (size_t i = 0; i < doc->nframes; i++) {
if (i) sb_puts(&sb, ",");
st = tgb_render_flatten(doc, i, tmp);
if (TGB_FAILED(st) || !put_b64_png(&sb, tmp, &st)) goto done;
}
sb_puts(&sb, "]");
}
if (!opts.legacy_v2) {
size_t cap = doc->nframes * doc->nlayers;
for (size_t k = 0; k < doc->nrpoints; k++) if (doc->rpoints[k].compatible) cap += doc->rpoints[k].state.nframes * doc->rpoints[k].state.nlayers;
pool = malloc((cap ? cap : 1) * sizeof *pool);
if (!pool) { st = tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); goto done; }
size_t n = 0;
for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j] && !tgb_canvas_is_empty(doc->frames[i].cels[j])) pool[n++] = doc->frames[i].cels[j];
for (size_t k = 0; k < doc->nrpoints; k++) {
if (!doc->rpoints[k].compatible) continue;
const tgb_state *ps = &doc->rpoints[k].state;
for (size_t i = 0; i < ps->nframes; i++) for (size_t j = 0; j < ps->nlayers; j++) if (ps->frames[i].cels[j] && !tgb_canvas_is_empty(ps->frames[i].cels[j])) pool[n++] = ps->frames[i].cels[j];
}
qsort(pool, n, sizeof *pool, cmp_cptr);
for (size_t i = 0; i < n; i++) if (npool == 0 || pool[npool - 1] != pool[i]) pool[npool++] = pool[i]; /* sin repetidos: cada celda compartida se guarda UNA vez */
sb_puts(&sb, ",\"tgb3\":{");
write_state_json(&sb, doc->layers, doc->nlayers, doc->frames, doc->nframes, pool, npool);
sb_printf(&sb, ",\"next_id\":%u,\"images\":[", (unsigned)doc->next_id);
for (size_t i = 0; i < npool; i++) {
if (i) sb_puts(&sb, ",");
if (!put_b64_png(&sb, pool[i], &st)) goto done;
}
sb_puts(&sb, "],\"recovery_points\":[");
for (size_t k = 0; k < doc->nrpoints; k++) {
const tgb_rpoint *p = &doc->rpoints[k];
if (k) sb_puts(&sb, ",");
if (!p->compatible) { sb_puts(&sb, p->raw_json ? p->raw_json : "null"); continue; }
sb_printf(&sb, "{\"id\":%u,\"name\":", (unsigned)p->id);
sb_json_string(&sb, p->name, strlen(p->name));
sb_printf(&sb, ",\"created\":%lld,\"state_version\":1,\"state\":{\"fps\":%d,", (long long)p->created, p->state.fps);
write_state_json(&sb, p->state.layers, p->state.nlayers, p->state.frames, p->state.nframes, pool, npool);
sb_puts(&sb, "}}");
}
sb_puts(&sb, "]}");
}
sb_puts(&sb, "}\n");
if (sb.oom) { st = tgb_fail(TGB_ERR_NOMEM, "Sin memoria al preparar el archivo"); goto done; }
if (sb.len > TGB_MAX_FILE_BYTES) { st = tgb_fail(TGB_ERR_LIMIT, "El proyecto es demasiado grande para guardarse (%zu bytes)", sb.len); goto done; }
*out_data = (uint8_t *)sb_take(&sb, out_len);
if (!*out_data) st = tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
done:
tgb_canvas_release(tmp);
free(pool);
sb_free(&sb);
return st;
}
tgb_status tgb_document_save(tgb_document *doc, const char *path, const tgb_save_options *opts) {
if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo");
const char *target = path ? path : doc->path;
if (!target || !target[0]) return tgb_fail(TGB_ERR_INVALID_ARG, "El documento no tiene ruta: indica dónde guardarlo");
uint8_t *data; size_t len;
tgb_status s = tgb_document_save_memory(doc, opts, &data, &len);
if (TGB_FAILED(s)) return s;
s = tgb_plat_write_atomic(target, data, len); /* si falla, el archivo anterior queda intacto */
free(data);
if (TGB_FAILED(s)) return s;
if (opts && opts->legacy_v2) return TGB_OK; /* exportación: el proyecto "actual" sigue siendo el de formato 3 */
s = tgb_document_set_path(doc, target); /* OJO: 'target' puede ser doc->path, que esta llamada libera */
if (TGB_FAILED(s)) return s;
tgb_document_mark_saved(doc);
tgb_event ev = {TGB_EVENT_DOCUMENT_SAVED, doc, 0, 0, {0, 0, 0, 0}, doc->path};
tgb_emit(doc->ctx, &ev);
return TGB_OK;
}
+46
View File
@@ -0,0 +1,46 @@
/* Tahgibu 2.0.0: punto de entrada.
* Uso: tahgibu [--version] [--safe-mode] [archivo.tgb] */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "tahgibu/tahgibu.h"
#include "ui/ui_run.h"
int main(int argc, char **argv) {
const char *open_path = NULL;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--version")) { printf("%s %s (API %d, formato .tgb %d)\n", TGB_APP_NAME, TGB_VERSION_STRING, TGB_API_VERSION, TGB_FORMAT_VERSION_CURRENT); return 0; }
if (!strcmp(argv[i], "--safe-mode")) {
#ifdef _WIN32
_putenv_s("TGB_SAFE_MODE", "1");
#else
setenv("TGB_SAFE_MODE", "1", 1);
#endif
continue;
}
if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) {
printf("Uso: %s [--version] [--safe-mode] [archivo.tgb]\n --safe-mode arranca sin cargar ninguna extension\n", argv[0]);
return 0;
}
if (argv[i][0] == '-') { fprintf(stderr, "Opcion desconocida: %s\n", argv[i]); return 2; }
open_path = argv[i];
}
tgb_context *ctx = NULL;
if (TGB_FAILED(tgb_context_create(NULL, &ctx))) { fprintf(stderr, "No se pudo iniciar Tahgibu: %s\n", tgb_last_error()); return 1; }
tgb_recovery *rec = NULL;
if (TGB_FAILED(tgb_recovery_open(ctx, &rec))) { /* sin recuperación se puede trabajar, pero se avisa */
fprintf(stderr, "Aviso: la recuperacion de sesiones esta desactivada: %s\n", tgb_last_error());
rec = NULL;
}
tgb_plugin_manager *plugins = NULL;
if (TGB_FAILED(tgb_plugin_manager_create(ctx, &plugins))) {
fprintf(stderr, "Aviso: las extensiones estan desactivadas: %s\n", tgb_last_error());
plugins = NULL;
}
int rc = tgb_ui_run(ctx, rec, plugins, open_path);
tgb_plugin_manager_destroy(plugins);
tgb_recovery_close(rec, true); /* cierre normal: se borran marcador y autoguardados de esta sesión */
tgb_context_destroy(ctx);
return rc;
}
+217
View File
@@ -0,0 +1,217 @@
/* Adaptación a Linux (POSIX). */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dirent.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <time.h>
#include <unistd.h>
#include "../../core/internal.h"
#include "../platform.h"
static tgb_status join_dir(char *out, size_t cap, const char *base, const char *sub) {
int n = snprintf(out, cap, "%s/%s", base, sub);
if (n < 0 || (size_t)n >= cap) return tgb_fail(TGB_ERR_LIMIT, "Ruta demasiado larga: %s/%s", base, sub);
return TGB_OK;
}
tgb_status tgb_plat_default_dirs(char *config, char *data, char *cache, char *state, size_t cap) {
const char *home = getenv("HOME");
const char *xc = getenv("XDG_CONFIG_HOME"), *xd = getenv("XDG_DATA_HOME");
const char *xk = getenv("XDG_CACHE_HOME"), *xs = getenv("XDG_STATE_HOME");
char tmp[512];
struct { char *out; const char *xdg; const char *fallback; } t[4] = {
{config, xc, ".config"}, {data, xd, ".local/share"}, {cache, xk, ".cache"}, {state, xs, ".local/state"}};
for (int i = 0; i < 4; i++) {
const char *base = NULL;
if (t[i].xdg && t[i].xdg[0] == '/') base = t[i].xdg; /* XDG exige rutas absolutas */
else if (home && home[0]) {
if (join_dir(tmp, sizeof tmp, home, t[i].fallback) != TGB_OK) return TGB_ERR_LIMIT;
base = tmp;
} else return tgb_fail(TGB_ERR_NOT_FOUND, "No se pudo determinar la carpeta personal (HOME no definida)");
if (join_dir(t[i].out, cap, base, "tahgibu") != TGB_OK) return TGB_ERR_LIMIT;
}
return TGB_OK;
}
tgb_status tgb_plat_mkdirs(const char *path) {
char buf[1024];
size_t n = strlen(path);
if (n == 0 || n >= sizeof buf) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta de carpeta no válida");
memcpy(buf, path, n + 1);
for (size_t i = 1; i <= n; i++) {
if (buf[i] == '/' || buf[i] == '\0') {
char c = buf[i];
buf[i] = '\0';
if (mkdir(buf, 0700) != 0 && errno != EEXIST)
return tgb_fail(TGB_ERR_IO, "No se pudo crear la carpeta '%s': %s", buf, strerror(errno));
buf[i] = c;
}
}
if (!tgb_plat_is_dir(path)) return tgb_fail(TGB_ERR_IO, "'%s' existe pero no es una carpeta", path);
return TGB_OK;
}
bool tgb_plat_exists(const char *path) { struct stat st; return stat(path, &st) == 0; }
bool tgb_plat_is_dir(const char *path) { struct stat st; return stat(path, &st) == 0 && S_ISDIR(st.st_mode); }
tgb_status tgb_plat_read_file(const char *path, uint8_t **out, size_t *len, size_t max_bytes) {
FILE *f = fopen(path, "rb");
if (!f) return tgb_fail(errno == ENOENT ? TGB_ERR_NOT_FOUND : TGB_ERR_IO, "No se pudo abrir '%s': %s", path, strerror(errno));
struct stat st;
if (fstat(fileno(f), &st) != 0 || !S_ISREG(st.st_mode)) {
fclose(f);
return tgb_fail(TGB_ERR_IO, "'%s' no es un archivo normal", path);
}
if ((uint64_t)st.st_size > (uint64_t)max_bytes) {
fclose(f);
return tgb_fail(TGB_ERR_LIMIT, "'%s' es demasiado grande (%llu bytes, máximo %zu)", path,
(unsigned long long)st.st_size, max_bytes);
}
size_t size = (size_t)st.st_size;
uint8_t *buf = malloc(size ? size : 1);
if (!buf) { fclose(f); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para leer '%s'", path); }
size_t got = size ? fread(buf, 1, size, f) : 0;
bool err = ferror(f) != 0;
fclose(f);
if (err || got != size) { free(buf); return tgb_fail(TGB_ERR_IO, "Error leyendo '%s'", path); }
*out = buf;
*len = size;
return TGB_OK;
}
tgb_status tgb_plat_write_atomic(const char *path, const void *data, size_t len) {
char tmp[1100];
int n = snprintf(tmp, sizeof tmp, "%s.tmp-%ld", path, (long)getpid());
if (n < 0 || (size_t)n >= sizeof tmp) return tgb_fail(TGB_ERR_LIMIT, "Ruta demasiado larga: %s", path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) return tgb_fail(TGB_ERR_IO, "No se pudo crear el temporal '%s': %s", tmp, strerror(errno));
const uint8_t *p = data;
size_t left = len;
while (left > 0) {
ssize_t w = write(fd, p, left);
if (w < 0) {
if (errno == EINTR) continue;
int e = errno;
close(fd); unlink(tmp);
return tgb_fail(TGB_ERR_IO, "Error escribiendo '%s': %s%s", path, strerror(e),
e == ENOSPC ? " (disco lleno; el archivo anterior no se modificó)" : "");
}
p += w; left -= (size_t)w;
}
if (fsync(fd) != 0) { int e = errno; close(fd); unlink(tmp); return tgb_fail(TGB_ERR_IO, "fsync falló en '%s': %s", path, strerror(e)); }
if (close(fd) != 0) { int e = errno; unlink(tmp); return tgb_fail(TGB_ERR_IO, "No se pudo cerrar '%s': %s", tmp, strerror(e)); }
struct stat st;
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) chmod(tmp, st.st_mode & 07777); /* conserva permisos */
if (rename(tmp, path) != 0) { int e = errno; unlink(tmp); return tgb_fail(TGB_ERR_IO, "No se pudo reemplazar '%s': %s", path, strerror(e)); }
return TGB_OK;
}
tgb_status tgb_plat_remove(const char *path) {
if (unlink(path) != 0 && errno != ENOENT) return tgb_fail(TGB_ERR_IO, "No se pudo borrar '%s': %s", path, strerror(errno));
return TGB_OK;
}
tgb_status tgb_plat_copy_file(const char *src, const char *dst) {
uint8_t *buf; size_t len;
tgb_status s = tgb_plat_read_file(src, &buf, &len, (size_t)1 << 30);
if (TGB_FAILED(s)) return s;
s = tgb_plat_write_atomic(dst, buf, len);
free(buf);
return s;
}
tgb_status tgb_plat_list_dir(const char *dir, char ***names, size_t *count) {
DIR *d = opendir(dir);
if (!d) return tgb_fail(errno == ENOENT ? TGB_ERR_NOT_FOUND : TGB_ERR_IO, "No se pudo abrir la carpeta '%s': %s", dir, strerror(errno));
char **list = NULL; size_t n = 0, cap = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
if (n == cap) {
size_t nc = cap ? cap * 2 : 16;
char **nl = realloc(list, nc * sizeof *nl);
if (!nl) { closedir(d); tgb_plat_free_list(list, n); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
list = nl; cap = nc;
}
list[n] = tgb_strdup(e->d_name);
if (!list[n]) { closedir(d); tgb_plat_free_list(list, n); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
n++;
}
closedir(d);
*names = list; *count = n;
return TGB_OK;
}
void tgb_plat_free_list(char **names, size_t count) {
for (size_t i = 0; i < count; i++) free(names[i]);
free(names);
}
size_t tgb_plat_available_memory(void) {
FILE *f = fopen("/proc/meminfo", "r");
if (!f) return 0;
char line[256]; unsigned long long kb = 0;
while (fgets(line, sizeof line, f)) if (sscanf(line, "MemAvailable: %llu kB", &kb) == 1) break;
fclose(f);
return (size_t)(kb * 1024ull);
}
size_t tgb_plat_free_disk(const char *dir) {
struct statvfs sv;
if (!dir || statvfs(dir, &sv) != 0) return 0;
return (size_t)((unsigned long long)sv.f_bavail * (unsigned long long)sv.f_frsize);
}
long tgb_plat_pid(void) { return (long)getpid(); }
int64_t tgb_plat_process_start_token(long pid) {
char path[64], buf[1024];
snprintf(path, sizeof path, "/proc/%ld/stat", pid);
FILE *f = fopen(path, "r");
if (!f) return 0;
size_t n = fread(buf, 1, sizeof buf - 1, f);
fclose(f);
buf[n] = '\0';
char *p = strrchr(buf, ')'); /* el nombre puede contener espacios: saltar tras ')' */
if (!p) return 0;
p++;
for (int field = 3; field < 22 && p; field++) {
p = strchr(p + 1, ' ');
}
if (!p) return 0;
return (int64_t)strtoll(p, NULL, 10);
}
bool tgb_plat_process_alive(long pid, int64_t start_token) {
if (pid <= 0) return false;
if (kill((pid_t)pid, 0) != 0 && errno != EPERM) return false;
if (start_token != 0) {
int64_t cur = tgb_plat_process_start_token(pid);
if (cur != 0 && cur != start_token) return false; /* PID reutilizado por otro proceso */
}
return true;
}
int64_t tgb_plat_now_unix(void) { return (int64_t)time(NULL); }
int64_t tgb_plat_file_mtime(const char *path) { struct stat st; return stat(path, &st) == 0 ? (int64_t)st.st_mtime : 0; }
double tgb_plat_monotonic_seconds(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
void *tgb_plat_dlopen(const char *path) { return dlopen(path, RTLD_NOW | RTLD_LOCAL); }
void *tgb_plat_dlsym(void *lib, const char *name) { return dlsym(lib, name); }
void tgb_plat_dlclose(void *lib) { if (lib) dlclose(lib); }
const char *tgb_plat_dlerror(void) { const char *e = dlerror(); return e ? e : "error desconocido"; }
const char *tgb_plat_dll_extension(void) { return ".so"; }
+48
View File
@@ -0,0 +1,48 @@
/* Capa de adaptación al sistema operativo (INTERNA). Hay una implementación
* para Linux (probada) y otra para Windows (escrita, sin compilar ni probar
* todavía: ver docs/STATUS.md). */
#ifndef TAHGIBU_PLATFORM_H
#define TAHGIBU_PLATFORM_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "tahgibu/error.h"
/* Rutas estándar de usuario. Cada buffer debe tener al menos 512 bytes. */
tgb_status tgb_plat_default_dirs(char *config, char *data, char *cache, char *state, size_t cap);
/* Crea la carpeta y sus padres (como mkdir -p). */
tgb_status tgb_plat_mkdirs(const char *path);
bool tgb_plat_exists(const char *path);
bool tgb_plat_is_dir(const char *path);
/* Lee un archivo completo. Falla con TGB_ERR_LIMIT si supera max_bytes. Liberar con free(). */
tgb_status tgb_plat_read_file(const char *path, uint8_t **out, size_t *len, size_t max_bytes);
/* Escritura atómica: temporal en la misma carpeta + fsync + rename. Si algo
* falla, el destino queda intacto y el temporal se borra. */
tgb_status tgb_plat_write_atomic(const char *path, const void *data, size_t len);
tgb_status tgb_plat_remove(const char *path); /* TGB_OK también si no existía */
tgb_status tgb_plat_copy_file(const char *src, const char *dst);
/* Lista nombres (sin "." ni "..") de una carpeta. Liberar con tgb_plat_free_list. */
tgb_status tgb_plat_list_dir(const char *dir, char ***names, size_t *count);
void tgb_plat_free_list(char **names, size_t count);
size_t tgb_plat_available_memory(void); /* bytes de RAM disponibles (0 = desconocido) */
size_t tgb_plat_free_disk(const char *dir); /* bytes libres (0 = desconocido) */
long tgb_plat_pid(void);
/* Marca de inicio del proceso para distinguir PIDs reutilizados (0 = desconocido). */
int64_t tgb_plat_process_start_token(long pid);
bool tgb_plat_process_alive(long pid, int64_t start_token);
int64_t tgb_plat_now_unix(void);
int64_t tgb_plat_file_mtime(const char *path); /* segundos Unix, 0 si no se puede */
double tgb_plat_monotonic_seconds(void);
/* Bibliotecas dinámicas (extensiones nativas). */
void *tgb_plat_dlopen(const char *path);
void *tgb_plat_dlsym(void *lib, const char *name);
void tgb_plat_dlclose(void *lib);
const char *tgb_plat_dlerror(void);
const char *tgb_plat_dll_extension(void); /* ".so" o ".dll" */
#endif
+195
View File
@@ -0,0 +1,195 @@
/* Adaptación a Windows (Win32).
*
* ESTADO: escrito, NO compilado ni probado (el entorno de desarrollo era Linux y no había
* compilador para Windows). Hay que compilarlo y pasar las pruebas en Windows antes de
* distribuirlo. Ver docs/STATUS.md.
*
* Todas las rutas de la API pública son UTF-8; aquí se convierten a UTF-16 para las funciones W. */
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "../../core/internal.h"
#include "../platform.h"
static wchar_t *to_wide(const char *s) {
int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0);
if (n <= 0) return NULL;
wchar_t *w = malloc((size_t)n * sizeof *w);
if (w && MultiByteToWideChar(CP_UTF8, 0, s, -1, w, n) <= 0) { free(w); return NULL; }
return w;
}
static bool from_wide(const wchar_t *w, char *out, size_t cap) {
return WideCharToMultiByte(CP_UTF8, 0, w, -1, out, (int)cap, NULL, NULL) > 0;
}
static void slashes_to_backslash(wchar_t *w) { for (; *w; w++) if (*w == L'/') *w = L'\\'; }
tgb_status tgb_plat_default_dirs(char *config, char *data, char *cache, char *state, size_t cap) {
const wchar_t *roaming = _wgetenv(L"APPDATA"), *local = _wgetenv(L"LOCALAPPDATA");
if (!roaming || !local) return tgb_fail(TGB_ERR_NOT_FOUND, "No se encontraron APPDATA/LOCALAPPDATA");
char r[400], l[400];
if (!from_wide(roaming, r, sizeof r) || !from_wide(local, l, sizeof l)) return tgb_fail(TGB_ERR_LIMIT, "Ruta de datos de usuario demasiado larga");
snprintf(config, cap, "%s\\Tahgibu", r);
snprintf(data, cap, "%s\\Tahgibu\\data", l);
snprintf(cache, cap, "%s\\Tahgibu\\cache", l);
snprintf(state, cap, "%s\\Tahgibu\\state", l);
return TGB_OK;
}
tgb_status tgb_plat_mkdirs(const char *path) {
wchar_t *w = to_wide(path);
if (!w) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta no válida");
slashes_to_backslash(w);
for (wchar_t *p = w + 1; ; p++) {
if (*p == L'\\' || *p == L'\0') {
wchar_t c = *p; *p = L'\0';
if (!CreateDirectoryW(w, NULL) && GetLastError() != ERROR_ALREADY_EXISTS && GetLastError() != ERROR_ACCESS_DENIED) {
free(w); return tgb_fail(TGB_ERR_IO, "No se pudo crear la carpeta '%s' (error %lu)", path, GetLastError());
}
*p = c;
if (c == L'\0') break;
}
}
DWORD a = GetFileAttributesW(w);
free(w);
return (a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY)) ? TGB_OK : tgb_fail(TGB_ERR_IO, "'%s' no es una carpeta", path);
}
bool tgb_plat_exists(const char *path) { wchar_t *w = to_wide(path); if (!w) return false; DWORD a = GetFileAttributesW(w); free(w); return a != INVALID_FILE_ATTRIBUTES; }
bool tgb_plat_is_dir(const char *path) { wchar_t *w = to_wide(path); if (!w) return false; DWORD a = GetFileAttributesW(w); free(w); return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY); }
tgb_status tgb_plat_read_file(const char *path, uint8_t **out, size_t *len, size_t max_bytes) {
wchar_t *w = to_wide(path);
if (!w) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta no válida");
HANDLE h = CreateFileW(w, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
free(w);
if (h == INVALID_HANDLE_VALUE) return tgb_fail(GetLastError() == ERROR_FILE_NOT_FOUND ? TGB_ERR_NOT_FOUND : TGB_ERR_IO, "No se pudo abrir '%s' (error %lu)", path, GetLastError());
LARGE_INTEGER sz;
if (!GetFileSizeEx(h, &sz) || sz.QuadPart < 0) { CloseHandle(h); return tgb_fail(TGB_ERR_IO, "No se pudo leer '%s'", path); }
if ((unsigned long long)sz.QuadPart > (unsigned long long)max_bytes) { CloseHandle(h); return tgb_fail(TGB_ERR_LIMIT, "'%s' es demasiado grande", path); }
size_t size = (size_t)sz.QuadPart;
uint8_t *buf = malloc(size ? size : 1);
if (!buf) { CloseHandle(h); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
size_t done = 0;
while (done < size) {
DWORD got = 0, want = (DWORD)((size - done) > 0x40000000u ? 0x40000000u : (size - done));
if (!ReadFile(h, buf + done, want, &got, NULL) || got == 0) { free(buf); CloseHandle(h); return tgb_fail(TGB_ERR_IO, "Error leyendo '%s'", path); }
done += got;
}
CloseHandle(h);
*out = buf; *len = size;
return TGB_OK;
}
tgb_status tgb_plat_write_atomic(const char *path, const void *data, size_t len) {
char tmp[1100];
snprintf(tmp, sizeof tmp, "%s.tmp-%lu", path, GetCurrentProcessId());
wchar_t *wt = to_wide(tmp), *wp = to_wide(path);
if (!wt || !wp) { free(wt); free(wp); return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta no válida"); }
HANDLE h = CreateFileW(wt, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (h == INVALID_HANDLE_VALUE) { free(wt); free(wp); return tgb_fail(TGB_ERR_IO, "No se pudo crear el temporal '%s' (error %lu)", tmp, GetLastError()); }
const uint8_t *p = data; size_t left = len; bool ok = true;
while (left > 0 && ok) {
DWORD w = 0, want = (DWORD)(left > 0x40000000u ? 0x40000000u : left);
ok = WriteFile(h, p, want, &w, NULL) && w == want;
p += w; left -= w;
}
if (ok) ok = FlushFileBuffers(h) != 0;
DWORD err = ok ? 0 : GetLastError();
CloseHandle(h);
if (ok && !MoveFileExW(wt, wp, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { ok = false; err = GetLastError(); }
if (!ok) { DeleteFileW(wt); free(wt); free(wp); return tgb_fail(TGB_ERR_IO, "Error escribiendo '%s' (error %lu); el archivo anterior no se modificó", path, err); }
free(wt); free(wp);
return TGB_OK;
}
tgb_status tgb_plat_remove(const char *path) {
wchar_t *w = to_wide(path);
if (!w) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta no válida");
BOOL ok = DeleteFileW(w);
DWORD e = GetLastError();
free(w);
if (!ok && e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) return tgb_fail(TGB_ERR_IO, "No se pudo borrar '%s' (error %lu)", path, e);
return TGB_OK;
}
tgb_status tgb_plat_copy_file(const char *src, const char *dst) {
uint8_t *buf; size_t len;
tgb_status s = tgb_plat_read_file(src, &buf, &len, (size_t)1 << 30);
if (TGB_FAILED(s)) return s;
s = tgb_plat_write_atomic(dst, buf, len);
free(buf);
return s;
}
tgb_status tgb_plat_list_dir(const char *dir, char ***names, size_t *count) {
char pat[1100]; snprintf(pat, sizeof pat, "%s\\*", dir);
wchar_t *w = to_wide(pat);
if (!w) return tgb_fail(TGB_ERR_INVALID_ARG, "Ruta no válida");
WIN32_FIND_DATAW fd;
HANDLE h = FindFirstFileW(w, &fd);
free(w);
if (h == INVALID_HANDLE_VALUE) return tgb_fail(GetLastError() == ERROR_PATH_NOT_FOUND ? TGB_ERR_NOT_FOUND : TGB_ERR_IO, "No se pudo abrir la carpeta '%s'", dir);
char **list = NULL; size_t n = 0, cap = 0;
do {
if (!wcscmp(fd.cFileName, L".") || !wcscmp(fd.cFileName, L"..")) continue;
char u[600];
if (!from_wide(fd.cFileName, u, sizeof u)) continue;
if (n == cap) { size_t nc = cap ? cap * 2 : 16; char **nl = realloc(list, nc * sizeof *nl); if (!nl) { FindClose(h); tgb_plat_free_list(list, n); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); } list = nl; cap = nc; }
list[n] = tgb_strdup(u);
if (!list[n]) { FindClose(h); tgb_plat_free_list(list, n); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); }
n++;
} while (FindNextFileW(h, &fd));
FindClose(h);
*names = list; *count = n;
return TGB_OK;
}
void tgb_plat_free_list(char **names, size_t count) { for (size_t i = 0; i < count; i++) free(names[i]); free(names); }
size_t tgb_plat_available_memory(void) { MEMORYSTATUSEX m; m.dwLength = sizeof m; return GlobalMemoryStatusEx(&m) ? (size_t)m.ullAvailPhys : 0; }
size_t tgb_plat_free_disk(const char *dir) {
wchar_t *w = to_wide(dir); ULARGE_INTEGER freeb;
BOOL ok = w && GetDiskFreeSpaceExW(w, &freeb, NULL, NULL);
free(w);
return ok ? (size_t)freeb.QuadPart : 0;
}
long tgb_plat_pid(void) { return (long)GetCurrentProcessId(); }
int64_t tgb_plat_process_start_token(long pid) {
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)pid);
if (!h) return 0;
FILETIME c, e, k, u; int64_t v = 0;
if (GetProcessTimes(h, &c, &e, &k, &u)) v = (int64_t)(((uint64_t)c.dwHighDateTime << 32 | c.dwLowDateTime) / 10000ull);
CloseHandle(h);
return v;
}
bool tgb_plat_process_alive(long pid, int64_t start_token) {
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)pid);
if (!h) return GetLastError() == ERROR_ACCESS_DENIED;
DWORD code = 0; BOOL ok = GetExitCodeProcess(h, &code);
CloseHandle(h);
if (!ok || code != STILL_ACTIVE) return false;
if (start_token != 0) { int64_t cur = tgb_plat_process_start_token(pid); if (cur != 0 && cur != start_token) return false; }
return true;
}
int64_t tgb_plat_now_unix(void) { return (int64_t)time(NULL); }
int64_t tgb_plat_file_mtime(const char *path) {
wchar_t *w = to_wide(path); WIN32_FILE_ATTRIBUTE_DATA d; int64_t r = 0;
if (w && GetFileAttributesExW(w, GetFileExInfoStandard, &d)) r = (int64_t)((((uint64_t)d.ftLastWriteTime.dwHighDateTime << 32 | d.ftLastWriteTime.dwLowDateTime) / 10000000ull) - 11644473600ull);
free(w);
return r;
}
double tgb_plat_monotonic_seconds(void) {
LARGE_INTEGER f, c; QueryPerformanceFrequency(&f); QueryPerformanceCounter(&c);
return (double)c.QuadPart / (double)f.QuadPart;
}
void *tgb_plat_dlopen(const char *path) { wchar_t *w = to_wide(path); HMODULE m = w ? LoadLibraryW(w) : NULL; free(w); return (void *)m; }
void *tgb_plat_dlsym(void *lib, const char *name) { return (void *)GetProcAddress((HMODULE)lib, name); }
void tgb_plat_dlclose(void *lib) { if (lib) FreeLibrary((HMODULE)lib); }
const char *tgb_plat_dlerror(void) { static char msg[64]; snprintf(msg, sizeof msg, "error de Windows %lu", GetLastError()); return msg; }
const char *tgb_plat_dll_extension(void) { return ".dll"; }
+347
View File
@@ -0,0 +1,347 @@
/* Autoguardado y recuperación de sesiones.
*
* Carpeta: <state_dir>/recovery/
* session-<id>.lock marcador de una sesión en marcha (id = pid-inicio)
* autosave-<id>-<n>.tgb copia temporal del documento n de esa sesión
* autosave-<id>-<n>.json metadatos (ruta original, título, hora)
* Si una sesión muere sin cerrarse, su .lock queda apuntando a un proceso que
* ya no existe: sus autoguardados pasan a ser "candidatos a recuperar".
* Los archivos originales del usuario NUNCA se tocan desde aquí. */
#include <stdio.h>
#include "../document/doc_internal.h"
#include "../io/io_internal.h"
#include "../platform/platform.h"
#define DIRLEN 512
#define PATHLEN 640
#define DEFAULT_INTERVAL 300.0 /* 5 minutos */
typedef struct entry {
tgb_document *doc;
char title[128];
char tgb_path[PATHLEN], meta_path[PATHLEN];
uint64_t last_revision; /* revisión guardada en el último autoguardado */
bool has_autosave, have_time, ever_saved;
double last_time;
} entry;
typedef struct cand {
tgb_recovery_candidate pub;
char meta_path[PATHLEN];
} cand;
struct tgb_recovery {
tgb_context *ctx;
char dir[DIRLEN];
char session_id[64];
char lock_path[PATHLEN];
double interval;
entry *entries; size_t nentries, capentries;
uint32_t next_n;
cand *cands; size_t ncands;
};
static void path_join(char *out, size_t cap, const char *dir, const char *name) {
snprintf(out, cap, "%s/%s", dir, name);
}
/* ---------- sesiones ---------- */
static bool lock_is_alive(const char *path, long *pid_out) {
uint8_t *buf; size_t len;
if (TGB_FAILED(tgb_plat_read_file(path, &buf, &len, 4096))) return false;
jnode *root = NULL;
bool alive = false;
if (TGB_SUCCEEDED(json_parse((const char *)buf, len, &root))) {
long long pid = 0, start = 0;
if (json_is_int(json_get(root, "pid"), 1, 0x7FFFFFFF, &pid)) {
json_is_int(json_get(root, "start"), 0, INT64_MAX / 2, &start);
alive = tgb_plat_process_alive((long)pid, (int64_t)start);
if (pid_out) *pid_out = (long)pid;
}
json_free(root);
}
free(buf);
return alive; /* ilegible = tratado como sesión muerta */
}
static bool starts_with(const char *s, const char *p) { return strncmp(s, p, strlen(p)) == 0; }
static bool ends_with(const char *s, const char *suf) { size_t a = strlen(s), b = strlen(suf); return a >= b && !strcmp(s + a - b, suf); }
/* Extrae el id de sesión de "autosave-<id>-<n>.tgb" (el id es "pid-inicio", así que contiene un guion). */
static bool session_of_autosave(const char *name, char *sid, size_t cap) {
if (!starts_with(name, "autosave-") || !ends_with(name, ".tgb")) return false;
const char *s = name + 9, *end = name + strlen(name) - 4;
const char *last_dash = NULL;
for (const char *p = s; p < end; p++) if (*p == '-') last_dash = p;
if (!last_dash || last_dash == s) return false;
size_t n = (size_t)(last_dash - s);
if (n >= cap) return false;
memcpy(sid, s, n); sid[n] = '\0';
return true;
}
static int cmp_cand(const void *a, const void *b) {
const cand *x = a, *y = b;
return (x->pub.saved_unix > y->pub.saved_unix) - (x->pub.saved_unix < y->pub.saved_unix);
}
static void read_meta(cand *c) {
uint8_t *buf; size_t len;
if (TGB_FAILED(tgb_plat_read_file(c->meta_path, &buf, &len, 65536))) return;
jnode *root = NULL;
if (TGB_SUCCEEDED(json_parse((const char *)buf, len, &root))) {
const jnode *o = json_get(root, "original_path"), *t = json_get(root, "title");
long long when;
if (o && o->t == J_STR && o->slen < sizeof c->pub.original_path) { memcpy(c->pub.original_path, o->s, o->slen); c->pub.original_path[o->slen] = '\0'; }
if (t && t->t == J_STR && t->slen < sizeof c->pub.title) { memcpy(c->pub.title, t->s, t->slen); c->pub.title[t->slen] = '\0'; }
if (json_is_int(json_get(root, "saved_at"), 0, 4102444800LL, &when)) c->pub.saved_unix = when;
json_free(root);
}
free(buf);
}
static void scan(tgb_recovery *r) {
char **names; size_t n;
if (TGB_FAILED(tgb_plat_list_dir(r->dir, &names, &n))) return;
/* 1) sesiones vivas y muertas */
char (*live)[64] = calloc(n ? n : 1, sizeof *live);
size_t nlive = 0;
for (size_t i = 0; i < n; i++) {
if (!starts_with(names[i], "session-") || !ends_with(names[i], ".lock")) continue;
char p[PATHLEN];
path_join(p, sizeof p, r->dir, names[i]);
if (lock_is_alive(p, NULL)) {
if (live && strlen(names[i]) - 13 < 64) { size_t l = strlen(names[i]) - 13; memcpy(live[nlive], names[i] + 8, l); live[nlive][l] = '\0'; nlive++; }
} else tgb_plat_remove(p); /* marcador de una sesión muerta: ya cumplió su función */
}
/* 2) autoguardados huérfanos = candidatos; restos de temporales de procesos muertos = basura */
for (size_t i = 0; i < n; i++) {
char p[PATHLEN];
char *tmp = strstr(names[i], ".tmp-");
if (tmp) {
long pid = strtol(tmp + 5, NULL, 10);
if (pid > 0 && !tgb_plat_process_alive(pid, 0)) { path_join(p, sizeof p, r->dir, names[i]); tgb_plat_remove(p); }
continue;
}
char sid[64];
if (!session_of_autosave(names[i], sid, sizeof sid)) continue;
bool owner_alive = false;
for (size_t k = 0; k < nlive; k++) if (!strcmp(live[k], sid)) owner_alive = true;
if (owner_alive) continue;
cand *nc = realloc(r->cands, (r->ncands + 1) * sizeof *nc);
if (!nc) break;
r->cands = nc;
cand *c = &r->cands[r->ncands];
memset(c, 0, sizeof *c);
path_join(c->pub.autosave_path, sizeof c->pub.autosave_path, r->dir, names[i]);
snprintf(c->meta_path, sizeof c->meta_path, "%.*s.json", (int)(strlen(c->pub.autosave_path) - 4), c->pub.autosave_path);
tgb_strlcpy(c->pub.title, names[i], sizeof c->pub.title);
c->pub.saved_unix = tgb_plat_file_mtime(c->pub.autosave_path);
read_meta(c);
r->ncands++;
}
free(live);
tgb_plat_free_list(names, n);
if (r->ncands > 1) qsort(r->cands, r->ncands, sizeof *r->cands, cmp_cand);
}
static void clean_orphan_history(tgb_context *ctx) {
char dir[DIRLEN];
snprintf(dir, sizeof dir, "%s/history", tgb_context_cache_dir(ctx));
char **names; size_t n;
if (TGB_FAILED(tgb_plat_list_dir(dir, &names, &n))) return;
for (size_t i = 0; i < n; i++) {
if (!starts_with(names[i], "tgb-hist-")) continue;
long pid = strtol(names[i] + 9, NULL, 10);
if (pid > 0 && !tgb_plat_process_alive(pid, 0)) { char p[PATHLEN]; path_join(p, sizeof p, dir, names[i]); tgb_plat_remove(p); }
}
tgb_plat_free_list(names, n);
}
tgb_status tgb_recovery_open(tgb_context *ctx, tgb_recovery **out) {
if (!ctx || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_recovery_open: argumento nulo");
*out = NULL;
tgb_recovery *r = calloc(1, sizeof *r);
if (!r) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
r->ctx = ctx;
r->interval = DEFAULT_INTERVAL;
int n = snprintf(r->dir, sizeof r->dir, "%s/recovery", tgb_context_state_dir(ctx));
if (n < 0 || (size_t)n >= sizeof r->dir) { free(r); return tgb_fail(TGB_ERR_LIMIT, "Ruta de recuperación demasiado larga"); }
tgb_status s = tgb_plat_mkdirs(r->dir);
if (TGB_FAILED(s)) { free(r); return s; }
scan(r);
clean_orphan_history(ctx);
long pid = tgb_plat_pid();
int64_t start = tgb_plat_process_start_token(pid);
snprintf(r->session_id, sizeof r->session_id, "%ld-%lld", pid, (long long)start);
char name[96];
snprintf(name, sizeof name, "session-%s.lock", r->session_id);
path_join(r->lock_path, sizeof r->lock_path, r->dir, name);
char body[200];
int bl = snprintf(body, sizeof body, "{\"pid\":%ld,\"start\":%lld,\"started\":%lld,\"app\":\"%s\"}\n", pid, (long long)start, (long long)tgb_plat_now_unix(), TGB_VERSION_STRING);
s = tgb_plat_write_atomic(r->lock_path, body, (size_t)bl);
if (TGB_FAILED(s)) { free(r->cands); free(r); return s; }
*out = r;
return TGB_OK;
}
/* ---------- candidatos ---------- */
size_t tgb_recovery_candidate_count(const tgb_recovery *r) { return r ? r->ncands : 0; }
tgb_status tgb_recovery_candidate_at(const tgb_recovery *r, size_t i, tgb_recovery_candidate *out) {
if (!r || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (i >= r->ncands) return tgb_fail(TGB_ERR_RANGE, "Candidato %zu fuera de rango", i);
*out = r->cands[i].pub;
return TGB_OK;
}
tgb_status tgb_recovery_candidate_load(tgb_recovery *r, size_t i, tgb_document **out) {
if (!r || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
*out = NULL;
if (i >= r->ncands) return tgb_fail(TGB_ERR_RANGE, "Candidato %zu fuera de rango", i);
tgb_document *doc = NULL;
tgb_status s = tgb_document_load(r->ctx, r->cands[i].pub.autosave_path, &doc);
if (TGB_FAILED(s)) return s; /* el archivo se conserva: puede que el usuario quiera inspeccionarlo */
tgb_document_set_path(doc, NULL); /* sin ruta: guardar obliga a elegir destino, nunca pisa el original por accidente */
doc->revision = 1; /* "sucio": hay trabajo sin guardar */
doc->saved_revision = 0;
*out = doc;
return TGB_OK;
}
tgb_status tgb_recovery_candidate_discard(tgb_recovery *r, size_t i) {
if (!r) return tgb_fail(TGB_ERR_INVALID_ARG, "Gestor nulo");
if (i >= r->ncands) return tgb_fail(TGB_ERR_RANGE, "Candidato %zu fuera de rango", i);
tgb_status s = tgb_plat_remove(r->cands[i].pub.autosave_path);
tgb_status m = tgb_plat_remove(r->cands[i].meta_path);
if (TGB_FAILED(s)) return s;
if (TGB_FAILED(m)) return m;
memmove(&r->cands[i], &r->cands[i + 1], (r->ncands - i - 1) * sizeof r->cands[0]);
r->ncands--;
return TGB_OK;
}
/* ---------- documentos en curso ---------- */
static entry *find_entry(tgb_recovery *r, const tgb_document *doc) {
for (size_t i = 0; i < r->nentries; i++) if (r->entries[i].doc == doc) return &r->entries[i];
return NULL;
}
tgb_status tgb_recovery_attach(tgb_recovery *r, tgb_document *doc, const char *title) {
if (!r || !doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (find_entry(r, doc)) return tgb_fail(TGB_ERR_EXISTS, "El documento ya está en la gestión de recuperación");
if (r->nentries == r->capentries) {
size_t nc = r->capentries ? r->capentries * 2 : 4;
entry *ne = realloc(r->entries, nc * sizeof *ne);
if (!ne) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
r->entries = ne; r->capentries = nc;
}
entry *e = &r->entries[r->nentries];
memset(e, 0, sizeof *e);
e->doc = doc;
tgb_strlcpy(e->title, (title && title[0]) ? title : "Sin título", sizeof e->title);
char name[160];
uint32_t n = r->next_n++;
snprintf(name, sizeof name, "autosave-%s-%u.tgb", r->session_id, (unsigned)n);
path_join(e->tgb_path, sizeof e->tgb_path, r->dir, name);
snprintf(name, sizeof name, "autosave-%s-%u.json", r->session_id, (unsigned)n);
path_join(e->meta_path, sizeof e->meta_path, r->dir, name);
e->last_revision = doc->revision;
e->ever_saved = !tgb_document_dirty(doc); /* si ya está sucio, hace falta un primer autoguardado */
if (!e->ever_saved) e->last_revision = (uint64_t)-1;
r->nentries++;
return TGB_OK;
}
static void remove_entry_files(entry *e) {
tgb_plat_remove(e->tgb_path);
tgb_plat_remove(e->meta_path);
e->has_autosave = false;
}
tgb_status tgb_recovery_detach(tgb_recovery *r, tgb_document *doc) {
if (!r || !doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
entry *e = find_entry(r, doc);
if (!e) return tgb_fail(TGB_ERR_NOT_FOUND, "El documento no está en la gestión de recuperación");
remove_entry_files(e);
size_t i = (size_t)(e - r->entries);
memmove(&r->entries[i], &r->entries[i + 1], (r->nentries - i - 1) * sizeof r->entries[0]);
r->nentries--;
return TGB_OK;
}
void tgb_recovery_set_interval(tgb_recovery *r, double seconds) {
if (r && seconds >= 0.0 && seconds < 86400.0 * 7) r->interval = seconds;
}
static tgb_status write_autosave(tgb_recovery *r, entry *e) {
tgb_save_options o;
tgb_save_options_defaults(&o);
o.include_flat_frames = false; /* copia interna: más pequeña y rápida */
uint8_t *data; size_t len;
tgb_status s = tgb_document_save_memory(e->doc, &o, &data, &len);
if (TGB_FAILED(s)) return s;
s = tgb_plat_write_atomic(e->tgb_path, data, len); /* si falla (p. ej. disco lleno) el autoguardado anterior sigue intacto */
free(data);
if (TGB_FAILED(s)) return s;
tgb_sb sb; sb_init(&sb);
sb_puts(&sb, "{\"original_path\":");
const char *op = tgb_document_path(e->doc);
sb_json_string(&sb, op ? op : "", op ? strlen(op) : 0);
sb_puts(&sb, ",\"title\":");
sb_json_string(&sb, e->title, strlen(e->title));
sb_printf(&sb, ",\"saved_at\":%lld,\"app\":\"%s\"}\n", (long long)tgb_plat_now_unix(), TGB_VERSION_STRING);
if (!sb.oom) tgb_plat_write_atomic(e->meta_path, sb.data, sb.len); /* los metadatos son opcionales: sin ellos se usan valores por defecto */
sb_free(&sb);
e->has_autosave = true;
e->last_revision = e->doc->revision;
e->ever_saved = true;
tgb_event ev = {TGB_EVENT_AUTOSAVED, e->doc, 0, 0, {0, 0, 0, 0}, e->tgb_path};
tgb_emit(r->ctx, &ev);
return TGB_OK;
}
tgb_status tgb_recovery_save_now(tgb_recovery *r, tgb_document *doc) {
if (!r || !doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
entry *e = find_entry(r, doc);
if (!e) return tgb_fail(TGB_ERR_NOT_FOUND, "El documento no está en la gestión de recuperación");
return write_autosave(r, e);
}
tgb_status tgb_recovery_tick(tgb_recovery *r, double now) {
if (!r) return tgb_fail(TGB_ERR_INVALID_ARG, "Gestor nulo");
tgb_status first_error = TGB_OK;
char msg[400] = "";
for (size_t i = 0; i < r->nentries; i++) {
entry *e = &r->entries[i];
if (!e->have_time) { e->have_time = true; e->last_time = now; continue; }
if (now - e->last_time < r->interval) continue;
if (!tgb_document_dirty(e->doc) || e->doc->revision == e->last_revision) { e->last_time = now; continue; } /* nada nuevo */
if (e->doc->edit.active) continue; /* en mitad de un trazo: reintentar en el siguiente tick */
e->last_time = now; /* tanto si sale bien como si no, esperar un intervalo */
tgb_status s = write_autosave(r, e);
if (TGB_FAILED(s) && first_error == TGB_OK) { first_error = s; snprintf(msg, sizeof msg, "%s", tgb_last_error()); }
}
if (first_error != TGB_OK) return tgb_fail(first_error, "Falló el autoguardado: %s", msg);
return TGB_OK;
}
void tgb_recovery_notify_saved(tgb_recovery *r, tgb_document *doc) {
if (!r || !doc) return;
entry *e = find_entry(r, doc);
if (!e) return;
remove_entry_files(e); /* el proyecto ya está en su sitio: la copia temporal sobra */
e->last_revision = doc->revision;
e->ever_saved = true;
}
void tgb_recovery_close(tgb_recovery *r, bool clean_exit) {
if (!r) return;
if (clean_exit) {
for (size_t i = 0; i < r->nentries; i++) remove_entry_files(&r->entries[i]);
tgb_plat_remove(r->lock_path);
}
free(r->entries);
free(r->cands);
free(r);
}
+170
View File
@@ -0,0 +1,170 @@
/* Puntos de recuperación nombrados (viven dentro del documento y del .tgb).
* Son instantáneas de la estructura que COMPARTEN las celdas con el
* documento (copy-on-write): crear uno es barato; cuesta memoria solo
* lo que después se edita. */
#include <stdio.h>
#include <time.h>
#include "../history/history_internal.h"
#include "../platform/platform.h"
static size_t find_point(const tgb_document *doc, uint32_t id) {
for (size_t i = 0; i < doc->nrpoints; i++) if (doc->rpoints[i].id == id) return i;
return NPOS;
}
size_t tgb_recovery_point_count(const tgb_document *doc) { return doc ? doc->nrpoints : 0; }
tgb_status tgb_recovery_point_info_at(const tgb_document *doc, size_t index, tgb_recovery_point_info *out) {
if (!doc || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo");
if (index >= doc->nrpoints) return tgb_fail(TGB_ERR_RANGE, "Punto de recuperación %zu fuera de rango", index);
const tgb_rpoint *p = &doc->rpoints[index];
out->id = p->id;
tgb_strlcpy(out->name, p->name, sizeof out->name);
out->created_unix = p->created;
out->compatible = p->compatible;
return TGB_OK;
}
static tgb_status check_point_name(const tgb_document *doc, const char *name) {
if (!name) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre no puede ser nulo");
size_t n = strlen(name), start = 0;
while (start < n && (name[start] == ' ' || name[start] == '\t')) start++;
if (start == n) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre del punto de recuperación no puede estar vacío");
if (n >= TGB_RECOVERY_POINT_NAME_MAX) return tgb_fail(TGB_ERR_LIMIT, "Nombre demasiado largo (máximo %d bytes)", TGB_RECOVERY_POINT_NAME_MAX - 1);
if (!tgb_utf8_valid(name, n)) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre no es UTF-8 válido");
for (size_t i = 0; i < n; i++) if ((unsigned char)name[i] < 0x20) return tgb_fail(TGB_ERR_INVALID_ARG, "El nombre contiene caracteres de control");
for (size_t i = 0; i < doc->nrpoints; i++)
if (!strcmp(doc->rpoints[i].name, name)) return tgb_fail(TGB_ERR_EXISTS, "Ya existe un punto de recuperación llamado '%s'", name);
return TGB_OK;
}
static tgb_status add_point(tgb_document *doc, const char *name, uint32_t *out_id) {
if (doc->nrpoints >= TGB_MAX_RECOVERY_POINTS)
return tgb_fail(TGB_ERR_LIMIT, "Máximo de %d puntos de recuperación alcanzado: elimina alguno", TGB_MAX_RECOVERY_POINTS);
if (doc->nrpoints == doc->caprpoints) {
size_t nc = doc->caprpoints ? doc->caprpoints * 2 : 8;
tgb_rpoint *np = realloc(doc->rpoints, nc * sizeof *np);
if (!np) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria");
doc->rpoints = np;
doc->caprpoints = nc;
}
tgb_rpoint *p = &doc->rpoints[doc->nrpoints];
memset(p, 0, sizeof *p);
tgb_status s = state_capture(doc, &p->state);
if (TGB_FAILED(s)) return s;
p->id = doc->next_rpoint_id++;
tgb_strlcpy(p->name, name, sizeof p->name);
p->created = tgb_plat_now_unix();
p->compatible = true;
doc->nrpoints++;
doc->revision++; /* el proyecto cambió: hay algo que guardar */
if (out_id) *out_id = p->id;
tgb_emit_simple(doc, TGB_EVENT_RECOVERY_POINTS_CHANGED);
tgb_emit_simple(doc, TGB_EVENT_DOCUMENT_CHANGED);
return TGB_OK;
}
tgb_status tgb_recovery_point_create(tgb_document *doc, const char *name, uint32_t *out_id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
s = check_point_name(doc, name);
if (TGB_FAILED(s)) return s;
return add_point(doc, name, out_id);
}
tgb_status tgb_recovery_point_restore(tgb_document *doc, uint32_t id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
size_t i = find_point(doc, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el punto de recuperación %u", (unsigned)id);
tgb_rpoint *p = &doc->rpoints[i];
if (!p->compatible)
return tgb_fail(TGB_ERR_INCOMPATIBLE, "El punto '%s' se guardó con un formato incompatible y no se puede restaurar (se conserva intacto en el archivo)", p->name);
if (p->state.nframes && p->state.frames[0].cels == NULL)
return tgb_fail(TGB_ERR_CORRUPT, "El punto '%s' está dañado", p->name);
/* 1) punto de seguridad automático con el estado actual */
char safety[TGB_RECOVERY_POINT_NAME_MAX];
int n = snprintf(safety, sizeof safety, "Antes de restaurar «%s»", p->name);
if (n < 0) return tgb_fail(TGB_ERR_INVALID_ARG, "Nombre no válido");
if ((size_t)n >= sizeof safety) { /* recortar sin cortar a mitad de un carácter UTF-8 */
size_t cut = sizeof safety - 1;
while (cut > 0 && ((unsigned char)safety[cut] & 0xC0) == 0x80) cut--;
safety[cut] = '\0';
}
char unique[TGB_RECOVERY_POINT_NAME_MAX];
tgb_strlcpy(unique, safety, sizeof unique);
for (int k = 2; k < 1000; k++) { /* nombre único: «…» (2), (3)... */
bool taken = false;
for (size_t q = 0; q < doc->nrpoints; q++) if (!strcmp(doc->rpoints[q].name, unique)) taken = true;
if (!taken) break;
snprintf(unique, sizeof unique, "%.100s (%d)", safety, k);
}
uint32_t keep_id = p->id;
s = add_point(doc, unique, NULL); /* puede reubicar doc->rpoints: no usar 'p' después */
if (TGB_FAILED(s)) return s;
i = find_point(doc, keep_id);
/* 2) aplicar la instantánea y vaciar el historial ordinario */
s = state_apply(doc, &doc->rpoints[i].state);
if (TGB_FAILED(s)) return s;
tgb_history_clear(doc);
doc_touch(doc);
tgb_emit_simple(doc, TGB_EVENT_FRAMES_CHANGED);
tgb_emit_simple(doc, TGB_EVENT_LAYERS_CHANGED);
tgb_event ev = {TGB_EVENT_PIXELS_CHANGED, doc, 0, 0, {0, 0, doc->width, doc->height}, NULL};
tgb_emit(doc->ctx, &ev);
return TGB_OK;
}
tgb_status tgb_recovery_point_delete(tgb_document *doc, uint32_t id) {
tgb_status s = doc_check_mutable(doc);
if (TGB_FAILED(s)) return s;
size_t i = find_point(doc, id);
if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el punto de recuperación %u", (unsigned)id);
if (doc->rpoints[i].compatible) state_free(&doc->rpoints[i].state);
free(doc->rpoints[i].raw_json);
memmove(&doc->rpoints[i], &doc->rpoints[i + 1], (doc->nrpoints - i - 1) * sizeof doc->rpoints[0]);
doc->nrpoints--;
doc->revision++;
tgb_emit_simple(doc, TGB_EVENT_RECOVERY_POINTS_CHANGED);
tgb_emit_simple(doc, TGB_EVENT_DOCUMENT_CHANGED);
return TGB_OK;
}
static int cmp_ptr(const void *a, const void *b) {
const void *x = *(const void *const *)a, *y = *(const void *const *)b;
return (x > y) - (x < y);
}
/* Bytes de celdas retenidas por los puntos y que ya no están en el documento
* (cada celda se cuenta una sola vez aunque la compartan varios puntos). */
size_t tgb_recovery_points_extra_bytes(const tgb_document *doc) {
if (!doc) return 0;
size_t cap = 0;
for (size_t i = 0; i < doc->nframes; i++) cap += doc->nlayers;
for (size_t k = 0; k < doc->nrpoints; k++)
if (doc->rpoints[k].compatible) cap += doc->rpoints[k].state.nframes * doc->rpoints[k].state.nlayers;
const void **seen = malloc((cap ? cap : 1) * sizeof *seen);
if (!seen) return 0;
size_t n = 0;
for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j]) seen[n++] = doc->frames[i].cels[j];
size_t in_doc = n;
qsort(seen, in_doc, sizeof *seen, cmp_ptr); /* ordenado solo lo del documento */
size_t total = 0;
const void **extra = malloc((cap ? cap : 1) * sizeof *extra);
if (!extra) { free(seen); return 0; }
size_t ne = 0;
for (size_t k = 0; k < doc->nrpoints; k++) {
if (!doc->rpoints[k].compatible) continue;
const tgb_state *st = &doc->rpoints[k].state;
for (size_t i = 0; i < st->nframes; i++) for (size_t j = 0; j < st->nlayers; j++) {
tgb_canvas *c = st->frames[i].cels[j];
if (c && !bsearch(&c, seen, in_doc, sizeof *seen, cmp_ptr)) extra[ne++] = c;
}
}
qsort(extra, ne, sizeof *extra, cmp_ptr);
for (size_t i = 0; i < ne; i++)
if (i == 0 || extra[i] != extra[i - 1]) total += tgb_canvas_bytes((const tgb_canvas *)extra[i]);
free(seen);
free(extra);
return total;
}
+207
View File
@@ -0,0 +1,207 @@
/* Lienzo RGBA8 no premultiplicado con seguimiento de zona modificada. */
#include "../core/internal.h"
struct tgb_canvas {
int refs;
int w, h;
int dx0, dy0, dx1, dy1; /* zona modificada, [x0,x1) x [y0,y1); vacía si x0>=x1 */
uint8_t *px;
};
tgb_rect tgb_rect_intersect(tgb_rect a, tgb_rect b) {
int x0 = a.x > b.x ? a.x : b.x;
int y0 = a.y > b.y ? a.y : b.y;
int x1 = (a.x + a.w) < (b.x + b.w) ? (a.x + a.w) : (b.x + b.w);
int y1 = (a.y + a.h) < (b.y + b.h) ? (a.y + a.h) : (b.y + b.h);
tgb_rect r = {x0, y0, x1 - x0, y1 - y0};
if (r.w <= 0 || r.h <= 0) { r.w = 0; r.h = 0; }
return r;
}
tgb_rect tgb_rect_union(tgb_rect a, tgb_rect b) {
if (a.w <= 0 || a.h <= 0) return b;
if (b.w <= 0 || b.h <= 0) return a;
int x0 = a.x < b.x ? a.x : b.x;
int y0 = a.y < b.y ? a.y : b.y;
int x1 = (a.x + a.w) > (b.x + b.w) ? (a.x + a.w) : (b.x + b.w);
int y1 = (a.y + a.h) > (b.y + b.h) ? (a.y + a.h) : (b.y + b.h);
tgb_rect r = {x0, y0, x1 - x0, y1 - y0};
return r;
}
tgb_canvas *tgb_canvas_create(int width, int height) {
if (width < 1 || height < 1 || width > TGB_MAX_DIMENSION || height > TGB_MAX_DIMENSION) {
tgb_fail(TGB_ERR_RANGE, "Dimensiones de lienzo no válidas: %dx%d (máximo %d)", width, height,
TGB_MAX_DIMENSION);
return NULL;
}
tgb_canvas *c = calloc(1, sizeof *c);
if (!c) { tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el lienzo"); return NULL; }
c->px = calloc((size_t)width * (size_t)height, 4);
if (!c->px) { free(c); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para %dx%d píxeles", width, height); return NULL; }
c->refs = 1;
c->w = width;
c->h = height;
return c;
}
tgb_canvas *tgb_canvas_clone(const tgb_canvas *src) {
if (!src) return NULL;
tgb_canvas *c = tgb_canvas_create(src->w, src->h);
if (!c) return NULL;
memcpy(c->px, src->px, tgb_canvas_bytes(src));
return c;
}
tgb_canvas *tgb_canvas_retain(tgb_canvas *c) { if (c) c->refs++; return c; }
void tgb_canvas_release(tgb_canvas *c) {
if (!c) return;
if (--c->refs <= 0) { free(c->px); free(c); }
}
int tgb_canvas_refs(const tgb_canvas *c) { return c ? c->refs : 0; }
int tgb_canvas_width(const tgb_canvas *c) { return c ? c->w : 0; }
int tgb_canvas_height(const tgb_canvas *c) { return c ? c->h : 0; }
size_t tgb_canvas_bytes(const tgb_canvas *c) { return c ? (size_t)c->w * (size_t)c->h * 4u : 0; }
const uint8_t *tgb_canvas_pixels(const tgb_canvas *c) { return c ? c->px : NULL; }
uint8_t *tgb_canvas_pixels_mut(tgb_canvas *c) { return c ? c->px : NULL; }
static inline void mark_px(tgb_canvas *c, int x, int y) {
if (c->dx0 >= c->dx1) { c->dx0 = x; c->dx1 = x + 1; c->dy0 = y; c->dy1 = y + 1; return; }
if (x < c->dx0) c->dx0 = x;
if (x >= c->dx1) c->dx1 = x + 1;
if (y < c->dy0) c->dy0 = y;
if (y >= c->dy1) c->dy1 = y + 1;
}
tgb_rect tgb_canvas_dirty(const tgb_canvas *c) {
tgb_rect r = {0, 0, 0, 0};
if (c && c->dx0 < c->dx1) { r.x = c->dx0; r.y = c->dy0; r.w = c->dx1 - c->dx0; r.h = c->dy1 - c->dy0; }
return r;
}
void tgb_canvas_reset_dirty(tgb_canvas *c) { if (c) c->dx0 = c->dy0 = c->dx1 = c->dy1 = 0; }
void tgb_canvas_mark_dirty(tgb_canvas *c, tgb_rect r) {
if (!c) return;
tgb_rect bounds = {0, 0, c->w, c->h};
r = tgb_rect_intersect(r, bounds);
if (r.w <= 0) return;
mark_px(c, r.x, r.y);
mark_px(c, r.x + r.w - 1, r.y + r.h - 1);
}
tgb_color tgb_canvas_get(const tgb_canvas *c, int x, int y) {
tgb_color z = {0, 0, 0, 0};
if (!c || x < 0 || y < 0 || x >= c->w || y >= c->h) return z;
const uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u;
tgb_color r = {p[0], p[1], p[2], p[3]};
return r;
}
void tgb_canvas_set(tgb_canvas *c, int x, int y, tgb_color col) {
if (!c || x < 0 || y < 0 || x >= c->w || y >= c->h) return;
uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u;
if (col.a == 0) { p[0] = p[1] = p[2] = p[3] = 0; } /* forma canónica: transparente = 0,0,0,0 */
else { p[0] = col.r; p[1] = col.g; p[2] = col.b; p[3] = col.a; }
mark_px(c, x, y);
}
static inline void blend_into(uint8_t *d, uint8_t sr, uint8_t sg, uint8_t sb, unsigned sa) {
if (sa == 0) return;
unsigned da = d[3];
if (sa == 255 || da == 0) { d[0] = sr; d[1] = sg; d[2] = sb; d[3] = (uint8_t)sa; return; }
unsigned inv = 255u - sa;
unsigned oa = sa + (da * inv + 127u) / 255u;
unsigned den = oa * 255u;
d[0] = (uint8_t)((sr * sa * 255u + d[0] * da * inv + den / 2u) / den);
d[1] = (uint8_t)((sg * sa * 255u + d[1] * da * inv + den / 2u) / den);
d[2] = (uint8_t)((sb * sa * 255u + d[2] * da * inv + den / 2u) / den);
d[3] = (uint8_t)oa;
}
void tgb_canvas_blend(tgb_canvas *c, int x, int y, tgb_color col) {
if (!c || col.a == 0 || x < 0 || y < 0 || x >= c->w || y >= c->h) return;
blend_into(c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u, col.r, col.g, col.b, col.a);
mark_px(c, x, y);
}
void tgb_canvas_erase_px(tgb_canvas *c, int x, int y, uint8_t amount) {
if (!c || amount == 0 || x < 0 || y < 0 || x >= c->w || y >= c->h) return;
uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u;
if (p[3] == 0) return;
unsigned na = (p[3] * (255u - amount) + 127u) / 255u;
if (na == 0) p[0] = p[1] = p[2] = p[3] = 0; else p[3] = (uint8_t)na;
mark_px(c, x, y);
}
void tgb_canvas_clear(tgb_canvas *c) {
if (!c) return;
memset(c->px, 0, tgb_canvas_bytes(c));
tgb_rect all = {0, 0, c->w, c->h};
tgb_canvas_mark_dirty(c, all);
}
void tgb_canvas_fill_rect(tgb_canvas *c, tgb_rect r, tgb_color col, bool blend) {
if (!c) return;
tgb_rect bounds = {0, 0, c->w, c->h};
r = tgb_rect_intersect(r, bounds);
for (int y = r.y; y < r.y + r.h; y++)
for (int x = r.x; x < r.x + r.w; x++) {
if (blend) tgb_canvas_blend(c, x, y, col); else tgb_canvas_set(c, x, y, col);
}
}
void tgb_canvas_blit(tgb_canvas *dst, const tgb_canvas *src, int dx, int dy, uint8_t opacity, bool blend) {
if (!dst || !src || opacity == 0 || dst == src) return;
tgb_rect bounds = {0, 0, dst->w, dst->h};
tgb_rect area = {dx, dy, src->w, src->h};
area = tgb_rect_intersect(area, bounds);
for (int y = area.y; y < area.y + area.h; y++) {
const uint8_t *s = src->px + ((size_t)(y - dy) * (size_t)src->w + (size_t)(area.x - dx)) * 4u;
uint8_t *d = dst->px + ((size_t)y * (size_t)dst->w + (size_t)area.x) * 4u;
for (int x = 0; x < area.w; x++, s += 4, d += 4) {
unsigned sa = opacity == 255 ? s[3] : (s[3] * (unsigned)opacity + 127u) / 255u;
if (blend) {
if (sa == 0) continue;
blend_into(d, s[0], s[1], s[2], sa);
} else if (sa == 0) { d[0] = d[1] = d[2] = d[3] = 0; }
else { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = (uint8_t)sa; }
mark_px(dst, area.x + x, y);
}
}
}
tgb_status tgb_canvas_crop(const tgb_canvas *src, tgb_rect r, tgb_canvas **out) {
if (!src || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_canvas_crop: argumento nulo");
if (r.w < 1 || r.h < 1 || r.x < 0 || r.y < 0 || r.x + r.w > src->w || r.y + r.h > src->h)
return tgb_fail(TGB_ERR_RANGE, "Recorte fuera del lienzo");
tgb_canvas *c = tgb_canvas_create(r.w, r.h);
if (!c) return TGB_ERR_NOMEM;
for (int y = 0; y < r.h; y++)
memcpy(c->px + (size_t)y * (size_t)r.w * 4u,
src->px + ((size_t)(r.y + y) * (size_t)src->w + (size_t)r.x) * 4u, (size_t)r.w * 4u);
*out = c;
return TGB_OK;
}
bool tgb_canvas_is_empty(const tgb_canvas *c) {
if (!c) return true;
size_t n = (size_t)c->w * (size_t)c->h;
for (size_t i = 0; i < n; i++) if (c->px[i * 4u + 3u]) return false;
return true;
}
bool tgb_canvas_equal(const tgb_canvas *a, const tgb_canvas *b) {
if (a == b) return true;
if (!a || !b || a->w != b->w || a->h != b->h) return false;
return memcmp(a->px, b->px, tgb_canvas_bytes(a)) == 0;
}
tgb_status tgb_canvas_copy_pixels(tgb_canvas *dst, const tgb_canvas *src) {
if (!dst || !src) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_canvas_copy_pixels: argumento nulo");
if (dst->w != src->w || dst->h != src->h) return tgb_fail(TGB_ERR_RANGE, "Los lienzos tienen distinto tamaño");
memcpy(dst->px, src->px, tgb_canvas_bytes(src));
tgb_rect all = {0, 0, dst->w, dst->h};
tgb_canvas_mark_dirty(dst, all);
return TGB_OK;
}
+125
View File
@@ -0,0 +1,125 @@
/* INTERNO de la interfaz gráfica (raylib). Estado de la aplicación y funciones compartidas.
* La interfaz SOLO usa la API pública (include/tahgibu): no toca estructuras internas. */
#ifndef TAHGIBU_UI_APP_H
#define TAHGIBU_UI_APP_H
#include <raylib.h>
#include "tahgibu/tahgibu.h"
typedef enum { TOOL_PENCIL, TOOL_BRUSH, TOOL_ERASER, TOOL_LINE, TOOL_RECT, TOOL_ELLIPSE, TOOL_CURVE, TOOL_FILL, TOOL_PICK, TOOL_SELECT, TOOL_COUNT } tool_id;
typedef enum { DLG_NONE, DLG_PROMPT, DLG_CONFIRM, DLG_RECOVERY, DLG_PLUGIN_CAPS } dialog_kind;
typedef enum {
PROMPT_NEW, PROMPT_OPEN, PROMPT_SAVE_AS, PROMPT_EXPORT_PNG, PROMPT_EXPORT_SEQ, PROMPT_EXPORT_APNG,
PROMPT_IMPORT_PNG, PROMPT_LAYER_NAME, PROMPT_POINT_NAME, PROMPT_DURATION, PROMPT_INSTALL_PLUGIN, PROMPT_FPS
} prompt_kind;
typedef enum { PEND_NONE, PEND_QUIT, PEND_NEW, PEND_OPEN } pending_action;
typedef struct app {
tgb_context *ctx;
tgb_document *doc;
tgb_recovery *rec;
tgb_plugin_manager *plugins;
tgb_player *player;
size_t frame, layer; /* fotograma y capa activos (índices) */
/* herramientas */
tool_id tool;
float size, hardness;
tgb_color color;
bool fill_shapes;
int tolerance;
tgb_onion_settings onion;
tgb_play_mode play_mode;
/* vista */
float zoom;
Vector2 pan;
Rectangle canvas_rect;
Texture2D tex;
tgb_canvas *view;
bool view_dirty;
/* trazo en curso */
bool drawing, panning;
bool straight_stroke; /* Shift durante el trazo: convertirlo en una línea recta */
tgb_canvas *image_clipboard; /* imagen temporal copiada dentro de Tahgibu */
bool clipboard_from_selection; /* al pegar, colocar la selección en el cursor */
Vector2 last, start;
int curve_stage;
Vector2 curve_a, curve_b;
bool has_sel, sel_moving;
tgb_rect sel;
Vector2 sel_grab;
/* mensajes */
char status[300];
bool status_error;
double status_time;
/* diálogos */
dialog_kind dialog;
prompt_kind prompt;
char text[512];
size_t text_len;
char dlg_title[120];
pending_action pending;
size_t plugin_target;
uint32_t plugin_caps;
/* paneles */
float panel_scroll;
tgb_color palette[32];
size_t palette_n;
bool quit;
double last_adapt, last_autosave_warn;
} app;
/* mensajes en la barra de estado */
void ui_say(app *a, const char *fmt, ...);
void ui_fail(app *a, tgb_status s); /* muestra tgb_last_error() */
void ui_check(app *a, tgb_status s); /* si falla, avisa */
/* ids de la celda activa */
bool ui_active_ids(const app *a, uint32_t *frame_id, uint32_t *layer_id);
void ui_clamp(app *a);
void ui_set_document(app *a, tgb_document *doc);
void ui_open_prompt(app *a, prompt_kind kind, const char *title, const char *initial);
void ui_guard_unsaved(app *a, pending_action what);
void ui_refresh_view(app *a);
void ui_fit_view(app *a);
tgb_brush ui_make_brush(const app *a);
/* partes de la interfaz */
void ui_draw_topbar(app *a, Rectangle r);
void ui_draw_canvas(app *a, Rectangle r);
void ui_draw_panel(app *a, Rectangle r);
void ui_draw_timeline(app *a, Rectangle r);
void ui_draw_dialogs(app *a);
void ui_handle_shortcuts(app *a);
void ui_copy_image(app *a);
void ui_paste_image(app *a);
void ui_load_palette(app *a);
/* ---- widgets (estilo inmediato) ---- */
extern bool ui_modal; /* true: hay un diálogo y los widgets normales no responden */
void ui_begin_frame(void);
bool ui_button(Rectangle r, const char *label, bool selected, bool enabled);
bool ui_slider(Rectangle r, const char *label, float *value, float lo, float hi, bool integer);
bool ui_toggle(Rectangle r, const char *label, bool *value);
bool ui_hover(Rectangle r);
/* Texto compartido: carga una fuente del sistema si existe y mantiene un espaciado legible. */
bool ui_text_init(void);
void ui_text_shutdown(void);
void ui_text(int x, int y, const char *s, Color c);
void ui_text_size(int x, int y, const char *s, float font_size, Color c);
float ui_measure_text(const char *s, float font_size);
void ui_fit_text(char *dst, size_t capacity, const char *src, float max_width, float font_size);
void ui_panel_bg(Rectangle r, Color c);
Color ui_col(int which); /* colores del tema */
enum { UC_BG, UC_PANEL, UC_PANEL2, UC_TEXT, UC_DIM, UC_ACCENT, UC_BORDER, UC_DANGER, UC_OK };
#endif
+455
View File
@@ -0,0 +1,455 @@
#if defined(__linux__)
#define _POSIX_C_SOURCE 200809L
#endif
/* Lienzo central: zoom, desplazamiento y herramientas de dibujo.
* Todo lo que se dibuja pasa por tgb_edit_* (sesión de edición): nada se
* pinta "solo en pantalla"; cada trazo queda en el modelo y en el historial. */
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ui_app.h"
static Vector2 origin(const app *a) { return (Vector2){a->canvas_rect.x + a->pan.x, a->canvas_rect.y + a->pan.y}; }
static Vector2 to_doc(const app *a, Vector2 s) { Vector2 o = origin(a); return (Vector2){(s.x - o.x) / a->zoom, (s.y - o.y) / a->zoom}; }
static Vector2 to_screen(const app *a, float x, float y) { Vector2 o = origin(a); return (Vector2){o.x + x * a->zoom, o.y + y * a->zoom}; }
static bool begin_edit(app *a, const char *label);
static void end_edit(app *a);
static bool shift_down(void) {
return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
}
/* Shift ajusta el ángulo a horizontal, vertical o diagonal de 45 grados. */
static Vector2 snap_line_endpoint(Vector2 start, Vector2 end) {
float dx = end.x - start.x, dy = end.y - start.y;
float len = hypotf(dx, dy);
if (len < 0.001f) return end;
const float step = 0.7853981633974483f; /* PI / 4 */
float angle = atan2f(dy, dx);
float snapped = roundf(angle / step) * step;
return (Vector2){start.x + cosf(snapped) * len, start.y + sinf(snapped) * len};
}
#if defined(__linux__)
static bool clipboard_write_png(const uint8_t *data, size_t len) {
/* No abrir una tubería de escritura si falta wl-copy: evita SIGPIPE. */
if (system("command -v wl-copy >/dev/null 2>&1") != 0) return false;
FILE *pipe = popen("wl-copy --type image/png 2>/dev/null", "w");
if (!pipe) return false;
bool ok = fwrite(data, 1, len, pipe) == len;
int status = pclose(pipe);
return ok && status == 0;
}
static tgb_canvas *clipboard_read_png(void) {
FILE *pipe = popen("wl-paste --no-newline --type image/png 2>/dev/null", "r");
if (!pipe) return NULL;
const size_t limit = 64u * 1024u * 1024u;
size_t len = 0, cap = 8192;
uint8_t *data = malloc(cap);
if (!data) { pclose(pipe); return NULL; }
for (;;) {
if (len == cap) {
if (cap >= limit) { free(data); pclose(pipe); return NULL; }
size_t next = cap * 2;
if (next > limit) next = limit;
uint8_t *grown = realloc(data, next);
if (!grown) { free(data); pclose(pipe); return NULL; }
data = grown; cap = next;
}
size_t got = fread(data + len, 1, cap - len, pipe);
len += got;
if (got == 0) break;
}
int status = pclose(pipe);
tgb_canvas *image = NULL;
if (status == 0 && len > 8 && TGB_SUCCEEDED(tgb_png_decode_memory(data, len, &image))) {
if ((size_t)tgb_canvas_width(image) * (size_t)tgb_canvas_height(image) > 16000000u) {
tgb_canvas_release(image);
image = NULL;
}
}
free(data);
return image;
}
#else
static bool clipboard_write_png(const uint8_t *data, size_t len) { (void)data; (void)len; return false; }
static tgb_canvas *clipboard_read_png(void) { return NULL; }
#endif
void ui_copy_image(app *a) {
if (!a || !a->doc || tgb_player_is_playing(a->player) || tgb_edit_active(a->doc)) return;
int w = tgb_document_width(a->doc), h = tgb_document_height(a->doc);
tgb_canvas *flat = tgb_canvas_create(w, h);
if (!flat) { ui_say(a, "No hay memoria para copiar la imagen"); return; }
tgb_status status = tgb_render_flatten(a->doc, a->frame, flat);
if (TGB_FAILED(status)) {
tgb_canvas_release(flat);
ui_fail(a, status);
return;
}
tgb_rect r = {0, 0, w, h};
a->clipboard_from_selection = a->has_sel;
if (a->has_sel) {
r = tgb_rect_intersect(a->sel, (tgb_rect){0, 0, w, h});
if (r.w <= 0 || r.h <= 0) {
tgb_canvas_release(flat);
ui_say(a, "La seleccion no contiene pixeles para copiar");
return;
}
}
tgb_canvas *cropped = NULL;
status = tgb_canvas_crop(flat, r, &cropped);
tgb_canvas_release(flat);
if (TGB_FAILED(status) || !cropped) { ui_fail(a, status); return; }
tgb_canvas_release(a->image_clipboard);
a->image_clipboard = cropped;
uint8_t *png = NULL;
size_t png_len = 0;
bool encoded = TGB_SUCCEEDED(tgb_png_encode_memory(cropped, &png, &png_len));
bool exported = encoded && clipboard_write_png(png, png_len);
tgb_free(png);
if (exported) ui_say(a, "Imagen copiada. Podes pegarla con Ctrl+V en Tahgibu u otra app");
#if defined(__linux__)
else ui_say(a, "Imagen copiada dentro de Tahgibu. Para compartirla con otras apps instala wl-clipboard");
#else
else ui_say(a, "Imagen copiada dentro de Tahgibu; portapapeles de imagen externo no disponible en esta plataforma");
#endif
}
void ui_paste_image(app *a) {
if (!a || !a->doc || tgb_player_is_playing(a->player) || tgb_edit_active(a->doc)) return;
tgb_canvas *external = clipboard_read_png();
bool from_external = external != NULL;
if (external) {
tgb_canvas_release(a->image_clipboard);
a->image_clipboard = external;
a->clipboard_from_selection = false;
}
tgb_canvas *source = a->image_clipboard;
if (!source) {
#if defined(__linux__)
ui_say(a, "No hay imagen PNG en el portapapeles. Para pegar desde otras apps instala wl-clipboard");
#else
ui_say(a, "No hay una imagen copiada dentro de Tahgibu");
#endif
return;
}
int x = 0, y = 0;
Vector2 mouse = GetMousePosition();
if (from_external || a->clipboard_from_selection) {
if (mouse.x >= a->canvas_rect.x && mouse.y >= a->canvas_rect.y &&
mouse.x < a->canvas_rect.x + a->canvas_rect.width && mouse.y < a->canvas_rect.y + a->canvas_rect.height) {
Vector2 at = to_doc(a, mouse);
x = (int)floorf(at.x);
y = (int)floorf(at.y);
} else {
x = (tgb_document_width(a->doc) - tgb_canvas_width(source)) / 2;
y = (tgb_document_height(a->doc) - tgb_canvas_height(source)) / 2;
}
}
if (!begin_edit(a, "Pegar imagen")) return;
tgb_canvas_blit(tgb_edit_canvas(a->doc), source, x, y, 255, true);
end_edit(a);
ui_say(a, "Imagen pegada en la capa activa; la operacion se puede deshacer");
}
tgb_brush ui_make_brush(const app *a) {
switch (a->tool) {
case TOOL_PENCIL: return tgb_brush_pencil(a->color, (int)(a->size + 0.5f));
case TOOL_BRUSH: return tgb_brush_soft(a->color, a->size, a->hardness);
case TOOL_ERASER: return tgb_brush_eraser(a->size, a->hardness);
default: return a->size <= 1.0f ? tgb_brush_pencil(a->color, 1) : tgb_brush_soft(a->color, a->size, 1.0f);
}
}
void ui_fit_view(app *a) {
if (!a->doc) return;
float w = (float)tgb_document_width(a->doc), h = (float)tgb_document_height(a->doc);
float zx = (a->canvas_rect.width - 40) / w, zy = (a->canvas_rect.height - 40) / h;
a->zoom = zx < zy ? zx : zy;
if (a->zoom < 0.1f) a->zoom = 0.1f;
if (a->zoom > 16.0f) a->zoom = 16.0f;
a->pan.x = (a->canvas_rect.width - w * a->zoom) / 2;
a->pan.y = (a->canvas_rect.height - h * a->zoom) / 2;
}
void ui_refresh_view(app *a) {
if (!a->doc) return;
int w = tgb_document_width(a->doc), h = tgb_document_height(a->doc);
if (!a->view || tgb_canvas_width(a->view) != w || tgb_canvas_height(a->view) != h) {
if (a->tex.id != 0) UnloadTexture(a->tex);
tgb_canvas_release(a->view);
a->view = tgb_canvas_create(w, h);
if (!a->view) { ui_fail(a, TGB_ERR_NOMEM); return; }
Image img = {(void *)tgb_canvas_pixels(a->view), w, h, 1, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8};
a->tex = LoadTextureFromImage(img);
SetTextureFilter(a->tex, TEXTURE_FILTER_POINT);
}
tgb_onion_settings on = a->onion;
if (tgb_player_is_playing(a->player)) on.enabled = false; /* durante la reproducción no hay Onion Skin */
tgb_color transparent = {0, 0, 0, 0};
tgb_status s = tgb_render_view(a->doc, a->frame, &on, transparent, a->view);
if (TGB_FAILED(s)) { ui_fail(a, s); return; }
UpdateTexture(a->tex, tgb_canvas_pixels(a->view));
a->view_dirty = false;
}
static bool begin_edit(app *a, const char *label) {
uint32_t f, l;
if (!ui_active_ids(a, &f, &l)) return false;
tgb_status s = tgb_edit_begin(a->doc, f, l, label);
if (TGB_FAILED(s)) { ui_fail(a, s); return false; }
return true;
}
static void end_edit(app *a) {
tgb_status s = tgb_edit_end(a->doc);
if (TGB_FAILED(s)) ui_fail(a, s);
else if (s == TGB_WARN_HISTORY_LOST) ui_say(a, "Aviso: el cambio es demasiado grande para el historial y no se podra deshacer");
a->view_dirty = true;
}
static void cancel_edit(app *a) {
if (tgb_edit_active(a->doc)) tgb_edit_cancel(a->doc);
a->drawing = false; a->straight_stroke = false; a->curve_stage = 0; a->sel_moving = false;
a->view_dirty = true;
}
static tgb_rect rect_from(Vector2 p, Vector2 q) {
int x0 = (int)floorf(p.x < q.x ? p.x : q.x), y0 = (int)floorf(p.y < q.y ? p.y : q.y);
int x1 = (int)floorf(p.x > q.x ? p.x : q.x), y1 = (int)floorf(p.y > q.y ? p.y : q.y);
tgb_rect r = {x0, y0, x1 - x0 + 1, y1 - y0 + 1};
return r;
}
static void draw_curve_preview(app *a, Vector2 cursor) {
tgb_edit_reset(a->doc);
tgb_brush b = ui_make_brush(a);
Vector2 A = a->curve_a, B = a->curve_b;
Vector2 mid = {(A.x + B.x) / 2, (A.y + B.y) / 2};
Vector2 d = {(cursor.x - mid.x) * 4.0f / 3.0f, (cursor.y - mid.y) * 4.0f / 3.0f}; /* la curva pasa por el cursor */
tgb_draw_bezier(tgb_edit_canvas(a->doc), &b, A.x, A.y, A.x + (B.x - A.x) / 3 + d.x, A.y + (B.y - A.y) / 3 + d.y,
A.x + 2 * (B.x - A.x) / 3 + d.x, A.y + 2 * (B.y - A.y) / 3 + d.y, B.x, B.y);
a->view_dirty = true;
}
static void tool_press(app *a, Vector2 p) {
a->last = a->start = p;
switch (a->tool) {
case TOOL_PENCIL: case TOOL_BRUSH: case TOOL_ERASER: {
a->straight_stroke = shift_down();
if (!begin_edit(a, a->tool == TOOL_ERASER ? "Borrar" : (a->tool == TOOL_PENCIL ? "Lapiz" : "Pincel"))) return;
tgb_brush b = ui_make_brush(a);
tgb_draw_dab(tgb_edit_canvas(a->doc), &b, p.x, p.y);
a->drawing = true; a->view_dirty = true;
break;
}
case TOOL_LINE: case TOOL_RECT: case TOOL_ELLIPSE:
if (begin_edit(a, a->tool == TOOL_LINE ? "Linea" : (a->tool == TOOL_RECT ? "Rectangulo" : "Elipse"))) a->drawing = true;
break;
case TOOL_CURVE:
if (a->curve_stage == 1) { end_edit(a); a->curve_stage = 0; } /* 2.º clic: confirmar */
else if (begin_edit(a, "Curva")) { a->curve_a = p; a->drawing = true; a->curve_stage = 0; }
break;
case TOOL_FILL:
if (begin_edit(a, "Relleno")) {
tgb_flood_fill(tgb_edit_canvas(a->doc), (int)floorf(p.x), (int)floorf(p.y), a->color, a->tolerance);
end_edit(a);
}
break;
case TOOL_PICK: {
tgb_canvas *tmp = tgb_canvas_create(tgb_document_width(a->doc), tgb_document_height(a->doc));
if (tmp) {
if (TGB_SUCCEEDED(tgb_render_flatten(a->doc, a->frame, tmp))) {
tgb_color c = tgb_eyedropper(tmp, (int)floorf(p.x), (int)floorf(p.y));
if (c.a) { a->color = c; ui_say(a, "Color tomado: %d, %d, %d, %d", c.r, c.g, c.b, c.a); }
}
tgb_canvas_release(tmp);
}
break;
}
case TOOL_SELECT:
if (a->has_sel && p.x >= a->sel.x && p.y >= a->sel.y && p.x < a->sel.x + a->sel.w && p.y < a->sel.y + a->sel.h) {
if (begin_edit(a, "Mover seleccion")) { a->sel_moving = true; a->sel_grab = p; a->drawing = true; }
} else { a->has_sel = false; a->drawing = true; }
break;
default: break;
}
}
static void tool_drag(app *a, Vector2 p) {
if (!a->drawing && a->curve_stage != 1) return;
switch (a->tool) {
case TOOL_PENCIL: case TOOL_BRUSH: case TOOL_ERASER: {
if (shift_down()) a->straight_stroke = true;
tgb_brush b = ui_make_brush(a);
if (a->straight_stroke) {
tgb_edit_reset(a->doc);
Vector2 end = snap_line_endpoint(a->start, p);
tgb_draw_line(tgb_edit_canvas(a->doc), &b, a->start.x, a->start.y, end.x, end.y);
} else {
if (p.x == a->last.x && p.y == a->last.y) break;
tgb_draw_line(tgb_edit_canvas(a->doc), &b, a->last.x, a->last.y, p.x, p.y);
}
a->last = p; a->view_dirty = true;
break;
}
case TOOL_LINE: {
tgb_edit_reset(a->doc);
tgb_brush b = ui_make_brush(a);
Vector2 end = shift_down() ? snap_line_endpoint(a->start, p) : p;
tgb_draw_line(tgb_edit_canvas(a->doc), &b, a->start.x, a->start.y, end.x, end.y);
a->view_dirty = true;
break;
}
case TOOL_RECT: case TOOL_ELLIPSE: {
tgb_edit_reset(a->doc);
tgb_brush b = ui_make_brush(a);
tgb_rect r = rect_from(a->start, p);
if (a->tool == TOOL_RECT) tgb_draw_rect(tgb_edit_canvas(a->doc), &b, r, a->fill_shapes);
else tgb_draw_ellipse(tgb_edit_canvas(a->doc), &b, r, a->fill_shapes);
a->view_dirty = true;
break;
}
case TOOL_CURVE:
if (a->curve_stage == 0) {
tgb_edit_reset(a->doc);
tgb_brush b = ui_make_brush(a);
tgb_draw_line(tgb_edit_canvas(a->doc), &b, a->curve_a.x, a->curve_a.y, p.x, p.y);
a->view_dirty = true;
} else draw_curve_preview(a, p);
break;
case TOOL_SELECT:
if (a->sel_moving) {
tgb_edit_reset(a->doc);
tgb_region_move(tgb_edit_canvas(a->doc), a->sel, (int)lroundf(p.x - a->sel_grab.x), (int)lroundf(p.y - a->sel_grab.y));
a->view_dirty = true;
} else { a->sel = rect_from(a->start, p); a->has_sel = true; }
break;
default: break;
}
}
static void tool_release(app *a, Vector2 p) {
if (!a->drawing) return;
switch (a->tool) {
case TOOL_PENCIL: case TOOL_BRUSH: case TOOL_ERASER:
if (shift_down()) a->straight_stroke = true;
if (a->straight_stroke) {
tgb_edit_reset(a->doc);
tgb_brush b = ui_make_brush(a);
Vector2 end = snap_line_endpoint(a->start, p);
tgb_draw_line(tgb_edit_canvas(a->doc), &b, a->start.x, a->start.y, end.x, end.y);
}
end_edit(a); a->drawing = false; a->straight_stroke = false;
break;
case TOOL_LINE: case TOOL_RECT: case TOOL_ELLIPSE:
end_edit(a); a->drawing = false;
break;
case TOOL_CURVE:
if (fabsf(p.x - a->curve_a.x) + fabsf(p.y - a->curve_a.y) < 2.0f) { cancel_edit(a); } /* un clic sin arrastrar: nada */
else { a->curve_b = p; a->curve_stage = 1; a->drawing = false; } /* ahora se dobla con el ratón */
break;
case TOOL_SELECT:
if (a->sel_moving) {
a->sel.x += (int)lroundf(p.x - a->sel_grab.x);
a->sel.y += (int)lroundf(p.y - a->sel_grab.y);
end_edit(a);
a->sel_moving = false;
}
a->drawing = false;
break;
default: a->drawing = false; break;
}
}
/* Operaciones de la selección (botones del panel / atajos). */
void ui_selection_op(app *a, int op) {
if (!a->has_sel || !begin_edit(a, op == 0 ? "Voltear horizontal" : op == 1 ? "Voltear vertical" : op == 2 ? "Borrar seleccion" : "Escalar seleccion")) return;
tgb_canvas *c = tgb_edit_canvas(a->doc);
tgb_status s = TGB_OK;
if (op == 0) s = tgb_region_flip(c, a->sel, true);
else if (op == 1) s = tgb_region_flip(c, a->sel, false);
else if (op == 2) s = tgb_region_clear(c, a->sel);
else if (op == 3 || op == 4) {
int nw = op == 3 ? a->sel.w * 2 : (a->sel.w + 1) / 2, nh = op == 3 ? a->sel.h * 2 : (a->sel.h + 1) / 2;
s = tgb_region_scale(c, a->sel, nw, nh);
if (TGB_SUCCEEDED(s)) { a->sel.w = nw; a->sel.h = nh; }
}
if (TGB_FAILED(s)) { ui_fail(a, s); cancel_edit(a); return; }
end_edit(a);
}
void ui_draw_canvas(app *a, Rectangle r) {
a->canvas_rect = r;
Vector2 m = GetMousePosition();
bool over = ui_hover(r);
bool playing = tgb_player_is_playing(a->player);
Vector2 p = to_doc(a, m);
bool space = IsKeyDown(KEY_SPACE);
if (over) { /* zoom con la rueda, centrado en el cursor */
float wheel = GetMouseWheelMove();
if (wheel != 0.0f) {
float nz = a->zoom * powf(1.15f, wheel);
if (nz < 0.1f) nz = 0.1f;
if (nz > 32.0f) nz = 32.0f;
a->pan.x = (m.x - r.x) - p.x * nz;
a->pan.y = (m.y - r.y) - p.y * nz;
a->zoom = nz;
p = to_doc(a, m);
}
}
if (a->panning) { /* desplazar con botón central o Espacio + clic */
if (IsMouseButtonDown(MOUSE_BUTTON_MIDDLE) || (space && IsMouseButtonDown(MOUSE_BUTTON_LEFT))) {
a->pan.x += m.x - a->last.x; a->pan.y += m.y - a->last.y; a->last = m;
} else a->panning = false;
} else if (over && (IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE) || (space && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)))) { a->panning = true; a->last = m; }
if (!a->panning && !playing && a->doc) {
if (over && IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && !space) tool_press(a, p);
else if (over && a->tool == TOOL_CURVE && a->curve_stage == 1 && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) cancel_edit(a);
if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) || a->curve_stage == 1) tool_drag(a, p);
if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) tool_release(a, p);
if (IsKeyPressed(KEY_ESCAPE)) cancel_edit(a);
}
if (!IsMouseButtonDown(MOUSE_BUTTON_LEFT) && a->drawing && a->tool != TOOL_CURVE) tool_release(a, p); /* se soltó fuera de la ventana */
if (a->view_dirty) ui_refresh_view(a);
DrawRectangleRec(r, (Color){24, 25, 29, 255});
BeginScissorMode((int)r.x, (int)r.y, (int)r.width, (int)r.height);
if (a->doc) {
int dw = tgb_document_width(a->doc), dh = tgb_document_height(a->doc);
Vector2 o = origin(a);
Rectangle dst = {o.x, o.y, (float)dw * a->zoom, (float)dh * a->zoom};
float x0 = dst.x > r.x ? dst.x : r.x, y0 = dst.y > r.y ? dst.y : r.y;
float x1 = (dst.x + dst.width) < (r.x + r.width) ? (dst.x + dst.width) : (r.x + r.width);
float y1 = (dst.y + dst.height) < (r.y + r.height) ? (dst.y + dst.height) : (r.y + r.height);
const float tile = 16.0f;
for (float ty = floorf((y0 - dst.y) / tile) * tile + dst.y; ty < y1; ty += tile)
for (float tx = floorf((x0 - dst.x) / tile) * tile + dst.x; tx < x1; tx += tile) {
bool dark = (((int)floorf((tx - dst.x) / tile) + (int)floorf((ty - dst.y) / tile)) & 1) != 0;
float ax = tx < x0 ? x0 : tx, ay = ty < y0 ? y0 : ty, bx = tx + tile > x1 ? x1 : tx + tile, by = ty + tile > y1 ? y1 : ty + tile;
if (bx > ax && by > ay) DrawRectangleRec((Rectangle){ax, ay, bx - ax, by - ay}, dark ? (Color){120, 120, 126, 255} : (Color){160, 160, 166, 255});
}
if (a->tex.id != 0) DrawTexturePro(a->tex, (Rectangle){0, 0, (float)dw, (float)dh}, dst, (Vector2){0, 0}, 0.0f, (Color){255, 255, 255, 255});
DrawRectangleLinesEx((Rectangle){dst.x - 1, dst.y - 1, dst.width + 2, dst.height + 2}, 1, (Color){0, 0, 0, 255});
if (a->has_sel) {
Vector2 s0 = to_screen(a, (float)a->sel.x, (float)a->sel.y);
Rectangle sr = {s0.x, s0.y, (float)a->sel.w * a->zoom, (float)a->sel.h * a->zoom};
DrawRectangleLinesEx(sr, 1, (Color){255, 255, 255, 255});
DrawRectangleLinesEx((Rectangle){sr.x + 1, sr.y + 1, sr.width - 2, sr.height - 2}, 1, (Color){0, 0, 0, 255});
}
if (over && !a->panning && !playing && (a->tool <= TOOL_ERASER)) {
DrawCircleLines((int)m.x, (int)m.y, a->size * 0.5f * a->zoom + 0.5f, (Color){255, 255, 255, 220});
}
}
EndScissorMode();
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
}
+510
View File
@@ -0,0 +1,510 @@
/* Ventana principal: barra superior, diálogos, atajos y bucle de la aplicación. */
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "ui_app.h"
#include "ui_run.h"
void ui_selection_op(app *a, int op);
/* ================= mensajes y utilidades ================= */
void ui_say(app *a, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(a->status, sizeof a->status, fmt, ap);
va_end(ap);
a->status_error = false;
a->status_time = GetTime();
}
void ui_fail(app *a, tgb_status s) {
snprintf(a->status, sizeof a->status, "%s", tgb_last_error());
(void)s;
a->status_error = true;
a->status_time = GetTime();
}
void ui_check(app *a, tgb_status s) {
if (TGB_FAILED(s)) ui_fail(a, s);
a->view_dirty = true;
}
bool ui_active_ids(const app *a, uint32_t *f, uint32_t *l) {
if (!a->doc) return false;
*f = tgb_frame_id(a->doc, a->frame);
*l = tgb_layer_id(a->doc, a->layer);
return *f != 0 && *l != 0;
}
void ui_clamp(app *a) {
if (!a->doc) return;
size_t nf = tgb_frame_count(a->doc), nl = tgb_layer_count(a->doc);
if (a->frame >= nf) a->frame = nf - 1;
if (a->layer >= nl) a->layer = nl - 1;
}
static size_t utf8_encode(char *o, int cp) {
if (cp < 0x80) { o[0] = (char)cp; return 1; }
if (cp < 0x800) { o[0] = (char)(0xC0 | (cp >> 6)); o[1] = (char)(0x80 | (cp & 0x3F)); return 2; }
if (cp < 0x10000) { o[0] = (char)(0xE0 | (cp >> 12)); o[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[2] = (char)(0x80 | (cp & 0x3F)); return 3; }
o[0] = (char)(0xF0 | (cp >> 18)); o[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); o[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}
/* ================= paleta ================= */
static const unsigned BUILTIN_PALETTE[] = {0x000000, 0xFFFFFF, 0x7F7F7F, 0xC3C3C3, 0x880015, 0xED1C24, 0xFF7F27, 0xFFF200, 0x22B14C, 0xB5E61D,
0x00A2E8, 0x99D9EA, 0x3F48CC, 0x7092BE, 0xA349A4, 0xC8BFE7, 0xB97A57, 0xFFAEC9, 0xFFC90E, 0xEFE4B0};
void ui_load_palette(app *a) {
a->palette_n = 0;
const char *paths[] = {"resources/palettes/default.hex", "../share/tahgibu/palettes/default.hex", "../resources/palettes/default.hex"};
for (size_t p = 0; p < sizeof paths / sizeof paths[0] && a->palette_n == 0; p++) {
FILE *f = fopen(paths[p], "r");
if (!f) continue;
char line[64];
while (fgets(line, sizeof line, f) && a->palette_n < 32) {
unsigned v;
if (line[0] == '#' || line[0] == ';') continue;
if (sscanf(line, "%6x", &v) == 1) { tgb_color c = {(uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v, 255}; a->palette[a->palette_n++] = c; }
}
fclose(f);
}
if (a->palette_n == 0) /* si no se encuentra el archivo, paleta integrada */
for (size_t i = 0; i < sizeof BUILTIN_PALETTE / sizeof BUILTIN_PALETTE[0]; i++) {
unsigned v = BUILTIN_PALETTE[i];
tgb_color c = {(uint8_t)(v >> 16), (uint8_t)(v >> 8), (uint8_t)v, 255};
a->palette[a->palette_n++] = c;
}
}
/* ================= documento ================= */
void ui_set_document(app *a, tgb_document *doc) {
if (a->player) { tgb_player_destroy(a->player); a->player = NULL; }
if (a->doc) {
if (a->rec) tgb_recovery_detach(a->rec, a->doc);
tgb_document_destroy(a->doc);
}
a->doc = doc;
a->player = tgb_player_create(doc);
tgb_player_set_mode(a->player, a->play_mode);
a->frame = a->layer = 0;
a->has_sel = false; a->drawing = false; a->curve_stage = 0; a->sel_moving = false;
if (a->rec) {
const char *p = tgb_document_path(doc), *base = p ? strrchr(p, '/') : NULL;
tgb_recovery_attach(a->rec, doc, p ? (base ? base + 1 : p) : "Sin titulo");
}
a->view_dirty = true;
ui_fit_view(a);
}
void ui_open_prompt(app *a, prompt_kind kind, const char *title, const char *initial) {
a->dialog = DLG_PROMPT;
a->prompt = kind;
snprintf(a->dlg_title, sizeof a->dlg_title, "%s", title);
snprintf(a->text, sizeof a->text, "%s", initial ? initial : "");
a->text_len = strlen(a->text);
}
static bool do_save_to(app *a, const char *path) {
tgb_status s = tgb_document_save(a->doc, path, NULL);
if (TGB_FAILED(s)) { ui_fail(a, s); return false; }
if (a->rec) tgb_recovery_notify_saved(a->rec, a->doc);
ui_say(a, "Guardado: %s", tgb_document_path(a->doc));
return true;
}
static bool do_save(app *a) {
if (tgb_document_path(a->doc)) return do_save_to(a, NULL);
ui_open_prompt(a, PROMPT_SAVE_AS, "Guardar proyecto como (ruta del archivo .tgb)", "proyecto.tgb");
return false;
}
static void run_pending(app *a, pending_action what) {
a->pending = PEND_NONE;
if (what == PEND_QUIT) a->quit = true;
else if (what == PEND_NEW) ui_open_prompt(a, PROMPT_NEW, "Nuevo proyecto: ANCHOxALTO@FPS (p. ej. 960x540@12)", "960x540@12");
else if (what == PEND_OPEN) ui_open_prompt(a, PROMPT_OPEN, "Abrir proyecto (ruta del archivo .tgb)", "");
}
void ui_guard_unsaved(app *a, pending_action what) {
if (a->doc && tgb_document_dirty(a->doc)) { a->pending = what; a->dialog = DLG_CONFIRM; }
else run_pending(a, what);
}
static void prompt_accept(app *a) {
char *t = a->text;
prompt_kind k = a->prompt;
a->dialog = DLG_NONE;
switch (k) {
case PROMPT_NEW: {
int w = 0, h = 0, fps = 12;
if (sscanf(t, "%dx%d@%d", &w, &h, &fps) < 2) { ui_say(a, "Formato no valido: usa ANCHOxALTO@FPS"); a->status_error = true; return; }
tgb_document *d = NULL;
tgb_status s = tgb_document_create(a->ctx, w, h, fps, &d);
if (TGB_FAILED(s)) { ui_fail(a, s); return; }
ui_set_document(a, d);
ui_say(a, "Proyecto nuevo %dx%d a %d fps", w, h, fps);
break;
}
case PROMPT_OPEN: {
tgb_document *d = NULL;
tgb_status s = tgb_document_load(a->ctx, t, &d);
if (TGB_FAILED(s)) { ui_fail(a, s); return; }
ui_set_document(a, d);
ui_say(a, "Abierto: %s", t);
break;
}
case PROMPT_SAVE_AS:
if (do_save_to(a, t) && a->pending != PEND_NONE) run_pending(a, a->pending);
break;
case PROMPT_EXPORT_PNG: { tgb_status s = tgb_export_png(a->doc, a->frame, t); if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Exportado: %s", t); break; }
case PROMPT_EXPORT_APNG: { tgb_status s = tgb_export_apng(a->doc, t); if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Animacion exportada: %s", t); break; }
case PROMPT_EXPORT_SEQ: {
char dir[512], *slash = strrchr(t, '/'), *prefix = slash ? slash + 1 : t;
snprintf(dir, sizeof dir, "%s", slash ? t : ".");
if (slash) dir[slash - t] = '\0';
if (!prefix[0]) prefix = "anim";
tgb_status s = tgb_export_png_sequence(a->doc, dir, prefix);
if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Secuencia exportada en %s (%s_0001.png ...)", dir, prefix);
break;
}
case PROMPT_IMPORT_PNG: {
uint32_t id;
tgb_status s = tgb_import_png(a->doc, t, a->frame + 1, a->layer, &id);
if (TGB_FAILED(s)) ui_fail(a, s); else { a->frame++; ui_say(a, "Imagen importada como fotograma %zu", a->frame + 1); }
a->view_dirty = true;
break;
}
case PROMPT_LAYER_NAME: ui_check(a, tgb_layer_rename(a->doc, a->layer, t)); break;
case PROMPT_POINT_NAME: { uint32_t id; tgb_status s = tgb_recovery_point_create(a->doc, t, &id); if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Punto de recuperacion creado: %s", t); break; }
case PROMPT_DURATION: ui_check(a, tgb_frame_set_duration(a->doc, a->frame, (uint32_t)strtoul(t, NULL, 10))); break;
case PROMPT_FPS: ui_check(a, tgb_document_set_fps(a->doc, atoi(t), true)); break;
case PROMPT_INSTALL_PLUGIN: {
if (!a->plugins) break;
char id[64], *slash = strrchr(t, '/');
snprintf(id, sizeof id, "%.47s", slash ? slash + 1 : t); /* el id admite 48 caracteres como máximo */
char *dot = strchr(id, '.');
if (dot) *dot = '\0';
for (char *c = id; *c; c++) { if (*c >= 'A' && *c <= 'Z') *c = (char)(*c - 'A' + 'a'); else if (!((*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9') || *c == '-' || *c == '_')) *c = '-'; }
tgb_status s = tgb_plugin_install(a->plugins, t, id, NULL);
if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Extension '%s' instalada (apagada). Habilitala cuando quieras.", id);
break;
}
}
}
/* ================= acciones ================= */
static void do_undo(app *a) {
if (tgb_edit_active(a->doc)) return;
tgb_status s = tgb_history_undo(a->doc);
if (s == TGB_ERR_NOTHING) ui_say(a, "No hay nada que deshacer"); else if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Deshecho");
ui_clamp(a); a->view_dirty = true;
}
static void do_redo(app *a) {
if (tgb_edit_active(a->doc)) return;
tgb_status s = tgb_history_redo(a->doc);
if (s == TGB_ERR_NOTHING) ui_say(a, "No hay nada que rehacer"); else if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Rehecho");
ui_clamp(a); a->view_dirty = true;
}
void ui_handle_shortcuts(app *a) {
if (ui_modal || !a->doc) return;
bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL), shift = IsKeyDown(KEY_LEFT_SHIFT);
if (ctrl) {
if (IsKeyPressed(KEY_Z)) { if (shift) do_redo(a); else do_undo(a); }
else if (IsKeyPressed(KEY_Y)) do_redo(a);
else if (IsKeyPressed(KEY_S)) { if (shift) ui_open_prompt(a, PROMPT_SAVE_AS, "Guardar proyecto como (ruta del archivo .tgb)", tgb_document_path(a->doc) ? tgb_document_path(a->doc) : "proyecto.tgb"); else do_save(a); }
else if (IsKeyPressed(KEY_O)) ui_guard_unsaved(a, PEND_OPEN);
else if (IsKeyPressed(KEY_N)) ui_guard_unsaved(a, PEND_NEW);
else if (IsKeyPressed(KEY_C)) ui_copy_image(a);
else if (IsKeyPressed(KEY_V)) ui_paste_image(a);
return;
}
if (a->drawing) return;
static const struct { int key; tool_id tool; } map[] = {{KEY_P, TOOL_PENCIL}, {KEY_B, TOOL_BRUSH}, {KEY_E, TOOL_ERASER}, {KEY_L, TOOL_LINE}, {KEY_R, TOOL_RECT},
{KEY_O, TOOL_ELLIPSE}, {KEY_C, TOOL_CURVE}, {KEY_F, TOOL_FILL}, {KEY_I, TOOL_PICK}, {KEY_S, TOOL_SELECT}};
for (size_t i = 0; i < sizeof map / sizeof map[0]; i++) if (IsKeyPressed(map[i].key)) { a->tool = map[i].tool; a->curve_stage = 0; }
if (IsKeyPressed(KEY_LEFT_BRACKET) && a->size > 1) a->size -= 1;
if (IsKeyPressed(KEY_RIGHT_BRACKET) && a->size < 128) a->size += 1;
if (IsKeyPressed(KEY_ENTER)) { if (tgb_player_is_playing(a->player)) tgb_player_pause(a->player); else { tgb_player_seek(a->player, a->frame); tgb_player_play(a->player); } a->view_dirty = true; }
if (!tgb_player_is_playing(a->player)) {
if (IsKeyPressed(KEY_COMMA) && a->frame > 0) { a->frame--; tgb_player_seek(a->player, a->frame); a->view_dirty = true; }
if (IsKeyPressed(KEY_PERIOD) && a->frame + 1 < tgb_frame_count(a->doc)) { a->frame++; tgb_player_seek(a->player, a->frame); a->view_dirty = true; }
if (IsKeyPressed(KEY_DELETE) && a->has_sel) ui_selection_op(a, 2);
}
}
/* ================= barra superior ================= */
void ui_draw_topbar(app *a, Rectangle r) {
DrawRectangleRec(r, ui_col(UC_PANEL));
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
bool idle = !tgb_player_is_playing(a->player);
const float gap = 4.0f;
/* Fila 1: archivos e importación/exportación. Los títulos completos caben incluso a 960 px. */
static const char *file_labels[] = {
"Nuevo", "Abrir", "Guardar", "Guardar como", "Importar PNG",
"Exportar PNG", "Exportar APNG", "Exportar fotogramas"
};
const size_t file_count = sizeof file_labels / sizeof file_labels[0];
float bw = (r.width - 12.0f - gap * (float)(file_count - 1)) / (float)file_count;
if (bw > 150.0f) bw = 150.0f;
for (size_t i = 0; i < file_count; ++i) {
Rectangle b = {r.x + 6.0f + (bw + gap) * (float)i, r.y + 4.0f, bw, 28.0f};
bool enabled = idle && a->doc != NULL;
if (!ui_button(b, file_labels[i], false, enabled)) continue;
switch (i) {
case 0: ui_guard_unsaved(a, PEND_NEW); break;
case 1: ui_guard_unsaved(a, PEND_OPEN); break;
case 2: do_save(a); break;
case 3: ui_open_prompt(a, PROMPT_SAVE_AS, "Guardar proyecto como (ruta del archivo .tgb)", tgb_document_path(a->doc) ? tgb_document_path(a->doc) : "proyecto.tgb"); break;
case 4: ui_open_prompt(a, PROMPT_IMPORT_PNG, "Importar PNG como nuevo fotograma (ruta del archivo)", ""); break;
case 5: ui_open_prompt(a, PROMPT_EXPORT_PNG, "Exportar el fotograma actual a PNG (ruta del archivo)", "fotograma.png"); break;
case 6: ui_open_prompt(a, PROMPT_EXPORT_APNG, "Exportar animacion APNG (ruta del archivo)", "animacion.apng"); break;
case 7: ui_open_prompt(a, PROMPT_EXPORT_SEQ, "Guardar cada fotograma como PNG separado: carpeta/prefijo (la carpeta debe existir)", "./anim"); break;
}
}
/* Fila 2: historial y vista. */
static const char *view_labels[] = {"Deshacer", "Rehacer", "Zoom -", "Zoom +", "Ajustar vista", "Escala 1:1"};
const size_t view_count = sizeof view_labels / sizeof view_labels[0];
float vw = (r.width - 12.0f - gap * (float)(view_count - 1)) / (float)view_count;
if (vw > 150.0f) vw = 150.0f;
for (size_t i = 0; i < view_count; ++i) {
Rectangle b = {r.x + 6.0f + (vw + gap) * (float)i, r.y + 36.0f, vw, 28.0f};
bool enabled = idle && a->doc != NULL;
if (i == 0) enabled = enabled && tgb_history_can_undo(a->doc);
if (i == 1) enabled = enabled && tgb_history_can_redo(a->doc);
if (!ui_button(b, view_labels[i], false, enabled)) continue;
switch (i) {
case 0: do_undo(a); break;
case 1: do_redo(a); break;
case 2: a->zoom = fmaxf(0.1f, a->zoom / 1.25f); break;
case 3: a->zoom = fminf(32.0f, a->zoom * 1.25f); break;
case 4: ui_fit_view(a); break;
case 5:
a->zoom = 1.0f;
a->pan.x = (a->canvas_rect.width - (float)tgb_document_width(a->doc)) / 2.0f;
a->pan.y = (a->canvas_rect.height - (float)tgb_document_height(a->doc)) / 2.0f;
break;
}
}
/* Fila 3: herramientas. Nombres breves evitan que las etiquetas se invadan. */
static const char *tools[TOOL_COUNT] = {
"Lapiz (P)", "Pincel (B)", "Borrar (E)", "Linea (L)", "Rect. (R)",
"Elipse (O)", "Curva (C)", "Relleno (F)", "Gota (I)", "Selec. (S)"
};
float tw = (r.width - 12.0f - gap * (float)(TOOL_COUNT - 1)) / (float)TOOL_COUNT;
if (tw > 130.0f) tw = 130.0f;
for (int i = 0; i < TOOL_COUNT; ++i) {
Rectangle b = {r.x + 6.0f + (tw + gap) * (float)i, r.y + 68.0f, tw, 28.0f};
if (ui_button(b, tools[i], a->tool == (tool_id)i, idle)) {
if (a->curve_stage) { tgb_edit_cancel(a->doc); a->curve_stage = 0; a->view_dirty = true; }
a->tool = (tool_id)i;
}
}
}
/* ================= diálogos ================= */
static void draw_text_field(app *a, Rectangle r) {
DrawRectangleRec(r, (Color){18, 19, 23, 255});
DrawRectangleLinesEx(r, 1, ui_col(UC_ACCENT));
BeginScissorMode((int)r.x + 2, (int)r.y, (int)r.width - 4, (int)r.height);
int tw = (int)ui_measure_text(a->text, 16.0f);
int off = tw > (int)r.width - 16 ? tw - ((int)r.width - 16) : 0;
ui_text_size((int)r.x + 8 - off, (int)r.y + 8, a->text, 16.0f, ui_col(UC_TEXT));
if (((int)(GetTime() * 2)) % 2 == 0) DrawRectangle((int)r.x + 8 + tw - off, (int)r.y + 6, 2, 20, ui_col(UC_TEXT));
EndScissorMode();
}
static void text_input_keys(app *a) {
for (int cp = GetCharPressed(); cp > 0; cp = GetCharPressed()) {
if (cp < 32) continue;
char enc[4];
size_t n = utf8_encode(enc, cp);
if (a->text_len + n < sizeof a->text) { memcpy(a->text + a->text_len, enc, n); a->text_len += n; a->text[a->text_len] = '\0'; }
}
if (IsKeyPressed(KEY_BACKSPACE) || IsKeyPressedRepeat(KEY_BACKSPACE)) {
while (a->text_len > 0) { a->text_len--; if (((unsigned char)a->text[a->text_len] & 0xC0) != 0x80) break; } /* borra un carácter UTF-8 entero */
a->text[a->text_len] = '\0';
}
if ((IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL)) && IsKeyPressed(KEY_V)) {
const char *clip = GetClipboardText();
if (clip) for (size_t i = 0; clip[i] && a->text_len + 1 < sizeof a->text; i++) if ((unsigned char)clip[i] >= 32 || (unsigned char)clip[i] >= 0x80) a->text[a->text_len++] = clip[i];
a->text[a->text_len] = '\0';
}
}
void ui_draw_dialogs(app *a) {
if (a->dialog == DLG_NONE) return;
float sw = (float)GetScreenWidth(), sh = (float)GetScreenHeight();
DrawRectangle(0, 0, (int)sw, (int)sh, (Color){0, 0, 0, 150});
ui_modal = false; /* los widgets del diálogo SÍ responden */
Rectangle box = {sw / 2 - 300, sh / 2 - 110, 600, 220};
if (a->dialog == DLG_RECOVERY) box = (Rectangle){sw / 2 - 330, sh / 2 - 190, 660, 380};
DrawRectangleRec(box, ui_col(UC_PANEL));
DrawRectangleLinesEx(box, 2, ui_col(UC_ACCENT));
if (a->dialog == DLG_PROMPT) {
ui_text((int)box.x + 16, (int)box.y + 14, a->dlg_title, ui_col(UC_TEXT));
text_input_keys(a);
draw_text_field(a, (Rectangle){box.x + 16, box.y + 50, box.width - 32, 34});
ui_text((int)box.x + 16, (int)box.y + 96, "Enter = aceptar Esc = cancelar Ctrl+V = pegar", ui_col(UC_DIM));
bool ok = ui_button((Rectangle){box.x + box.width - 220, box.y + box.height - 50, 100, 32}, "Aceptar", true, true) || IsKeyPressed(KEY_ENTER);
bool cancel = ui_button((Rectangle){box.x + box.width - 112, box.y + box.height - 50, 100, 32}, "Cancelar", false, true) || IsKeyPressed(KEY_ESCAPE);
if (ok) prompt_accept(a);
else if (cancel) { a->dialog = DLG_NONE; a->pending = PEND_NONE; }
} else if (a->dialog == DLG_CONFIRM) {
ui_text((int)box.x + 16, (int)box.y + 16, "Hay cambios sin guardar en el proyecto.", ui_col(UC_TEXT));
ui_text((int)box.x + 16, (int)box.y + 44, "Que quieres hacer?", ui_col(UC_DIM));
pending_action pend = a->pending;
if (ui_button((Rectangle){box.x + 16, box.y + box.height - 50, 150, 32}, "Guardar", true, true)) {
a->dialog = DLG_NONE;
if (tgb_document_path(a->doc)) { if (do_save_to(a, NULL)) run_pending(a, pend); else a->pending = PEND_NONE; }
else { a->pending = pend; ui_open_prompt(a, PROMPT_SAVE_AS, "Guardar proyecto como (ruta del archivo .tgb)", "proyecto.tgb"); }
} else if (ui_button((Rectangle){box.x + 176, box.y + box.height - 50, 150, 32}, "Descartar cambios", false, true)) { a->dialog = DLG_NONE; run_pending(a, pend); }
else if (ui_button((Rectangle){box.x + box.width - 116, box.y + box.height - 50, 100, 32}, "Cancelar", false, true) || IsKeyPressed(KEY_ESCAPE)) { a->dialog = DLG_NONE; a->pending = PEND_NONE; }
} else if (a->dialog == DLG_RECOVERY && a->rec) {
ui_text((int)box.x + 16, (int)box.y + 12, "Se encontro trabajo de una sesion que no se cerro bien", ui_col(UC_TEXT));
ui_text((int)box.x + 16, (int)box.y + 34, "Tus archivos originales no se han tocado. Elige que hacer con cada copia:", ui_col(UC_DIM));
size_t n = tgb_recovery_candidate_count(a->rec);
for (size_t i = 0; i < n && i < 6; i++) {
tgb_recovery_candidate c;
if (TGB_FAILED(tgb_recovery_candidate_at(a->rec, i, &c))) continue;
float y = box.y + 66 + (float)i * 46;
char when[64] = "?";
time_t tt = (time_t)c.saved_unix;
struct tm *lt = localtime(&tt);
if (lt) strftime(when, sizeof when, "%d/%m/%Y %H:%M", lt);
char line[400];
snprintf(line, sizeof line, "%s (%s)", c.title, when);
ui_text_size((int)box.x + 16, (int)y, line, 14.0f, ui_col(UC_TEXT));
ui_text_size((int)box.x + 16, (int)y + 18, c.original_path[0] ? c.original_path : "(proyecto que nunca se guardo)", 12.0f, ui_col(UC_DIM));
if (ui_button((Rectangle){box.x + box.width - 250, y, 130, 30}, "Recuperar", true, true)) {
tgb_document *d = NULL;
tgb_status s = tgb_recovery_candidate_load(a->rec, i, &d);
if (TGB_FAILED(s)) ui_fail(a, s);
else {
ui_set_document(a, d);
tgb_recovery_save_now(a->rec, d); /* nueva copia de seguridad en esta sesión... */
tgb_recovery_candidate_discard(a->rec, i); /* ...y solo entonces se retira la antigua */
ui_say(a, "Trabajo recuperado. Guardalo con 'Guardar como' cuando quieras.");
}
if (tgb_recovery_candidate_count(a->rec) == 0) a->dialog = DLG_NONE;
break;
}
if (ui_button((Rectangle){box.x + box.width - 112, y, 96, 30}, "Descartar", false, true)) {
tgb_status s = tgb_recovery_candidate_discard(a->rec, i);
if (TGB_FAILED(s)) ui_fail(a, s);
if (tgb_recovery_candidate_count(a->rec) == 0) a->dialog = DLG_NONE;
break;
}
}
if (ui_button((Rectangle){box.x + box.width - 190, box.y + box.height - 46, 174, 32}, "Decidir mas tarde", false, true) || IsKeyPressed(KEY_ESCAPE)) a->dialog = DLG_NONE;
} else if (a->dialog == DLG_PLUGIN_CAPS && a->plugins) {
tgb_plugin_entry_info e;
if (TGB_FAILED(tgb_plugin_info_at(a->plugins, a->plugin_target, &e))) { a->dialog = DLG_NONE; return; }
char t[200];
snprintf(t, sizeof t, "Habilitar la extension '%s'", e.id);
ui_text((int)box.x + 16, (int)box.y + 14, t, ui_col(UC_TEXT));
ui_text((int)box.x + 16, (int)box.y + 38, "Elige que permisos concedes. Una extension nativa es codigo que corre dentro", ui_col(UC_DIM));
ui_text((int)box.x + 16, (int)box.y + 56, "de Tahgibu con tus mismos permisos: instala solo las que sean de confianza.", ui_col(UC_DANGER));
const char *nm[3] = {"Leer el documento", "Modificar el documento", "Recibir eventos"};
const uint32_t bit[3] = {TGB_CAP_READ_DOCUMENT, TGB_CAP_EDIT_DOCUMENT, TGB_CAP_EVENTS};
for (int i = 0; i < 3; i++) {
bool on = (a->plugin_caps & bit[i]) != 0;
if (ui_toggle((Rectangle){box.x + 16 + (float)i * 190, box.y + 88, 180, 30}, nm[i], &on)) a->plugin_caps = on ? (a->plugin_caps | bit[i]) : (a->plugin_caps & ~bit[i]);
}
if (ui_button((Rectangle){box.x + box.width - 226, box.y + box.height - 50, 110, 32}, "Habilitar", true, true)) {
tgb_status s = tgb_plugin_enable(a->plugins, a->plugin_target, a->plugin_caps);
if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Extension habilitada");
a->dialog = DLG_NONE;
} else if (ui_button((Rectangle){box.x + box.width - 112, box.y + box.height - 50, 100, 32}, "Cancelar", false, true) || IsKeyPressed(KEY_ESCAPE)) a->dialog = DLG_NONE;
}
ui_modal = a->dialog != DLG_NONE;
}
/* ================= bucle principal ================= */
int tgb_ui_run(tgb_context *ctx, tgb_recovery *rec, tgb_plugin_manager *plugins, const char *open_path) {
static app a;
memset(&a, 0, sizeof a);
a.ctx = ctx; a.rec = rec; a.plugins = plugins;
a.size = 4; a.hardness = 0.8f; a.color = (tgb_color){20, 20, 20, 255}; a.tolerance = 16; a.tool = TOOL_PENCIL;
a.play_mode = TGB_PLAY_LOOP;
tgb_onion_defaults(&a.onion);
a.onion.enabled = true;
ui_load_palette(&a);
SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT);
InitWindow(1280, 800, "Tahgibu 2.0.0");
(void)ui_text_init();
SetWindowMinSize(960, 600);
SetExitKey(KEY_NULL); /* Esc no cierra la ventana: la cierra el usuario con la X */
SetTargetFPS(60);
tgb_document *doc = NULL;
tgb_status s = open_path ? tgb_document_load(ctx, open_path, &doc) : TGB_ERR_NOT_FOUND;
if (TGB_FAILED(s)) {
if (open_path) fprintf(stderr, "No se pudo abrir '%s': %s\n", open_path, tgb_last_error());
s = tgb_document_create(ctx, 960, 540, 12, &doc);
if (TGB_FAILED(s)) { fprintf(stderr, "No se pudo crear el documento: %s\n", tgb_last_error()); ui_text_shutdown(); CloseWindow(); return 1; }
}
a.canvas_rect = (Rectangle){0, 104, 980, 502};
ui_set_document(&a, doc);
if (rec && tgb_recovery_candidate_count(rec) > 0) a.dialog = DLG_RECOVERY;
if (open_path && a.status_error) { /* el mensaje de error ya está en stderr */ }
while (!a.quit) {
double now = GetTime();
if (WindowShouldClose()) ui_guard_unsaved(&a, PEND_QUIT);
ui_begin_frame();
ui_modal = a.dialog != DLG_NONE;
ui_clamp(&a);
if (!ui_modal) ui_handle_shortcuts(&a);
if (tgb_player_is_playing(a.player)) {
size_t f = tgb_player_update(a.player, (double)GetFrameTime() * 1000.0);
if (f != a.frame) { a.frame = f; a.view_dirty = true; }
if (!tgb_player_is_playing(a.player)) a.view_dirty = true;
}
if (rec) {
tgb_status as = tgb_recovery_tick(rec, now); /* autoguardado cada 5 minutos */
if (TGB_FAILED(as) && now - a.last_autosave_warn > 60) { ui_fail(&a, as); a.last_autosave_warn = now; }
}
if (now - a.last_adapt > 60) { tgb_history_adapt(a.doc); a.last_adapt = now; }
float sw = (float)GetScreenWidth(), sh = (float)GetScreenHeight();
const float top_h = 104, status_h = 24, panel_w = 300, tl_h = 170;
Rectangle topbar = {0, 0, sw, top_h};
Rectangle panel = {sw - panel_w, top_h, panel_w, sh - top_h - tl_h - status_h};
Rectangle canvas = {0, top_h, sw - panel_w, sh - top_h - tl_h - status_h};
Rectangle timeline = {0, sh - tl_h - status_h, sw, tl_h};
Rectangle status = {0, sh - status_h, sw, status_h};
BeginDrawing();
ClearBackground(ui_col(UC_BG));
ui_draw_canvas(&a, canvas);
ui_draw_panel(&a, panel);
ui_draw_timeline(&a, timeline);
ui_draw_topbar(&a, topbar);
DrawRectangleRec(status, ui_col(UC_PANEL));
char info[300];
snprintf(info, sizeof info, "Fotograma %zu/%zu Capa %zu/%zu Zoom %d%% %dx%d @ %d fps%s", a.frame + 1, tgb_frame_count(a.doc), a.layer + 1, tgb_layer_count(a.doc),
(int)(a.zoom * 100), tgb_document_width(a.doc), tgb_document_height(a.doc), tgb_document_fps(a.doc), tgb_document_dirty(a.doc) ? " [sin guardar]" : "");
ui_text(8, (int)status.y + 5, info, ui_col(UC_DIM));
if (a.status[0] && now - a.status_time < 12.0) {
int w = (int)ui_measure_text(a.status, 14.0f);
ui_text((int)(sw - (float)w - 10), (int)status.y + 5, a.status, a.status_error ? ui_col(UC_DANGER) : ui_col(UC_OK));
}
ui_draw_dialogs(&a);
EndDrawing();
}
if (a.tex.id != 0) UnloadTexture(a.tex);
tgb_canvas_release(a.view);
tgb_canvas_release(a.image_clipboard);
if (a.player) tgb_player_destroy(a.player);
if (a.rec) tgb_recovery_detach(a.rec, a.doc); /* el usuario ya decidió sobre los cambios: no dejar autoguardado de este documento */
tgb_document_destroy(a.doc);
ui_text_shutdown();
CloseWindow();
return 0;
}
+191
View File
@@ -0,0 +1,191 @@
/* Panel de edición (derecha): opciones de herramienta, color, capas, Onion Skin,
* puntos de recuperación y extensiones. */
#include <stdio.h>
#include <string.h>
#include "ui_app.h"
void ui_selection_op(app *a, int op);
typedef struct { Rectangle clip; float x, w, y; } lay;
static Rectangle next_row(lay *L, float h) { Rectangle q = {L->x, L->y, L->w, h}; L->y += h + 4; return q; }
static bool visible(const lay *L, Rectangle q) { return q.y + q.height > L->clip.y && q.y < L->clip.y + L->clip.height; }
static bool btn(const lay *L, Rectangle q, const char *t, bool sel, bool en) { return visible(L, q) ? ui_button(q, t, sel, en) : false; }
static Rectangle part(Rectangle q, int n, int i) {
float gap = 4, w = (q.width - gap * (float)(n - 1)) / (float)n;
return (Rectangle){q.x + (w + gap) * (float)i, q.y, w, q.height};
}
static void header(lay *L, const char *title) {
L->y += 4;
if (L->y + 18 > L->clip.y && L->y < L->clip.y + L->clip.height) {
DrawRectangle((int)L->x - 4, (int)L->y, (int)L->w + 8, 20, ui_col(UC_PANEL2));
ui_text((int)L->x, (int)L->y + 3, title, ui_col(UC_TEXT));
}
L->y += 24;
}
static void fit_text(char *dst, size_t cap, const char *s, int maxw) {
ui_fit_text(dst, cap, s, (float)maxw, 14.0f);
}
static float s_opacity_drag = -1; /* la opacidad de capa solo se aplica al soltar (una operación de historial) */
void ui_draw_panel(app *a, Rectangle r) {
DrawRectangleRec(r, ui_col(UC_PANEL));
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
if (!a->doc) return;
if (ui_hover(r)) { float w = GetMouseWheelMove(); a->panel_scroll -= w * 40.0f; }
if (a->panel_scroll < 0) a->panel_scroll = 0;
BeginScissorMode((int)r.x, (int)r.y, (int)r.width, (int)r.height);
lay L = {r, r.x + 8, r.width - 16, r.y + 6 - a->panel_scroll};
float top = L.y;
char buf[160], name[120];
/* ---------- herramienta ---------- */
static const char *tool_names[TOOL_COUNT] = {"Lapiz", "Pincel", "Borrador", "Linea", "Rectangulo", "Elipse", "Curva", "Relleno", "Cuentagotas", "Seleccion"};
snprintf(buf, sizeof buf, "Herramienta: %s", tool_names[a->tool]);
header(&L, buf);
Rectangle q;
if (a->tool != TOOL_FILL && a->tool != TOOL_PICK && a->tool != TOOL_SELECT) {
q = next_row(&L, 22);
if (visible(&L, q)) ui_slider(q, "Tamano", &a->size, 1, 128, true);
}
if (a->tool == TOOL_BRUSH || a->tool == TOOL_ERASER) { q = next_row(&L, 22); if (visible(&L, q)) ui_slider(q, "Dureza", &a->hardness, 0, 1, false); }
if (a->tool == TOOL_RECT || a->tool == TOOL_ELLIPSE) { q = next_row(&L, 24); if (visible(&L, q)) ui_toggle(q, "Rellenar forma", &a->fill_shapes); }
if (a->tool == TOOL_FILL) { q = next_row(&L, 22); float t = (float)a->tolerance; if (visible(&L, q) && ui_slider(q, "Tolerancia", &t, 0, 255, true)) a->tolerance = (int)t; }
if (a->tool == TOOL_SELECT) {
q = next_row(&L, 24);
if (btn(&L, part(q, 3, 0), "Voltear H", false, a->has_sel)) ui_selection_op(a, 0);
if (btn(&L, part(q, 3, 1), "Voltear V", false, a->has_sel)) ui_selection_op(a, 1);
if (btn(&L, part(q, 3, 2), "Borrar", false, a->has_sel)) ui_selection_op(a, 2);
q = next_row(&L, 24);
if (btn(&L, part(q, 2, 0), "Escalar x2", false, a->has_sel)) ui_selection_op(a, 3);
if (btn(&L, part(q, 2, 1), "Escalar x0.5", false, a->has_sel)) ui_selection_op(a, 4);
q = next_row(&L, 16);
if (visible(&L, q)) ui_text((int)q.x, (int)q.y, a->has_sel ? "Arrastra dentro para mover" : "Arrastra para seleccionar", ui_col(UC_DIM));
}
/* ---------- color ---------- */
header(&L, "Color");
q = next_row(&L, 28);
if (visible(&L, q)) {
DrawRectangle((int)q.x, (int)q.y, 40, 28, (Color){a->color.r, a->color.g, a->color.b, a->color.a});
DrawRectangleLines((int)q.x, (int)q.y, 40, 28, ui_col(UC_BORDER));
snprintf(buf, sizeof buf, "#%02X%02X%02X alfa %d", a->color.r, a->color.g, a->color.b, a->color.a);
ui_text((int)q.x + 48, (int)q.y + 7, buf, ui_col(UC_TEXT));
}
int per_row = (int)(L.w / 22);
for (size_t i = 0; i < a->palette_n; i += (size_t)per_row) {
q = next_row(&L, 20);
for (size_t k = 0; k < (size_t)per_row && i + k < a->palette_n; k++) {
Rectangle sw = {q.x + (float)k * 22, q.y, 20, 20};
if (!visible(&L, sw)) continue;
tgb_color c = a->palette[i + k];
DrawRectangleRec(sw, (Color){c.r, c.g, c.b, 255});
DrawRectangleLinesEx(sw, 1, ui_col(UC_BORDER));
if (ui_hover(sw) && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { uint8_t al = a->color.a; a->color = c; a->color.a = al; }
}
}
float ch[4] = {a->color.r, a->color.g, a->color.b, a->color.a};
const char *cn[4] = {"R", "G", "B", "Alfa"};
for (int i = 0; i < 4; i++) { q = next_row(&L, 20); if (visible(&L, q)) ui_slider(q, cn[i], &ch[i], 0, 255, true); }
a->color.r = (uint8_t)ch[0]; a->color.g = (uint8_t)ch[1]; a->color.b = (uint8_t)ch[2]; a->color.a = (uint8_t)ch[3];
/* ---------- capas ---------- */
header(&L, "Capas");
size_t nl = tgb_layer_count(a->doc);
for (size_t k = nl; k-- > 0;) {
q = next_row(&L, 24);
bool vis = tgb_layer_visible(a->doc, k), lock = tgb_layer_locked(a->doc, k);
if (btn(&L, (Rectangle){q.x, q.y, 24, 24}, "V", vis, true)) ui_check(a, tgb_layer_set_visible(a->doc, k, !vis));
if (btn(&L, (Rectangle){q.x + 26, q.y, 24, 24}, "B", lock, true)) ui_check(a, tgb_layer_set_locked(a->doc, k, !lock));
fit_text(name, sizeof name, tgb_layer_name(a->doc, k), (int)q.width - 70);
if (btn(&L, (Rectangle){q.x + 52, q.y, q.width - 52, 24}, name, k == a->layer, true)) a->layer = k;
}
q = next_row(&L, 24);
if (btn(&L, part(q, 5, 0), "+", false, true)) { ui_check(a, tgb_layer_add(a->doc, a->layer + 1, NULL, NULL)); a->layer = a->layer + 1; }
if (btn(&L, part(q, 5, 1), "-", false, nl > 1)) ui_check(a, tgb_layer_delete(a->doc, a->layer));
if (btn(&L, part(q, 5, 2), "Sube", false, a->layer + 1 < nl)) { if (TGB_SUCCEEDED(tgb_layer_move(a->doc, a->layer, a->layer + 1))) a->layer++; else ui_fail(a, TGB_ERR_STATE); }
if (btn(&L, part(q, 5, 3), "Baja", false, a->layer > 0)) { if (TGB_SUCCEEDED(tgb_layer_move(a->doc, a->layer, a->layer - 1))) a->layer--; else ui_fail(a, TGB_ERR_STATE); }
if (btn(&L, part(q, 5, 4), "Nombre", false, true)) ui_open_prompt(a, PROMPT_LAYER_NAME, "Nuevo nombre de la capa", tgb_layer_name(a->doc, a->layer));
ui_clamp(a);
q = next_row(&L, 22);
if (visible(&L, q)) {
float op = s_opacity_drag >= 0 ? s_opacity_drag : (float)tgb_layer_opacity(a->doc, a->layer);
if (ui_slider(q, "Opacidad", &op, 0, 255, true)) s_opacity_drag = op;
if (s_opacity_drag >= 0 && !IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { ui_check(a, tgb_layer_set_opacity(a->doc, a->layer, (uint8_t)s_opacity_drag)); s_opacity_drag = -1; }
}
/* ---------- Onion Skin ---------- */
header(&L, "Onion Skin");
q = next_row(&L, 24);
if (visible(&L, q) && ui_toggle(q, a->onion.enabled ? "Onion Skin: activado" : "Onion Skin: desactivado", &a->onion.enabled)) a->view_dirty = true;
float fb = (float)a->onion.frames_before, fa = (float)a->onion.frames_after;
q = next_row(&L, 20); if (visible(&L, q) && ui_slider(q, "Anteriores", &fb, 0, 8, true)) a->view_dirty = true;
q = next_row(&L, 20); if (visible(&L, q) && ui_slider(q, "Posteriores", &fa, 0, 8, true)) a->view_dirty = true;
q = next_row(&L, 20); if (visible(&L, q) && ui_slider(q, "Opacidad ant.", &a->onion.opacity_before, 0, 1, false)) a->view_dirty = true;
q = next_row(&L, 20); if (visible(&L, q) && ui_slider(q, "Opacidad post.", &a->onion.opacity_after, 0, 1, false)) a->view_dirty = true;
q = next_row(&L, 20); if (visible(&L, q) && ui_slider(q, "Atenuacion", &a->onion.falloff, 0, 1, false)) a->view_dirty = true;
q = next_row(&L, 24); if (visible(&L, q) && ui_toggle(q, a->onion.tint ? "Tinte: si (rojo/azul)" : "Tinte: no", &a->onion.tint)) a->view_dirty = true;
a->onion.frames_before = (int)fb; a->onion.frames_after = (int)fa;
/* ---------- puntos de recuperación ---------- */
header(&L, "Puntos de recuperacion");
q = next_row(&L, 24);
if (btn(&L, q, "Crear punto con nombre...", false, true)) ui_open_prompt(a, PROMPT_POINT_NAME, "Nombre del punto de recuperacion", "");
size_t np = tgb_recovery_point_count(a->doc);
for (size_t i = 0; i < np; i++) {
tgb_recovery_point_info info;
if (TGB_FAILED(tgb_recovery_point_info_at(a->doc, i, &info))) continue;
q = next_row(&L, 24);
fit_text(name, sizeof name, info.name, (int)q.width - 110);
if (visible(&L, q)) { ui_text((int)q.x + 2, (int)q.y + 5, name, info.compatible ? ui_col(UC_TEXT) : ui_col(UC_DANGER)); }
if (btn(&L, (Rectangle){q.x + q.width - 104, q.y, 74, 24}, "Volver", false, info.compatible)) { ui_check(a, tgb_recovery_point_restore(a->doc, info.id)); ui_clamp(a); a->view_dirty = true; ui_say(a, "Restaurado: %s", info.name); break; }
if (btn(&L, (Rectangle){q.x + q.width - 28, q.y, 28, 24}, "x", false, true)) { ui_check(a, tgb_recovery_point_delete(a->doc, info.id)); break; }
}
q = next_row(&L, 16);
if (visible(&L, q)) { snprintf(buf, sizeof buf, "Memoria extra de puntos: %.1f MB", (double)tgb_recovery_points_extra_bytes(a->doc) / (1024.0 * 1024.0)); ui_text((int)q.x, (int)q.y, buf, ui_col(UC_DIM)); }
/* ---------- extensiones ---------- */
header(&L, "Extensiones");
if (!a->plugins) { q = next_row(&L, 16); if (visible(&L, q)) ui_text((int)q.x, (int)q.y, "No disponible", ui_col(UC_DIM)); }
else {
q = next_row(&L, 24);
if (btn(&L, q, "Instalar extension (.so/.lua)...", false, true)) ui_open_prompt(a, PROMPT_INSTALL_PLUGIN, "Ruta del archivo de la extension", "");
for (size_t i = 0; i < tgb_plugin_count(a->plugins); i++) {
tgb_plugin_entry_info e;
if (TGB_FAILED(tgb_plugin_info_at(a->plugins, i, &e))) continue;
q = next_row(&L, 24);
snprintf(buf, sizeof buf, "%s (%s)%s", e.name, e.kind, e.loaded ? "" : " - apagada");
fit_text(name, sizeof name, buf, (int)q.width - 150);
if (visible(&L, q)) ui_text((int)q.x + 2, (int)q.y + 5, name, e.loaded ? ui_col(UC_OK) : ui_col(UC_DIM));
if (btn(&L, (Rectangle){q.x + q.width - 146, q.y, 90, 24}, e.loaded ? "Apagar" : "Habilitar", false, true)) {
if (e.loaded) ui_check(a, tgb_plugin_disable(a->plugins, i));
else { a->plugin_target = i; a->plugin_caps = 0; a->dialog = DLG_PLUGIN_CAPS; }
break;
}
if (btn(&L, (Rectangle){q.x + q.width - 52, q.y, 52, 24}, "Quitar", false, true)) { ui_check(a, tgb_plugin_remove(a->plugins, i)); break; }
if (e.last_error[0] && !e.loaded) { Rectangle er = next_row(&L, 16); if (visible(&L, er)) { char t[120]; fit_text(t, sizeof t, e.last_error, (int)er.width); ui_text((int)er.x, (int)er.y, t, ui_col(UC_DANGER)); } }
}
char cname[200];
for (size_t i = 0; i < tgb_plugin_command_count(a->plugins); i++) {
if (TGB_FAILED(tgb_plugin_command_name(a->plugins, i, cname, sizeof cname))) continue;
q = next_row(&L, 24);
fit_text(name, sizeof name, cname, (int)q.width - 12);
if (btn(&L, q, name, false, !tgb_player_is_playing(a->player))) {
tgb_status s = tgb_plugin_run_command_named(a->plugins, cname, a->doc); /* por nombre: los índices cambian */
if (TGB_FAILED(s)) ui_fail(a, s); else ui_say(a, "Comando ejecutado: %s (se puede deshacer)", cname);
ui_clamp(a); a->view_dirty = true;
break;
}
}
}
float content = L.y - top + 10;
EndScissorMode();
float maxscroll = content - r.height;
if (maxscroll < 0) maxscroll = 0;
if (a->panel_scroll > maxscroll) a->panel_scroll = maxscroll;
if (maxscroll > 0) { /* barra de desplazamiento */
float th = r.height * r.height / content, ty = r.y + (r.height - th) * (a->panel_scroll / maxscroll);
DrawRectangle((int)(r.x + r.width - 5), (int)ty, 4, (int)th, (Color){110, 114, 128, 200});
}
}
+11
View File
@@ -0,0 +1,11 @@
/* Punto de entrada de la interfaz gráfica. */
#ifndef TAHGIBU_UI_RUN_H
#define TAHGIBU_UI_RUN_H
#include "tahgibu/tahgibu.h"
/* Abre la ventana y ejecuta el editor hasta que el usuario sale. open_path puede ser NULL.
* recovery/plugins pueden ser NULL (se desactivan esas funciones). Devuelve 0 si todo fue bien. */
int tgb_ui_run(tgb_context *ctx, tgb_recovery *recovery, tgb_plugin_manager *plugins, const char *open_path);
#endif
+89
View File
@@ -0,0 +1,89 @@
/* Línea de tiempo: representa los fotogramas REALES del documento. */
#include <stdio.h>
#include "ui_app.h"
static float s_scroll = 0;
static bool frame_has_content(const app *a, size_t f) {
for (size_t l = 0; l < tgb_layer_count(a->doc); l++) { const tgb_canvas *c = tgb_frame_cel(a->doc, f, l); if (c && !tgb_canvas_is_empty(c)) return true; }
return false;
}
static void select_frame(app *a, size_t f) {
if (f >= tgb_frame_count(a->doc)) return;
a->frame = f;
tgb_player_seek(a->player, f);
a->view_dirty = true;
}
void ui_draw_timeline(app *a, Rectangle r) {
DrawRectangleRec(r, ui_col(UC_PANEL));
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
if (!a->doc) return;
size_t n = tgb_frame_count(a->doc);
bool playing = tgb_player_is_playing(a->player);
bool idle = !playing;
const float gap = 4.0f;
float x = r.x + 8.0f;
float y = r.y + 5.0f;
float bw = (r.width - 16.0f - gap * 6.0f) / 7.0f;
if (bw > 108.0f) bw = 108.0f;
const float bh = 26.0f;
char buf[96];
#define TB(label, sel, en) (ui_button((Rectangle){x, y, bw, bh}, label, sel, en) ? (x += bw + gap, true) : (x += bw + gap, false))
if (TB("Nuevo", false, idle)) { uint32_t id; ui_check(a, tgb_frame_add(a->doc, a->frame + 1, &id)); a->frame++; a->view_dirty = true; }
if (TB("Duplicar", false, idle)) { ui_check(a, tgb_frame_duplicate(a->doc, a->frame, NULL)); a->frame++; a->view_dirty = true; }
if (TB("Eliminar", false, idle && n > 1)) { ui_check(a, tgb_frame_delete(a->doc, a->frame)); ui_clamp(a); a->view_dirty = true; }
if (TB("< Mover", false, idle && a->frame > 0)) { if (TGB_SUCCEEDED(tgb_frame_move(a->doc, a->frame, a->frame - 1))) a->frame--; else ui_fail(a, TGB_ERR_STATE); a->view_dirty = true; }
if (TB("Mover >", false, idle && a->frame + 1 < n)) { if (TGB_SUCCEEDED(tgb_frame_move(a->doc, a->frame, a->frame + 1))) a->frame++; else ui_fail(a, TGB_ERR_STATE); a->view_dirty = true; }
if (TB("Copiar", false, idle)) { ui_check(a, tgb_frame_copy(a->doc, a->frame)); ui_say(a, "Fotograma copiado"); }
if (TB("Pegar", false, idle)) { ui_check(a, tgb_frame_paste(a->doc, a->frame + 1, NULL)); a->frame++; a->view_dirty = true; }
#undef TB
x = r.x + 8.0f;
y = r.y + 35.0f;
bw = 130.0f;
#define TB2(label, width, sel, en) (ui_button((Rectangle){x, y, width, bh}, label, sel, en) ? (x += (width) + gap, true) : (x += (width) + gap, false))
snprintf(buf, sizeof buf, "Duracion %u ms", (unsigned)tgb_frame_duration_ms(a->doc, a->frame));
if (TB2(buf, 130.0f, false, idle)) { snprintf(buf, sizeof buf, "%u", (unsigned)tgb_frame_duration_ms(a->doc, a->frame)); ui_open_prompt(a, PROMPT_DURATION, "Duracion del fotograma en milisegundos (16 a 60000)", buf); }
if (TB2(playing ? "Pausa" : "Reproducir", 102.0f, playing, true)) { if (playing) tgb_player_pause(a->player); else { tgb_player_seek(a->player, a->frame); tgb_player_play(a->player); } a->view_dirty = true; }
if (TB2("Detener", 78.0f, false, true)) { tgb_player_stop(a->player); a->frame = tgb_player_frame(a->player); a->view_dirty = true; }
static const char *modes[3] = {"Una vez", "Bucle", "Ida y vuelta"};
if (TB2(modes[a->play_mode], 112.0f, false, true)) { a->play_mode = (tgb_play_mode)(((int)a->play_mode + 1) % 3); tgb_player_set_mode(a->player, a->play_mode); }
snprintf(buf, sizeof buf, "%d fps", tgb_document_fps(a->doc));
if (TB2(buf, 70.0f, false, idle)) { snprintf(buf, sizeof buf, "%d", tgb_document_fps(a->doc)); ui_open_prompt(a, PROMPT_FPS, "Fotogramas por segundo (1 a 60); cambia la duracion de todos", buf); }
#undef TB2
if (playing) { a->frame = tgb_player_frame(a->player); }
/* tira de fotogramas */
Rectangle strip = {r.x + 6, r.y + 67, r.width - 12, r.height - 73};
DrawRectangleRec(strip, ui_col(UC_BG));
float cw = 78, strip_gap = 4, total = (float)n * (cw + strip_gap);
if (ui_hover(strip)) s_scroll -= GetMouseWheelMove() * 60.0f;
float maxs = total - strip.width;
if (maxs < 0) maxs = 0;
if (a->frame < n && !playing) { /* mantener a la vista el fotograma activo al cambiar de fotograma */
float fx = (float)a->frame * (cw + strip_gap);
if (fx < s_scroll) s_scroll = fx;
if (fx + cw > s_scroll + strip.width) s_scroll = fx + cw - strip.width;
}
if (s_scroll < 0) s_scroll = 0;
if (s_scroll > maxs) s_scroll = maxs;
BeginScissorMode((int)strip.x, (int)strip.y, (int)strip.width, (int)strip.height);
for (size_t i = 0; i < n; i++) {
Rectangle c = {strip.x + (float)i * (cw + strip_gap) - s_scroll, strip.y + 4, cw, strip.height - 12};
if (c.x + c.width < strip.x || c.x > strip.x + strip.width) continue;
bool cur = i == a->frame;
bool onion = a->onion.enabled && !cur && ((i < a->frame && a->frame - i <= (size_t)a->onion.frames_before) || (i > a->frame && i - a->frame <= (size_t)a->onion.frames_after));
Color bg = cur ? ui_col(UC_ACCENT) : (onion ? (Color){90, 70, 110, 255} : ui_col(UC_PANEL2));
DrawRectangleRec(c, bg);
DrawRectangleLinesEx(c, 1, ui_col(UC_BORDER));
snprintf(buf, sizeof buf, "%zu", i + 1);
ui_text_size((int)c.x + 6, (int)c.y + 6, buf, 15.0f, ui_col(UC_TEXT));
snprintf(buf, sizeof buf, "%u ms", (unsigned)tgb_frame_duration_ms(a->doc, i));
ui_text_size((int)c.x + 6, (int)(c.y + c.height - 19), buf, 12.0f, cur ? ui_col(UC_TEXT) : ui_col(UC_DIM));
if (frame_has_content(a, i)) DrawRectangle((int)(c.x + c.width - 14), (int)c.y + 8, 8, 8, ui_col(UC_OK)); /* tiene dibujo */
if (idle && ui_hover(c) && ui_hover(strip) && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) select_frame(a, i);
}
EndScissorMode();
}
+181
View File
@@ -0,0 +1,181 @@
/* Widgets mínimos de interfaz de estilo inmediato sobre raylib. */
#include <stdio.h>
#include <string.h>
#include "ui_app.h"
bool ui_modal = false;
static unsigned g_active = 0; /* widget que tiene el ratón pulsado */
static Font g_ui_font;
static bool g_ui_font_loaded = false;
static const float UI_LETTER_SPACING = 0.35f;
static const Color THEME[] = {
{30, 31, 36, 255}, /* UC_BG */
{42, 44, 52, 255}, /* UC_PANEL */
{55, 58, 68, 255}, /* UC_PANEL2 */
{230, 232, 238, 255}, /* UC_TEXT */
{150, 154, 166, 255}, /* UC_DIM */
{80, 140, 255, 255}, /* UC_ACCENT */
{20, 21, 25, 255}, /* UC_BORDER */
{235, 90, 90, 255}, /* UC_DANGER */
{110, 200, 130, 255}, /* UC_OK */
};
Color ui_col(int which) { return THEME[which]; }
static Font current_font(void) {
return g_ui_font_loaded ? g_ui_font : GetFontDefault();
}
bool ui_text_init(void) {
/* No empaquetamos una fuente de terceros: se usa una instalada en el sistema.
Si no se encuentra, raylib conserva su fuente predeterminada como alternativa. */
static const char *paths[] = {
#if defined(_WIN32)
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
#elif defined(__APPLE__)
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Helvetica.ttf",
#else
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
#endif
NULL
};
int codepoints[224];
for (int i = 0; i < 224; ++i) codepoints[i] = i + 32; /* ASCII + Latin-1: acentos y ñ */
g_ui_font_loaded = false;
for (size_t i = 0; paths[i] != NULL; ++i) {
FILE *file = fopen(paths[i], "rb");
if (!file) continue;
fclose(file);
Font candidate = LoadFontEx(paths[i], 32, codepoints, 224);
if (candidate.texture.id != 0) {
g_ui_font = candidate;
g_ui_font_loaded = true;
return true;
}
}
/* La fuente integrada de raylib no se descarga ni necesita inicialización. */
return false;
}
void ui_text_shutdown(void) {
if (g_ui_font_loaded) UnloadFont(g_ui_font);
g_ui_font_loaded = false;
memset(&g_ui_font, 0, sizeof g_ui_font);
}
float ui_measure_text(const char *s, float font_size) {
if (!s) s = "";
Vector2 measured = MeasureTextEx(current_font(), s, font_size, UI_LETTER_SPACING);
return measured.x;
}
void ui_text_size(int x, int y, const char *s, float font_size, Color c) {
if (!s || !s[0]) return;
DrawTextEx(current_font(), s, (Vector2){(float)x, (float)y}, font_size, UI_LETTER_SPACING, c);
}
void ui_text(int x, int y, const char *s, Color c) {
ui_text_size(x, y, s, 14.0f, c);
}
void ui_fit_text(char *dst, size_t capacity, const char *src, float max_width, float font_size) {
if (!dst || capacity == 0) return;
if (!src) src = "";
snprintf(dst, capacity, "%s", src);
if (max_width <= 0.0f) { dst[0] = '\0'; return; }
if (ui_measure_text(dst, font_size) <= max_width) return;
if (capacity < 4) { dst[0] = '\0'; return; }
/* Recorta por puntos de código UTF-8, no por bytes, para no romper ñ/á/é/... */
size_t length = strlen(dst);
while (length > 0) {
size_t cut = length;
do {
--cut;
} while (cut > 0 && (((unsigned char)dst[cut] & 0xC0u) == 0x80u));
dst[cut] = '\0';
if (cut + 4 <= capacity) memcpy(dst + cut, "...", 4);
if (ui_measure_text(dst, font_size) <= max_width) return;
dst[cut] = '\0';
length = cut;
}
dst[0] = '\0';
}
static unsigned rect_id(Rectangle r) { return (unsigned)(r.x * 7 + r.y * 131 + r.width * 17 + r.height * 29) | 1u; }
void ui_begin_frame(void) {
/* El widget "activo" debe sobrevivir al fotograma en que se SUELTA el botón: ahí se decide el clic. */
if (!IsMouseButtonDown(MOUSE_BUTTON_LEFT) && !IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) g_active = 0;
}
bool ui_hover(Rectangle r) { return !ui_modal && CheckCollisionPointRec(GetMousePosition(), r); }
void ui_panel_bg(Rectangle r, Color c) { DrawRectangleRec(r, c); }
bool ui_button(Rectangle r, const char *label, bool selected, bool enabled) {
unsigned id = rect_id(r);
bool over = enabled && ui_hover(r);
bool clicked = false;
if (over && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) g_active = id;
if (over && g_active == id && IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { clicked = true; g_active = 0; }
Color bg = selected ? ui_col(UC_ACCENT) : (over ? (Color){72, 76, 90, 255} : ui_col(UC_PANEL2));
if (!enabled) bg = (Color){38, 40, 46, 255};
DrawRectangleRec(r, bg);
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
char visible_label[160];
float font_size = 14.0f;
ui_fit_text(visible_label, sizeof visible_label, label, r.width - 12.0f, font_size);
float tw = ui_measure_text(visible_label, font_size);
Color tc = enabled ? ui_col(UC_TEXT) : (Color){100, 104, 114, 255};
int tx = (int)(r.x + (r.width - tw) / 2.0f);
int ty = (int)(r.y + (r.height - font_size) / 2.0f);
BeginScissorMode((int)r.x + 2, (int)r.y + 1, (int)r.width - 4, (int)r.height - 2);
ui_text_size(tx, ty, visible_label, font_size, tc);
EndScissorMode();
return clicked;
}
bool ui_toggle(Rectangle r, const char *label, bool *value) {
bool c = ui_button(r, label, *value, true);
if (c) *value = !*value;
return c;
}
bool ui_slider(Rectangle r, const char *label, float *value, float lo, float hi, bool integer) {
unsigned id = rect_id(r);
bool changed = false;
bool over = ui_hover(r);
if (over && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) g_active = id;
if (g_active == id && IsMouseButtonDown(MOUSE_BUTTON_LEFT)) {
float t = r.width > 0.0f ? (GetMousePosition().x - r.x) / r.width : 0.0f;
if (t < 0) t = 0;
if (t > 1) t = 1;
float v = lo + t * (hi - lo);
if (integer) v = (float)(int)(v + 0.5f);
if (v != *value) { *value = v; changed = true; }
}
DrawRectangleRec(r, ui_col(UC_PANEL2));
float t = hi > lo ? (*value - lo) / (hi - lo) : 0;
if (t < 0.0f) t = 0.0f;
if (t > 1.0f) t = 1.0f;
DrawRectangleRec((Rectangle){r.x, r.y, r.width * t, r.height}, (Color){60, 100, 180, 255});
DrawRectangleLinesEx(r, 1, ui_col(UC_BORDER));
char buf[128], visible_label[128];
if (integer) snprintf(buf, sizeof buf, "%s: %d", label, (int)(*value + 0.5f));
else snprintf(buf, sizeof buf, "%s: %.2f", label, (double)*value);
ui_fit_text(visible_label, sizeof visible_label, buf, r.width - 12.0f, 13.0f);
BeginScissorMode((int)r.x + 2, (int)r.y, (int)r.width - 4, (int)r.height);
ui_text_size((int)r.x + 6, (int)(r.y + (r.height - 13.0f) / 2.0f), visible_label, 13.0f, ui_col(UC_TEXT));
EndScissorMode();
return changed;
}
-955
View File
@@ -1,955 +0,0 @@
#!/usr/bin/env python3
"""
Tahgibu Animator
Animación 2D cuadro por cuadro, simple y sin marca de agua.
Pensado para andar liviano en equipos modestos (Linux).
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, colorchooser
import os, json, base64, io, subprocess
from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageChops
APP_NAME = "Tahgibu Animator"
VERSION = "1.0.0"
FORMAT_NAME = "TAHGIBU"
LEGACY_FORMAT_NAMES = ("TAHGIBU", "ASM") # ASM = formato del proyecto original, se puede seguir abriendo
FILE_EXT = ".tgb"
UNDO_LIMIT = 40
THUMB_W, THUMB_H = 34, 34
SLOT_W = 44
class TahgibuAnimator:
def __init__(self, root):
self.root = root
self.root.title(f"{APP_NAME} {VERSION}")
self.root.geometry("1280x800")
self.root.minsize(980, 650)
# --- paleta ---
self.bg_color = "#171a20"
self.panel = "#20242b"
self.panel2 = "#292f38"
self.accent = "#6c7cff"
self.text = "#eef1f6"
self.muted = "#9ca6b5"
self.root.configure(bg=self.bg_color)
# --- estado de la animación ---
self.W, self.H = 960, 540
self.fps = 12
self.current = 0
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.background = None # imagen original (para guardar)
self._bg_scaled = None # versión ya escalada (cache, no se recalcula en cada render)
self._bg_pos = (0, 0)
self.sprites = [] # lista de (nombre, imagen)
# --- herramientas ---
self.color = "#111111"
self.brush_size = 7
self.tool = "solid"
self.stamp_mode = False
self.pending_sprite = None
# --- reproducción ---
self.playing = False
self.play_after = None
# --- deshacer / rehacer (por acción, no copia toda la animación) ---
self.undo_stack = []
self.redo_stack = []
# --- varios ---
self.tk_image = None
self.last = None
self.dirty = False
self.current_path = None
self._thumb_cache = {}
self._onion_index = None
self._onion_valid = False
self._onion_prev = None
self._onion_next = None
self.make_ui()
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.update_idletasks() # asegura tamaño real del canvas antes del primer dibujo
self.render()
# ---------------------------------------------------------------- UI ---
def make_ui(self):
top = tk.Frame(self.root, bg=self.panel, height=54)
top.pack(fill="x")
top.pack_propagate(False)
tk.Label(top, text="Tahgibu", bg=self.panel, fg=self.accent,
font=("DejaVu Sans", 16, "bold")).pack(side="left", padx=(16, 3))
tk.Label(top, text="ANIMATOR", bg=self.panel, fg=self.text,
font=("DejaVu Sans", 11, "bold")).pack(side="left", padx=(0, 18))
def top_btn(txt, cmd):
return tk.Button(top, text=txt, command=cmd, bg=self.panel2, fg=self.text,
activebackground="#3a414d", activeforeground="white",
relief="flat", padx=10)
for txt, cmd in [
("Nuevo", self.new_project),
("Abrir", self.open_asm),
("Guardar", self.save_asm),
("Guardar como…", self.save_asm_as),
("Importar", self.import_asm),
("Pegar imagen", self.paste_image),
]:
top_btn(txt, cmd).pack(side="left", padx=3, pady=10)
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
top_btn("↶ Deshacer", self.undo).pack(side="left", padx=3, pady=10)
top_btn("↷ Rehacer", self.redo).pack(side="left", padx=3, pady=10)
tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6)
self.export_menu_btn = tk.Menubutton(top, text="Exportar ▾", bg=self.panel2, fg=self.text,
activebackground="#3a414d", relief="flat", padx=10)
export_menu = tk.Menu(self.export_menu_btn, tearoff=0)
export_menu.add_command(label="GIF animado…", command=self.export_gif)
export_menu.add_command(label="Secuencia de PNG…", command=self.export_png_sequence)
self.export_menu_btn.configure(menu=export_menu)
self.export_menu_btn.pack(side="left", padx=3, pady=10)
top_btn("❓ Ayuda", self.show_help).pack(side="left", padx=3, pady=10)
tk.Button(top, text="▶", command=self.toggle_play, bg=self.accent, fg="white",
relief="flat", width=4).pack(side="right", padx=(3, 16), pady=10)
self.fps_var = tk.IntVar(value=self.fps)
tk.Spinbox(top, from_=1, to=60, textvariable=self.fps_var, width=4,
bg=self.panel2, fg=self.text, buttonbackground=self.panel2,
relief="flat").pack(side="right", padx=4)
tk.Label(top, text="FPS", bg=self.panel, fg=self.muted).pack(side="right")
body = tk.Frame(self.root, bg=self.bg_color)
body.pack(fill="both", expand=True)
# ------------------------------------------------------ panel izq ---
tools = tk.Frame(body, bg=self.panel, width=210)
tools.pack(side="left", fill="y")
tools.pack_propagate(False)
tk.Label(tools, text="HERRAMIENTAS", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 8))
self.tool_buttons = {}
for key, label in [
("soft", "◌ Lápiz suave"),
("solid", "● Lápiz sólido"),
("eraser", "⌫ Borrador"),
]:
b = tk.Button(tools, text=label, command=lambda k=key: self.set_tool(k),
bg=self.panel2, fg=self.text, activebackground=self.accent,
activeforeground="white", relief="flat", anchor="w", padx=12)
b.pack(fill="x", padx=10, pady=3)
self.tool_buttons[key] = b
tk.Button(tools, text="🎨 Elegir color", command=self.choose_color,
bg=self.panel2, fg=self.text, activebackground="#3a414d",
relief="flat", anchor="w", padx=12).pack(fill="x", padx=10, pady=(14, 4))
self.color_preview = tk.Label(tools, text=" ", bg=self.color, relief="solid", bd=1)
self.color_preview.pack(anchor="w", padx=14, pady=3)
size_row = tk.Frame(tools, bg=self.panel)
size_row.pack(fill="x", padx=14, pady=(14, 0))
tk.Label(size_row, text="Tamaño", bg=self.panel, fg=self.muted).pack(side="left")
self.size_label = tk.Label(size_row, text=str(self.brush_size), bg=self.panel, fg=self.text)
self.size_label.pack(side="right")
self.size_scale = tk.Scale(tools, from_=1, to=60, orient="horizontal",
command=self.on_size_change,
bg=self.panel, fg=self.text, troughcolor="#363d48",
highlightthickness=0)
self.size_scale.set(self.brush_size)
self.size_scale.pack(fill="x", padx=8)
self.onion_var = tk.BooleanVar(value=True)
tk.Checkbutton(tools, text="Onion Skin", variable=self.onion_var,
command=self.render, bg=self.panel, fg=self.text,
selectcolor=self.panel2, activebackground=self.panel,
activeforeground=self.text).pack(anchor="w", padx=10, pady=(12, 2))
tk.Button(tools, text="🗑 Vaciar frame actual", command=self.clear_frame,
bg=self.panel2, fg=self.text, relief="flat", anchor="w",
padx=12).pack(fill="x", padx=10, pady=(6, 4))
tk.Label(tools, text="FONDO", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
bg_row = tk.Frame(tools, bg=self.panel)
bg_row.pack(fill="x", padx=10)
tk.Button(bg_row, text="Elegir…", command=self.choose_background,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
tk.Button(bg_row, text="Quitar", command=self.clear_background,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
tk.Label(tools, text="SPRITES", bg=self.panel, fg=self.muted,
font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6))
sprite_btn_row = tk.Frame(tools, bg=self.panel)
sprite_btn_row.pack(fill="x", padx=10)
tk.Button(sprite_btn_row, text="+ Añadir", command=self.add_sprite,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3))
tk.Button(sprite_btn_row, text="− Quitar", command=self.remove_sprite,
bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0))
self.sprite_list = tk.Listbox(tools, bg="#171a20", fg=self.text, height=6,
relief="flat", highlightthickness=0)
self.sprite_list.pack(fill="both", expand=True, padx=10, pady=5)
tk.Button(tools, text="Poner sprite en el lienzo", command=self.place_sprite,
bg=self.accent, fg="white", relief="flat").pack(fill="x", padx=10, pady=(2, 10))
# ---------------------------------------------------------- lienzo ---
stage = tk.Frame(body, bg="#0f1115")
stage.pack(side="left", fill="both", expand=True)
self.canvas = tk.Canvas(stage, bg="#0f1115", highlightthickness=0)
self.canvas.pack(fill="both", expand=True, padx=16, pady=16)
self.canvas.bind("<Configure>", lambda e: self.render())
self.canvas.bind("<ButtonPress-1>", self.start_draw)
self.canvas.bind("<B1-Motion>", self.paint)
self.canvas.bind("<ButtonRelease-1>", self.end_draw)
self.status = tk.Label(stage, text="", bg="#0f1115", fg=self.muted, anchor="w")
self.status.pack(fill="x", padx=16, pady=(0, 8))
# -------------------------------------------------------- timeline ---
timeline_wrap = tk.Frame(self.root, bg=self.panel, height=150)
timeline_wrap.pack(fill="x")
timeline_wrap.pack_propagate(False)
row = tk.Frame(timeline_wrap, bg=self.panel)
row.pack(fill="x")
tk.Label(row, text="TIMELINE", bg=self.panel, fg=self.text,
font=("DejaVu Sans", 10, "bold")).pack(side="left", padx=12, pady=8)
for txt, cmd in [("+ Frame", self.new_frame), ("Duplicar", self.duplicate_frame),
("Eliminar", self.delete_frame)]:
tk.Button(row, text=txt, command=cmd, bg=self.panel2, fg=self.text,
relief="flat").pack(side="left", padx=3)
self.frame_count_label = tk.Label(row, text="", bg=self.panel, fg=self.muted)
self.frame_count_label.pack(side="right", padx=12)
tl_body = tk.Frame(timeline_wrap, bg=self.panel)
tl_body.pack(fill="x", padx=10, pady=(0, 8))
self.timeline = tk.Canvas(tl_body, bg="#15181d", height=92, highlightthickness=0,
xscrollincrement=20)
self.timeline.pack(fill="x", side="top")
self.timeline_scroll = tk.Scrollbar(tl_body, orient="horizontal", command=self.timeline.xview)
self.timeline_scroll.pack(fill="x", side="top")
self.timeline.configure(xscrollcommand=self.timeline_scroll.set)
self.timeline.bind("<Button-1>", self.timeline_click)
self.timeline.bind("<MouseWheel>", self._on_timeline_wheel)
self.timeline.bind("<Button-4>", self._on_timeline_wheel)
self.timeline.bind("<Button-5>", self._on_timeline_wheel)
# -------------------------------------------------------- atajos ---
self.root.bind("<F7>", lambda e: self.new_frame())
self.root.bind("<Left>", lambda e: self.select_frame(self.current - 1))
self.root.bind("<Right>", lambda e: self.select_frame(self.current + 1))
self.root.bind("<Home>", lambda e: self.select_frame(0))
self.root.bind("<End>", lambda e: self.select_frame(len(self.frames) - 1))
self.root.bind("<space>", lambda e: self.toggle_play())
self.root.bind("<Control-z>", lambda e: self.undo())
self.root.bind("<Control-y>", lambda e: self.redo())
self.root.bind("<Control-Shift-Z>", lambda e: self.redo())
self.root.bind("<Control-v>", lambda e: self.paste_image())
self.root.bind("<Control-s>", lambda e: self.save_asm())
self.root.bind("<Control-n>", lambda e: self.new_project())
self.root.bind("<Control-o>", lambda e: self.open_asm())
self.root.bind("<Escape>", self.on_escape)
self.set_tool("solid")
# ----------------------------------------------------------- ayudas ---
def show_help(self):
messagebox.showinfo(
"Ayuda - " + APP_NAME,
"Atajos de teclado:\n\n"
"F7 — nuevo frame\n"
"←/→ — frame anterior / siguiente\n"
"Inicio/Fin — primer / último frame\n"
"Espacio — reproducir / pausar\n"
"Ctrl+Z / Ctrl+Y — deshacer / rehacer\n"
"Ctrl+S — guardar\n"
"Ctrl+O — abrir\n"
"Ctrl+N — nuevo proyecto\n"
"Ctrl+V — pegar imagen del portapapeles\n"
"Esc — cancelar acción / detener reproducción\n\n"
"Flujo básico: dibujá en el lienzo, apretá F7 para el siguiente\n"
"frame, repetí, y usá ▶ para ver la animación."
)
def on_escape(self, e):
if self.stamp_mode:
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
self.update_status()
elif self.playing:
self.stop()
self.render(full=False)
def mark_dirty(self):
self.dirty = True
def confirm_discard(self, action="continuar"):
if not self.dirty:
return True
return messagebox.askyesno("Cambios sin guardar",
f"Hay cambios sin guardar. ¿Querés {action} de todas formas?")
def on_close(self):
self.stop()
if self.dirty and not messagebox.askyesno("Salir", "Hay cambios sin guardar. ¿Salir de todas formas?"):
return
self.root.destroy()
def get_fps(self):
try:
v = int(self.fps_var.get())
return max(1, min(60, v))
except Exception:
return self.fps
def on_size_change(self, v):
self.brush_size = max(1, int(float(v)))
self.size_label.configure(text=str(self.brush_size))
def set_tool(self, tool):
self.tool = tool
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
for k, b in self.tool_buttons.items():
b.configure(bg=self.accent if k == tool else self.panel2)
def choose_color(self):
picked = colorchooser.askcolor(color=self.color, title="Elegir color")
if picked and picked[1]:
self.color = picked[1]
self.color_preview.configure(bg=self.color)
# ------------------------------------------------- deshacer / rehacer ---
def push_action(self, action):
self.undo_stack.append(action)
if len(self.undo_stack) > UNDO_LIMIT:
self.undo_stack.pop(0)
self.redo_stack.clear()
self.mark_dirty()
def _begin_frame_edit(self):
"""Guarda una copia del frame actual ANTES de modificarlo (para poder deshacer)."""
self.push_action(("paint", self.current, self.frames[self.current].copy()))
self._invalidate_thumb(self.current)
def _invalidate_onion(self):
self._onion_valid = False
def _invalidate_thumb(self, i=None):
if i is None:
self._thumb_cache.clear()
else:
self._thumb_cache.pop(i, None)
def _apply_inverse(self, action):
"""Aplica una acción y devuelve la acción opuesta (usado tanto por undo como por redo)."""
kind = action[0]
if kind == "paint":
_, idx, img = action
idx = min(idx, len(self.frames) - 1)
current_img = self.frames[idx].copy()
self.frames[idx] = img
self.current = idx
self._invalidate_thumb(idx)
return ("paint", idx, current_img)
elif kind == "insert":
_, idx, prev_current = action
idx = min(idx, len(self.frames) - 1)
removed = self.frames.pop(idx)
if not self.frames:
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.current = min(prev_current, len(self.frames) - 1)
self._invalidate_thumb()
return ("delete", idx, removed, prev_current)
elif kind == "delete":
_, idx, img, prev_current = action
idx = min(idx, len(self.frames))
self.frames.insert(idx, img)
self.current = idx
self._invalidate_thumb()
return ("insert", idx, prev_current)
return action
def undo(self):
if not self.undo_stack:
return
action = self.undo_stack.pop()
redo_action = self._apply_inverse(action)
self.redo_stack.append(redo_action)
if len(self.redo_stack) > UNDO_LIMIT:
self.redo_stack.pop(0)
self._invalidate_onion()
self.mark_dirty()
self.render()
def redo(self):
if not self.redo_stack:
return
action = self.redo_stack.pop()
undo_action = self._apply_inverse(action)
self.undo_stack.append(undo_action)
self._invalidate_onion()
self.mark_dirty()
self.render()
# ------------------------------------------------------------ frames ---
def new_project(self):
if not self.confirm_discard("crear un proyecto nuevo"):
return
self.stop()
self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.background = None
self._bg_scaled = None
self.sprites = []
self.sprite_list.delete(0, "end")
self.current = 0
self.current_path = None
self.undo_stack.clear()
self.redo_stack.clear()
self._invalidate_onion()
self._invalidate_thumb()
self.dirty = False
self.render()
def new_frame(self):
idx = self.current + 1
prev_current = self.current
self.frames.insert(idx, Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0)))
self.current = idx
self.push_action(("insert", idx, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def duplicate_frame(self):
idx = self.current + 1
prev_current = self.current
self.frames.insert(idx, self.frames[self.current].copy())
self.current = idx
self.push_action(("insert", idx, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def delete_frame(self):
if len(self.frames) <= 1:
messagebox.showinfo(APP_NAME, "No podés borrar el único frame que queda.")
return
idx = self.current
img = self.frames[idx]
prev_current = self.current
del self.frames[idx]
self.current = min(idx, len(self.frames) - 1)
self.push_action(("delete", idx, img, prev_current))
self._invalidate_onion()
self._invalidate_thumb()
self.render()
def clear_frame(self):
self._begin_frame_edit()
self.frames[self.current] = Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))
self.render()
def select_frame(self, i):
i = max(0, min(i, len(self.frames) - 1))
if i == self.current:
return
self.current = i
self.render()
def timeline_click(self, e):
cx = self.timeline.canvasx(e.x)
i = int((cx) // (SLOT_W))
if 0 <= i < len(self.frames):
self.select_frame(i)
def _on_timeline_wheel(self, event):
if getattr(event, "delta", 0):
self.timeline.xview_scroll(-3 if event.delta > 0 else 3, "units")
elif getattr(event, "num", None) == 4:
self.timeline.xview_scroll(-3, "units")
elif getattr(event, "num", None) == 5:
self.timeline.xview_scroll(3, "units")
# -------------------------------------------------------- background ---
def choose_background(self):
path = filedialog.askopenfilename(
filetypes=[("Imágenes", "*.png *.jpg *.jpeg *.webp *.bmp"), ("Todos", "*.*")])
if not path:
return
try:
im = Image.open(path).convert("RGBA")
except Exception as e:
messagebox.showerror("Fondo", f"No se pudo abrir la imagen:\n{e}")
return
self.background = im
self._rebuild_bg_cache()
self.mark_dirty()
self.render()
def clear_background(self):
self.background = None
self._bg_scaled = None
self.mark_dirty()
self.render()
def _rebuild_bg_cache(self):
if self.background is None:
self._bg_scaled = None
return
bg = self.background.copy()
bg.thumbnail((self.W, self.H), Image.Resampling.LANCZOS)
self._bg_scaled = bg
self._bg_pos = ((self.W - bg.width) // 2, (self.H - bg.height) // 2)
# ------------------------------------------------------------ pintar ---
def coords(self, e):
cw = max(1, self.canvas.winfo_width() - 32)
ch = max(1, self.canvas.winfo_height() - 32)
scale = min(cw / self.W, ch / self.H)
if scale <= 0:
scale = 1
ox = (self.canvas.winfo_width() - self.W * scale) / 2
oy = (self.canvas.winfo_height() - self.H * scale) / 2
return (e.x - ox) / scale, (e.y - oy) / scale
def start_draw(self, e):
x, y = self.coords(e)
if not (0 <= x < self.W and 0 <= y < self.H):
return
if self.stamp_mode and self.pending_sprite is not None:
self.stamp_sprite_at(x, y)
return
self._begin_frame_edit()
self.last = (x, y)
self.paint(e)
def paint(self, e):
if self.last is None:
return
x, y = self.coords(e)
x = max(0, min(self.W - 1, x))
y = max(0, min(self.H - 1, y))
im = self.frames[self.current]
if self.tool == "eraser":
self.erase_line(im, self.last, (x, y), self.brush_size)
elif self.tool == "soft":
self.soft_line(im, self.last, (x, y), self.brush_size, self.hex_rgba(self.color))
else:
draw = ImageDraw.Draw(im, "RGBA")
width = max(1, int(self.brush_size))
color = self.hex_rgba(self.color)
draw.line([self.last, (x, y)], fill=color, width=width, joint="curve")
r = width / 2
draw.ellipse([x - r, y - r, x + r, y + r], fill=color)
self.last = (x, y)
self._invalidate_thumb(self.current)
self.render(full=False)
def end_draw(self, e):
self.last = None
self.render(full=True)
def hex_rgba(self, h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + (255,)
def soft_line(self, im, a, b, size, color):
x1, y1 = a
x2, y2 = b
r = size / 2
blur = max(1, size / 3)
pad = int(r + blur * 2 + 2)
minx = max(0, int(min(x1, x2) - pad))
miny = max(0, int(min(y1, y2) - pad))
maxx = min(im.width, int(max(x1, x2) + pad))
maxy = min(im.height, int(max(y1, y2) + pad))
w, h = max(1, maxx - minx), max(1, maxy - miny)
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(layer, "RGBA")
dist = max(1, int(((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5))
for n in range(dist + 1):
t = n / dist
x = x1 + (x2 - x1) * t - minx
y = y1 + (y2 - y1) * t - miny
d.ellipse((x - r, y - r, x + r, y + r), fill=(color[0], color[1], color[2], 70))
layer = layer.filter(ImageFilter.GaussianBlur(blur))
im.alpha_composite(layer, (minx, miny))
def erase_line(self, im, a, b, size):
x1, y1 = a
x2, y2 = b
r = size / 2
pad = int(r + 2)
minx = max(0, int(min(x1, x2) - pad))
miny = max(0, int(min(y1, y2) - pad))
maxx = min(im.width, int(max(x1, x2) + pad))
maxy = min(im.height, int(max(y1, y2) + pad))
w, h = max(1, maxx - minx), max(1, maxy - miny)
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
width = max(1, int(size))
lx1, ly1 = x1 - minx, y1 - miny
lx2, ly2 = x2 - minx, y2 - miny
d.line([(lx1, ly1), (lx2, ly2)], fill=255, width=width, joint="curve")
d.ellipse((lx1 - r, ly1 - r, lx1 + r, ly1 + r), fill=255)
d.ellipse((lx2 - r, ly2 - r, lx2 + r, ly2 + r), fill=255)
region = im.crop((minx, miny, maxx, maxy))
alpha = region.getchannel("A")
inv_mask = mask.point(lambda p: 255 - p)
region.putalpha(ImageChops.multiply(alpha, inv_mask))
im.paste(region, (minx, miny))
# ------------------------------------------------------------ sprites ---
def add_sprite(self):
paths = filedialog.askopenfilenames(
filetypes=[("Imágenes", "*.png *.jpg *.jpeg *.webp *.gif"), ("Todos", "*.*")])
for p in paths:
try:
im = Image.open(p).convert("RGBA")
self.sprites.append((os.path.basename(p), im))
self.sprite_list.insert("end", os.path.basename(p))
self.mark_dirty()
except Exception as e:
messagebox.showerror("Sprite", str(e))
def remove_sprite(self):
sel = self.sprite_list.curselection()
if not sel:
return
i = sel[0]
del self.sprites[i]
self.sprite_list.delete(i)
self.mark_dirty()
def place_sprite(self):
sel = self.sprite_list.curselection()
if not sel:
messagebox.showinfo(APP_NAME, "Elegí primero un sprite de la lista.")
return
_, im = self.sprites[sel[0]]
self.pending_sprite = im
self.stamp_mode = True
self.canvas.configure(cursor="crosshair")
self.status.configure(text="Hacé clic en el lienzo para colocar el sprite (Esc para cancelar).")
def stamp_sprite_at(self, x, y):
self._begin_frame_edit()
layer = self.pending_sprite.copy()
maxw, maxh = self.W // 2, self.H // 2
if layer.width > maxw or layer.height > maxh:
layer.thumbnail((maxw, maxh), Image.Resampling.LANCZOS)
px = int(x - layer.width / 2)
py = int(y - layer.height / 2)
self.frames[self.current].alpha_composite(layer, (px, py))
self.stamp_mode = False
self.pending_sprite = None
self.canvas.configure(cursor="")
self.render()
def paste_image(self):
im = self._read_clipboard_image()
if im is None:
messagebox.showinfo(
"Pegar imagen",
"No encontré una imagen en el portapapeles.\n"
"En CachyOS/Wayland instalá wl-clipboard: sudo pacman -S wl-clipboard")
return
self._begin_frame_edit()
im.thumbnail((self.W, self.H), Image.Resampling.LANCZOS)
x = (self.W - im.width) // 2
y = (self.H - im.height) // 2
self.frames[self.current].alpha_composite(im, (x, y))
self.render()
def _read_clipboard_image(self):
for cmd in (["wl-paste", "--no-newline", "--type", "image/png"],
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]):
try:
p = subprocess.run(cmd, capture_output=True, timeout=2)
if p.returncode == 0 and p.stdout:
return Image.open(io.BytesIO(p.stdout)).convert("RGBA")
except Exception:
continue
return None
# ---------------------------------------------------------- render ----
def composite(self, index):
out = Image.new("RGBA", (self.W, self.H), (255, 255, 255, 255))
if self._bg_scaled is not None:
out.alpha_composite(self._bg_scaled, self._bg_pos)
out.alpha_composite(self.frames[index])
return out
def _ensure_onion_cache(self):
if self._onion_valid and self._onion_index == self.current:
return
self._onion_index = self.current
self._onion_valid = True
if self.current > 0:
prev = self.frames[self.current - 1].copy()
prev.putalpha(prev.getchannel("A").point(lambda p: int(p * 0.22)))
self._onion_prev = prev
else:
self._onion_prev = None
if self.current < len(self.frames) - 1:
nxt = self.frames[self.current + 1].copy()
nxt.putalpha(nxt.getchannel("A").point(lambda p: int(p * 0.12)))
self._onion_next = nxt
else:
self._onion_next = None
def render(self, full=True):
cw = max(50, self.canvas.winfo_width() - 32)
ch = max(50, self.canvas.winfo_height() - 32)
scale = min(cw / self.W, ch / self.H)
size = (max(1, int(self.W * scale)), max(1, int(self.H * scale)))
img = self.composite(self.current)
if self.onion_var.get():
self._ensure_onion_cache()
if self._onion_prev is not None:
img.alpha_composite(self._onion_prev)
if self._onion_next is not None:
img.alpha_composite(self._onion_next)
resample = Image.Resampling.LANCZOS if full else Image.Resampling.BILINEAR
img = img.resize(size, resample)
self.tk_image = ImageTk.PhotoImage(img)
self.canvas.delete("all")
self.canvas.create_image(self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2,
image=self.tk_image)
if full:
self.draw_timeline()
self.update_status()
def update_status(self):
mode = "colocando sprite" if self.stamp_mode else self.tool
star = " •" if self.dirty else ""
self.status.configure(
text=f"Frame {self.current + 1}/{len(self.frames)} • {self.get_fps()} FPS • {mode}{star}")
self.frame_count_label.configure(text=f"{len(self.frames)} frame(s)")
def _get_thumb(self, i):
if i not in self._thumb_cache:
im = self.composite(i).convert("RGB")
im.thumbnail((THUMB_W, THUMB_H), Image.Resampling.LANCZOS)
self._thumb_cache[i] = ImageTk.PhotoImage(im)
return self._thumb_cache[i]
def draw_timeline(self):
self.timeline.delete("all")
total = len(self.frames)
view_w = self.timeline.winfo_width() or 600
xleft = self.timeline.canvasx(0)
first = max(0, int((xleft - 22) // SLOT_W) - 1)
last = min(total - 1, int((xleft + view_w - 22) // SLOT_W) + 1)
self._thumb_refs = []
for i in range(first, last + 1):
x = 22 + i * SLOT_W
self.timeline.create_text(x, 10, text=str(i + 1), fill="#8f99a8")
fill = self.accent if i == self.current else "#333a45"
self.timeline.create_rectangle(x - 19, 22, x + 19, 22 + THUMB_H + 4, outline=fill, width=2)
thumb = self._get_thumb(i)
self.timeline.create_image(x, 24 + THUMB_H // 2, image=thumb)
self._thumb_refs.append(thumb)
self.timeline.configure(scrollregion=(0, 0, 22 + total * SLOT_W + 40, 92))
# asegura que el frame actual quede visible
cur_x = 22 + self.current * SLOT_W
if cur_x < xleft + 40 or cur_x > xleft + view_w - 40:
frac_total = max(1, 22 + total * SLOT_W + 40)
self.timeline.xview_moveto(max(0.0, (cur_x - view_w / 2) / frac_total))
# ------------------------------------------------------ reproducción ---
def toggle_play(self):
if self.playing:
self.stop()
else:
self.playing = True
self.play_next()
def stop(self):
self.playing = False
if self.play_after:
try:
self.root.after_cancel(self.play_after)
except Exception:
pass
self.play_after = None
def play_next(self):
if not self.playing:
return
self.current = (self.current + 1) % len(self.frames)
self.render()
delay = max(1, int(1000 / self.get_fps()))
self.play_after = self.root.after(delay, self.play_next)
# ------------------------------------------------------- exportación ---
def export_gif(self):
if len(self.frames) < 1:
return
path = filedialog.asksaveasfilename(defaultextension=".gif",
filetypes=[("GIF animado", "*.gif")])
if not path:
return
try:
duration_ms = max(20, int(1000 / self.get_fps()))
composed = [self.composite(i).convert("RGB") for i in range(len(self.frames))]
composed[0].save(path, save_all=True, append_images=composed[1:],
duration=duration_ms, loop=0)
messagebox.showinfo("Exportar GIF", f"GIF exportado:\n{path}")
except Exception as e:
messagebox.showerror("Exportar GIF", str(e))
def export_png_sequence(self):
folder = filedialog.askdirectory(title="Elegí una carpeta para los PNG")
if not folder:
return
try:
for i in range(len(self.frames)):
self.composite(i).save(os.path.join(folder, f"frame_{i + 1:03d}.png"))
messagebox.showinfo("Exportar PNG", f"Se exportaron {len(self.frames)} imágenes a:\n{folder}")
except Exception as e:
messagebox.showerror("Exportar PNG", str(e))
# ------------------------------------------------------ guardar/abrir ---
def project_data(self):
payload = {
"format": FORMAT_NAME, "version": 2, "app": APP_NAME,
"fps": self.get_fps(), "width": self.W, "height": self.H,
"community_owned": True, "frames": [], "sprites": [],
}
for im in self.frames:
buf = io.BytesIO()
im.save(buf, "PNG")
payload["frames"].append(base64.b64encode(buf.getvalue()).decode())
if self.background is not None:
buf = io.BytesIO()
self.background.save(buf, "PNG")
payload["background"] = base64.b64encode(buf.getvalue()).decode()
for name, im in self.sprites:
buf = io.BytesIO()
im.save(buf, "PNG")
payload["sprites"].append({"name": name, "image": base64.b64encode(buf.getvalue()).decode()})
return payload
def load_project_data(self, data):
if data.get("format") not in LEGACY_FORMAT_NAMES:
raise ValueError("No es un archivo de Tahgibu Animator válido")
self.stop()
self.fps_var.set(max(1, min(60, int(data.get("fps", 12)))))
self.W = int(data.get("width", 960))
self.H = int(data.get("height", 540))
frames = []
for raw in data.get("frames", []):
im = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGBA")
if im.size != (self.W, self.H):
im = im.resize((self.W, self.H), Image.Resampling.LANCZOS)
frames.append(im)
self.frames = frames or [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))]
self.current = 0
self.background = None
if data.get("background"):
self.background = Image.open(io.BytesIO(base64.b64decode(data["background"]))).convert("RGBA")
self._rebuild_bg_cache()
self.sprites = []
self.sprite_list.delete(0, "end")
for s in data.get("sprites", []):
try:
im = Image.open(io.BytesIO(base64.b64decode(s["image"]))).convert("RGBA")
self.sprites.append((s.get("name", "sprite"), im))
self.sprite_list.insert("end", s.get("name", "sprite"))
except Exception:
pass
self.undo_stack.clear()
self.redo_stack.clear()
self._invalidate_onion()
self._invalidate_thumb()
self.dirty = False
self.render()
def save_asm(self):
if self.current_path:
self._write_project(self.current_path)
else:
self.save_asm_as()
def save_asm_as(self):
path = filedialog.asksaveasfilename(defaultextension=FILE_EXT,
filetypes=[("Tahgibu Animator", f"*{FILE_EXT}")])
if not path:
return
self._write_project(path)
def _write_project(self, path):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.project_data(), f, separators=(",", ":"))
self.current_path = path
self.dirty = False
self.update_status()
messagebox.showinfo(APP_NAME, "Proyecto guardado.")
except Exception as e:
messagebox.showerror(APP_NAME, f"No se pudo guardar:\n{e}")
def open_asm(self):
if not self.confirm_discard("abrir otro proyecto"):
return
path = filedialog.askopenfilename(filetypes=[("Tahgibu Animator", f"*{FILE_EXT} *.asm"),
("Todos", "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
self.load_project_data(data)
self.current_path = path
except Exception as e:
messagebox.showerror(APP_NAME, f"No se pudo abrir:\n{e}")
def import_asm(self):
path = filedialog.askopenfilename(filetypes=[("Tahgibu Animator", f"*{FILE_EXT} *.asm"),
("Todos", "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if data.get("format") not in LEGACY_FORMAT_NAMES:
raise ValueError("Archivo incompatible")
incoming = []
for raw in data.get("frames", []):
im = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGBA")
im = im.resize((self.W, self.H), Image.Resampling.LANCZOS)
incoming.append(im)
if incoming:
idx = self.current + 1
self.frames[idx:idx] = incoming
self.current = idx
# Importar varios frames de golpe no se registra en Deshacer (Ctrl+Z)
# por simplicidad; se avisa al usuario en el mensaje de abajo.
self._invalidate_onion()
self._invalidate_thumb()
self.mark_dirty()
self.render()
messagebox.showinfo("Importar", f"Se importaron {len(incoming)} frame(s). "
"Nota: esta acción no se puede deshacer con Ctrl+Z.")
except Exception as e:
messagebox.showerror("Importar", str(e))
if __name__ == "__main__":
root = tk.Tk()
app = TahgibuAnimator(root)
root.mainloop()
-101
View File
@@ -1,101 +0,0 @@
// Pruebas del núcleo. No usan raylib.
#include <cmath>
#include <cstdio>
#include <limits>
#include <stdexcept>
#include "core/Constants.hpp"
#include "core/Frame.hpp"
#include "core/Project.hpp"
namespace {
int g_failures = 0;
void check(bool condition, const char* expression, int line) {
if (!condition) {
std::fprintf(stderr, "FALLO (línea %d): %s\n", line, expression);
++g_failures;
}
}
bool near(double a, double b) {
return std::fabs(a - b) < 1e-9;
}
} // namespace
#define CHECK(expr) check((expr), #expr, __LINE__)
int main() {
using namespace tahgibu;
// --- Límite de FPS -----------------------------------------------------
CHECK(Frame::clampFps(120.0f) == kMaxFps);
CHECK(Frame::clampFps(60.0f) == 60.0f);
CHECK(Frame::clampFps(1.0f) == 1.0f);
CHECK(Frame::clampFps(0.0f) == kMinFps);
CHECK(Frame::clampFps(-5.0f) == kMinFps);
CHECK(Frame::clampFps(std::numeric_limits<float>::quiet_NaN()) == kDefaultFps);
CHECK(Frame::clampFps(std::numeric_limits<float>::infinity()) == kDefaultFps);
// --- Frame ---------------------------------------------------------------
Frame frame;
CHECK(!frame.hasFpsOverride());
CHECK(near(frame.durationSeconds(24.0f), 1.0 / 24.0));
frame.setFpsOverride(1.0f);
CHECK(frame.hasFpsOverride());
CHECK(near(frame.durationSeconds(60.0f), 1.0));
frame.setFpsOverride(1000.0f); // nunca por encima de 60
CHECK(frame.effectiveFps(24.0f) == kMaxFps);
frame.clearFpsOverride();
CHECK(!frame.hasFpsOverride());
CHECK(frame.effectiveFps(30.0f) == 30.0f);
CHECK(frame.effectiveFps(500.0f) == kMaxFps); // la base también se limita
// --- Project -------------------------------------------------------------
Project project;
CHECK(project.frameCount() == 1);
CHECK(project.baseFps() == kDefaultFps);
project.setBaseFps(999.0f);
CHECK(project.baseFps() == kMaxFps);
project.setBaseFps(std::numeric_limits<float>::quiet_NaN());
CHECK(project.baseFps() == kDefaultFps);
// Ejemplo de la especificación: A(60) B(60) C(1) D(60)
Project example;
example.setBaseFps(60.0f);
example.addFrame(); // B
const std::size_t c = example.addFrame(); // C
example.addFrame(); // D
example.frame(c).setFpsOverride(1.0f);
CHECK(example.frameCount() == 4);
CHECK(near(example.frameDurationSeconds(0), 1.0 / 60.0));
CHECK(near(example.frameDurationSeconds(c), 1.0));
CHECK(near(example.totalDurationSeconds(), 3.0 / 60.0 + 1.0));
// Cambiar el FPS base afecta a los frames sin FPS propio, pero no al de C.
example.setBaseFps(30.0f);
CHECK(near(example.frameDurationSeconds(0), 1.0 / 30.0));
CHECK(near(example.frameDurationSeconds(c), 1.0));
bool threw = false;
try {
example.frame(99);
} catch (const std::out_of_range&) {
threw = true;
}
CHECK(threw);
if (g_failures == 0) {
std::puts("OK: todas las pruebas del núcleo pasaron.");
return 0;
}
std::fprintf(stderr, "%d prueba(s) fallaron.\n", g_failures);
return 1;
}
+35
View File
@@ -0,0 +1,35 @@
/* GENERADO por tools/gen_png_fixtures.py con Pillow: no editar a mano. */
#ifndef TGB_FIXTURES_PNG_H
#define TGB_FIXTURES_PNG_H
#include <stddef.h>
#include <stdint.h>
#define FIX_W 4
#define FIX_H 3
static const uint8_t fix_rgba8[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,8,6,0,0,0,180,244,174,198,0,0,0,43,73,68,65,84,120,156,69,199,161,13,0,32,12,0,176,146,236,172,241,205,62,197,112,209,44,8,4,117,29,108,169,79,106,169,103,40,199,51,177,66,253,192,5,51,235,9,107,80,244,30,89,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_rgba8_px[] = {0,0,0,0,60,0,190,255,120,0,180,255,180,0,170,60,0,100,200,255,60,100,190,255,120,100,180,60,180,100,170,255,0,200,200,255,60,200,190,60,120,200,180,255,180,200,170,255};
static const uint8_t fix_rgb8[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,8,2,0,0,0,59,150,57,145,0,0,0,26,73,68,65,84,120,156,99,100,96,96,48,98,16,132,32,22,134,40,65,6,6,40,66,225,0,0,44,82,2,15,131,0,81,21,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_rgb8_px[] = {0,0,0,255,50,0,17,255,100,0,34,255,150,0,51,255,0,90,17,255,50,90,34,255,100,90,51,255,150,90,68,255,0,180,34,255,50,180,51,255,100,180,68,255,150,180,85,255};
static const uint8_t fix_gray8[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,8,0,0,0,0,145,159,241,26,0,0,0,23,73,68,65,84,120,156,99,100,208,208,208,96,226,226,226,226,98,226,226,226,226,2,0,7,108,0,206,40,183,200,126,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_gray8_px[] = {0,0,0,255,40,40,40,255,80,80,80,255,120,120,120,255,10,10,10,255,50,50,50,255,90,90,90,255,130,130,130,255,20,20,20,255,60,60,60,255,100,100,100,255,140,140,140,255};
static const uint8_t fix_graya8[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,8,4,0,0,0,30,253,102,77,0,0,0,27,73,68,65,84,120,156,99,100,253,47,199,32,199,32,199,192,194,48,135,129,129,129,129,1,193,0,0,47,203,2,160,83,77,14,190,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_graya8_px[] = {5,5,5,255,35,35,35,255,65,65,65,255,95,95,95,255,5,5,5,155,35,35,35,155,65,65,65,155,95,95,95,155,5,5,5,55,35,35,35,55,65,65,65,55,95,95,95,55};
static const uint8_t fix_palette_trns[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,8,3,0,0,0,131,42,94,244,0,0,3,0,80,76,84,69,0,255,0,60,195,20,120,135,40,180,75,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,159,71,4,107,0,0,0,4,116,82,78,83,255,128,0,255,161,161,148,102,0,0,0,17,73,68,65,84,120,156,99,96,96,100,98,6,99,6,16,13,0,0,149,0,19,22,208,45,139,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_palette_trns_px[] = {0,255,0,255,60,195,20,128,0,0,0,0,180,75,60,255,60,195,20,128,0,0,0,0,180,75,60,255,0,255,0,255,0,0,0,0,180,75,60,255,0,255,0,255,60,195,20,128};
static const uint8_t fix_bilevel[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,1,0,0,0,0,156,143,147,107,0,0,0,14,73,68,65,84,120,156,99,8,96,10,96,8,0,0,2,222,0,243,147,161,216,28,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_bilevel_px[] = {0,0,0,255,255,255,255,255,0,0,0,255,255,255,255,255,255,255,255,255,0,0,0,255,255,255,255,255,0,0,0,255,0,0,0,255,255,255,255,255,0,0,0,255,255,255,255,255};
static const uint8_t fix_palette2bit[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,2,3,0,0,0,201,154,70,85,0,0,0,12,80,76,84,69,255,0,0,0,255,0,0,0,255,255,255,0,214,2,143,123,0,0,0,14,73,68,65,84,120,156,99,80,98,10,101,18,4,0,1,204,0,141,255,92,63,121,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_palette2bit_px[] = {255,0,0,255,0,0,255,255,255,0,0,255,0,0,255,255,0,255,0,255,255,255,0,255,0,255,0,255,255,255,0,255,0,0,255,255,255,0,0,255,0,0,255,255,255,0,0,255};
static const uint8_t fix_gray16[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,4,0,0,0,3,16,0,0,0,0,193,15,45,89,0,0,0,28,73,68,65,84,120,156,99,96,96,224,95,32,239,160,247,128,69,89,131,95,67,64,67,64,3,206,0,0,61,151,4,9,163,240,125,70,0,0,0,0,73,69,78,68,174,66,96,130};
static const uint8_t fix_gray16_px[] = {0,0,0,255,15,15,15,255,31,31,31,255,46,46,46,255,35,35,35,255,50,50,50,255,66,66,66,255,82,82,82,255,70,70,70,255,85,85,85,255,101,101,101,255,117,117,117,255};
typedef struct { const char *name; const uint8_t *png; size_t len; const uint8_t *px; } png_fixture;
static const png_fixture png_fixtures[] = {
{"rgba8", fix_rgba8, sizeof fix_rgba8, fix_rgba8_px},
{"rgb8", fix_rgb8, sizeof fix_rgb8, fix_rgb8_px},
{"gray8", fix_gray8, sizeof fix_gray8, fix_gray8_px},
{"graya8", fix_graya8, sizeof fix_graya8, fix_graya8_px},
{"palette_trns", fix_palette_trns, sizeof fix_palette_trns, fix_palette_trns_px},
{"bilevel", fix_bilevel, sizeof fix_bilevel, fix_bilevel_px},
{"palette2bit", fix_palette2bit, sizeof fix_palette2bit, fix_palette2bit_px},
{"gray16", fix_gray16, sizeof fix_gray16, fix_gray16_px},
};
#endif
+14
View File
@@ -0,0 +1,14 @@
/* Ayudas comunes de las extensiones de PRUEBA. */
#ifndef PLUG_COMMON_H
#define PLUG_COMMON_H
#include <stdio.h>
#include <stdlib.h>
#include "tahgibu/plugin.h"
static __attribute__((unused)) void mark(const char *what) { /* deja constancia en un archivo para que la prueba compruebe qué se ejecutó */
const char *p = getenv("TGB_TEST_MARK");
if (!p) return;
FILE *f = fopen(p, "a");
if (f) { fputs(what, f); fputc('\n', f); fclose(f); }
}
#endif
+14
View File
@@ -0,0 +1,14 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
static const tgb_host_api *H;
static int seen_pixels = 0, seen_total = 0;
static void on_event(const tgb_event *ev, void *user) { (void)user; seen_total++; if (ev->type == TGB_EVENT_PIXELS_CHANGED) seen_pixels++; }
static tgb_status cmd_seen(tgb_plugin_handle *self, void *user) { (void)self; (void)user; return seen_pixels > 0 ? TGB_OK : TGB_ERR_PLUGIN; }
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
H = host;
tgb_status s = host->subscribe(self, on_event, NULL);
if (s < 0) return s;
host->register_command(self, "eventos-vistos", cmd_seen, NULL);
info->name = "Escucha eventos"; info->version = "1.0"; info->required_caps = TGB_CAP_EVENTS | TGB_CAP_READ_DOCUMENT;
return TGB_OK;
}
+57
View File
@@ -0,0 +1,57 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
static const tgb_host_api *H;
static const tgb_color RED = {255, 0, 0, 255};
static tgb_status cmd_cross(tgb_plugin_handle *self, void *user) {
(void)user;
tgb_status s = H->edit_begin(self, 0, 0);
if (s < 0) return s;
int cx = H->doc_width(self) / 2, cy = H->doc_height(self) / 2;
for (int i = -3; i <= 3; i++) { H->put_pixel(self, cx + i, cy, RED); H->put_pixel(self, cx, cy + i, RED); }
return H->edit_end(self);
}
static tgb_status cmd_two_steps(tgb_plugin_handle *self, void *user) {
(void)user;
tgb_status s = H->add_frame(self, H->doc_frame_count(self)); /* paso 1: estructura */
if (s < 0) return s;
s = H->edit_begin(self, 0, 0); /* paso 2: píxeles */
if (s < 0) return s;
H->put_pixel(self, 1, 1, RED);
return H->edit_end(self);
}
static tgb_status cmd_fail_midway(tgb_plugin_handle *self, void *user) {
(void)user;
H->add_frame(self, 0);
if (H->edit_begin(self, 0, 0) == TGB_OK) { H->put_pixel(self, 2, 2, RED); H->edit_end(self); }
return TGB_ERR_PLUGIN; /* falla a mitad: el host debe revertirlo todo */
}
static tgb_status cmd_forget_end(tgb_plugin_handle *self, void *user) {
(void)user;
tgb_status s = H->edit_begin(self, 0, 0);
if (s < 0) return s;
H->put_pixel(self, 3, 3, RED);
return TGB_OK; /* olvida edit_end */
}
static tgb_status cmd_read(tgb_plugin_handle *self, void *user) {
(void)user;
return H->get_pixel(self, 0, 0, 0, 0).a == 0 ? TGB_OK : TGB_ERR_PLUGIN;
}
static tgb_status cmd_outside(tgb_plugin_handle *self, void *user) { (void)user; (void)self; return TGB_OK; }
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
H = host;
mark("init");
if (host->api_version != TGB_PLUGIN_API_VERSION || host->struct_size < sizeof(tgb_host_api)) return TGB_ERR_INCOMPATIBLE;
host->register_command(self, "dibujar-cruz", cmd_cross, NULL);
host->register_command(self, "dos-pasos", cmd_two_steps, NULL);
host->register_command(self, "falla-a-medias", cmd_fail_midway, NULL);
host->register_command(self, "olvida-cerrar", cmd_forget_end, NULL);
host->register_command(self, "lee-pixel", cmd_read, NULL);
host->register_command(self, "no-hace-nada", cmd_outside, NULL);
info->name = "Cruz de prueba";
info->version = "1.0.0";
info->required_caps = TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT;
return TGB_OK;
}
TGB_PLUGIN_EXPORT void tgb_plugin_shutdown(tgb_plugin_handle *self) { (void)self; mark("shutdown"); }
+6
View File
@@ -0,0 +1,6 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
(void)host; (void)self; (void)info;
return TGB_ERR_PLUGIN;
}
+9
View File
@@ -0,0 +1,9 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
(void)host; (void)self;
mark("init-needs-edit");
info->name = "Pide editar"; info->version = "1"; info->required_caps = TGB_CAP_EDIT_DOCUMENT;
return TGB_OK;
}
TGB_PLUGIN_EXPORT void tgb_plugin_shutdown(tgb_plugin_handle *self) { (void)self; mark("shutdown-needs-edit"); }
+3
View File
@@ -0,0 +1,3 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
int plug_unrelated_symbol(void) { return 42; } /* una biblioteca válida pero sin tgb_plugin_init */
+5
View File
@@ -0,0 +1,5 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
(void)host; (void)self; info->name = NULL; info->required_caps = 0; return TGB_OK;
}
+4
View File
@@ -0,0 +1,4 @@
#include "plug_common.h"
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
(void)host; (void)self; (void)info; mark("init-no-version"); return TGB_OK;
}
+5
View File
@@ -0,0 +1,5 @@
#include "plug_common.h"
TGB_PLUGIN_EXPORT const uint32_t tgb_plugin_api_version = 0; /* compilada para otra API */
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
(void)host; (void)self; (void)info; mark("init-old-api"); return TGB_OK;
}
+12
View File
@@ -0,0 +1,12 @@
#include "plug_common.h"
TGB_PLUGIN_DECLARE_API_VERSION;
static const tgb_host_api *H;
static tgb_status cmd_try_edit(tgb_plugin_handle *self, void *user) { (void)user; return H->edit_begin(self, 0, 0); }
static tgb_status cmd_try_edit_unchecked(tgb_plugin_handle *self, void *user) { (void)user; return H->add_frame(self, 0); }
TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) {
H = host;
host->register_command(self, "intenta-editar", cmd_try_edit, NULL);
host->register_command(self, "intenta-anadir", cmd_try_edit_unchecked, NULL);
info->name = "Solo lectura"; info->version = "0.1"; info->required_caps = TGB_CAP_READ_DOCUMENT; /* NO pide editar */
return TGB_OK;
}
+180
View File
@@ -0,0 +1,180 @@
/* Composición de capas, Onion Skin y reproductor. */
#include <math.h>
#include "tgb_test.h"
static tgb_context *g_ctx;
static char g_root[200];
static tgb_document *new_doc(int w, int h, int fps) {
tgb_document *d = NULL;
CHECK_OK(tgb_document_create(g_ctx, w, h, fps, &d));
return d;
}
TEST(flatten_layers) {
tgb_document *d = new_doc(8, 8, 12);
CHECK_OK(tgb_layer_add(d, 1, "Arriba", NULL));
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
CHECK_OK(paint_px(d, 0, 1, 1, 1, BLUE));
tgb_canvas *out = tgb_canvas_create(8, 8);
CHECK_OK(tgb_render_flatten(d, 0, out));
CHECK(is_px(out, 1, 1, BLUE)); /* la capa de arriba tapa a la de abajo */
CHECK_OK(tgb_layer_set_visible(d, 1, false));
CHECK_OK(tgb_render_flatten(d, 0, out));
CHECK(is_px(out, 1, 1, RED)); /* capa oculta: no cuenta */
CHECK_OK(tgb_layer_set_visible(d, 1, true));
CHECK_OK(tgb_layer_set_opacity(d, 1, 128));
CHECK_OK(tgb_render_flatten(d, 0, out));
tgb_color p = tgb_canvas_get(out, 1, 1);
CHECK(p.a == 255 && abs(p.r - 127) <= 2 && abs(p.b - 128) <= 2); /* mezcla 50 % */
CHECK_OK(tgb_layer_move(d, 1, 0));
CHECK_OK(tgb_layer_set_opacity(d, 0, 255));
CHECK_OK(tgb_render_flatten(d, 0, out));
CHECK(is_px(out, 1, 1, RED)); /* cambiar el orden cambia el resultado */
CHECK_OK(tgb_render_flatten(d, 0, out));
CHECK_EQ(tgb_canvas_dirty(out).w, 0);
tgb_canvas *small = tgb_canvas_create(4, 4);
CHECK_STATUS(tgb_render_flatten(d, 0, small), TGB_ERR_RANGE);
CHECK_STATUS(tgb_render_flatten(d, 5, out), TGB_ERR_RANGE);
CHECK_STATUS(tgb_render_flatten(NULL, 0, out), TGB_ERR_INVALID_ARG);
tgb_canvas_release(small); tgb_canvas_release(out);
tgb_document_destroy(d);
}
TEST(onion_skin) {
tgb_document *d = new_doc(8, 8, 12);
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(tgb_frame_add(d, 2, NULL));
CHECK_OK(paint_px(d, 0, 0, 2, 2, RED)); /* fotograma 0: rojo */
CHECK_OK(paint_px(d, 2, 0, 5, 5, GREEN)); /* fotograma 2: verde */
tgb_onion_settings on; tgb_onion_defaults(&on);
CHECK(!on.enabled);
on.tint = false; on.opacity_before = 0.4f; on.opacity_after = 0.5f; on.falloff = 0.5f;
tgb_canvas *out = tgb_canvas_create(8, 8);
tgb_color none = {0, 0, 0, 0};
/* desactivado: no se ve nada del resto */
CHECK_OK(tgb_render_view(d, 1, &on, none, out));
CHECK(tgb_canvas_is_empty(out));
on.enabled = true;
CHECK_OK(tgb_render_view(d, 1, &on, none, out));
tgb_color pr = tgb_canvas_get(out, 2, 2), pg = tgb_canvas_get(out, 5, 5);
CHECK(pr.r == 255 && pr.g == 0 && abs(pr.a - 102) <= 1); /* anterior: 40 % */
CHECK(pg.g == 255 && abs(pg.a - 128) <= 1); /* posterior: 50 % */
/* tinte: el color original se mezcla con el tinte */
on.tint = true; on.tint_before = BLUE;
CHECK_OK(tgb_render_view(d, 1, &on, none, out));
pr = tgb_canvas_get(out, 2, 2);
CHECK(pr.b > 100 && pr.r < 255);
/* dos fotogramas hacia atrás con atenuación */
on.tint = false; on.frames_before = 2; on.frames_after = 0;
CHECK_OK(tgb_render_view(d, 2, &on, none, out));
CHECK(abs(tgb_canvas_get(out, 2, 2).a - 51) <= 1); /* 0,4 * 0,5 */
CHECK(is_px(out, 5, 5, GREEN)); /* el fotograma actual va encima, sin transparencia */
/* sin fotogramas anteriores al primero */
CHECK_OK(tgb_render_view(d, 0, &on, none, out));
CHECK(is_px(out, 2, 2, RED));
CHECK_EQ(count_alpha(out), 1);
/* fondo opaco */
tgb_color white = {255, 255, 255, 255};
on.frames_after = 2; on.frames_before = 0;
CHECK_OK(tgb_render_view(d, 1, &on, white, out));
CHECK(is_px(out, 0, 0, white));
CHECK(tgb_canvas_get(out, 5, 5).g == 255 && tgb_canvas_get(out, 5, 5).r > 100);
/* validación de ajustes */
tgb_onion_settings bad = on;
bad.frames_before = 99; bad.frames_after = -3; bad.opacity_before = 7.0f; bad.opacity_after = NAN; bad.falloff = -1;
CHECK_OK(tgb_onion_validate(&bad));
CHECK(bad.frames_before == 8 && bad.frames_after == 0 && bad.opacity_before == 1.0f && bad.opacity_after == 0.0f && bad.falloff == 0.0f);
CHECK_STATUS(tgb_onion_validate(NULL), TGB_ERR_INVALID_ARG);
tgb_canvas_release(out);
tgb_document_destroy(d);
}
TEST(player_loop_once_pingpong) {
tgb_document *d = new_doc(8, 8, 10); /* 10 fps = 100 ms por fotograma */
CHECK_OK(tgb_frame_add(d, 1, NULL)); CHECK_OK(tgb_frame_add(d, 2, NULL));
tgb_player *p = tgb_player_create(d);
CHECK(p != NULL);
CHECK_EQ(tgb_player_update(p, 500), 0); /* parado: no avanza */
tgb_player_play(p);
CHECK(tgb_player_is_playing(p));
CHECK_EQ(tgb_player_update(p, 50), 0);
CHECK_EQ(tgb_player_update(p, 60), 1);
CHECK_EQ(tgb_player_update(p, 100), 2);
CHECK_EQ(tgb_player_update(p, 100), 0); /* bucle */
tgb_player_pause(p);
CHECK_EQ(tgb_player_update(p, 1000), 0);
tgb_player_stop(p);
CHECK(!tgb_player_is_playing(p));
/* una vez */
tgb_player_set_mode(p, TGB_PLAY_ONCE);
tgb_player_play(p);
CHECK_EQ(tgb_player_update(p, 100), 1);
CHECK_EQ(tgb_player_update(p, 100), 2);
CHECK_EQ(tgb_player_update(p, 100), 2);
CHECK(!tgb_player_is_playing(p));
tgb_player_play(p); /* volver a dar a play reinicia */
CHECK(tgb_player_is_playing(p) && tgb_player_frame(p) == 0);
/* ping-pong: 0,1,2,1,0,1,2 */
tgb_player_stop(p);
tgb_player_set_mode(p, TGB_PLAY_PINGPONG);
tgb_player_play(p);
size_t expect[] = {1, 2, 1, 0, 1, 2};
for (int i = 0; i < 6; i++) CHECK_EQ(tgb_player_update(p, 100), expect[i]);
/* velocidad doble */
tgb_player_stop(p);
tgb_player_set_mode(p, TGB_PLAY_LOOP);
tgb_player_set_speed(p, 2.0f);
tgb_player_play(p);
CHECK_EQ(tgb_player_update(p, 50), 1);
tgb_player_destroy(p);
CHECK_OK(0);
tgb_document_destroy(d);
}
TEST(player_durations_range_and_edge_cases) {
tgb_document *d = new_doc(8, 8, 10);
CHECK_OK(tgb_frame_add(d, 1, NULL)); CHECK_OK(tgb_frame_add(d, 2, NULL));
CHECK_OK(tgb_frame_set_duration(d, 1, 300)); /* duraciones: 100, 300, 100 */
tgb_player *p = tgb_player_create(d);
tgb_player_play(p);
CHECK_EQ(tgb_player_update(p, 150), 1);
CHECK_EQ(tgb_player_update(p, 240), 1); /* 390 ms: aún en el fotograma de 300 ms (100..400) */
CHECK_EQ(tgb_player_update(p, 20), 2); /* 410 ms: ya en el tercero */
/* un dt gigante no cuelga ni sale del rango */
size_t f = tgb_player_update(p, 1e12);
CHECK(f < 3);
CHECK_EQ(tgb_player_update(p, -5), f); /* dt negativo se ignora */
/* rango 1..2 */
CHECK_STATUS(tgb_player_set_range(p, 2, 1), TGB_ERR_RANGE);
CHECK_STATUS(tgb_player_set_range(p, 0, 9), TGB_ERR_RANGE);
CHECK_OK(tgb_player_set_range(p, 1, 2));
tgb_player_stop(p);
CHECK_EQ(tgb_player_frame(p), 1);
tgb_player_play(p);
CHECK_EQ(tgb_player_update(p, 300), 2);
CHECK_EQ(tgb_player_update(p, 100), 1); /* vuelve al inicio del rango, no al 0 */
tgb_player_clear_range(p);
CHECK_STATUS(tgb_player_seek(p, 7), TGB_ERR_RANGE);
CHECK_OK(tgb_player_seek(p, 2));
CHECK_EQ(tgb_player_frame(p), 2);
/* el documento cambia mientras se reproduce */
CHECK_OK(tgb_frame_delete(d, 2));
CHECK(tgb_player_update(p, 10) < 2);
tgb_player_set_speed(p, -1.0f); tgb_player_set_speed(p, 1000.0f); /* se recortan sin fallar */
tgb_player_destroy(p);
tgb_player_destroy(NULL);
CHECK(tgb_player_create(NULL) == NULL);
tgb_document_destroy(d);
}
int main(void) {
g_ctx = tgb_test_context(g_root, sizeof g_root);
RUN(flatten_layers);
RUN(onion_skin);
RUN(player_loop_once_pingpong);
RUN(player_durations_range_and_edge_cases);
tgb_context_destroy(g_ctx);
tgb_test_rmtree(g_root);
TEST_SUMMARY();
}
+192
View File
@@ -0,0 +1,192 @@
/* Lienzo, herramientas de dibujo, relleno y transformaciones. */
#include <math.h>
#include "tgb_test.h"
TEST(canvas_basics) {
CHECK(tgb_canvas_create(0, 10) == NULL);
CHECK(tgb_canvas_create(10, -1) == NULL);
CHECK(tgb_canvas_create(TGB_MAX_DIMENSION + 1, 10) == NULL);
tgb_canvas *c = tgb_canvas_create(8, 6);
CHECK(c != NULL);
CHECK_EQ(tgb_canvas_width(c), 8);
CHECK_EQ(tgb_canvas_bytes(c), 8 * 6 * 4);
CHECK(tgb_canvas_is_empty(c));
tgb_canvas_set(c, 2, 3, RED);
CHECK(is_px(c, 2, 3, RED));
CHECK(!tgb_canvas_is_empty(c));
CHECK_EQ(tgb_canvas_get(c, -1, 0).a, 0); /* fuera del lienzo = transparente */
CHECK_EQ(tgb_canvas_get(c, 8, 0).a, 0);
tgb_canvas_set(c, 100, 100, RED); /* fuera: se ignora sin fallar */
tgb_canvas *d = tgb_canvas_clone(c);
CHECK(tgb_canvas_equal(c, d));
tgb_canvas_set(d, 0, 0, BLUE);
CHECK(!tgb_canvas_equal(c, d));
CHECK_EQ(tgb_canvas_refs(c), 1);
tgb_canvas_retain(c);
CHECK_EQ(tgb_canvas_refs(c), 2);
tgb_canvas_release(c);
tgb_canvas_release(c);
tgb_canvas_release(d);
tgb_canvas_release(NULL); /* aceptar NULL */
}
TEST(canvas_blend_math) {
tgb_canvas *c = tgb_canvas_create(2, 1);
tgb_color half_red = {255, 0, 0, 128};
tgb_canvas_blend(c, 0, 0, half_red); /* sobre transparente: queda igual */
CHECK(is_px(c, 0, 0, half_red));
tgb_canvas_set(c, 1, 0, BLUE);
tgb_canvas_blend(c, 1, 0, half_red); /* 50 % rojo sobre azul opaco */
tgb_color p = tgb_canvas_get(c, 1, 0);
CHECK_EQ(p.a, 255);
CHECK(abs(p.r - 128) <= 1 && abs(p.b - 127) <= 1 && p.g == 0);
tgb_canvas_erase_px(c, 1, 0, 255); /* borrado total: forma canónica 0,0,0,0 */
CHECK(is_px(c, 1, 0, (tgb_color){0, 0, 0, 0}));
tgb_canvas_release(c);
}
TEST(canvas_crop_blit_dirty) {
tgb_canvas *c = tgb_canvas_create(10, 10);
CHECK_EQ(tgb_canvas_dirty(c).w, 0);
tgb_canvas_set(c, 3, 4, RED);
tgb_canvas_set(c, 6, 8, RED);
tgb_rect d = tgb_canvas_dirty(c);
CHECK(d.x == 3 && d.y == 4 && d.w == 4 && d.h == 5);
tgb_canvas_reset_dirty(c);
CHECK_EQ(tgb_canvas_dirty(c).w, 0);
tgb_canvas *crop = NULL;
CHECK_OK(tgb_canvas_crop(c, (tgb_rect){3, 4, 4, 5}, &crop));
CHECK(is_px(crop, 0, 0, RED) && is_px(crop, 3, 4, RED));
tgb_canvas *bad = NULL;
CHECK_STATUS(tgb_canvas_crop(c, (tgb_rect){8, 8, 5, 5}, &bad), TGB_ERR_RANGE);
tgb_canvas *dst = tgb_canvas_create(10, 10);
tgb_canvas_blit(dst, crop, 1, 1, 128, true); /* mitad de opacidad */
CHECK_EQ(tgb_canvas_get(dst, 1, 1).a, 128);
tgb_canvas_blit(dst, crop, 20, 20, 255, true); /* totalmente fuera: no falla */
tgb_canvas_release(crop); tgb_canvas_release(dst); tgb_canvas_release(c);
}
TEST(pencil_and_brush) {
tgb_canvas *c = tgb_canvas_create(20, 20);
tgb_brush pen = tgb_brush_pencil(RED, 1);
tgb_draw_line(c, &pen, 0, 0, 9, 9);
CHECK_EQ(count_alpha(c), 10); /* diagonal exacta de 10 píxeles */
for (int i = 0; i < 10; i++) CHECK(is_px(c, i, i, RED));
tgb_canvas_clear(c);
tgb_draw_line(c, &pen, 2, 5, 12, 5); /* horizontal */
CHECK_EQ(count_alpha(c), 11);
tgb_canvas_clear(c);
tgb_brush fat = tgb_brush_pencil(BLUE, 3); /* lápiz grueso sin suavizado: todo opaco */
tgb_draw_dab(c, &fat, 10.5f, 10.5f);
bool all_opaque = true;
for (int y = 0; y < 20; y++) for (int x = 0; x < 20; x++) { tgb_color p = tgb_canvas_get(c, x, y); if (p.a && p.a != 255) all_opaque = false; }
CHECK(all_opaque);
CHECK(count_alpha(c) >= 5 && count_alpha(c) <= 9);
tgb_canvas_clear(c);
tgb_brush soft = tgb_brush_soft(BLACK, 10.0f, 0.5f);
tgb_draw_dab(c, &soft, 10.0f, 10.0f);
CHECK_EQ(tgb_canvas_get(c, 10, 10).a, 255); /* centro opaco */
int a = tgb_canvas_get(c, 14, 10).a;
CHECK(a > 0 && a < 255); /* borde suave */
CHECK_EQ(tgb_canvas_get(c, 17, 10).a, 0);
tgb_brush er = tgb_brush_eraser(6.0f, 1.0f);
tgb_draw_dab(c, &er, 10.0f, 10.0f);
CHECK_EQ(tgb_canvas_get(c, 10, 10).a, 0); /* el borrador quita opacidad */
tgb_canvas_release(c);
}
TEST(pencil_robust_inputs) {
tgb_canvas *c = tgb_canvas_create(16, 16);
tgb_brush pen = tgb_brush_pencil(RED, 1);
tgb_draw_line(c, &pen, NAN, 0, 5, 5); /* no debe colgarse ni escribir fuera */
tgb_draw_line(c, &pen, -1e30f, -1e30f, 1e30f, 1e30f);
tgb_draw_dab(c, &pen, INFINITY, 3);
tgb_draw_line(c, NULL, 0, 0, 1, 1);
tgb_draw_dab(NULL, &pen, 0, 0);
CHECK(c != NULL);
tgb_canvas_release(c);
}
TEST(shapes) {
tgb_canvas *c = tgb_canvas_create(30, 30);
tgb_brush pen = tgb_brush_pencil(RED, 1);
tgb_draw_rect(c, &pen, (tgb_rect){2, 2, 4, 3}, true);
CHECK_EQ(count_alpha(c), 12);
tgb_canvas_clear(c);
tgb_draw_rect(c, &pen, (tgb_rect){0, 0, 5, 5}, false);
CHECK_EQ(count_alpha(c), 16); /* perímetro de 5x5 */
CHECK(!is_px(c, 2, 2, RED));
tgb_canvas_clear(c);
tgb_draw_ellipse(c, &pen, (tgb_rect){5, 5, 9, 9}, true);
CHECK(is_px(c, 9, 9, RED));
CHECK(!is_px(c, 5, 5, RED));
bool sym = true; /* simetría horizontal */
for (int y = 5; y < 14; y++) for (int x = 5; x < 14; x++) if (is_px(c, x, y, RED) != is_px(c, 18 - x, y, RED)) sym = false;
CHECK(sym);
tgb_canvas_clear(c);
tgb_draw_ellipse(c, &pen, (tgb_rect){5, 5, 15, 9}, false);
CHECK(count_alpha(c) > 20);
CHECK(!is_px(c, 12, 9, RED)); /* hueco en el centro */
tgb_canvas_clear(c);
tgb_draw_bezier(c, &pen, 2, 2, 10, 25, 20, 25, 27, 2);
CHECK(is_px(c, 2, 2, RED) && is_px(c, 27, 2, RED));
CHECK(count_alpha(c) > 25);
tgb_canvas_release(c);
}
TEST(flood_fill_and_eyedropper) {
tgb_canvas *c = tgb_canvas_create(10, 10);
for (int y = 0; y < 10; y++) tgb_canvas_set(c, 5, y, BLACK); /* muro vertical */
size_t n = tgb_flood_fill(c, 1, 1, RED, 0);
CHECK_EQ(n, 50);
CHECK(is_px(c, 0, 9, RED) && is_px(c, 4, 0, RED));
CHECK(is_px(c, 5, 5, BLACK));
CHECK_EQ(tgb_canvas_get(c, 7, 7).a, 0); /* el otro lado no se toca */
CHECK_EQ(tgb_flood_fill(c, 1, 1, RED, 0), 0); /* mismo color: no hace nada (y no se cuelga) */
CHECK_EQ(tgb_flood_fill(c, -1, 0, RED, 0), 0);
tgb_color e = tgb_eyedropper(c, 5, 3);
CHECK(e.r == 0 && e.a == 255);
/* tolerancia: gris casi igual se rellena con tolerancia alta */
tgb_canvas *g = tgb_canvas_create(4, 1);
for (int x = 0; x < 4; x++) { tgb_color p = {(uint8_t)(100 + x * 3), 100, 100, 255}; tgb_canvas_set(g, x, 0, p); }
CHECK_EQ(tgb_flood_fill(g, 0, 0, GREEN, 0), 1);
tgb_canvas_set(g, 0, 0, (tgb_color){100, 100, 100, 255});
CHECK_EQ(tgb_flood_fill(g, 0, 0, GREEN, 10), 4);
tgb_canvas_release(g); tgb_canvas_release(c);
}
TEST(regions) {
tgb_canvas *c = tgb_canvas_create(10, 10);
tgb_canvas_fill_rect(c, (tgb_rect){1, 1, 2, 2}, RED, false);
CHECK_OK(tgb_region_move(c, (tgb_rect){0, 0, 4, 4}, 3, 2));
CHECK(is_px(c, 4, 3, RED) && is_px(c, 5, 4, RED));
CHECK_EQ(tgb_canvas_get(c, 1, 1).a, 0);
CHECK_EQ(count_alpha(c), 4);
CHECK_STATUS(tgb_region_move(c, (tgb_rect){50, 50, 2, 2}, 1, 1), TGB_ERR_RANGE);
tgb_canvas_clear(c);
tgb_canvas_set(c, 1, 1, RED);
CHECK_OK(tgb_region_flip(c, (tgb_rect){0, 0, 4, 4}, true));
CHECK(is_px(c, 2, 1, RED) && count_alpha(c) == 1);
CHECK_OK(tgb_region_flip(c, (tgb_rect){0, 0, 4, 4}, false));
CHECK(is_px(c, 2, 2, RED) && count_alpha(c) == 1);
tgb_canvas_clear(c);
tgb_canvas_fill_rect(c, (tgb_rect){0, 0, 2, 2}, BLUE, false);
CHECK_OK(tgb_region_scale(c, (tgb_rect){0, 0, 2, 2}, 4, 4));
CHECK_EQ(count_alpha(c), 16);
CHECK_STATUS(tgb_region_scale(c, (tgb_rect){0, 0, 2, 2}, 0, 4), TGB_ERR_RANGE);
CHECK_OK(tgb_region_clear(c, (tgb_rect){0, 0, 10, 10}));
CHECK(tgb_canvas_is_empty(c));
tgb_canvas_release(c);
}
int main(void) {
RUN(canvas_basics);
RUN(canvas_blend_math);
RUN(canvas_crop_blit_dirty);
RUN(pencil_and_brush);
RUN(pencil_robust_inputs);
RUN(shapes);
RUN(flood_fill_and_eyedropper);
RUN(regions);
TEST_SUMMARY();
}
+487
View File
@@ -0,0 +1,487 @@
/* Modelo de documento, sesiones de edición e historial. */
#include <dirent.h>
#include "tgb_test.h"
static tgb_context *g_ctx;
static char g_root[200];
static tgb_document *new_doc(int w, int h, int fps) {
tgb_document *d = NULL;
CHECK_OK(tgb_document_create(g_ctx, w, h, fps, &d));
return d;
}
static tgb_status paint_block(tgb_document *doc, size_t f, size_t l, int x, int y, int size, tgb_color col) {
tgb_status s = tgb_edit_begin(doc, tgb_frame_id(doc, f), tgb_layer_id(doc, l), "Bloque");
if (s != TGB_OK) return s;
tgb_canvas_fill_rect(tgb_edit_canvas(doc), (tgb_rect){x, y, size, size}, col, false);
return tgb_edit_end(doc);
}
static size_t undo_count(const tgb_document *d) { tgb_history_stats s; tgb_history_get_stats(d, &s); return s.undo_count; }
TEST(document_create_and_props) {
tgb_document *d = NULL;
CHECK_STATUS(tgb_document_create(NULL, 10, 10, 12, &d), TGB_ERR_INVALID_ARG);
CHECK_STATUS(tgb_document_create(g_ctx, 0, 10, 12, &d), TGB_ERR_RANGE);
CHECK_STATUS(tgb_document_create(g_ctx, 10, 10, 0, &d), TGB_ERR_RANGE);
CHECK_STATUS(tgb_document_create(g_ctx, 10, 10, 61, &d), TGB_ERR_RANGE);
CHECK(d == NULL);
d = new_doc(64, 48, 12);
CHECK_EQ(tgb_document_width(d), 64);
CHECK_EQ(tgb_document_height(d), 48);
CHECK_EQ(tgb_document_fps(d), 12);
CHECK_EQ(tgb_frame_count(d), 1);
CHECK_EQ(tgb_layer_count(d), 1);
CHECK(!strcmp(tgb_layer_name(d, 0), "Capa 1"));
CHECK_EQ(tgb_frame_duration_ms(d, 0), 83);
CHECK(tgb_frame_cel(d, 0, 0) == NULL); /* celda vacía: no gasta memoria */
CHECK(!tgb_document_dirty(d));
CHECK(tgb_document_is_consistent(d));
CHECK(tgb_document_path(d) == NULL);
CHECK_OK(tgb_document_set_path(d, "/tmp/x.tgb"));
CHECK(!strcmp(tgb_document_path(d), "/tmp/x.tgb"));
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
CHECK(tgb_document_dirty(d));
tgb_document_mark_saved(d);
CHECK(!tgb_document_dirty(d));
CHECK_OK(tgb_history_undo(d));
CHECK(tgb_document_dirty(d)); /* deshacer también cambia el documento */
tgb_document_destroy(d);
tgb_document_destroy(NULL);
}
TEST(edit_session_undo_redo) {
tgb_document *d = new_doc(32, 32, 12);
CHECK(!tgb_history_can_undo(d));
CHECK_STATUS(tgb_history_undo(d), TGB_ERR_NOTHING);
CHECK_STATUS(tgb_history_redo(d), TGB_ERR_NOTHING);
CHECK_OK(paint_px(d, 0, 0, 3, 4, RED));
CHECK(is_px(tgb_frame_cel(d, 0, 0), 3, 4, RED));
CHECK(tgb_history_can_undo(d));
CHECK(!strcmp(tgb_history_undo_label(d), "Punto"));
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(px_at(d, 0, 0, 3, 4).a, 0);
CHECK(tgb_history_can_redo(d));
CHECK(!strcmp(tgb_history_redo_label(d), "Punto"));
CHECK_OK(tgb_history_redo(d));
CHECK(is_px(tgb_frame_cel(d, 0, 0), 3, 4, RED));
/* una sesión sin cambios reales no crea operación */
size_t before = undo_count(d);
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "Nada"));
tgb_canvas_set(tgb_edit_canvas(d), 3, 4, RED); /* mismo valor */
CHECK_OK(tgb_edit_end(d));
CHECK_EQ(undo_count(d), before);
/* estados inválidos */
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "A"));
CHECK(tgb_edit_active(d));
CHECK_STATUS(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "B"), TGB_ERR_STATE);
CHECK_STATUS(tgb_history_undo(d), TGB_ERR_STATE);
CHECK_STATUS(tgb_frame_add(d, 1, NULL), TGB_ERR_STATE);
CHECK_STATUS(tgb_layer_add(d, 1, "x", NULL), TGB_ERR_STATE);
/* cancelar devuelve la celda a como estaba */
tgb_canvas_set(tgb_edit_canvas(d), 10, 10, BLUE);
CHECK_OK(tgb_edit_cancel(d));
CHECK_EQ(px_at(d, 0, 0, 10, 10).a, 0);
CHECK(!tgb_edit_active(d));
CHECK_STATUS(tgb_edit_end(d), TGB_ERR_STATE);
CHECK_STATUS(tgb_edit_cancel(d), TGB_ERR_STATE);
CHECK(tgb_edit_canvas(d) == NULL);
CHECK_STATUS(tgb_edit_begin(d, 9999, tgb_layer_id(d, 0), "x"), TGB_ERR_NOT_FOUND);
/* reset = volver al inicio de la sesión (vista previa de formas) */
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "Línea"));
tgb_brush pen = tgb_brush_pencil(GREEN, 1);
tgb_draw_line(tgb_edit_canvas(d), &pen, 0, 20, 10, 20);
CHECK_OK(tgb_edit_reset(d));
tgb_draw_line(tgb_edit_canvas(d), &pen, 0, 25, 10, 25);
CHECK_OK(tgb_edit_end(d));
CHECK_EQ(px_at(d, 0, 0, 5, 20).a, 0);
CHECK(is_px(tgb_frame_cel(d, 0, 0), 5, 25, GREEN));
tgb_document_destroy(d);
}
TEST(locked_layer_rejects_edits) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(tgb_layer_set_locked(d, 0, true));
CHECK_STATUS(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "x"), TGB_ERR_LOCKED);
CHECK(!tgb_edit_active(d));
CHECK_OK(tgb_history_undo(d)); /* desbloquear por deshacer */
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
tgb_document_destroy(d);
}
TEST(multiple_strokes_and_redo_cleared) {
tgb_document *d = new_doc(16, 16, 12);
for (int i = 0; i < 3; i++) CHECK_OK(paint_px(d, 0, 0, i, 0, RED));
CHECK_EQ(undo_count(d), 3);
CHECK_OK(tgb_history_undo(d)); CHECK_OK(tgb_history_undo(d));
CHECK(is_px(tgb_frame_cel(d, 0, 0), 0, 0, RED));
CHECK_EQ(px_at(d, 0, 0, 1, 0).a, 0);
CHECK(tgb_history_can_redo(d));
CHECK_OK(paint_px(d, 0, 0, 9, 9, BLUE)); /* una acción nueva invalida el "rehacer" */
CHECK(!tgb_history_can_redo(d));
CHECK_STATUS(tgb_history_redo(d), TGB_ERR_NOTHING);
tgb_document_destroy(d);
}
TEST(frame_operations) {
tgb_document *d = new_doc(16, 16, 10);
uint32_t id1 = tgb_frame_id(d, 0), id2 = 0, id3 = 0;
CHECK_OK(tgb_frame_add(d, 1, &id2));
CHECK_OK(tgb_frame_add(d, 0, &id3));
CHECK_EQ(tgb_frame_count(d), 3);
CHECK(tgb_frame_id(d, 0) == id3 && tgb_frame_id(d, 1) == id1 && tgb_frame_id(d, 2) == id2);
size_t idx = 99;
CHECK_OK(tgb_frame_index_of(d, id2, &idx));
CHECK_EQ(idx, 2);
CHECK_STATUS(tgb_frame_index_of(d, 424242, &idx), TGB_ERR_NOT_FOUND);
CHECK_STATUS(tgb_frame_add(d, 9, NULL), TGB_ERR_RANGE);
CHECK_OK(tgb_frame_move(d, 0, 2));
CHECK(tgb_frame_id(d, 2) == id3 && tgb_frame_id(d, 0) == id1);
CHECK_STATUS(tgb_frame_move(d, 0, 7), TGB_ERR_RANGE);
CHECK_OK(tgb_history_undo(d)); /* deshacer el movimiento */
CHECK(tgb_frame_id(d, 0) == id3 && tgb_frame_id(d, 2) == id2);
CHECK_OK(tgb_frame_delete(d, 1));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(tgb_frame_count(d), 3);
CHECK(tgb_frame_id(d, 1) == id1);
CHECK_OK(tgb_history_redo(d));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK_OK(tgb_frame_delete(d, 0));
CHECK_STATUS(tgb_frame_delete(d, 0), TGB_ERR_STATE); /* nunca el último */
/* duración */
CHECK_STATUS(tgb_frame_set_duration(d, 0, 5), TGB_ERR_RANGE);
CHECK_STATUS(tgb_frame_set_duration(d, 0, 70000), TGB_ERR_RANGE);
CHECK_STATUS(tgb_frame_set_duration(d, 5, 100), TGB_ERR_RANGE);
CHECK_OK(tgb_frame_set_duration(d, 0, 250));
CHECK_EQ(tgb_frame_duration_ms(d, 0), 250);
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(tgb_frame_duration_ms(d, 0), 100);
tgb_document_destroy(d);
}
TEST(duplicate_shares_memory_until_edited) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(paint_px(d, 0, 0, 2, 2, RED));
CHECK_OK(tgb_frame_duplicate(d, 0, NULL));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK(tgb_frame_cel(d, 0, 0) == tgb_frame_cel(d, 1, 0)); /* misma celda en memoria */
CHECK_EQ(tgb_canvas_refs(tgb_frame_cel(d, 0, 0)), 2);
CHECK_OK(paint_px(d, 1, 0, 5, 5, BLUE)); /* copy-on-write */
CHECK(tgb_frame_cel(d, 0, 0) != tgb_frame_cel(d, 1, 0));
CHECK_EQ(px_at(d, 0, 0, 5, 5).a, 0); /* el original NO cambia */
CHECK(is_px(tgb_frame_cel(d, 1, 0), 2, 2, RED) && is_px(tgb_frame_cel(d, 1, 0), 5, 5, BLUE));
CHECK_OK(tgb_history_undo(d)); /* deshacer la edición de la copia */
CHECK_EQ(px_at(d, 1, 0, 5, 5).a, 0);
CHECK_OK(tgb_history_undo(d)); /* deshacer la duplicación */
CHECK_EQ(tgb_frame_count(d), 1);
CHECK(is_px(tgb_frame_cel(d, 0, 0), 2, 2, RED));
CHECK_OK(tgb_history_redo(d)); CHECK_OK(tgb_history_redo(d));
CHECK(is_px(tgb_frame_cel(d, 1, 0), 5, 5, BLUE));
CHECK_EQ(px_at(d, 0, 0, 5, 5).a, 0);
tgb_document_destroy(d);
}
TEST(delete_frame_keeps_pixels_for_undo) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(paint_px(d, 1, 0, 7, 7, GREEN));
CHECK_OK(tgb_frame_delete(d, 1));
CHECK_EQ(tgb_frame_count(d), 1);
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK(is_px(tgb_frame_cel(d, 1, 0), 7, 7, GREEN));
CHECK_OK(tgb_history_undo(d)); /* deshacer el dibujo en el fotograma restaurado */
CHECK_EQ(px_at(d, 1, 0, 7, 7).a, 0);
tgb_document_destroy(d);
}
TEST(layer_operations) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(tgb_frame_add(d, 1, NULL));
uint32_t lid = 0;
CHECK_OK(tgb_layer_add(d, 1, "Tinta", &lid));
CHECK_EQ(tgb_layer_count(d), 2);
CHECK(!strcmp(tgb_layer_name(d, 1), "Tinta"));
CHECK(tgb_layer_visible(d, 1) && !tgb_layer_locked(d, 1) && tgb_layer_opacity(d, 1) == 255);
CHECK_STATUS(tgb_layer_add(d, 1, "", NULL), TGB_ERR_INVALID_ARG);
CHECK_STATUS(tgb_layer_add(d, 1, "\xff\xfe", NULL), TGB_ERR_INVALID_ARG); /* UTF-8 inválido */
char longname[200]; memset(longname, 'a', sizeof longname - 1); longname[sizeof longname - 1] = 0;
CHECK_STATUS(tgb_layer_add(d, 1, longname, NULL), TGB_ERR_LIMIT);
CHECK_STATUS(tgb_layer_add(d, 9, "x", NULL), TGB_ERR_RANGE);
CHECK_OK(paint_px(d, 0, 1, 4, 4, RED));
CHECK_OK(paint_px(d, 1, 1, 6, 6, BLUE));
CHECK_OK(tgb_layer_move(d, 1, 0));
CHECK(tgb_layer_id(d, 0) == lid);
CHECK(is_px(tgb_frame_cel(d, 0, 0), 4, 4, RED)); /* las celdas se mueven con su capa */
CHECK_OK(tgb_history_undo(d));
CHECK(tgb_layer_id(d, 1) == lid && is_px(tgb_frame_cel(d, 0, 1), 4, 4, RED));
CHECK_OK(tgb_layer_rename(d, 1, "Color"));
CHECK(!strcmp(tgb_layer_name(d, 1), "Color"));
CHECK_OK(tgb_layer_set_visible(d, 1, false));
CHECK_OK(tgb_layer_set_opacity(d, 1, 128));
CHECK(!tgb_layer_visible(d, 1) && tgb_layer_opacity(d, 1) == 128);
CHECK_OK(tgb_history_undo(d)); CHECK_OK(tgb_history_undo(d)); CHECK_OK(tgb_history_undo(d));
CHECK(!strcmp(tgb_layer_name(d, 1), "Tinta") && tgb_layer_visible(d, 1) && tgb_layer_opacity(d, 1) == 255);
CHECK_OK(tgb_history_redo(d));
CHECK(!strcmp(tgb_layer_name(d, 1), "Color"));
/* borrar capa con dibujos y deshacer */
CHECK_OK(tgb_layer_delete(d, 1));
CHECK_EQ(tgb_layer_count(d), 1);
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(tgb_layer_count(d), 2);
CHECK(is_px(tgb_frame_cel(d, 0, 1), 4, 4, RED) && is_px(tgb_frame_cel(d, 1, 1), 6, 6, BLUE));
CHECK_OK(tgb_layer_delete(d, 1));
CHECK_STATUS(tgb_layer_delete(d, 0), TGB_ERR_STATE); /* nunca la última */
tgb_document_destroy(d);
}
TEST(fps_change_is_one_undo_step) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(tgb_frame_add(d, 1, NULL));
size_t before = undo_count(d);
CHECK_OK(tgb_document_set_fps(d, 24, true));
CHECK_EQ(tgb_document_fps(d), 24);
CHECK_EQ(tgb_frame_duration_ms(d, 0), 42);
CHECK_EQ(tgb_frame_duration_ms(d, 1), 42);
CHECK_EQ(undo_count(d), before + 1);
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(tgb_document_fps(d), 12);
CHECK_EQ(tgb_frame_duration_ms(d, 1), 83);
CHECK_STATUS(tgb_document_set_fps(d, 0, true), TGB_ERR_RANGE);
tgb_document_destroy(d);
}
TEST(frame_clipboard) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_STATUS(tgb_frame_paste(d, 0, NULL), TGB_ERR_STATE); /* portapapeles vacío */
CHECK_OK(paint_px(d, 0, 0, 2, 3, RED));
CHECK_OK(tgb_frame_copy(d, 0));
uint32_t id = 0;
CHECK_OK(tgb_frame_paste(d, 1, &id));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK(tgb_frame_id(d, 1) == id);
CHECK(is_px(tgb_frame_cel(d, 1, 0), 2, 3, RED));
tgb_document *other = new_doc(20, 20, 12); /* otras dimensiones */
CHECK_STATUS(tgb_frame_paste(other, 0, NULL), TGB_ERR_UNSUPPORTED);
CHECK_STATUS(tgb_frame_copy(d, 9), TGB_ERR_RANGE);
tgb_document_destroy(other);
tgb_document_destroy(d);
}
TEST(groups) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_STATUS(tgb_history_end_group(d), TGB_ERR_STATE);
CHECK_OK(tgb_history_begin_group(d, "Tres puntos"));
for (int i = 0; i < 3; i++) CHECK_OK(paint_px(d, 0, 0, i, 0, RED));
CHECK(!tgb_history_can_undo(d)); /* con un grupo abierto no se deshace */
CHECK_STATUS(tgb_history_undo(d), TGB_ERR_STATE);
CHECK_OK(tgb_history_end_group(d));
CHECK_EQ(undo_count(d), 1);
CHECK(!strcmp(tgb_history_undo_label(d), "Tres puntos"));
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 0);
CHECK_OK(tgb_history_redo(d));
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 3);
/* anidados y mezclando estructura + píxeles */
CHECK_OK(tgb_history_begin_group(d, "Exterior"));
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(tgb_history_begin_group(d, "Interior"));
CHECK_OK(paint_px(d, 1, 0, 5, 5, BLUE));
CHECK_OK(tgb_layer_add(d, 1, "Extra", NULL));
CHECK_OK(tgb_history_end_group(d));
CHECK_OK(paint_px(d, 0, 1, 1, 1, GREEN));
CHECK_OK(tgb_history_end_group(d));
CHECK_EQ(undo_count(d), 2);
CHECK_EQ(tgb_frame_count(d), 2);
CHECK_EQ(tgb_layer_count(d), 2);
CHECK_OK(tgb_history_undo(d)); /* todo el grupo exterior de una vez */
CHECK_EQ(tgb_frame_count(d), 1);
CHECK_EQ(tgb_layer_count(d), 1);
CHECK_OK(tgb_history_redo(d));
CHECK(is_px(tgb_frame_cel(d, 1, 0), 5, 5, BLUE) && is_px(tgb_frame_cel(d, 0, 1), 1, 1, GREEN));
/* grupo vacío: no crea operación */
size_t n = undo_count(d);
CHECK_OK(tgb_history_begin_group(d, "Vacío"));
CHECK_OK(tgb_history_end_group(d));
CHECK_EQ(undo_count(d), n);
/* sesión de edición abierta impide cerrar el grupo */
CHECK_OK(tgb_history_begin_group(d, "G"));
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "x"));
CHECK_STATUS(tgb_history_end_group(d), TGB_ERR_STATE);
CHECK_OK(tgb_edit_cancel(d));
CHECK_OK(tgb_history_end_group(d));
tgb_document_destroy(d);
}
TEST(group_abort_restores_state) {
tgb_document *d = new_doc(16, 16, 12);
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
size_t n = undo_count(d);
CHECK_OK(tgb_history_begin_group(d, "Fallido"));
CHECK_OK(paint_px(d, 0, 0, 2, 2, BLUE));
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(tgb_layer_add(d, 1, "L", NULL));
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "pendiente")); /* sesión abierta a medias */
tgb_canvas_set(tgb_edit_canvas(d), 8, 8, GREEN);
CHECK_OK(tgb_history_abort_group(d));
CHECK(!tgb_edit_active(d));
CHECK_EQ(tgb_frame_count(d), 1);
CHECK_EQ(tgb_layer_count(d), 1);
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 1);
CHECK(is_px(tgb_frame_cel(d, 0, 0), 1, 1, RED));
CHECK_EQ(undo_count(d), n); /* el grupo abortado no queda en el historial */
CHECK(tgb_document_is_consistent(d));
CHECK_STATUS(tgb_history_abort_group(d), TGB_ERR_STATE);
tgb_document_destroy(d);
}
TEST(limit_operation_count) {
tgb_document *d = new_doc(16, 16, 12);
tgb_history_limits lim; tgb_history_get_limits(d, &lim);
lim.max_operations = 3;
CHECK_OK(tgb_history_set_limits(d, &lim));
for (int i = 0; i < 5; i++) CHECK_OK(paint_px(d, 0, 0, i, 0, RED));
tgb_history_stats st; tgb_history_get_stats(d, &st);
CHECK_EQ(st.undo_count, 3);
CHECK_EQ(st.dropped_ops, 2);
CHECK_OK(tgb_history_undo(d)); CHECK_OK(tgb_history_undo(d)); CHECK_OK(tgb_history_undo(d));
CHECK_STATUS(tgb_history_undo(d), TGB_ERR_NOTHING);
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 2); /* lo más antiguo ya no se puede deshacer */
lim.max_operations = 0;
CHECK_STATUS(tgb_history_set_limits(d, &lim), TGB_ERR_RANGE);
tgb_document_destroy(d);
}
TEST(limit_memory_drops_oldest_without_disk) {
tgb_document *d = new_doc(256, 64, 12);
tgb_history_limits lim; tgb_history_get_limits(d, &lim);
lim.max_memory_bytes = 12000; lim.max_temp_bytes = 0; lim.temp_dir[0] = '\0';
CHECK_OK(tgb_history_set_limits(d, &lim));
for (int i = 0; i < 10; i++) CHECK_OK(paint_block(d, 0, 0, i * 24, 0, 20, RED)); /* ~3,3 KB cada una */
tgb_history_stats st; tgb_history_get_stats(d, &st);
CHECK(st.memory_bytes <= 12000);
CHECK(st.dropped_ops > 0 && st.undo_count + st.dropped_ops == 10);
size_t undone = 0;
while (tgb_history_undo(d) == TGB_OK) undone++;
CHECK_EQ(undone, st.undo_count);
/* una sola operación mayor que todo el presupuesto: se aplica, pero avisa de que no se puede deshacer */
lim.max_memory_bytes = 100;
CHECK_OK(tgb_history_set_limits(d, &lim));
CHECK_STATUS(paint_block(d, 0, 0, 0, 30, 20, BLUE), TGB_WARN_HISTORY_LOST);
CHECK(is_px(tgb_frame_cel(d, 0, 0), 5, 35, BLUE)); /* la edición SÍ se aplicó */
CHECK(!tgb_history_can_undo(d));
tgb_document_destroy(d);
}
static size_t count_spill_files(const char *dir) {
DIR *dp = opendir(dir);
if (!dp) return 0;
size_t n = 0; struct dirent *e;
while ((e = readdir(dp))) if (!strncmp(e->d_name, "tgb-hist-", 9)) n++;
closedir(dp);
return n;
}
TEST(limit_memory_spills_to_disk_and_restores) {
tgb_document *d = new_doc(256, 64, 12);
char dir[500]; snprintf(dir, sizeof dir, "%s/spill", g_root);
tgb_history_limits lim; tgb_history_get_limits(d, &lim);
lim.max_memory_bytes = 10000;
lim.max_temp_bytes = 5u * 1024 * 1024;
snprintf(lim.temp_dir, sizeof lim.temp_dir, "%s", dir);
CHECK_OK(tgb_history_set_limits(d, &lim));
for (int i = 0; i < 10; i++) CHECK_OK(paint_block(d, 0, 0, i * 24, 0, 20, RED));
tgb_history_stats st; tgb_history_get_stats(d, &st);
CHECK(st.spilled_ops > 0);
CHECK(st.temp_bytes > 0);
CHECK(st.memory_bytes <= 10000);
CHECK_EQ(st.dropped_ops, 0); /* con disco disponible no se pierde nada */
CHECK_EQ(st.undo_count, 10);
CHECK(count_spill_files(dir) > 0);
for (int i = 9; i >= 0; i--) { /* deshacer TODO, incluidas las operaciones en disco */
CHECK_OK(tgb_history_undo(d));
CHECK_EQ(px_at(d, 0, 0, i * 24, 0).a, 0);
if (i > 0) CHECK(is_px(tgb_frame_cel(d, 0, 0), (i - 1) * 24, 0, RED));
}
for (int i = 0; i < 10; i++) CHECK_OK(tgb_history_redo(d));
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 4000);
tgb_document_destroy(d);
CHECK_EQ(count_spill_files(dir), 0); /* no quedan archivos temporales */
}
TEST(limit_disk_exhausted_drops_oldest) {
tgb_document *d = new_doc(256, 64, 12);
char dir[500]; snprintf(dir, sizeof dir, "%s/spill2", g_root);
tgb_history_limits lim; tgb_history_get_limits(d, &lim);
lim.max_memory_bytes = 6000; lim.max_temp_bytes = 8000;
snprintf(lim.temp_dir, sizeof lim.temp_dir, "%s", dir);
CHECK_OK(tgb_history_set_limits(d, &lim));
for (int i = 0; i < 12; i++) CHECK_OK(paint_block(d, 0, 0, i * 17, 0, 16, RED));
tgb_history_stats st; tgb_history_get_stats(d, &st);
CHECK(st.memory_bytes <= 6000 && st.temp_bytes <= 8000);
CHECK(st.dropped_ops > 0);
CHECK(tgb_document_is_consistent(d));
tgb_document_destroy(d);
}
typedef struct { int counts[16]; } event_counts;
static void on_event(const tgb_event *ev, void *user) { ((event_counts *)user)->counts[ev->type]++; }
TEST(events_are_emitted) {
tgb_document *d = new_doc(16, 16, 12);
event_counts ec; memset(&ec, 0, sizeof ec);
uint32_t sub = 0;
CHECK_OK(tgb_events_subscribe(g_ctx, on_event, &ec, &sub));
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
CHECK(ec.counts[TGB_EVENT_PIXELS_CHANGED] >= 1 && ec.counts[TGB_EVENT_HISTORY_CHANGED] >= 1 && ec.counts[TGB_EVENT_DOCUMENT_CHANGED] >= 1);
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK(ec.counts[TGB_EVENT_FRAMES_CHANGED] >= 1);
CHECK_OK(tgb_layer_add(d, 1, "L", NULL));
CHECK(ec.counts[TGB_EVENT_LAYERS_CHANGED] >= 1);
CHECK_OK(tgb_events_unsubscribe(g_ctx, sub));
int before = ec.counts[TGB_EVENT_PIXELS_CHANGED];
CHECK_OK(paint_px(d, 0, 0, 2, 2, RED));
CHECK_EQ(ec.counts[TGB_EVENT_PIXELS_CHANGED], before); /* ya no recibe */
CHECK_STATUS(tgb_events_unsubscribe(g_ctx, sub), TGB_ERR_NOT_FOUND);
tgb_document_destroy(d);
}
TEST(adaptive_limits) {
tgb_document *d = new_doc(960, 540, 12);
tgb_history_limits lim;
tgb_history_get_limits(d, &lim);
CHECK(lim.max_memory_bytes >= 960u * 540u * 4u * 8u); /* al menos 8 celdas completas */
CHECK(lim.max_operations >= 100);
CHECK_OK(tgb_history_adapt(d));
tgb_history_get_limits(d, &lim);
CHECK(lim.max_memory_bytes > 0);
tgb_document_destroy(d);
}
int main(void) {
g_ctx = tgb_test_context(g_root, sizeof g_root);
RUN(document_create_and_props);
RUN(edit_session_undo_redo);
RUN(locked_layer_rejects_edits);
RUN(multiple_strokes_and_redo_cleared);
RUN(frame_operations);
RUN(duplicate_shares_memory_until_edited);
RUN(delete_frame_keeps_pixels_for_undo);
RUN(layer_operations);
RUN(fps_change_is_one_undo_step);
RUN(frame_clipboard);
RUN(groups);
RUN(group_abort_restores_state);
RUN(limit_operation_count);
RUN(limit_memory_drops_oldest_without_disk);
RUN(limit_memory_spills_to_disk_and_restores);
RUN(limit_disk_exhausted_drops_oldest);
RUN(events_are_emitted);
RUN(adaptive_limits);
tgb_context_destroy(g_ctx);
tgb_test_rmtree(g_root);
TEST_SUMMARY();
}
+323
View File
@@ -0,0 +1,323 @@
/* Guardado atómico, importación y exportación de archivos reales. */
#include <dirent.h>
#include <signal.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <zlib.h>
#include "tgb_test.h"
#include "fixtures_png.h"
static tgb_context *g_ctx;
static char g_root[200];
static tgb_document *new_doc(int w, int h) {
tgb_document *d = NULL;
CHECK_OK(tgb_document_create(g_ctx, w, h, 12, &d));
return d;
}
static void path_in(char *out, size_t cap, const char *name) { snprintf(out, cap, "%s/%s", g_root, name); }
static uint8_t *slurp(const char *path, size_t *len) {
FILE *f = fopen(path, "rb");
if (!f) return NULL;
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
uint8_t *b = malloc((size_t)n + 1);
size_t got = fread(b, 1, (size_t)n, f);
fclose(f);
*len = got;
return b;
}
static void spit(const char *path, const void *data, size_t len) {
FILE *f = fopen(path, "wb");
fwrite(data, 1, len, f);
fclose(f);
}
static size_t count_tmp_files(void) {
DIR *dp = opendir(g_root);
size_t n = 0; struct dirent *e;
while (dp && (e = readdir(dp))) if (strstr(e->d_name, ".tmp-")) n++;
if (dp) closedir(dp);
return n;
}
static size_t undo_count(const tgb_document *d) { tgb_history_stats s; tgb_history_get_stats(d, &s); return s.undo_count; }
static int g_saved_events;
static char g_saved_path[300];
static void on_event(const tgb_event *ev, void *u) {
(void)u;
if (ev->type == TGB_EVENT_DOCUMENT_SAVED) { g_saved_events++; snprintf(g_saved_path, sizeof g_saved_path, "%s", ev->text ? ev->text : ""); }
}
TEST(save_and_load_file) {
tgb_document *d = new_doc(16, 16);
char p[300]; path_in(p, sizeof p, "proyecto.tgb");
CHECK_OK(paint_px(d, 0, 0, 4, 4, RED));
CHECK(tgb_document_dirty(d));
CHECK_STATUS(tgb_document_save(d, NULL, NULL), TGB_ERR_INVALID_ARG); /* sin ruta */
uint32_t sub; CHECK_OK(tgb_events_subscribe(g_ctx, on_event, NULL, &sub));
g_saved_events = 0;
CHECK_OK(tgb_document_save(d, p, NULL));
CHECK(!tgb_document_dirty(d));
CHECK(tgb_document_path(d) && !strcmp(tgb_document_path(d), p));
CHECK_EQ(g_saved_events, 1);
CHECK(!strcmp(g_saved_path, p));
CHECK_EQ(count_tmp_files(), 0);
tgb_document *r = NULL;
CHECK_OK(tgb_document_load(g_ctx, p, &r));
if (r) { CHECK(is_px(tgb_frame_cel(r, 0, 0), 4, 4, RED)); CHECK(!strcmp(tgb_document_path(r), p)); CHECK(!tgb_document_dirty(r)); tgb_document_destroy(r); }
/* guardar de nuevo usando la ruta asociada */
CHECK_OK(paint_px(d, 0, 0, 5, 5, BLUE));
CHECK_OK(tgb_document_save(d, NULL, NULL));
CHECK_OK(tgb_document_load(g_ctx, p, &r));
if (r) { CHECK(is_px(tgb_frame_cel(r, 0, 0), 5, 5, BLUE)); tgb_document_destroy(r); }
/* no se guarda con una edición a medias */
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "x"));
CHECK_STATUS(tgb_document_save(d, p, NULL), TGB_ERR_STATE);
CHECK_OK(tgb_edit_cancel(d));
/* la exportación a formato 2 no cambia la ruta ni marca como guardado */
CHECK_OK(paint_px(d, 0, 0, 6, 6, GREEN));
char p2[300]; path_in(p2, sizeof p2, "legado.tgb");
tgb_save_options o; tgb_save_options_defaults(&o); o.legacy_v2 = true;
CHECK_OK(tgb_document_save(d, p2, &o));
CHECK(tgb_document_dirty(d) && !strcmp(tgb_document_path(d), p));
CHECK_OK(tgb_events_unsubscribe(g_ctx, sub));
tgb_document_destroy(d);
}
TEST(failed_save_never_damages_existing_file) {
tgb_document *d = new_doc(64, 64);
char p[300]; path_in(p, sizeof p, "valioso.tgb");
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
CHECK_OK(tgb_document_save(d, p, NULL));
size_t before_len; uint8_t *before = slurp(p, &before_len);
CHECK(before != NULL);
/* Simula "disco lleno / no cabe": límite de tamaño de archivo para este proceso */
for (int i = 0; i < 60; i++) { tgb_canvas_set(NULL, 0, 0, RED); CHECK_OK(paint_px(d, 0, 0, i, 10, BLUE)); }
signal(SIGXFSZ, SIG_IGN);
struct rlimit old, lim;
getrlimit(RLIMIT_FSIZE, &old);
lim = old; lim.rlim_cur = 100;
setrlimit(RLIMIT_FSIZE, &lim);
tgb_status s = tgb_document_save(d, p, NULL);
setrlimit(RLIMIT_FSIZE, &old);
CHECK_STATUS(s, TGB_ERR_IO);
CHECK(strlen(tgb_last_error()) > 5);
CHECK(tgb_document_dirty(d)); /* no se declara guardado lo que falló */
size_t after_len; uint8_t *after = slurp(p, &after_len);
CHECK(after && after_len == before_len && memcmp(before, after, before_len) == 0); /* el archivo anterior sigue idéntico */
CHECK_EQ(count_tmp_files(), 0); /* no quedan temporales */
tgb_document *r = NULL;
CHECK_OK(tgb_document_load(g_ctx, p, &r)); /* y sigue siendo un proyecto válido */
if (r) tgb_document_destroy(r);
free(before); free(after);
/* destino imposible: carpeta inexistente / el destino es una carpeta */
char bad[300]; path_in(bad, sizeof bad, "no_existe/dentro.tgb");
CHECK_STATUS(tgb_document_save(d, bad, NULL), TGB_ERR_IO);
char dirp[300]; path_in(dirp, sizeof dirp, "es_carpeta.tgb"); mkdir(dirp, 0700);
CHECK_STATUS(tgb_document_save(d, dirp, NULL), TGB_ERR_IO);
CHECK(access(dirp, F_OK) == 0);
CHECK_EQ(count_tmp_files(), 0);
CHECK(tgb_document_dirty(d));
/* abrir lo que no se puede abrir */
CHECK_STATUS(tgb_document_load(g_ctx, "/tmp/tgb-no-existe-xyz.tgb", &r), TGB_ERR_NOT_FOUND);
CHECK_STATUS(tgb_document_load(g_ctx, dirp, &r), TGB_ERR_IO);
char junk[300]; path_in(junk, sizeof junk, "basura.tgb"); spit(junk, "\x00\x01\x02 no soy json", 15);
CHECK_STATUS(tgb_document_load(g_ctx, junk, &r), TGB_ERR_FORMAT);
CHECK(strstr(tgb_last_error(), "basura.tgb") != NULL); /* el mensaje dice qué archivo falló */
CHECK(r == NULL);
tgb_document_destroy(d);
}
TEST(import_png_as_frame) {
char img[300]; path_in(img, sizeof img, "entrada.png");
spit(img, png_fixtures[0].png, png_fixtures[0].len);
tgb_document *d = new_doc(8, 6);
size_t n0 = undo_count(d);
uint32_t fid = 0;
CHECK_OK(tgb_import_png(d, img, 1, 0, &fid));
CHECK_EQ(tgb_frame_count(d), 2);
CHECK(tgb_frame_id(d, 1) == fid);
CHECK_EQ(undo_count(d), n0 + 1); /* una sola operación deshacible */
CHECK(!strcmp(tgb_history_undo_label(d), "Importar imagen"));
bool same = true; /* centrada: desplazamiento (2,1) */
for (int y = 0; y < FIX_H; y++) for (int x = 0; x < FIX_W; x++) {
tgb_color p = px_at(d, 1, 0, 2 + x, 1 + y);
const uint8_t *e = png_fixtures[0].px + (y * FIX_W + x) * 4;
if (p.r != e[0] || p.g != e[1] || p.b != e[2] || p.a != e[3]) same = false;
}
CHECK(same);
CHECK_EQ(px_at(d, 1, 0, 0, 0).a, 0);
CHECK_OK(tgb_history_undo(d)); CHECK_EQ(tgb_frame_count(d), 1);
CHECK_OK(tgb_history_redo(d)); CHECK_EQ(tgb_frame_count(d), 2);
tgb_document_destroy(d);
/* imagen mayor que el documento: se recorta centrada */
d = new_doc(2, 2);
CHECK_OK(tgb_import_png(d, img, 0, 0, NULL));
const uint8_t *e = png_fixtures[0].px + (0 * FIX_W + 1) * 4; /* el píxel (1,0) de la imagen cae en (0,0) */
tgb_color p = px_at(d, 0, 0, 0, 0);
CHECK(p.r == e[0] && p.g == e[1] && p.a == e[3]);
tgb_document_destroy(d);
}
TEST(import_failures_leave_document_untouched) {
char img[300]; path_in(img, sizeof img, "entrada.png");
spit(img, png_fixtures[0].png, png_fixtures[0].len);
tgb_document *d = new_doc(8, 6);
size_t n0 = undo_count(d);
CHECK_OK(tgb_layer_set_locked(d, 0, true));
n0 = undo_count(d);
CHECK_STATUS(tgb_import_png(d, img, 1, 0, NULL), TGB_ERR_LOCKED);
CHECK_OK(tgb_history_undo(d)); /* desbloquea */
n0 = undo_count(d);
CHECK_STATUS(tgb_import_png(d, img, 1, 5, NULL), TGB_ERR_RANGE);
CHECK_STATUS(tgb_import_png(d, img, 9, 0, NULL), TGB_ERR_RANGE);
CHECK_STATUS(tgb_import_png(d, "/tmp/tgb-no-existe.png", 1, 0, NULL), TGB_ERR_NOT_FOUND);
char bad[300]; path_in(bad, sizeof bad, "mala.png");
spit(bad, "no soy un png", 13);
CHECK_STATUS(tgb_import_png(d, bad, 1, 0, NULL), TGB_ERR_FORMAT);
CHECK(strstr(tgb_last_error(), "mala.png") != NULL);
char trunc[300]; path_in(trunc, sizeof trunc, "truncada.png");
spit(trunc, png_fixtures[0].png, png_fixtures[0].len - 20);
CHECK_STATUS(tgb_import_png(d, trunc, 1, 0, NULL), TGB_ERR_CORRUPT);
CHECK_STATUS(tgb_import_png(d, g_root, 1, 0, NULL), TGB_ERR_IO); /* una carpeta */
CHECK_EQ(tgb_frame_count(d), 1);
CHECK_EQ(undo_count(d), n0);
CHECK(tgb_document_is_consistent(d));
tgb_document_destroy(d);
}
TEST(export_png_and_sequence) {
tgb_document *d = new_doc(20, 10);
CHECK_OK(tgb_layer_add(d, 1, "Arriba", NULL));
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(paint_px(d, 0, 0, 3, 3, RED));
CHECK_OK(paint_px(d, 0, 1, 3, 3, BLUE));
CHECK_OK(paint_px(d, 1, 0, 7, 7, GREEN));
char p[300]; path_in(p, sizeof p, "salida.png");
CHECK_OK(tgb_export_png(d, 0, p));
size_t len; uint8_t *bytes = slurp(p, &len);
tgb_canvas *back = NULL, *flat = tgb_canvas_create(20, 10);
CHECK_OK(tgb_png_decode_memory(bytes, len, &back));
CHECK_OK(tgb_render_flatten(d, 0, flat));
CHECK(back && tgb_canvas_equal(back, flat));
CHECK(back && is_px(back, 3, 3, BLUE)); /* se exporta lo VISIBLE: la capa de arriba */
CHECK_OK(tgb_layer_set_visible(d, 1, false));
CHECK_OK(tgb_export_png(d, 0, p)); /* sobrescribe de forma atómica */
free(bytes); bytes = slurp(p, &len);
tgb_canvas_release(back); back = NULL;
CHECK_OK(tgb_png_decode_memory(bytes, len, &back));
CHECK(is_px(back, 3, 3, RED));
tgb_canvas_release(back); tgb_canvas_release(flat); free(bytes);
CHECK_STATUS(tgb_export_png(d, 9, p), TGB_ERR_RANGE);
CHECK_STATUS(tgb_export_png(NULL, 0, p), TGB_ERR_INVALID_ARG);
CHECK_STATUS(tgb_export_png(d, 0, "/tmp/tgb-carpeta-inexistente/x.png"), TGB_ERR_IO);
/* secuencia */
char dir[300]; path_in(dir, sizeof dir, "frames"); mkdir(dir, 0700);
CHECK_OK(tgb_export_png_sequence(d, dir, "anim"));
char f1[340], f2[340], f3[340];
snprintf(f1, sizeof f1, "%s/anim_0001.png", dir); snprintf(f2, sizeof f2, "%s/anim_0002.png", dir); snprintf(f3, sizeof f3, "%s/anim_0003.png", dir);
CHECK(access(f1, F_OK) == 0 && access(f2, F_OK) == 0 && access(f3, F_OK) != 0);
CHECK_STATUS(tgb_export_png_sequence(d, dir, "../malo"), TGB_ERR_INVALID_ARG);
CHECK_STATUS(tgb_export_png_sequence(d, dir, ""), TGB_ERR_INVALID_ARG);
CHECK_STATUS(tgb_export_png_sequence(d, "/tmp/tgb-carpeta-inexistente", "a"), TGB_ERR_NOT_FOUND);
tgb_document_destroy(d);
}
static uint32_t be32(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; }
TEST(export_apng_structure) {
tgb_document *d = new_doc(12, 8);
CHECK_OK(tgb_frame_add(d, 1, NULL)); CHECK_OK(tgb_frame_add(d, 2, NULL));
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED)); CHECK_OK(paint_px(d, 1, 0, 2, 2, GREEN)); CHECK_OK(paint_px(d, 2, 0, 3, 3, BLUE));
CHECK_OK(tgb_frame_set_duration(d, 0, 100)); CHECK_OK(tgb_frame_set_duration(d, 1, 250)); CHECK_OK(tgb_frame_set_duration(d, 2, 40));
char p[300]; path_in(p, sizeof p, "anim.apng");
CHECK_OK(tgb_export_apng(d, p));
size_t len; uint8_t *b = slurp(p, &len);
CHECK(b && len > 60 && !memcmp(b, "\x89PNG\r\n\x1a\n", 8));
size_t pos = 8; int fctl = 0, idat = 0, fdat = 0, actl = 0; uint32_t expect_seq = 0;
uint32_t delays[3] = {0}; bool crc_ok = true, order_ok = true;
uint8_t *payload[3] = {NULL}; size_t plen[3] = {0};
uint8_t ihdr[13]; memcpy(ihdr, b + 16, 13);
while (b && pos + 12 <= len) {
uint32_t cl = be32(b + pos); const uint8_t *t = b + pos + 4, *cd = b + pos + 8;
uLong crc = crc32(crc32(0, t, 4), cd, cl);
if ((uint32_t)crc != be32(cd + cl)) crc_ok = false;
if (!memcmp(t, "acTL", 4)) { actl++; CHECK(be32(cd) == 3 && be32(cd + 4) == 0); }
else if (!memcmp(t, "fcTL", 4)) {
if (be32(cd) != expect_seq++) order_ok = false;
if (fctl < 3) delays[fctl] = (uint32_t)((cd[20] << 8) | cd[21]);
CHECK(((cd[22] << 8) | cd[23]) == 1000);
CHECK(be32(cd + 4) == 12 && be32(cd + 8) == 8 && cd[24] == 0 && cd[25] == 0);
fctl++;
} else if (!memcmp(t, "IDAT", 4)) { if (fctl != 1) order_ok = false; payload[0] = malloc(cl); memcpy(payload[0], cd, cl); plen[0] = cl; idat++; }
else if (!memcmp(t, "fdAT", 4)) {
if (be32(cd) != expect_seq++) order_ok = false;
fdat++;
payload[fdat] = malloc(cl - 4); memcpy(payload[fdat], cd + 4, cl - 4); plen[fdat] = cl - 4;
}
pos += 12 + cl;
}
CHECK(crc_ok && order_ok);
CHECK(actl == 1 && fctl == 3 && idat == 1 && fdat == 2);
CHECK(delays[0] == 100 && delays[1] == 250 && delays[2] == 40);
/* cada fotograma, reempaquetado como PNG independiente, es exactamente el fotograma del documento */
for (int i = 0; i < 3; i++) {
uint8_t *png = malloc(8 + 25 + 12 + plen[i] + 12);
size_t o = 0;
memcpy(png, "\x89PNG\r\n\x1a\n", 8); o = 8;
uint8_t hdr[8] = {0, 0, 0, 13, 'I', 'H', 'D', 'R'};
memcpy(png + o, hdr, 8); memcpy(png + o + 8, ihdr, 13);
uLong c = crc32(crc32(0, (const Bytef *)"IHDR", 4), ihdr, 13); png[o + 21] = (uint8_t)(c >> 24); png[o + 22] = (uint8_t)(c >> 16); png[o + 23] = (uint8_t)(c >> 8); png[o + 24] = (uint8_t)c; o += 25;
uint8_t dh[8] = {(uint8_t)(plen[i] >> 24), (uint8_t)(plen[i] >> 16), (uint8_t)(plen[i] >> 8), (uint8_t)plen[i], 'I', 'D', 'A', 'T'};
memcpy(png + o, dh, 8); memcpy(png + o + 8, payload[i], plen[i]);
c = crc32(crc32(0, (const Bytef *)"IDAT", 4), payload[i], (uInt)plen[i]); o += 8 + plen[i];
png[o] = (uint8_t)(c >> 24); png[o + 1] = (uint8_t)(c >> 16); png[o + 2] = (uint8_t)(c >> 8); png[o + 3] = (uint8_t)c; o += 4;
uint8_t end[12] = {0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82};
memcpy(png + o, end, 12); o += 12;
tgb_canvas *got = NULL, *want = tgb_canvas_create(12, 8);
CHECK_OK(tgb_png_decode_memory(png, o, &got));
CHECK_OK(tgb_render_flatten(d, (size_t)i, want));
CHECK(got && tgb_canvas_equal(got, want));
tgb_canvas_release(got); tgb_canvas_release(want); free(png); free(payload[i]);
}
free(b);
tgb_document_destroy(d);
}
TEST(write_artifacts_for_external_verification) {
const char *dir = getenv("TGB_TEST_ARTIFACTS");
if (!dir) return; /* opcional: lo usa tools/verify-external.py */
tgb_document *d = new_doc(48, 32);
CHECK_OK(tgb_layer_add(d, 1, "Tinta", NULL));
CHECK_OK(tgb_frame_add(d, 1, NULL)); CHECK_OK(tgb_frame_add(d, 2, NULL));
tgb_brush br = tgb_brush_soft(RED, 9, 0.5f);
for (size_t f = 0; f < 3; f++) {
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, f), tgb_layer_id(d, f % 2), "arte"));
tgb_draw_dab(tgb_edit_canvas(d), &br, 10.0f + (float)f * 12, 16);
tgb_draw_line(tgb_edit_canvas(d), &br, 2, 2, 45, 28);
CHECK_OK(tgb_edit_end(d));
br.color.g = (uint8_t)(80 * f);
}
CHECK_OK(tgb_frame_set_duration(d, 0, 100)); CHECK_OK(tgb_frame_set_duration(d, 1, 250)); CHECK_OK(tgb_frame_set_duration(d, 2, 40));
char p[600];
snprintf(p, sizeof p, "%s/muestra_f0.png", dir); CHECK_OK(tgb_export_png(d, 0, p));
snprintf(p, sizeof p, "%s/muestra.apng", dir); CHECK_OK(tgb_export_apng(d, p));
snprintf(p, sizeof p, "%s/muestra_v3.tgb", dir); CHECK_OK(tgb_document_save(d, p, NULL));
tgb_save_options o; tgb_save_options_defaults(&o); o.legacy_v2 = true;
snprintf(p, sizeof p, "%s/muestra_v2.tgb", dir); CHECK_OK(tgb_document_save(d, p, &o));
tgb_document_destroy(d);
}
int main(void) {
g_ctx = tgb_test_context(g_root, sizeof g_root);
RUN(save_and_load_file);
RUN(failed_save_never_damages_existing_file);
RUN(import_png_as_frame);
RUN(import_failures_leave_document_untouched);
RUN(export_png_and_sequence);
RUN(export_apng_structure);
RUN(write_artifacts_for_external_verification);
tgb_context_destroy(g_ctx);
tgb_test_rmtree(g_root);
TEST_SUMMARY();
}
+450
View File
@@ -0,0 +1,450 @@
/* Base64, JSON, PNG y proyectos .tgb (formatos 2 y 3). */
#include <dirent.h>
#include "tgb_test.h"
#include "fixtures_png.h"
#include "../src/io/io_internal.h"
static tgb_context *g_ctx;
static char g_root[200];
static tgb_document *new_doc(int w, int h) {
tgb_document *d = NULL;
CHECK_OK(tgb_document_create(g_ctx, w, h, 12, &d));
return d;
}
/* ---------------- Base64 ---------------- */
TEST(base64_roundtrip_and_errors) {
srand(7);
for (size_t n = 0; n <= 40; n++) {
uint8_t in[40]; for (size_t i = 0; i < n; i++) in[i] = (uint8_t)rand();
char enc[100]; tgb_b64_encode(in, n, enc);
CHECK_EQ(strlen(enc), tgb_b64_encoded_len(n));
uint8_t *dec = NULL; size_t dl = 0;
if (n == 0) { CHECK_STATUS(tgb_b64_decode(enc, 0, 100, &dec, &dl), TGB_ERR_CORRUPT); continue; }
CHECK_OK(tgb_b64_decode(enc, strlen(enc), 100, &dec, &dl));
CHECK(dl == n && memcmp(dec, in, n) == 0);
free(dec);
}
uint8_t *d; size_t l;
CHECK_STATUS(tgb_b64_decode("QUJD", 4, 100, &d, &l), TGB_OK); free(d);
CHECK_STATUS(tgb_b64_decode("QUJ", 3, 100, &d, &l), TGB_ERR_CORRUPT); /* longitud */
CHECK_STATUS(tgb_b64_decode("QU!D", 4, 100, &d, &l), TGB_ERR_CORRUPT); /* carácter */
CHECK_STATUS(tgb_b64_decode("QU D", 4, 100, &d, &l), TGB_ERR_CORRUPT); /* espacio */
CHECK_STATUS(tgb_b64_decode("=UJD", 4, 100, &d, &l), TGB_ERR_CORRUPT); /* relleno al inicio */
CHECK_STATUS(tgb_b64_decode("QU=D", 4, 100, &d, &l), TGB_ERR_CORRUPT);
CHECK_STATUS(tgb_b64_decode("QUJD====", 8, 100, &d, &l), TGB_ERR_CORRUPT); /* relleno en medio */
CHECK_STATUS(tgb_b64_decode("QUJDREVG", 8, 3, &d, &l), TGB_ERR_LIMIT); /* más que el máximo */
const char *url = "data:image/png;base64,QUJD";
CHECK_OK(tgb_b64_decode(url, strlen(url), 100, &d, &l)); CHECK(l == 3 && !memcmp(d, "ABC", 3)); free(d);
}
/* ---------------- JSON ---------------- */
static tgb_status parse_str(const char *s, jnode **r) { return json_parse(s, strlen(s), r); }
TEST(json_valid_documents) {
jnode *r = NULL;
CHECK_OK(parse_str(" {\"a\": [1, -2.5e1, true, false, null, \"x\\n\\u00e9\\ud83d\\ude00\"], \"b\": {}} ", &r));
CHECK(r->t == J_OBJ && r->n == 2);
const jnode *a = json_get(r, "a");
CHECK(a && a->t == J_ARR && a->n == 6);
CHECK(a->items[0].num == 1 && a->items[1].num == -25.0 && a->items[2].b && !a->items[3].b && a->items[4].t == J_NULL);
CHECK(a->items[5].slen == 1 + 1 + 2 + 4 && !memcmp(a->items[5].s, "x\n\xc3\xa9\xf0\x9f\x98\x80", 8)); /* escapes y sustitutos UTF-16 */
CHECK(json_get(r, "nada") == NULL);
json_free(r);
long long v;
CHECK_OK(parse_str("12345678901", &r)); CHECK(json_is_int(r, 0, 1LL << 40, &v) && v == 12345678901LL); json_free(r);
CHECK_OK(parse_str("1.5", &r)); CHECK(!json_is_int(r, 0, 100, &v)); json_free(r);
CHECK_OK(parse_str("1e2", &r)); CHECK(json_is_int(r, 0, 1000, &v) && v == 100); json_free(r);
}
TEST(json_invalid_documents) {
const char *bad[] = {"", " ", "{", "[1,]", "[1 2]", "{\"a\":1,}", "{a:1}", "{\"a\" 1}", "[01]", "[1.]", "[.5]", "[+1]", "[1e]", "nul", "tru", "\"abc",
"\"a\\x\"", "\"\\u12\"", "\"\\ud800\"", "\"\\udc00\"", "[1] 2", "{\"a\":1} x", "\"tab\there\"", "[\"\xff\"]", "'a'", "[1,,2]", "{\"a\":}",
"[NaN]", "[Infinity]", "{\"\\u0000\":1}"};
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
jnode *r = NULL;
tgb_status s = parse_str(bad[i], &r);
if (s == TGB_OK) { printf(" se aceptó JSON inválido: %s\n", bad[i]); json_free(r); }
CHECK_STATUS(s, TGB_ERR_FORMAT);
CHECK(r == NULL);
}
char deep[400]; memset(deep, '[', 100); deep[100] = 0;
jnode *r = NULL;
CHECK_STATUS(parse_str(deep, &r), TGB_ERR_FORMAT); /* profundidad excesiva: sin desbordar la pila */
}
/* ---------------- PNG ---------------- */
static tgb_canvas *random_canvas(int w, int h, unsigned seed) {
srand(seed);
tgb_canvas *c = tgb_canvas_create(w, h);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) {
tgb_color p = {(uint8_t)rand(), (uint8_t)rand(), (uint8_t)rand(), (uint8_t)(rand() % 4 == 0 ? 0 : rand())};
tgb_canvas_set(c, x, y, p);
}
return c;
}
TEST(png_roundtrip) {
int sizes[][2] = {{1, 1}, {7, 5}, {64, 33}, {200, 120}};
for (size_t i = 0; i < 4; i++) {
tgb_canvas *c = random_canvas(sizes[i][0], sizes[i][1], (unsigned)i + 1);
uint8_t *png; size_t pl; tgb_canvas *back = NULL;
CHECK_OK(tgb_png_encode_memory(c, &png, &pl));
CHECK_OK(tgb_png_decode_memory(png, pl, &back));
CHECK(back && tgb_canvas_equal(c, back));
tgb_canvas_release(back); free(png); tgb_canvas_release(c);
}
tgb_canvas *empty = tgb_canvas_create(30, 30);
uint8_t *png; size_t pl; tgb_canvas *back;
CHECK_OK(tgb_png_encode_memory(empty, &png, &pl));
CHECK_OK(tgb_png_decode_memory(png, pl, &back));
CHECK(tgb_canvas_is_empty(back));
CHECK(pl < 200); /* se comprime muy bien */
tgb_canvas_release(back); free(png); tgb_canvas_release(empty);
}
TEST(png_reads_third_party_files) {
for (size_t i = 0; i < sizeof png_fixtures / sizeof png_fixtures[0]; i++) {
const png_fixture *f = &png_fixtures[i];
tgb_canvas *c = NULL;
tgb_status s = tgb_png_decode_memory(f->png, f->len, &c);
if (s != TGB_OK) { printf(" fixture %s: %s\n", f->name, tgb_last_error()); }
CHECK_OK(s);
if (!c) continue;
CHECK(tgb_canvas_width(c) == FIX_W && tgb_canvas_height(c) == FIX_H);
bool same = true;
for (int y = 0; y < FIX_H; y++) for (int x = 0; x < FIX_W; x++) {
tgb_color p = tgb_canvas_get(c, x, y);
const uint8_t *e = f->px + (y * FIX_W + x) * 4;
if (p.r != e[0] || p.g != e[1] || p.b != e[2] || p.a != e[3]) same = false;
}
if (!same) printf(" fixture %s: píxeles distintos de lo esperado\n", f->name);
CHECK(same);
tgb_canvas_release(c);
}
}
static void fix_crc(uint8_t *png, size_t chunk_off) { /* recalcula el CRC de un chunk (para fabricar casos de prueba) */
uint32_t len = ((uint32_t)png[chunk_off] << 24) | ((uint32_t)png[chunk_off + 1] << 16) | ((uint32_t)png[chunk_off + 2] << 8) | png[chunk_off + 3];
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len + 4; i++) {
crc ^= png[chunk_off + 4 + i];
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
crc = ~crc;
size_t o = chunk_off + 8 + len;
png[o] = (uint8_t)(crc >> 24); png[o + 1] = (uint8_t)(crc >> 16); png[o + 2] = (uint8_t)(crc >> 8); png[o + 3] = (uint8_t)crc;
}
TEST(png_rejects_bad_input) {
const png_fixture *f = &png_fixtures[0];
uint8_t *buf = malloc(f->len + 16);
tgb_canvas *c = NULL;
CHECK_STATUS(tgb_png_decode_memory((const uint8_t *)"hola", 4, &c), TGB_ERR_FORMAT);
CHECK_STATUS(tgb_png_decode_memory(NULL, 0, &c), TGB_ERR_INVALID_ARG);
/* truncado en cualquier punto: siempre error, nunca bloqueo ni lectura fuera de rango */
for (size_t n = 0; n < f->len; n++) {
tgb_status s = tgb_png_decode_memory(f->png, n, &c);
if (s == TGB_OK) { tgb_canvas_release(c); printf(" PNG truncado a %zu bytes se aceptó\n", n); }
CHECK(TGB_FAILED(s));
}
/* un byte alterado en los datos: el CRC lo detecta */
memcpy(buf, f->png, f->len);
buf[f->len / 2] ^= 0x55;
CHECK_STATUS(tgb_png_decode_memory(buf, f->len, &c), TGB_ERR_CORRUPT);
/* entrelazado (Adam7): reconocido y rechazado con mensaje claro */
memcpy(buf, f->png, f->len);
buf[8 + 8 + 12] = 1; fix_crc(buf, 8);
CHECK_STATUS(tgb_png_decode_memory(buf, f->len, &c), TGB_ERR_UNSUPPORTED);
CHECK(strstr(tgb_last_error(), "entrelazado") != NULL);
/* dimensiones enormes declaradas en IHDR: se rechaza antes de reservar memoria */
memcpy(buf, f->png, f->len);
buf[8 + 8 + 0] = 0x00; buf[8 + 8 + 1] = 0x01; buf[8 + 8 + 2] = 0x86; buf[8 + 8 + 3] = 0xA0; /* ancho = 100000 */
fix_crc(buf, 8);
CHECK_STATUS(tgb_png_decode_memory(buf, f->len, &c), TGB_ERR_LIMIT);
/* combinación de color/profundidad imposible */
memcpy(buf, f->png, f->len);
buf[8 + 8 + 8] = 4; buf[8 + 8 + 9] = 6; fix_crc(buf, 8);
CHECK_STATUS(tgb_png_decode_memory(buf, f->len, &c), TGB_ERR_CORRUPT);
free(buf);
}
/* ---------------- .tgb ---------------- */
static char *b64_of_png(const png_fixture *f) {
char *s = malloc(tgb_b64_encoded_len(f->len) + 1);
tgb_b64_encode(f->png, f->len, s);
return s;
}
static char *legacy_json(void) {
char *a = b64_of_png(&png_fixtures[0]), *b = b64_of_png(&png_fixtures[1]);
char *j = malloc(strlen(a) + strlen(b) + 400);
sprintf(j, "{\"format\":\"TAHGIBU\",\"version\":2,\"app\":\"Tahgibu Animator\",\"fps\":12,\"width\":4,\"height\":3,"
"\"community_owned\":true,\"frames\":[\"%s\",\"%s\"],\"sprites\":[{\"name\":\"s1\",\"x\":1}]}", a, b);
free(a); free(b);
return j;
}
static tgb_status load_str(const char *json, tgb_document **d) {
return tgb_document_load_memory(g_ctx, (const uint8_t *)json, strlen(json), d);
}
TEST(tgb_legacy_v2_load) {
char *j = legacy_json();
tgb_document *d = NULL;
CHECK_OK(load_str(j, &d));
CHECK(d != NULL);
if (d) {
CHECK(tgb_document_width(d) == 4 && tgb_document_height(d) == 3 && tgb_document_fps(d) == 12);
CHECK_EQ(tgb_frame_count(d), 2);
CHECK_EQ(tgb_layer_count(d), 1);
CHECK_EQ(tgb_frame_duration_ms(d, 0), 83);
const uint8_t *e = png_fixtures[0].px;
tgb_color p = px_at(d, 0, 0, 2, 1);
CHECK(p.r == e[(1 * 4 + 2) * 4] && p.g == e[(1 * 4 + 2) * 4 + 1] && p.a == e[(1 * 4 + 2) * 4 + 3]);
CHECK(!tgb_document_dirty(d));
/* campos desconocidos conservados */
CHECK(tgb_document_extra_field(d, "community_owned") && !strcmp(tgb_document_extra_field(d, "community_owned"), "true"));
CHECK(tgb_document_extra_field(d, "sprites") && !strcmp(tgb_document_extra_field(d, "sprites"), "[{\"name\":\"s1\",\"x\":1}]"));
CHECK(tgb_document_extra_field(d, "format") == NULL);
/* guardar (formato 3) y volver a cargar conserva todo */
uint8_t *out; size_t ol;
CHECK_OK(tgb_document_save_memory(d, NULL, &out, &ol));
CHECK(strstr((char *)out, "\"version\":3") != NULL && strstr((char *)out, "\"community_owned\":true") != NULL);
tgb_document *d2 = NULL;
CHECK_OK(tgb_document_load_memory(g_ctx, out, ol, &d2));
if (d2) {
CHECK(tgb_canvas_equal(tgb_frame_cel(d, 0, 0), tgb_frame_cel(d2, 0, 0)) && tgb_canvas_equal(tgb_frame_cel(d, 1, 0), tgb_frame_cel(d2, 1, 0)));
CHECK(!strcmp(tgb_document_extra_field(d2, "sprites"), tgb_document_extra_field(d, "sprites")));
tgb_document_destroy(d2);
}
free(out);
/* exportación explícita al formato 2: "frames" aplanados, sin 'tgb3', mismos campos extra */
tgb_save_options o; tgb_save_options_defaults(&o); o.legacy_v2 = true;
CHECK_OK(tgb_document_save_memory(d, &o, &out, &ol));
CHECK(strstr((char *)out, "\"version\":2") && !strstr((char *)out, "tgb3") && strstr((char *)out, "\"sprites\":[{"));
CHECK_OK(tgb_document_load_memory(g_ctx, out, ol, &d2));
if (d2) { CHECK_EQ(tgb_frame_count(d2), 2); CHECK(tgb_canvas_equal(tgb_frame_cel(d, 0, 0), tgb_frame_cel(d2, 0, 0))); tgb_document_destroy(d2); }
free(out);
tgb_document_destroy(d);
}
free(j);
}
TEST(tgb_v3_roundtrip_full) {
tgb_document *d = new_doc(32, 24);
CHECK_OK(tgb_layer_add(d, 1, "Tinta \"fina\" ñ", NULL));
CHECK_OK(tgb_layer_set_opacity(d, 1, 100));
CHECK_OK(tgb_layer_set_locked(d, 0, true));
CHECK_OK(tgb_layer_set_locked(d, 0, false));
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(paint_px(d, 0, 0, 3, 3, RED));
CHECK_OK(paint_px(d, 1, 1, 8, 8, BLUE));
CHECK_OK(tgb_frame_duplicate(d, 0, NULL)); /* comparte la celda con el fotograma 0 */
CHECK_OK(tgb_frame_set_duration(d, 1, 250));
CHECK_OK(tgb_layer_set_visible(d, 1, false));
uint32_t pa = 0, pb = 0;
CHECK_OK(tgb_recovery_point_create(d, "Antes del azul — ñandú", &pa));
CHECK_OK(paint_px(d, 2, 0, 20, 10, GREEN));
CHECK_OK(tgb_recovery_point_create(d, "Final", &pb));
uint8_t *data; size_t len;
CHECK_OK(tgb_document_save_memory(d, NULL, &data, &len));
tgb_document *r = NULL;
CHECK_OK(tgb_document_load_memory(g_ctx, data, len, &r));
CHECK(r != NULL);
if (r) {
CHECK(tgb_document_width(r) == 32 && tgb_document_height(r) == 24 && tgb_document_fps(r) == 12);
CHECK_EQ(tgb_frame_count(r), tgb_frame_count(d));
CHECK_EQ(tgb_layer_count(r), 2);
CHECK(!strcmp(tgb_layer_name(r, 1), "Tinta \"fina\" ñ"));
CHECK(!tgb_layer_visible(r, 1) && tgb_layer_visible(r, 0) && tgb_layer_opacity(r, 1) == 100 && !tgb_layer_locked(r, 0));
for (size_t f = 0; f < tgb_frame_count(d); f++) {
CHECK_EQ(tgb_frame_id(r, f), tgb_frame_id(d, f));
CHECK_EQ(tgb_frame_duration_ms(r, f), tgb_frame_duration_ms(d, f));
for (size_t l = 0; l < 2; l++) {
const tgb_canvas *a = tgb_frame_cel(d, f, l), *b = tgb_frame_cel(r, f, l);
CHECK((a == NULL || tgb_canvas_is_empty(a)) == (b == NULL || tgb_canvas_is_empty(b)));
if (a && b && !tgb_canvas_is_empty(a)) CHECK(tgb_canvas_equal(a, b));
}
}
/* la celda compartida (fotogramas 0 y 1 duplicados... ver ids) sigue compartida tras cargar */
CHECK(tgb_frame_cel(r, 0, 0) == tgb_frame_cel(r, 1, 0) || tgb_frame_cel(r, 0, 0) == tgb_frame_cel(r, 2, 0));
CHECK_EQ(tgb_layer_id(r, 0), tgb_layer_id(d, 0));
/* puntos de recuperación persistentes */
CHECK_EQ(tgb_recovery_point_count(r), 2);
tgb_recovery_point_info a, b;
CHECK_OK(tgb_recovery_point_info_at(r, 0, &a)); CHECK_OK(tgb_recovery_point_info_at(r, 1, &b));
CHECK(a.id == pa && !strcmp(a.name, "Antes del azul — ñandú") && a.compatible && a.created_unix > 1700000000);
CHECK(b.id == pb && !strcmp(b.name, "Final"));
CHECK_OK(tgb_recovery_point_restore(r, pa)); /* se puede volver a un estado anterior tras reabrir */
CHECK_EQ(tgb_frame_count(r), 3);
CHECK_EQ(px_at(r, 2, 0, 20, 10).a, 0); /* el verde aún no existía en ese punto */
CHECK(is_px(tgb_frame_cel(r, 0, 0), 3, 3, RED));
/* los ids nuevos no chocan con los existentes */
tgb_document *r2 = NULL;
CHECK_OK(tgb_document_load_memory(g_ctx, data, len, &r2));
uint32_t nid = 0;
CHECK_OK(tgb_frame_add(r2, 0, &nid));
for (size_t f = 1; f < tgb_frame_count(r2); f++) CHECK(tgb_frame_id(r2, f) != nid);
CHECK(nid > tgb_frame_id(d, 2) && nid > tgb_layer_id(d, 1));
tgb_document_destroy(r2);
tgb_document_destroy(r);
}
/* de-duplicación: las celdas compartidas se guardan una sola vez */
jnode *root = NULL;
CHECK_OK(json_parse((char *)data, len, &root));
const jnode *images = json_get(json_get(root, "tgb3"), "images");
CHECK(images && images->t == J_ARR);
CHECK(images->n <= 5); /* 4 celdas distintas como mucho (rojo compartido, azul, verde, + antes) */
json_free(root);
free(data);
/* sin fotogramas aplanados: archivo más pequeño y sin 'frames' */
tgb_save_options o; tgb_save_options_defaults(&o); o.include_flat_frames = false;
uint8_t *small; size_t sl;
CHECK_OK(tgb_document_save_memory(d, &o, &small, &sl));
CHECK(strstr((char *)small, "\"frames\":[\"") == NULL);
tgb_document *r3 = NULL;
CHECK_OK(tgb_document_load_memory(g_ctx, small, sl, &r3));
tgb_document_destroy(r3);
free(small);
tgb_document_destroy(d);
}
static void expect_load_fail(const char *what, const char *json, tgb_status expected) {
tgb_document *d = (tgb_document *)0x1;
tgb_status s = load_str(json, &d);
if (s != expected) printf(" [%s] se esperaba %s y devolvió %s (%s)\n", what, tgb_status_name(expected), tgb_status_name(s), tgb_last_error());
CHECK_EQ(s, expected);
CHECK(d == NULL);
CHECK(strlen(tgb_last_error()) > 5); /* mensaje comprensible */
}
TEST(tgb_rejects_corrupt_files) {
char *a = b64_of_png(&png_fixtures[0]);
char buf[8000];
expect_load_fail("vacío", "", TGB_ERR_FORMAT);
expect_load_fail("no json", "esto no es json", TGB_ERR_FORMAT);
expect_load_fail("raíz lista", "[1,2,3]", TGB_ERR_FORMAT);
expect_load_fail("sin format", "{\"version\":2}", TGB_ERR_FORMAT);
expect_load_fail("otro format", "{\"format\":\"OTRO\",\"version\":2}", TGB_ERR_FORMAT);
expect_load_fail("sin version", "{\"format\":\"TAHGIBU\"}", TGB_ERR_FORMAT);
expect_load_fail("version futura", "{\"format\":\"TAHGIBU\",\"version\":99,\"fps\":12,\"width\":4,\"height\":3}", TGB_ERR_INCOMPATIBLE);
expect_load_fail("version 1", "{\"format\":\"TAHGIBU\",\"version\":1,\"fps\":12,\"width\":4,\"height\":3,\"frames\":[]}", TGB_ERR_UNSUPPORTED);
expect_load_fail("version decimal", "{\"format\":\"TAHGIBU\",\"version\":2.5}", TGB_ERR_FORMAT);
const char *head = "{\"format\":\"TAHGIBU\",\"version\":2,";
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":0,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("ancho 0", buf, TGB_ERR_RANGE);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":5000,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("ancho enorme", buf, TGB_ERR_RANGE);
snprintf(buf, sizeof buf, "%s\"fps\":0,\"width\":4,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("fps 0", buf, TGB_ERR_RANGE);
snprintf(buf, sizeof buf, "%s\"fps\":100,\"width\":4,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("fps 100", buf, TGB_ERR_RANGE);
snprintf(buf, sizeof buf, "%s\"fps\":\"12\",\"width\":4,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("fps texto", buf, TGB_ERR_RANGE);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3}", head); expect_load_fail("sin frames", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":[]}", head); expect_load_fail("frames vacío", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":\"x\"}", head); expect_load_fail("frames no lista", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":[7]}", head); expect_load_fail("frame no cadena", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":[\"@@@@\"]}", head); expect_load_fail("base64 malo", buf, TGB_ERR_CORRUPT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":[\"QUJDRA==\"]}", head); expect_load_fail("no es PNG", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":9,\"height\":3,\"frames\":[\"%s\"]}", head, a); expect_load_fail("dimensiones distintas", buf, TGB_ERR_FORMAT);
/* PNG con un byte dañado */
uint8_t *png = malloc(png_fixtures[0].len);
memcpy(png, png_fixtures[0].png, png_fixtures[0].len); png[png_fixtures[0].len / 2] ^= 1;
char *bad = malloc(tgb_b64_encoded_len(png_fixtures[0].len) + 1); tgb_b64_encode(png, png_fixtures[0].len, bad);
snprintf(buf, sizeof buf, "%s\"fps\":12,\"width\":4,\"height\":3,\"frames\":[\"%s\"]}", head, bad); expect_load_fail("PNG dañado", buf, TGB_ERR_CORRUPT);
free(png); free(bad);
/* demasiada memoria: se rechaza ANTES de decodificar nada */
char *many = malloc(40000); strcpy(many, "[");
for (int i = 0; i < 600; i++) strcat(many, i ? ",\"AAAA\"" : "\"AAAA\"");
strcat(many, "]");
char *big = malloc(strlen(many) + 200);
sprintf(big, "%s\"fps\":12,\"width\":4096,\"height\":4096,\"frames\":%s}", head, many);
expect_load_fail("bomba de memoria", big, TGB_ERR_LIMIT);
free(many); free(big);
/* formato 3 */
const char *h3 = "{\"format\":\"TAHGIBU\",\"version\":3,\"fps\":12,\"width\":4,\"height\":3,";
snprintf(buf, sizeof buf, "%s\"frames\":[]}", h3); expect_load_fail("v3 sin tgb3", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],\"layers\":[],\"frames\":[]}}", h3); expect_load_fail("v3 sin capas", buf, TGB_ERR_FORMAT);
const char *lay = "\"layers\":[{\"id\":1,\"name\":\"L\"}]";
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],%s,\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[]}]}}", h3, lay); expect_load_fail("cels de menos", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],%s,\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[3]}]}}", h3, lay); expect_load_fail("imagen inexistente", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],%s,\"frames\":[{\"id\":2,\"duration_ms\":5,\"cels\":[null]}]}}", h3, lay); expect_load_fail("duración mala", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],%s,\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[null]},{\"id\":2,\"duration_ms\":83,\"cels\":[null]}]}}", h3, lay); expect_load_fail("ids repetidos", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[],\"layers\":[{\"id\":1,\"name\":\"\"}],\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[null]}]}}", h3); expect_load_fail("capa sin nombre", buf, TGB_ERR_FORMAT);
snprintf(buf, sizeof buf, "%s\"tgb3\":{\"images\":[\"%s\"],%s,\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[null]}]}}", h3, "QUJDRA==", lay); expect_load_fail("imagen no PNG", buf, TGB_ERR_FORMAT);
free(a);
}
TEST(tgb_incompatible_recovery_point_is_preserved) {
const char *json = "{\"format\":\"TAHGIBU\",\"version\":3,\"fps\":12,\"width\":4,\"height\":3,\"tgb3\":{\"images\":[],"
"\"layers\":[{\"id\":1,\"name\":\"L\"}],\"frames\":[{\"id\":2,\"duration_ms\":83,\"cels\":[null]}],\"next_id\":3,\"recovery_points\":["
"{\"id\":5,\"name\":\"Futuro\",\"created\":1700000000,\"state_version\":7,\"state\":{\"nuevo\":[1,2,3]}},"
"{\"id\":6,\"name\":\"Roto\",\"state_version\":1,\"state\":{\"layers\":[],\"frames\":[]}}]}}";
tgb_document *d = NULL;
CHECK_OK(load_str(json, &d));
CHECK(d != NULL);
if (!d) return;
CHECK_EQ(tgb_recovery_point_count(d), 2);
tgb_recovery_point_info a, b;
CHECK_OK(tgb_recovery_point_info_at(d, 0, &a)); CHECK_OK(tgb_recovery_point_info_at(d, 1, &b));
CHECK(a.id == 5 && !a.compatible && !strcmp(a.name, "Futuro"));
CHECK(b.id == 6 && !b.compatible);
CHECK_STATUS(tgb_recovery_point_restore(d, 5), TGB_ERR_INCOMPATIBLE); /* no se restaura... */
CHECK_EQ(tgb_frame_count(d), 1);
CHECK(!tgb_history_can_undo(d)); /* ...y el documento no se toca */
CHECK_OK(tgb_recovery_point_create(d, "Nuevo", NULL)); /* los ids no chocan con los existentes */
uint8_t *out; size_t ol;
CHECK_OK(tgb_document_save_memory(d, NULL, &out, &ol)); /* ...y se conservan al guardar */
CHECK(strstr((char *)out, "\"state_version\":7,\"state\":{\"nuevo\":[1,2,3]}") != NULL);
tgb_document *r = NULL;
CHECK_OK(tgb_document_load_memory(g_ctx, out, ol, &r));
if (r) { CHECK_EQ(tgb_recovery_point_count(r), 3); tgb_document_destroy(r); }
free(out);
tgb_document_destroy(d);
}
TEST(tgb_fuzz_truncation_and_bitflips) {
tgb_document *d = new_doc(24, 16);
CHECK_OK(tgb_frame_add(d, 1, NULL));
CHECK_OK(tgb_layer_add(d, 1, "B", NULL));
CHECK_OK(paint_px(d, 0, 0, 2, 2, RED));
CHECK_OK(paint_px(d, 1, 1, 5, 5, BLUE));
CHECK_OK(tgb_recovery_point_create(d, "P", NULL));
uint8_t *data; size_t len;
CHECK_OK(tgb_document_save_memory(d, NULL, &data, &len));
int ok_loads = 0, failed = 0;
for (size_t n = 0; n < len; n += 53) { /* truncar en muchos puntos */
tgb_document *r = NULL;
tgb_status s = tgb_document_load_memory(g_ctx, data, n, &r);
if (s == TGB_OK) { ok_loads++; tgb_document_destroy(r); } else { failed++; CHECK(r == NULL); }
}
CHECK(failed > 10);
srand(99);
uint8_t *copy = malloc(len);
for (int i = 0; i < 400; i++) { /* byte alterado al azar */
memcpy(copy, data, len);
copy[(size_t)rand() % len] = (uint8_t)rand();
tgb_document *r = NULL;
tgb_status s = tgb_document_load_memory(g_ctx, copy, len, &r);
if (s == TGB_OK) { ok_loads++; CHECK(r != NULL); tgb_document_destroy(r); } else { failed++; CHECK(r == NULL); }
}
printf(" (fuzz: %d cargas aceptadas, %d rechazadas, sin fallos de memoria)\n", ok_loads, failed);
free(copy); free(data);
tgb_document_destroy(d);
}
int main(void) {
g_ctx = tgb_test_context(g_root, sizeof g_root);
RUN(base64_roundtrip_and_errors);
RUN(json_valid_documents);
RUN(json_invalid_documents);
RUN(png_roundtrip);
RUN(png_reads_third_party_files);
RUN(png_rejects_bad_input);
RUN(tgb_legacy_v2_load);
RUN(tgb_v3_roundtrip_full);
RUN(tgb_rejects_corrupt_files);
RUN(tgb_incompatible_recovery_point_is_preserved);
RUN(tgb_fuzz_truncation_and_bitflips);
tgb_context_destroy(g_ctx);
tgb_test_rmtree(g_root);
TEST_SUMMARY();
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more