Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa376a445 | ||
|
|
b3d43a5004 | ||
|
|
16b95b1d81 | ||
|
|
adb5f65bc7 | ||
|
|
d5dd2da1cb |
No files matched your search
@@ -1 +1,3 @@
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
Whitespace-only changes.
@@ -1,34 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
project(Tahgibu VERSION 2.0.0 DESCRIPTION "Editor de animación 2D" LANGUAGES C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_C_EXTENSIONS OFF)
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Tipo de compilación" FORCE)
|
||||
endif()
|
||||
|
||||
option(TAHGIBU_BUILD_APP "Construir la aplicación gráfica (necesita raylib)" ON)
|
||||
option(TAHGIBU_BUILD_TESTS "Construir las pruebas" ON)
|
||||
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)
|
||||
include(CompilerOptions)
|
||||
include(PlatformConfig)
|
||||
include(Dependencies)
|
||||
include(BuildTargets)
|
||||
|
||||
message(STATUS "Tahgibu ${PROJECT_VERSION}: app=${TAHGIBU_BUILD_APP} tests=${TAHGIBU_BUILD_TESTS} lua=${TAHGIBU_WITH_LUA}")
|
||||
|
||||
# Paquetes (cpack): tar.gz y zip del ejecutable instalado. Sin probar todavía: ver docs/STATUS.md.
|
||||
set(CPACK_PACKAGE_NAME "Tahgibu")
|
||||
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
|
||||
set(CPACK_PACKAGE_VENDOR "Autores de Tahgibu")
|
||||
set(CPACK_RESOURCE_FILE_LICENSE ${CMAKE_SOURCE_DIR}/LICENSE)
|
||||
set(CPACK_GENERATOR "TGZ;ZIP")
|
||||
include(CPack)
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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 } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Tahgibu Animator
|
||||
|
||||
Animador 2D cuadro por cuadro, simple y sin marca de agua. Pensado para andar
|
||||
liviano incluso en notebooks modestas con gráfica integrada.
|
||||
|
||||
## Características
|
||||
- Frame por frame real, con miniaturas en la timeline.
|
||||
- Timeline con scroll (rueda del mouse) para animaciones con muchos frames.
|
||||
- `F7` crea un frame nuevo. `←` / `→` navegan. `Inicio`/`Fin` van al primero/último.
|
||||
- `Ctrl+V` pega imágenes del portapapeles (necesita `wl-clipboard` en Wayland).
|
||||
- Dos lápices:
|
||||
- Lápiz suave: acumula densidad al pasar varias veces (con difuminado).
|
||||
- Lápiz sólido: 100% de densidad.
|
||||
- Borrador que **de verdad borra** (baja la transparencia del trazo).
|
||||
- Selector de color y tamaño de pincel.
|
||||
- Onion Skin (ve el frame anterior/siguiente semi-transparente).
|
||||
- Fondos: botón para elegir o quitar una imagen de fondo.
|
||||
- Sprites: se agregan, se pueden quitar de la lista, y se colocan con un clic
|
||||
directo en el lienzo (apuntá y hacé clic; `Esc` cancela).
|
||||
- Deshacer / Rehacer (`Ctrl+Z` / `Ctrl+Y`) livianos en memoria: no duplican
|
||||
toda la animación en cada trazo, así no se cuelga en equipos con poca RAM.
|
||||
- FPS configurable y reproducción (`Espacio`).
|
||||
- Exportar a **GIF animado** o a una **secuencia de PNG**.
|
||||
- Guardar / abrir / importar proyectos `.tgb` (el archivo lleva adentro todos
|
||||
los frames, el fondo y los sprites, así se puede mover a otra PC sin
|
||||
necesitar los archivos originales).
|
||||
- Confirma antes de perder cambios sin guardar (al crear nuevo, abrir otro
|
||||
proyecto o cerrar la ventana).
|
||||
- Sin marca de agua, nunca.
|
||||
|
||||
## Qué se corrigió respecto a la versión anterior (ASM Animator)
|
||||
- **El borrador no borraba nada** (dibujaba "transparente sobre transparente"
|
||||
y no tenía ningún efecto visible). Ahora sí funciona.
|
||||
- **No existía ningún botón para poner un fondo**, aunque el formato de
|
||||
archivo y el propio programa lo mencionaban. Se agregó "Fondo: Elegir/Quitar".
|
||||
- **No había Rehacer** (`redo`), solo Deshacer.
|
||||
- El Deshacer copiaba **toda la animación completa** en cada trazo de lápiz.
|
||||
Con varios frames esto podía consumir varios GB de RAM y trabar el equipo.
|
||||
Ahora cada acción guarda solo lo mínimo necesario para revertirla.
|
||||
- El lápiz suave aplicaba un difuminado sobre el lienzo completo en cada
|
||||
movimiento del mouse, algo pesado en gráficas integradas. Ahora solo procesa
|
||||
la zona del trazo.
|
||||
- La timeline no tenía forma de desplazarse: con muchos frames, los cuadros
|
||||
quedaban fuera de la pantalla y no se podían seleccionar. Ahora tiene
|
||||
scroll y miniaturas reales de cada frame.
|
||||
- Se podía perder trabajo sin guardar sin ningún aviso al cerrar o crear un
|
||||
proyecto nuevo. Ahora se confirma antes.
|
||||
- Los sprites agregados no se guardaban dentro del proyecto `.tgb`. Ahora sí.
|
||||
- No había forma de sacar la animación fuera del programa. Se agregó
|
||||
exportar a GIF y a secuencia de PNG.
|
||||
|
||||
## Instalar en CachyOS / Arch
|
||||
```bash
|
||||
sudo pacman -S python python-pillow wl-clipboard
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Después abrí **Tahgibu Animator** desde el menú de aplicaciones (o ejecutá
|
||||
directamente `python3 tahgibu_animator.py`).
|
||||
|
||||
## Formato de archivo
|
||||
`.tgb` es el formato propio del proyecto (JSON con las imágenes en base64
|
||||
adentro). También podés **abrir e importar** archivos `.asm` de la versión
|
||||
anterior del programa: son totalmente compatibles.
|
||||
@@ -0,0 +1,2 @@
|
||||
python -m pip install pyinstaller
|
||||
python pyinstaller .\tahgibu_animator.py
|
||||
@@ -0,0 +1,2 @@
|
||||
python -m pip install pyinstaller
|
||||
python pyinstaller .\tahgibu_animator.py
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,17 +0,0 @@
|
||||
-- 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)
|
||||
@@ -1,34 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,62 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,35 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,60 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,68 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,44 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,50 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,36 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,55 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,56 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,31 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,121 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,78 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,20 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,23 +0,0 @@
|
||||
/* 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
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/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"
|
||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
||||
; 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
|
||||
@@ -1,109 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,104 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,372 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
/* 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; }
|
||||
@@ -1,193 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,248 +0,0 @@
|
||||
/* 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 */
|
||||
@@ -1,501 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,122 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,136 +0,0 @@
|
||||
/* 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
@@ -1,272 +0,0 @@
|
||||
/* 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
@@ -1,250 +0,0 @@
|
||||
/* 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); }
|
||||
@@ -1,464 +0,0 @@
|
||||
/* 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
@@ -1,46 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/* 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"; }
|
||||
@@ -1,48 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,195 +0,0 @@
|
||||
/* 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"; }
|
||||
@@ -1,347 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/* 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
@@ -1,125 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,455 +0,0 @@
|
||||
#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));
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/* 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});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,89 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
Executable
+955
@@ -0,0 +1,955 @@
|
||||
#!/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()
|
||||
@@ -1,35 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,14 +0,0 @@
|
||||
/* 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
|
||||
@@ -1,14 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
#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"); }
|
||||
@@ -1,6 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#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"); }
|
||||
@@ -1,3 +0,0 @@
|
||||
#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 */
|
||||
@@ -1,5 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/* 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();
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
/* Extensiones nativas: instalación, permisos, comandos con historial, errores y persistencia. */
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include "tgb_test.h"
|
||||
#include "../src/extensions/ext_internal.h"
|
||||
|
||||
#ifndef TGB_TEST_PLUGIN_DIR
|
||||
#error "define TGB_TEST_PLUGIN_DIR (lo hace tools/dev-build.sh)"
|
||||
#endif
|
||||
|
||||
static tgb_context *g_ctx;
|
||||
static char g_root[200];
|
||||
static char g_mark[400];
|
||||
|
||||
#define PLUGIN(name) TGB_TEST_PLUGIN_DIR "/" name ".so"
|
||||
#define ALL_CAPS (TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT | TGB_CAP_EVENTS)
|
||||
|
||||
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 size_t undo_count(const tgb_document *d) { tgb_history_stats s; tgb_history_get_stats(d, &s); return s.undo_count; }
|
||||
static size_t mark_count(const char *what) {
|
||||
FILE *f = fopen(g_mark, "r");
|
||||
if (!f) return 0;
|
||||
char line[100]; size_t n = 0;
|
||||
while (fgets(line, sizeof line, f)) { line[strcspn(line, "\n")] = 0; if (!strcmp(line, what)) n++; }
|
||||
fclose(f);
|
||||
return n;
|
||||
}
|
||||
static size_t find_cmd(tgb_plugin_manager *m, const char *full) {
|
||||
for (size_t i = 0; i < tgb_plugin_command_count(m); i++) {
|
||||
char b[200]; tgb_plugin_command_name(m, i, b, sizeof b);
|
||||
if (!strcmp(b, full)) return i;
|
||||
}
|
||||
return (size_t)-1;
|
||||
}
|
||||
static void reset_world(void) {
|
||||
char p[600];
|
||||
snprintf(p, sizeof p, "%s/config/extensions.json", g_root); remove(p);
|
||||
snprintf(p, sizeof p, "rm -rf '%s/data/extensions'", g_root); if (system(p)) {}
|
||||
remove(g_mark);
|
||||
}
|
||||
static void write_file(const char *path, const char *text) { FILE *f = fopen(path, "w"); fputs(text, f); fclose(f); }
|
||||
|
||||
static int g_loaded_events, g_unloaded_events;
|
||||
static void on_event(const tgb_event *ev, void *u) {
|
||||
(void)u;
|
||||
if (ev->type == TGB_EVENT_PLUGIN_LOADED) g_loaded_events++;
|
||||
if (ev->type == TGB_EVENT_PLUGIN_UNLOADED) g_unloaded_events++;
|
||||
}
|
||||
|
||||
TEST(install_never_runs_code) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_EQ(tgb_plugin_count(m), 0);
|
||||
size_t idx = 99;
|
||||
CHECK_STATUS(tgb_plugin_install(m, "/tmp/no-existe.so", "x", &idx), TGB_ERR_NOT_FOUND);
|
||||
CHECK_STATUS(tgb_plugin_install(m, PLUGIN("plug_good"), "../malo", &idx), TGB_ERR_INVALID_ARG); /* nada de rutas en el id */
|
||||
CHECK_STATUS(tgb_plugin_install(m, PLUGIN("plug_good"), "Mayus", &idx), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_plugin_install(m, PLUGIN("plug_good"), "", &idx), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_plugin_install(m, PLUGIN("plug_good"), "-raro", &idx), TGB_ERR_INVALID_ARG);
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", &idx));
|
||||
CHECK_STATUS(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", &idx), TGB_ERR_EXISTS);
|
||||
CHECK_EQ(idx, 0);
|
||||
tgb_plugin_entry_info info;
|
||||
CHECK_OK(tgb_plugin_info_at(m, 0, &info));
|
||||
CHECK(!strcmp(info.id, "cruz") && !strcmp(info.kind, "native") && !info.enabled && !info.loaded && info.granted_caps == 0);
|
||||
char p[600]; snprintf(p, sizeof p, "%s/data/extensions/cruz.so", g_root);
|
||||
CHECK(access(p, F_OK) == 0); /* copiada a la carpeta del usuario */
|
||||
snprintf(p, sizeof p, "%s/config/extensions.json", g_root);
|
||||
CHECK(access(p, F_OK) == 0);
|
||||
CHECK_EQ(mark_count("init"), 0); /* instalar NO ejecuta nada de la extensión */
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 0);
|
||||
/* un archivo suelto en la carpeta, sin pasar por el registro, jamás se carga */
|
||||
snprintf(p, sizeof p, "%s/data/extensions/intruso.so", g_root);
|
||||
CHECK_OK(0);
|
||||
FILE *src = fopen(PLUGIN("plug_good"), "rb"), *dst = fopen(p, "wb");
|
||||
char buf[4096]; size_t n;
|
||||
while ((n = fread(buf, 1, sizeof buf, src)) > 0) fwrite(buf, 1, n, dst);
|
||||
fclose(src); fclose(dst);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_EQ(tgb_plugin_count(m), 1);
|
||||
CHECK_EQ(mark_count("init"), 0);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(enable_needs_granted_permissions) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", NULL));
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 0, TGB_CAP_READ_DOCUMENT), TGB_ERR_PLUGIN); /* pide leer+editar, solo se concede leer */
|
||||
CHECK(strstr(tgb_last_error(), "permisos") != NULL);
|
||||
tgb_plugin_entry_info info; tgb_plugin_info_at(m, 0, &info);
|
||||
CHECK(!info.loaded && !info.enabled && strlen(info.last_error) > 5);
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 0); /* sin comandos residuales */
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 0, 0x80), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 5, ALL_CAPS), TGB_ERR_RANGE);
|
||||
uint32_t sub; g_loaded_events = g_unloaded_events = 0;
|
||||
CHECK_OK(tgb_events_subscribe(g_ctx, on_event, NULL, &sub));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT));
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 0, ALL_CAPS), TGB_ERR_STATE); /* ya habilitada */
|
||||
tgb_plugin_info_at(m, 0, &info);
|
||||
CHECK(info.loaded && info.enabled && !strcmp(info.name, "Cruz de prueba") && !strcmp(info.version, "1.0.0"));
|
||||
CHECK(info.required_caps == (TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT));
|
||||
CHECK_EQ(g_loaded_events, 1);
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 6);
|
||||
char name[200];
|
||||
CHECK_OK(tgb_plugin_command_name(m, 0, name, sizeof name));
|
||||
CHECK(!strcmp(name, "cruz:dibujar-cruz"));
|
||||
CHECK_STATUS(tgb_plugin_command_name(m, 99, name, sizeof name), TGB_ERR_RANGE);
|
||||
CHECK_OK(tgb_plugin_disable(m, 0));
|
||||
CHECK_EQ(g_unloaded_events, 1);
|
||||
CHECK_EQ(mark_count("shutdown"), 2); /* una al rechazarla por falta de permisos (su init ya había corrido) y otra al deshabilitarla */
|
||||
CHECK_OK(tgb_events_unsubscribe(g_ctx, sub));
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(commands_are_one_undoable_operation) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", NULL));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
tgb_document *d = new_doc(32, 32);
|
||||
size_t n0 = undo_count(d);
|
||||
size_t cross = find_cmd(m, "cruz:dibujar-cruz");
|
||||
CHECK_OK(tgb_plugin_run_command(m, cross, d));
|
||||
CHECK_EQ(undo_count(d), n0 + 1);
|
||||
CHECK_EQ(count_alpha(tgb_frame_cel(d, 0, 0)), 13);
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 16, 16, RED));
|
||||
CHECK(strstr(tgb_history_undo_label(d), "dibujar-cruz") != NULL);
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
CHECK_EQ(px_at(d, 0, 0, 16, 16).a, 0);
|
||||
CHECK_OK(tgb_history_redo(d));
|
||||
/* estructura + píxeles = UNA sola operación */
|
||||
size_t n1 = undo_count(d);
|
||||
CHECK_OK(tgb_plugin_run_command(m, find_cmd(m, "cruz:dos-pasos"), d));
|
||||
CHECK_EQ(undo_count(d), n1 + 1);
|
||||
CHECK_EQ(tgb_frame_count(d), 2);
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 1, 1, RED));
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
CHECK_EQ(tgb_frame_count(d), 1);
|
||||
CHECK_EQ(px_at(d, 0, 0, 1, 1).a, 0);
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 16, 16, RED)); /* lo anterior sigue ahí */
|
||||
/* lectura */
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, find_cmd(m, "cruz:lee-pixel"), d), TGB_OK);
|
||||
CHECK_EQ(undo_count(d), n1); /* un comando sin cambios no ensucia el historial */
|
||||
/* argumentos inválidos */
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, 99, d), TGB_ERR_RANGE);
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, cross, NULL), TGB_ERR_INVALID_ARG);
|
||||
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "x"));
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, cross, d), TGB_ERR_STATE); /* no se ejecuta con una edición abierta */
|
||||
CHECK_OK(tgb_edit_cancel(d));
|
||||
tgb_document_destroy(d);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(failing_command_restores_document) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", NULL));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
tgb_document *d = new_doc(32, 32);
|
||||
CHECK_OK(paint_px(d, 0, 0, 9, 9, BLUE));
|
||||
size_t n0 = undo_count(d);
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, find_cmd(m, "cruz:falla-a-medias"), d), TGB_ERR_PLUGIN);
|
||||
CHECK(strstr(tgb_last_error(), "Se deshicieron todos sus cambios") != NULL);
|
||||
CHECK_EQ(tgb_frame_count(d), 1); /* el fotograma que añadió ya no está */
|
||||
CHECK_EQ(px_at(d, 0, 0, 2, 2).a, 0); /* ni su píxel */
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 9, 9, BLUE)); /* lo anterior intacto */
|
||||
CHECK_EQ(undo_count(d), n0); /* no deja rastro en el historial */
|
||||
CHECK(tgb_document_is_consistent(d) && !tgb_edit_active(d));
|
||||
/* edición sin cerrar: se considera fallo y se revierte */
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, find_cmd(m, "cruz:olvida-cerrar"), d), TGB_ERR_PLUGIN);
|
||||
CHECK(strstr(tgb_last_error(), "edit_end") != NULL);
|
||||
CHECK_EQ(px_at(d, 0, 0, 3, 3).a, 0);
|
||||
CHECK(!tgb_edit_active(d));
|
||||
CHECK_EQ(undo_count(d), n0);
|
||||
/* el documento sigue plenamente utilizable */
|
||||
CHECK_OK(paint_px(d, 0, 0, 4, 4, GREEN));
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
tgb_document_destroy(d);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(permissions_are_enforced_by_the_host) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_readonly"), "lector", NULL));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, TGB_CAP_READ_DOCUMENT)); /* solo lectura concedida */
|
||||
tgb_document *d = new_doc(16, 16);
|
||||
size_t n0 = undo_count(d);
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, find_cmd(m, "lector:intenta-editar"), d), TGB_ERR_PLUGIN);
|
||||
CHECK(strstr(tgb_last_error(), "permiso") != NULL);
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, find_cmd(m, "lector:intenta-anadir"), d), TGB_ERR_PLUGIN);
|
||||
CHECK_EQ(tgb_frame_count(d), 1);
|
||||
CHECK_EQ(undo_count(d), n0);
|
||||
tgb_document_destroy(d);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(bad_extensions_are_refused_cleanly) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
struct { const char *file, *id; tgb_status expected; const char *words; } bad[] = {
|
||||
{"plug_needs_edit", "pide", TGB_ERR_PLUGIN, "permisos"},
|
||||
{"plug_init_fails", "falla", TGB_ERR_PLUGIN, "falló al iniciarse"},
|
||||
{"plug_no_entry", "sinentrada", TGB_ERR_PLUGIN, "tgb_plugin_init"},
|
||||
{"plug_old_api", "vieja", TGB_ERR_INCOMPATIBLE, "API"},
|
||||
{"plug_no_version", "sinversion", TGB_ERR_INCOMPATIBLE, "tgb_plugin_api_version"},
|
||||
{"plug_no_name", "sinnombre", TGB_ERR_PLUGIN, "nombre"},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||
size_t idx;
|
||||
char src[600]; snprintf(src, sizeof src, "%s/%s.so", TGB_TEST_PLUGIN_DIR, bad[i].file);
|
||||
CHECK_OK(tgb_plugin_install(m, src, bad[i].id, &idx));
|
||||
tgb_status s = tgb_plugin_enable(m, idx, TGB_CAP_READ_DOCUMENT);
|
||||
if (s != bad[i].expected) printf(" %s: se esperaba %s y devolvió %s (%s)\n", bad[i].file, tgb_status_name(bad[i].expected), tgb_status_name(s), tgb_last_error());
|
||||
CHECK_EQ(s, bad[i].expected);
|
||||
CHECK(strstr(tgb_last_error(), bad[i].words) != NULL);
|
||||
tgb_plugin_entry_info info; tgb_plugin_info_at(m, idx, &info);
|
||||
CHECK(!info.loaded && !info.enabled && strlen(info.last_error) > 3);
|
||||
}
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 0);
|
||||
CHECK_EQ(mark_count("shutdown-needs-edit"), 1); /* la que ya se había iniciado se cierra bien */
|
||||
/* el archivo desapareció del disco */
|
||||
char p[600]; snprintf(p, sizeof p, "%s/data/extensions/vieja.so", g_root); remove(p);
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 3, ALL_CAPS), TGB_ERR_NOT_FOUND);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(disable_remove_and_indexes) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "uno", NULL));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_readonly"), "dos", NULL));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_events"), "tres", NULL));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
CHECK_OK(tgb_plugin_enable(m, 1, TGB_CAP_READ_DOCUMENT));
|
||||
CHECK_OK(tgb_plugin_enable(m, 2, ALL_CAPS));
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 6 + 2 + 1);
|
||||
tgb_document *d = new_doc(32, 32);
|
||||
CHECK_OK(tgb_plugin_run_command_named(m, "uno:dibujar-cruz", d)); /* por nombre estable */
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
CHECK_OK(tgb_plugin_disable(m, 0));
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 3);
|
||||
CHECK_EQ(find_cmd(m, "uno:dibujar-cruz"), (size_t)-1);
|
||||
/* el nombre de una extensión deshabilitada NO ejecuta nada (y no ejecuta el comando "de al lado") */
|
||||
CHECK_STATUS(tgb_plugin_run_command_named(m, "uno:dibujar-cruz", d), TGB_ERR_NOT_FOUND);
|
||||
CHECK_STATUS(tgb_plugin_run_command_named(m, "uno", d), TGB_ERR_NOT_FOUND);
|
||||
CHECK_STATUS(tgb_plugin_run_command_named(m, NULL, d), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, 99, d), TGB_ERR_RANGE);
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS)); /* se puede volver a habilitar */
|
||||
CHECK_OK(tgb_plugin_run_command(m, find_cmd(m, "uno:dibujar-cruz"), d));
|
||||
CHECK_OK(tgb_plugin_remove(m, 1)); /* quitar la del medio */
|
||||
CHECK_EQ(tgb_plugin_count(m), 2);
|
||||
tgb_plugin_entry_info info; tgb_plugin_info_at(m, 1, &info);
|
||||
CHECK(!strcmp(info.id, "tres") && info.loaded);
|
||||
char p[600]; snprintf(p, sizeof p, "%s/data/extensions/dos.so", g_root);
|
||||
CHECK(access(p, F_OK) != 0); /* el archivo se borró */
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
CHECK_OK(tgb_plugin_run_command(m, find_cmd(m, "uno:dibujar-cruz"), d)); /* los índices siguen bien tras eliminar */
|
||||
CHECK(find_cmd(m, "tres:eventos-vistos") != (size_t)-1);
|
||||
CHECK_STATUS(tgb_plugin_remove(m, 9), TGB_ERR_RANGE);
|
||||
tgb_document_destroy(d);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m)); /* el registro quedó coherente en disco */
|
||||
CHECK_EQ(tgb_plugin_count(m), 2);
|
||||
tgb_plugin_info_at(m, 0, &info); CHECK(!strcmp(info.id, "uno"));
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
TEST(registry_persistence_safe_mode_and_corruption) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_good"), "cruz", NULL));
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
tgb_plugin_manager_destroy(m);
|
||||
CHECK_EQ(mark_count("shutdown"), 1);
|
||||
/* al reiniciar se vuelve a cargar lo que el usuario había habilitado, con sus permisos */
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
tgb_plugin_entry_info info; tgb_plugin_info_at(m, 0, &info);
|
||||
CHECK(info.enabled && info.loaded && info.granted_caps == ALL_CAPS);
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 6);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
/* modo seguro: no se carga NADA, pero se conserva la decisión del usuario */
|
||||
size_t before = mark_count("init");
|
||||
setenv("TGB_SAFE_MODE", "1", 1);
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
tgb_plugin_info_at(m, 0, &info);
|
||||
CHECK(info.enabled && !info.loaded);
|
||||
CHECK_EQ(mark_count("init"), before);
|
||||
CHECK_EQ(tgb_plugin_command_count(m), 0);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
unsetenv("TGB_SAFE_MODE");
|
||||
/* entradas peligrosas del registro se ignoran: nunca se carga una ruta arbitraria */
|
||||
char reg[600]; snprintf(reg, sizeof reg, "%s/config/extensions.json", g_root);
|
||||
write_file(reg, "{\"version\":1,\"plugins\":[{\"id\":\"../../etc/algo\",\"enabled\":true},{\"id\":\"cruz\",\"enabled\":false,\"granted_caps\":3},{\"id\":\"cruz\",\"enabled\":true}]}");
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_EQ(tgb_plugin_count(m), 1); /* la ruta con '..' y el duplicado se descartan */
|
||||
tgb_plugin_info_at(m, 0, &info); CHECK(!info.enabled && !info.loaded);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
/* registro dañado: se avisa y NO se sobrescribe */
|
||||
write_file(reg, "{esto no es json");
|
||||
CHECK_STATUS(tgb_plugin_manager_create(g_ctx, &m), TGB_ERR_FORMAT);
|
||||
CHECK(m == NULL && strstr(tgb_last_error(), "extensions.json") != NULL);
|
||||
FILE *f = fopen(reg, "r"); char line[100] = ""; if (fgets(line, sizeof line, f)) {} fclose(f);
|
||||
CHECK(!strcmp(line, "{esto no es json"));
|
||||
}
|
||||
|
||||
TEST(extension_receives_events_and_is_unsubscribed_on_disable) {
|
||||
reset_world();
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
CHECK_OK(tgb_plugin_install(m, PLUGIN("plug_events"), "oyente", NULL));
|
||||
CHECK_STATUS(tgb_plugin_enable(m, 0, TGB_CAP_READ_DOCUMENT), TGB_ERR_PLUGIN); /* sin permiso de eventos no puede suscribirse */
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
tgb_document *d = new_doc(16, 16);
|
||||
size_t cmd = find_cmd(m, "oyente:eventos-vistos");
|
||||
CHECK_STATUS(tgb_plugin_run_command(m, cmd, d), TGB_ERR_PLUGIN); /* aún no vio eventos de píxeles */
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
CHECK_OK(tgb_plugin_run_command(m, cmd, d)); /* ahora sí */
|
||||
CHECK_OK(tgb_plugin_disable(m, 0));
|
||||
CHECK_OK(paint_px(d, 0, 0, 2, 2, RED)); /* si la suscripción siguiera viva se llamaría a código ya descargado: el programa caería */
|
||||
CHECK_OK(tgb_plugin_enable(m, 0, ALL_CAPS));
|
||||
CHECK_OK(paint_px(d, 0, 0, 3, 3, RED));
|
||||
tgb_document_destroy(d);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
tgb_document *after = new_doc(4, 4); /* tras destruir el gestor tampoco queda ninguna suscripción */
|
||||
CHECK_OK(paint_px(after, 0, 0, 0, 0, RED));
|
||||
tgb_document_destroy(after);
|
||||
}
|
||||
|
||||
TEST(lua_extensions_without_lua_support) {
|
||||
reset_world();
|
||||
if (tgb_lua_available()) return; /* con Lua compilado se prueban en extensions/lua */
|
||||
tgb_plugin_manager *m = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &m));
|
||||
char src[600]; snprintf(src, sizeof src, "%s/script.lua", g_root);
|
||||
write_file(src, "info = {name='x', version='1', caps={}}\n");
|
||||
size_t idx;
|
||||
CHECK_OK(tgb_plugin_install(m, src, "script", &idx));
|
||||
tgb_plugin_entry_info info; tgb_plugin_info_at(m, idx, &info);
|
||||
CHECK(!strcmp(info.kind, "lua") && !strcmp(info.file, "script.lua"));
|
||||
CHECK_STATUS(tgb_plugin_enable(m, idx, TGB_CAP_READ_DOCUMENT), TGB_ERR_UNSUPPORTED);
|
||||
CHECK(strstr(tgb_last_error(), "TAHGIBU_WITH_LUA") != NULL);
|
||||
tgb_plugin_info_at(m, idx, &info); CHECK(!info.loaded && !info.enabled);
|
||||
tgb_plugin_manager_destroy(m);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
g_ctx = tgb_test_context(g_root, sizeof g_root);
|
||||
snprintf(g_mark, sizeof g_mark, "%s/marca.txt", g_root);
|
||||
setenv("TGB_TEST_MARK", g_mark, 1);
|
||||
RUN(install_never_runs_code);
|
||||
RUN(enable_needs_granted_permissions);
|
||||
RUN(commands_are_one_undoable_operation);
|
||||
RUN(failing_command_restores_document);
|
||||
RUN(permissions_are_enforced_by_the_host);
|
||||
RUN(bad_extensions_are_refused_cleanly);
|
||||
RUN(disable_remove_and_indexes);
|
||||
RUN(registry_persistence_safe_mode_and_corruption);
|
||||
RUN(extension_receives_events_and_is_unsubscribed_on_disable);
|
||||
RUN(lua_extensions_without_lua_support);
|
||||
tgb_context_destroy(g_ctx);
|
||||
tgb_test_rmtree(g_root);
|
||||
TEST_SUMMARY();
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
/* Autoguardado cada 5 minutos y recuperación tras cierres inesperados (con procesos reales). */
|
||||
#include <dirent.h>
|
||||
#include <signal.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include "tgb_test.h"
|
||||
|
||||
static tgb_context *g_ctx;
|
||||
static char g_root[200];
|
||||
|
||||
static tgb_document *new_doc(tgb_context *ctx, int w, int h) {
|
||||
tgb_document *d = NULL;
|
||||
CHECK_OK(tgb_document_create(ctx, w, h, 12, &d));
|
||||
return d;
|
||||
}
|
||||
static void rec_dir(char *out, size_t cap) { snprintf(out, cap, "%s/state/recovery", g_root); }
|
||||
static size_t count_files(const char *dir, const char *needle) {
|
||||
DIR *dp = opendir(dir);
|
||||
size_t n = 0; struct dirent *e;
|
||||
while (dp && (e = readdir(dp))) if (strstr(e->d_name, needle)) n++;
|
||||
if (dp) closedir(dp);
|
||||
return n;
|
||||
}
|
||||
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);
|
||||
*len = fread(b, 1, (size_t)n, f);
|
||||
fclose(f);
|
||||
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 void wipe_recovery(void) {
|
||||
char d[400]; rec_dir(d, sizeof d);
|
||||
DIR *dp = opendir(d); struct dirent *e;
|
||||
while (dp && (e = readdir(dp))) { if (e->d_name[0] == '.') continue; char p[700]; snprintf(p, sizeof p, "%s/%s", d, e->d_name); remove(p); }
|
||||
if (dp) closedir(dp);
|
||||
}
|
||||
static int g_autosaved;
|
||||
static void on_event(const tgb_event *ev, void *u) { (void)u; if (ev->type == TGB_EVENT_AUTOSAVED) g_autosaved++; }
|
||||
|
||||
/* Proceso hijo que trabaja y MUERE sin cerrar nada (simula un cierre inesperado). */
|
||||
static pid_t spawn_crashing_session(const char *saved_path, bool wait_on_pipe, int *go_fd, int *ready_fd) {
|
||||
int go[2] = {-1, -1}, ready[2] = {-1, -1};
|
||||
if (wait_on_pipe) { if (pipe(go) || pipe(ready)) exit(2); }
|
||||
fflush(stdout);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
tgb_context *ctx = tgb_test_context_at(g_root);
|
||||
tgb_recovery *rec = NULL;
|
||||
if (tgb_recovery_open(ctx, &rec) != TGB_OK) _exit(10);
|
||||
tgb_document *a = new_doc(ctx, 32, 32);
|
||||
paint_px(a, 0, 0, 5, 5, RED);
|
||||
if (saved_path) tgb_document_save(a, saved_path, NULL); /* guardado por el usuario... */
|
||||
paint_px(a, 0, 0, 6, 6, BLUE); /* ...y un cambio SIN guardar */
|
||||
tgb_document *b = new_doc(ctx, 16, 16); /* un documento que nunca se guardó */
|
||||
paint_px(b, 0, 0, 1, 1, GREEN);
|
||||
tgb_recovery_attach(rec, a, "Mi dibujo");
|
||||
tgb_recovery_attach(rec, b, "Sin título");
|
||||
tgb_recovery_set_interval(rec, 0.5);
|
||||
tgb_recovery_tick(rec, 0.0);
|
||||
if (tgb_recovery_tick(rec, 1.0) != TGB_OK) _exit(11);
|
||||
if (wait_on_pipe) { char c = 'r', x; if (write(ready[1], &c, 1) != 1 || read(go[0], &x, 1) != 1) _exit(12); }
|
||||
_exit(0); /* sin tgb_recovery_close: como un fallo de corriente */
|
||||
}
|
||||
if (go_fd) *go_fd = go[1];
|
||||
if (ready_fd) *ready_fd = ready[0];
|
||||
return pid;
|
||||
}
|
||||
|
||||
TEST(autosave_every_five_minutes) {
|
||||
wipe_recovery();
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
tgb_document *d = new_doc(g_ctx, 16, 16);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
CHECK_OK(tgb_recovery_attach(rec, d, "Prueba"));
|
||||
CHECK_STATUS(tgb_recovery_attach(rec, d, "Otra vez"), TGB_ERR_EXISTS);
|
||||
uint32_t sub; g_autosaved = 0;
|
||||
CHECK_OK(tgb_events_subscribe(g_ctx, on_event, NULL, &sub));
|
||||
char dir[400]; rec_dir(dir, sizeof dir);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1000.0)); /* primer tick: fija la hora de referencia */
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1299.0));
|
||||
CHECK_EQ(count_files(dir, ".tgb"), 0); /* aún no han pasado 5 minutos */
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1300.0));
|
||||
CHECK_EQ(count_files(dir, "autosave-"), 2); /* .tgb + .json */
|
||||
CHECK_EQ(g_autosaved, 1);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1700.0)); /* sin cambios nuevos: no vuelve a guardar */
|
||||
CHECK_EQ(g_autosaved, 1);
|
||||
CHECK_OK(paint_px(d, 0, 0, 2, 2, BLUE));
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1800.0)); /* 100 s después del último tick: aún no toca */
|
||||
CHECK_EQ(g_autosaved, 1);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 2000.0)); /* ya pasaron 300 s desde el último autoguardado fallido/omitido */
|
||||
CHECK_EQ(g_autosaved, 2);
|
||||
/* el usuario guarda el proyecto: el autoguardado sobra */
|
||||
char proj[400]; snprintf(proj, sizeof proj, "%s/p.tgb", g_root);
|
||||
CHECK_OK(tgb_document_save(d, proj, NULL));
|
||||
tgb_recovery_notify_saved(rec, d);
|
||||
CHECK_EQ(count_files(dir, "autosave-"), 0);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 9000.0)); /* documento limpio: nada que autoguardar */
|
||||
CHECK_EQ(g_autosaved, 2);
|
||||
CHECK_OK(paint_px(d, 0, 0, 3, 3, RED));
|
||||
CHECK_OK(tgb_recovery_save_now(rec, d)); /* guardado inmediato a petición */
|
||||
CHECK_EQ(count_files(dir, ".tgb"), 1);
|
||||
CHECK_OK(tgb_recovery_detach(rec, d)); /* cerrar el documento borra su autoguardado */
|
||||
CHECK_EQ(count_files(dir, "autosave-"), 0);
|
||||
CHECK_STATUS(tgb_recovery_detach(rec, d), TGB_ERR_NOT_FOUND);
|
||||
CHECK_OK(tgb_events_unsubscribe(g_ctx, sub));
|
||||
tgb_recovery_close(rec, true);
|
||||
CHECK_EQ(count_files(dir, "session-"), 0); /* cierre limpio: no queda marcador */
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(tick_waits_while_a_stroke_is_open) {
|
||||
wipe_recovery();
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
tgb_document *d = new_doc(g_ctx, 16, 16);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
tgb_recovery_attach(rec, d, "x");
|
||||
char dir[400]; rec_dir(dir, sizeof dir);
|
||||
tgb_recovery_tick(rec, 0.0);
|
||||
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "trazo"));
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1000.0)); /* trazo a medias: se aplaza sin error */
|
||||
CHECK_EQ(count_files(dir, ".tgb"), 0);
|
||||
CHECK_OK(tgb_edit_cancel(d));
|
||||
CHECK_OK(tgb_recovery_tick(rec, 1001.0)); /* ya se puede */
|
||||
CHECK_EQ(count_files(dir, ".tgb"), 1);
|
||||
tgb_recovery_close(rec, true);
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(recover_after_unexpected_exit) {
|
||||
wipe_recovery();
|
||||
char orig[400]; snprintf(orig, sizeof orig, "%s/original.tgb", g_root);
|
||||
pid_t pid = spawn_crashing_session(orig, false, NULL, NULL);
|
||||
int st = 0; waitpid(pid, &st, 0);
|
||||
CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0);
|
||||
size_t before_len; uint8_t *before = slurp(orig, &before_len);
|
||||
CHECK(before != NULL);
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 2); /* los dos documentos de la sesión muerta */
|
||||
size_t with_orig = 99, without = 99;
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
tgb_recovery_candidate c;
|
||||
CHECK_OK(tgb_recovery_candidate_at(rec, i, &c));
|
||||
CHECK(c.saved_unix > 1700000000);
|
||||
if (!strcmp(c.original_path, orig)) { with_orig = i; CHECK(!strcmp(c.title, "Mi dibujo")); }
|
||||
else { without = i; CHECK(!strcmp(c.title, "Sin título") && c.original_path[0] == '\0'); }
|
||||
}
|
||||
CHECK(with_orig != 99 && without != 99);
|
||||
tgb_document *r = NULL;
|
||||
CHECK_OK(tgb_recovery_candidate_load(rec, with_orig, &r));
|
||||
if (r) {
|
||||
CHECK(is_px(tgb_frame_cel(r, 0, 0), 5, 5, RED));
|
||||
CHECK(is_px(tgb_frame_cel(r, 0, 0), 6, 6, BLUE)); /* se recuperó el cambio que NO estaba guardado */
|
||||
CHECK(tgb_document_dirty(r));
|
||||
CHECK(tgb_document_path(r) == NULL); /* sin ruta: no puede pisar el original por accidente */
|
||||
char saveas[400]; snprintf(saveas, sizeof saveas, "%s/recuperado.tgb", g_root);
|
||||
CHECK_OK(tgb_document_save(r, saveas, NULL));
|
||||
tgb_document_destroy(r);
|
||||
}
|
||||
/* el archivo original no se ha tocado */
|
||||
size_t after_len; uint8_t *after = slurp(orig, &after_len);
|
||||
CHECK(after && after_len == before_len && !memcmp(before, after, before_len));
|
||||
tgb_document *loaded = NULL;
|
||||
CHECK_OK(tgb_document_load(g_ctx, orig, &loaded));
|
||||
if (loaded) { CHECK_EQ(px_at(loaded, 0, 0, 6, 6).a, 0); tgb_document_destroy(loaded); } /* el original no tiene el azul */
|
||||
free(before); free(after);
|
||||
/* los candidatos sobreviven a un reinicio hasta que el usuario decida */
|
||||
tgb_recovery_close(rec, true);
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 2);
|
||||
CHECK_OK(tgb_recovery_candidate_discard(rec, 0));
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 1);
|
||||
CHECK_STATUS(tgb_recovery_candidate_discard(rec, 5), TGB_ERR_RANGE);
|
||||
CHECK_STATUS(tgb_recovery_candidate_load(rec, 5, &r), TGB_ERR_RANGE);
|
||||
CHECK_OK(tgb_recovery_candidate_discard(rec, 0));
|
||||
char dir[400]; rec_dir(dir, sizeof dir);
|
||||
CHECK_EQ(count_files(dir, "autosave-"), 0);
|
||||
tgb_recovery_close(rec, true);
|
||||
}
|
||||
|
||||
TEST(live_session_is_not_recovered) {
|
||||
wipe_recovery();
|
||||
int go = -1, ready = -1;
|
||||
pid_t pid = spawn_crashing_session(NULL, true, &go, &ready);
|
||||
char c = 0;
|
||||
CHECK(read(ready, &c, 1) == 1); /* el hijo ya autoguardó y sigue VIVO */
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 0); /* otra instancia en marcha: sus archivos no son nuestros */
|
||||
tgb_recovery_close(rec, true);
|
||||
CHECK(write(go, "x", 1) == 1); /* ahora el hijo "se cae" */
|
||||
int st; waitpid(pid, &st, 0);
|
||||
close(go); close(ready);
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 2);
|
||||
tgb_recovery_close(rec, true);
|
||||
wipe_recovery();
|
||||
}
|
||||
|
||||
TEST(corrupt_autosave_and_leftovers) {
|
||||
wipe_recovery();
|
||||
pid_t pid = spawn_crashing_session(NULL, false, NULL, NULL);
|
||||
int st; waitpid(pid, &st, 0);
|
||||
char dir[400]; rec_dir(dir, sizeof dir);
|
||||
/* PID de un proceso que ya no existe */
|
||||
pid_t dead = fork(); if (dead == 0) _exit(0); waitpid(dead, &st, 0);
|
||||
char p[700];
|
||||
snprintf(p, sizeof p, "%s/autosave-9-9-1.tgb.tmp-%ld", dir, (long)dead); spit(p, "parcial", 7); /* resto de un guardado interrumpido */
|
||||
snprintf(p, sizeof p, "%s/autosave-9-9-2.tgb.tmp-%ld", dir, (long)getpid()); spit(p, "mio", 3); /* de un proceso vivo: se respeta */
|
||||
snprintf(p, sizeof p, "%s/session-basura.lock", dir); spit(p, "xx", 2); /* marcador ilegible */
|
||||
snprintf(p, sizeof p, "%s/session-%ld-1.lock", dir, (long)dead);
|
||||
char lock[100]; int ll = snprintf(lock, sizeof lock, "{\"pid\":%ld,\"start\":1}", (long)dead); spit(p, lock, (size_t)ll);
|
||||
/* dañar uno de los autoguardados */
|
||||
DIR *dp = opendir(dir); struct dirent *e; char victim[700] = "";
|
||||
while ((e = readdir(dp))) if (strstr(e->d_name, "autosave-") && strstr(e->d_name, ".tgb") && !strstr(e->d_name, ".tmp-") && !victim[0]) snprintf(victim, sizeof victim, "%s/%s", dir, e->d_name);
|
||||
closedir(dp);
|
||||
CHECK(victim[0] != 0);
|
||||
size_t vl; uint8_t *vb = slurp(victim, &vl);
|
||||
spit(victim, vb, vl / 2); /* truncado */
|
||||
free(vb);
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(count_files(dir, ".tmp-"), 1); /* solo queda el del proceso vivo */
|
||||
CHECK_EQ(count_files(dir, "session-basura"), 0); /* marcadores muertos retirados */
|
||||
CHECK_EQ(tgb_recovery_candidate_count(rec), 2);
|
||||
int failed = 0, ok = 0;
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
tgb_document *r = NULL;
|
||||
tgb_status s = tgb_recovery_candidate_load(rec, i, &r);
|
||||
if (TGB_FAILED(s)) { failed++; CHECK(r == NULL); CHECK(strlen(tgb_last_error()) > 5); } else { ok++; tgb_document_destroy(r); }
|
||||
}
|
||||
CHECK_EQ(failed, 1); CHECK_EQ(ok, 1); /* el dañado falla con mensaje claro; el sano se recupera */
|
||||
CHECK(access(victim, F_OK) == 0); /* el dañado NO se borra solo */
|
||||
tgb_recovery_close(rec, true);
|
||||
wipe_recovery();
|
||||
}
|
||||
|
||||
TEST(failed_autosave_keeps_previous_copy) {
|
||||
wipe_recovery();
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
tgb_document *d = new_doc(g_ctx, 64, 64);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
tgb_recovery_attach(rec, d, "t");
|
||||
tgb_recovery_set_interval(rec, 10.0);
|
||||
char dir[400]; rec_dir(dir, sizeof dir);
|
||||
tgb_recovery_tick(rec, 0.0);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 10.0));
|
||||
char file[700] = "";
|
||||
DIR *dp = opendir(dir); struct dirent *e;
|
||||
while ((e = readdir(dp))) if (strstr(e->d_name, ".tgb") && strstr(e->d_name, "autosave-")) snprintf(file, sizeof file, "%s/%s", dir, e->d_name);
|
||||
closedir(dp);
|
||||
size_t l1; uint8_t *first = slurp(file, &l1);
|
||||
CHECK(first != NULL);
|
||||
for (int i = 0; i < 40; i++) CHECK_OK(paint_px(d, 0, 0, i, 20, BLUE));
|
||||
signal(SIGXFSZ, SIG_IGN);
|
||||
struct rlimit old, lim; getrlimit(RLIMIT_FSIZE, &old); lim = old; lim.rlim_cur = 100; setrlimit(RLIMIT_FSIZE, &lim); /* "sin espacio" */
|
||||
tgb_status s = tgb_recovery_tick(rec, 20.0);
|
||||
setrlimit(RLIMIT_FSIZE, &old);
|
||||
CHECK_STATUS(s, TGB_ERR_IO);
|
||||
CHECK(strstr(tgb_last_error(), "autoguardado") != NULL);
|
||||
size_t l2; uint8_t *second = slurp(file, &l2);
|
||||
CHECK(second && l2 == l1 && !memcmp(first, second, l1)); /* la copia anterior sigue intacta y utilizable */
|
||||
CHECK_EQ(count_files(dir, ".tmp-"), 0);
|
||||
CHECK_OK(tgb_recovery_tick(rec, 25.0)); /* no reintenta antes de tiempo (ni hace ruido) */
|
||||
CHECK_OK(tgb_recovery_tick(rec, 30.0)); /* con espacio otra vez, funciona */
|
||||
uint8_t *third = slurp(file, &l2);
|
||||
CHECK(third && (l2 != l1 || memcmp(first, third, l1) != 0));
|
||||
free(first); free(second); free(third);
|
||||
tgb_recovery_close(rec, true);
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(orphan_history_files_are_cleaned) {
|
||||
wipe_recovery();
|
||||
char hdir[400]; snprintf(hdir, sizeof hdir, "%s/cache/history", g_root);
|
||||
mkdir(hdir, 0700);
|
||||
int st; pid_t dead = fork(); if (dead == 0) _exit(0); waitpid(dead, &st, 0);
|
||||
char p[700];
|
||||
snprintf(p, sizeof p, "%s/tgb-hist-%ld-3.bin", hdir, (long)dead); spit(p, "huérfano", 8);
|
||||
snprintf(p, sizeof p, "%s/tgb-hist-%ld-4.bin", hdir, (long)getpid()); spit(p, "vivo", 4);
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
CHECK_EQ(count_files(hdir, "tgb-hist-"), 1);
|
||||
tgb_recovery_close(rec, true);
|
||||
wipe_recovery();
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
g_ctx = tgb_test_context(g_root, sizeof g_root);
|
||||
RUN(autosave_every_five_minutes);
|
||||
RUN(tick_waits_while_a_stroke_is_open);
|
||||
RUN(recover_after_unexpected_exit);
|
||||
RUN(live_session_is_not_recovered);
|
||||
RUN(corrupt_autosave_and_leftovers);
|
||||
RUN(failed_autosave_keeps_previous_copy);
|
||||
RUN(orphan_history_files_are_cleaned);
|
||||
tgb_context_destroy(g_ctx);
|
||||
tgb_test_rmtree(g_root);
|
||||
TEST_SUMMARY();
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/* Puntos de recuperación nombrados (en memoria; la persistencia se prueba en test_io). */
|
||||
#include "tgb_test.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;
|
||||
}
|
||||
|
||||
TEST(create_list_delete) {
|
||||
tgb_document *d = new_doc(16, 16);
|
||||
CHECK_EQ(tgb_recovery_point_count(d), 0);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
uint64_t rev = tgb_document_revision(d);
|
||||
uint32_t a = 0, b = 0;
|
||||
CHECK_OK(tgb_recovery_point_create(d, "Base", &a));
|
||||
CHECK(tgb_document_revision(d) != rev); /* hay algo nuevo que guardar */
|
||||
CHECK_OK(paint_px(d, 0, 0, 2, 2, BLUE));
|
||||
CHECK_OK(tgb_recovery_point_create(d, "Después del azul", &b));
|
||||
CHECK_EQ(tgb_recovery_point_count(d), 2);
|
||||
tgb_recovery_point_info info;
|
||||
CHECK_OK(tgb_recovery_point_info_at(d, 0, &info));
|
||||
CHECK(info.id == a && !strcmp(info.name, "Base") && info.compatible && info.created_unix > 1700000000);
|
||||
CHECK_STATUS(tgb_recovery_point_info_at(d, 5, &info), TGB_ERR_RANGE);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "Base", NULL), TGB_ERR_EXISTS);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "", NULL), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, " ", NULL), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, NULL, NULL), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "\xff\xfe", NULL), TGB_ERR_INVALID_ARG);
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "a\nb", NULL), TGB_ERR_INVALID_ARG);
|
||||
char longname[300]; memset(longname, 'x', sizeof longname - 1); longname[sizeof longname - 1] = 0;
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, longname, NULL), TGB_ERR_LIMIT);
|
||||
CHECK_OK(tgb_recovery_point_delete(d, a));
|
||||
CHECK_EQ(tgb_recovery_point_count(d), 1);
|
||||
CHECK_STATUS(tgb_recovery_point_delete(d, a), TGB_ERR_NOT_FOUND);
|
||||
CHECK_STATUS(tgb_recovery_point_restore(d, 9999), TGB_ERR_NOT_FOUND);
|
||||
CHECK_OK(tgb_edit_begin(d, tgb_frame_id(d, 0), tgb_layer_id(d, 0), "x"));
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "Durante edición", NULL), TGB_ERR_STATE);
|
||||
CHECK_OK(tgb_edit_cancel(d));
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(restore_goes_back_and_keeps_safety_point) {
|
||||
tgb_document *d = new_doc(16, 16);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
uint32_t base = 0;
|
||||
CHECK_OK(tgb_recovery_point_create(d, "Base", &base));
|
||||
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, "Otra", NULL));
|
||||
CHECK_EQ(tgb_frame_count(d), 2);
|
||||
CHECK_OK(tgb_recovery_point_restore(d, base));
|
||||
CHECK_EQ(tgb_frame_count(d), 1);
|
||||
CHECK_EQ(tgb_layer_count(d), 1);
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 1, 1, RED));
|
||||
CHECK_EQ(px_at(d, 0, 0, 2, 2).a, 0);
|
||||
CHECK(!tgb_history_can_undo(d) && !tgb_history_can_redo(d)); /* el historial ordinario se vacía */
|
||||
CHECK_EQ(tgb_recovery_point_count(d), 2); /* Base + punto de seguridad */
|
||||
tgb_recovery_point_info info;
|
||||
CHECK_OK(tgb_recovery_point_info_at(d, 1, &info));
|
||||
CHECK(strstr(info.name, "Antes de restaurar") != NULL && strstr(info.name, "Base") != NULL);
|
||||
/* volver al estado anterior a la restauración usando el punto automático */
|
||||
CHECK_OK(tgb_recovery_point_restore(d, info.id));
|
||||
CHECK_EQ(tgb_frame_count(d), 2);
|
||||
CHECK_EQ(tgb_layer_count(d), 2);
|
||||
CHECK(is_px(tgb_frame_cel(d, 0, 0), 2, 2, BLUE));
|
||||
/* tras restaurar se sigue pudiendo editar y deshacer con normalidad */
|
||||
CHECK_OK(paint_px(d, 0, 0, 9, 9, GREEN));
|
||||
CHECK_OK(tgb_history_undo(d));
|
||||
CHECK_EQ(px_at(d, 0, 0, 9, 9).a, 0);
|
||||
/* restaurar dos veces el mismo punto: nombres de seguridad únicos */
|
||||
CHECK_OK(tgb_recovery_point_restore(d, base));
|
||||
CHECK_OK(tgb_recovery_point_restore(d, base));
|
||||
for (size_t i = 0; i < tgb_recovery_point_count(d); i++)
|
||||
for (size_t j = i + 1; j < tgb_recovery_point_count(d); j++) {
|
||||
tgb_recovery_point_info a, b;
|
||||
tgb_recovery_point_info_at(d, i, &a); tgb_recovery_point_info_at(d, j, &b);
|
||||
CHECK(strcmp(a.name, b.name) != 0);
|
||||
}
|
||||
CHECK(tgb_document_is_consistent(d));
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(points_share_memory_copy_on_write) {
|
||||
tgb_document *d = new_doc(64, 64);
|
||||
CHECK_OK(paint_px(d, 0, 0, 1, 1, RED));
|
||||
CHECK_OK(tgb_recovery_point_create(d, "P", NULL));
|
||||
CHECK_EQ(tgb_recovery_points_extra_bytes(d), 0); /* comparte la celda: coste cero */
|
||||
CHECK_EQ(tgb_canvas_refs(tgb_frame_cel(d, 0, 0)), 2);
|
||||
CHECK_OK(paint_px(d, 0, 0, 2, 2, BLUE)); /* editar separa la celda */
|
||||
CHECK_EQ(tgb_recovery_points_extra_bytes(d), 64 * 64 * 4); /* ahora el punto retiene la versión vieja */
|
||||
CHECK_OK(tgb_recovery_point_create(d, "Q", NULL)); /* otro punto de la misma versión: no duplica */
|
||||
CHECK_OK(tgb_recovery_point_create(d, "R", NULL));
|
||||
CHECK_EQ(tgb_recovery_points_extra_bytes(d), 64 * 64 * 4);
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
TEST(point_limit) {
|
||||
tgb_document *d = new_doc(8, 8);
|
||||
char name[32];
|
||||
for (int i = 0; i < TGB_MAX_RECOVERY_POINTS; i++) {
|
||||
snprintf(name, sizeof name, "P%d", i);
|
||||
CHECK_OK(tgb_recovery_point_create(d, name, NULL));
|
||||
}
|
||||
CHECK_STATUS(tgb_recovery_point_create(d, "Uno más", NULL), TGB_ERR_LIMIT); /* nunca borra puntos del usuario */
|
||||
CHECK_EQ(tgb_recovery_point_count(d), TGB_MAX_RECOVERY_POINTS);
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
g_ctx = tgb_test_context(g_root, sizeof g_root);
|
||||
RUN(create_list_delete);
|
||||
RUN(restore_goes_back_and_keeps_safety_point);
|
||||
RUN(points_share_memory_copy_on_write);
|
||||
RUN(point_limit);
|
||||
tgb_context_destroy(g_ctx);
|
||||
tgb_test_rmtree(g_root);
|
||||
TEST_SUMMARY();
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/* Mini marco de pruebas (solo cabecera). Cada CHECK que falla se cuenta e imprime. */
|
||||
#ifndef TGB_TEST_H
|
||||
#define TGB_TEST_H
|
||||
|
||||
#ifndef _GNU_SOURCE
|
||||
#define _GNU_SOURCE
|
||||
#endif
|
||||
#include <ftw.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include "tahgibu/tahgibu.h"
|
||||
|
||||
#pragma GCC diagnostic ignored "-Wunused-function" /* no todas las pruebas usan todos los ayudantes */
|
||||
|
||||
static int g_fail = 0, g_checks = 0;
|
||||
|
||||
#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; printf(" FALLO %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
||||
#define CHECK_EQ(a, b) do { long long _a = (long long)(a), _b = (long long)(b); g_checks++; \
|
||||
if (_a != _b) { g_fail++; printf(" FALLO %s:%d: %s == %s (%lld != %lld)\n", __FILE__, __LINE__, #a, #b, _a, _b); } } while (0)
|
||||
/* Espera un estado concreto (éxito = TGB_OK). */
|
||||
#define CHECK_STATUS(expr, expected) do { tgb_status _s = (expr); g_checks++; \
|
||||
if (_s != (expected)) { g_fail++; printf(" FALLO %s:%d: %s devolvió %s, se esperaba %s (%s)\n", __FILE__, __LINE__, #expr, \
|
||||
tgb_status_name(_s), tgb_status_name(expected), tgb_last_error()); } } while (0)
|
||||
#define CHECK_OK(expr) CHECK_STATUS(expr, TGB_OK)
|
||||
|
||||
#define TEST(name) static void name(void)
|
||||
#define RUN(name) do { int _before = g_fail; name(); printf("%s %s\n", g_fail == _before ? "[ok] " : "[FALLO]", #name); } while (0)
|
||||
#define TEST_SUMMARY() do { printf("\n%d comprobaciones, %d fallos\n", g_checks, g_fail); return g_fail ? 1 : 0; } while (0)
|
||||
|
||||
static int _rm_cb(const char *path, const struct stat *sb, int flag, struct FTW *ftw) {
|
||||
(void)sb; (void)flag; (void)ftw;
|
||||
return remove(path);
|
||||
}
|
||||
static void tgb_test_rmtree(const char *root) { nftw(root, _rm_cb, 16, FTW_DEPTH | FTW_PHYS); }
|
||||
|
||||
/* Crea un contexto cuyas carpetas viven en un directorio temporal propio. */
|
||||
static tgb_context *tgb_test_context(char *root, size_t cap) {
|
||||
char tmpl[] = "/tmp/tgb-test-XXXXXX";
|
||||
if (!mkdtemp(tmpl)) { printf("no se pudo crear carpeta temporal\n"); exit(2); }
|
||||
snprintf(root, cap, "%s", tmpl);
|
||||
char c[600], d[600], k[600], s[600];
|
||||
snprintf(c, sizeof c, "%s/config", root);
|
||||
snprintf(d, sizeof d, "%s/data", root);
|
||||
snprintf(k, sizeof k, "%s/cache", root);
|
||||
snprintf(s, sizeof s, "%s/state", root);
|
||||
tgb_context_config cfg = {c, d, k, s};
|
||||
tgb_context *ctx = NULL;
|
||||
if (tgb_context_create(&cfg, &ctx) != TGB_OK) { printf("contexto: %s\n", tgb_last_error()); exit(2); }
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/* Contexto sobre las MISMAS carpetas que otro (para simular otro proceso o un reinicio). */
|
||||
static tgb_context *tgb_test_context_at(const char *root) {
|
||||
char c[600], d[600], k[600], s[600];
|
||||
snprintf(c, sizeof c, "%s/config", root);
|
||||
snprintf(d, sizeof d, "%s/data", root);
|
||||
snprintf(k, sizeof k, "%s/cache", root);
|
||||
snprintf(s, sizeof s, "%s/state", root);
|
||||
tgb_context_config cfg = {c, d, k, s};
|
||||
tgb_context *ctx = NULL;
|
||||
if (tgb_context_create(&cfg, &ctx) != TGB_OK) { printf("contexto: %s\n", tgb_last_error()); exit(2); }
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static const tgb_color RED = {255, 0, 0, 255}, BLUE = {0, 0, 255, 255}, BLACK = {0, 0, 0, 255}, GREEN = {0, 255, 0, 255};
|
||||
|
||||
static size_t count_alpha(const tgb_canvas *c) {
|
||||
size_t n = 0;
|
||||
for (int y = 0; y < tgb_canvas_height(c); y++)
|
||||
for (int x = 0; x < tgb_canvas_width(c); x++)
|
||||
if (tgb_canvas_get(c, x, y).a) n++;
|
||||
return n;
|
||||
}
|
||||
static bool is_px(const tgb_canvas *c, int x, int y, tgb_color col) {
|
||||
tgb_color p = tgb_canvas_get(c, x, y);
|
||||
return p.r == col.r && p.g == col.g && p.b == col.b && p.a == col.a;
|
||||
}
|
||||
/* Dibuja un punto en la celda (frame_index, layer_index) con sesión de edición. */
|
||||
static tgb_status paint_px(tgb_document *doc, size_t f, size_t l, int x, int y, tgb_color col) {
|
||||
tgb_status s = tgb_edit_begin(doc, tgb_frame_id(doc, f), tgb_layer_id(doc, l), "Punto");
|
||||
if (s != TGB_OK) return s;
|
||||
tgb_canvas_set(tgb_edit_canvas(doc), x, y, col);
|
||||
return tgb_edit_end(doc);
|
||||
}
|
||||
static tgb_color px_at(const tgb_document *doc, size_t f, size_t l, int x, int y) {
|
||||
return tgb_canvas_get(tgb_frame_cel(doc, f, l), x, y);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,113 +0,0 @@
|
||||
/* Implementación FALSA de las funciones de raylib que usa Tahgibu: no abre ventanas ni dibuja,
|
||||
* pero reproduce un guion de ratón/teclado para ejercitar el bucle real de src/ui. */
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "raylib.h"
|
||||
#include "mock_raylib.h"
|
||||
|
||||
static const mock_event *g_events;
|
||||
static int g_nevents, g_next, g_last_frame, g_frame;
|
||||
static Vector2 g_mouse;
|
||||
static bool g_down[3], g_pressed[3], g_released[3];
|
||||
static bool g_key_down[512], g_key_pressed[512];
|
||||
static int g_chars[256], g_nchars, g_char_head;
|
||||
static float g_wheel;
|
||||
static int g_tex_updates, g_draw_calls;
|
||||
|
||||
void mock_script(const mock_event *events, int count, int last_frame) {
|
||||
g_events = events; g_nevents = count; g_last_frame = last_frame; g_next = 0; g_frame = 0;
|
||||
}
|
||||
int mock_frames_run(void) { return g_frame; }
|
||||
int mock_texture_updates(void) { return g_tex_updates; }
|
||||
int mock_draw_calls(void) { return g_draw_calls; }
|
||||
|
||||
void SetConfigFlags(unsigned int flags) { (void)flags; }
|
||||
void InitWindow(int width, int height, const char *title) { (void)width; (void)height; (void)title; }
|
||||
void CloseWindow(void) {}
|
||||
void SetWindowMinSize(int w, int h) { (void)w; (void)h; }
|
||||
void SetTargetFPS(int fps) { (void)fps; }
|
||||
void SetExitKey(int key) { (void)key; }
|
||||
int GetScreenWidth(void) { return 1280; }
|
||||
int GetScreenHeight(void) { return 800; }
|
||||
float GetFrameTime(void) { return 1.0f / 60.0f; }
|
||||
double GetTime(void) { return 1000.0 + (double)g_frame / 60.0; }
|
||||
|
||||
/* Se llama una vez por vuelta del bucle: avanza el guion. */
|
||||
bool WindowShouldClose(void) {
|
||||
g_frame++;
|
||||
memset(g_pressed, 0, sizeof g_pressed);
|
||||
memset(g_released, 0, sizeof g_released);
|
||||
memset(g_key_pressed, 0, sizeof g_key_pressed);
|
||||
g_wheel = 0;
|
||||
while (g_next < g_nevents && g_events[g_next].frame <= g_frame) {
|
||||
const mock_event *e = &g_events[g_next++];
|
||||
int k = (int)e->a;
|
||||
switch (e->type) {
|
||||
case EV_MOVE: g_mouse.x = e->a; g_mouse.y = e->b; break;
|
||||
case EV_DOWN: g_down[k] = true; g_pressed[k] = true; break;
|
||||
case EV_UP: g_down[k] = false; g_released[k] = true; break;
|
||||
case EV_KEY: if (k >= 0 && k < 512) g_key_pressed[k] = true; break;
|
||||
case EV_KEYDOWN: if (k >= 0 && k < 512) g_key_down[k] = true; break;
|
||||
case EV_KEYUP: if (k >= 0 && k < 512) g_key_down[k] = false; break;
|
||||
case EV_CHAR: if (g_nchars < 256) g_chars[g_nchars++] = k; break;
|
||||
case EV_WHEEL: g_wheel = e->a; break;
|
||||
}
|
||||
}
|
||||
return g_frame > g_last_frame;
|
||||
}
|
||||
|
||||
void BeginDrawing(void) {}
|
||||
void EndDrawing(void) {}
|
||||
void ClearBackground(Color c) { (void)c; g_draw_calls++; }
|
||||
void BeginScissorMode(int x, int y, int w, int h) { (void)x; (void)y; (void)w; (void)h; }
|
||||
void EndScissorMode(void) {}
|
||||
void DrawRectangle(int x, int y, int w, int h, Color c) { (void)x; (void)y; (void)w; (void)h; (void)c; g_draw_calls++; }
|
||||
void DrawRectangleRec(Rectangle r, Color c) { (void)r; (void)c; g_draw_calls++; }
|
||||
void DrawRectangleLines(int x, int y, int w, int h, Color c) { (void)x; (void)y; (void)w; (void)h; (void)c; g_draw_calls++; }
|
||||
void DrawRectangleLinesEx(Rectangle r, float t, Color c) { (void)r; (void)t; (void)c; g_draw_calls++; }
|
||||
void DrawCircleLines(int x, int y, float r, Color c) { (void)x; (void)y; (void)r; (void)c; g_draw_calls++; }
|
||||
void DrawText(const char *t, int x, int y, int s, Color c) { (void)t; (void)x; (void)y; (void)s; (void)c; g_draw_calls++; }
|
||||
int MeasureText(const char *t, int size) { return (int)strlen(t) * size / 2; }
|
||||
Font GetFontDefault(void) {
|
||||
Font f;
|
||||
memset(&f, 0, sizeof f);
|
||||
f.baseSize = 10;
|
||||
f.texture.id = 1;
|
||||
f.texture.width = 1;
|
||||
f.texture.height = 1;
|
||||
return f;
|
||||
}
|
||||
Font LoadFontEx(const char *path, int size, int *codepoints, int count) {
|
||||
(void)path; (void)codepoints; (void)count;
|
||||
Font f = GetFontDefault();
|
||||
f.baseSize = size;
|
||||
f.texture.id = 2;
|
||||
return f;
|
||||
}
|
||||
void UnloadFont(Font f) { (void)f; }
|
||||
void DrawTextEx(Font f, const char *t, Vector2 p, float size, float spacing, Color c) {
|
||||
(void)f; (void)t; (void)p; (void)size; (void)spacing; (void)c; g_draw_calls++;
|
||||
}
|
||||
Vector2 MeasureTextEx(Font f, const char *t, float size, float spacing) {
|
||||
(void)f;
|
||||
float n = (float)strlen(t);
|
||||
return (Vector2){n * size * 0.52f + (n > 0.0f ? (n - 1.0f) * spacing : 0.0f), size};
|
||||
}
|
||||
|
||||
Texture2D LoadTextureFromImage(Image img) { Texture2D t = {1, img.width, img.height, 1, img.format}; return t; }
|
||||
void UpdateTexture(Texture2D t, const void *pixels) { (void)t; if (pixels) g_tex_updates++; }
|
||||
void UnloadTexture(Texture2D t) { (void)t; }
|
||||
void SetTextureFilter(Texture2D t, int f) { (void)t; (void)f; }
|
||||
void DrawTexturePro(Texture2D t, Rectangle s, Rectangle d, Vector2 o, float r, Color c) { (void)t; (void)s; (void)d; (void)o; (void)r; (void)c; g_draw_calls++; }
|
||||
|
||||
Vector2 GetMousePosition(void) { return g_mouse; }
|
||||
bool IsMouseButtonPressed(int b) { return g_pressed[b]; }
|
||||
bool IsMouseButtonDown(int b) { return g_down[b]; }
|
||||
bool IsMouseButtonReleased(int b) { return g_released[b]; }
|
||||
float GetMouseWheelMove(void) { return g_wheel; }
|
||||
bool IsKeyPressed(int k) { return k >= 0 && k < 512 && g_key_pressed[k]; }
|
||||
bool IsKeyPressedRepeat(int k) { (void)k; return false; }
|
||||
bool IsKeyDown(int k) { return k >= 0 && k < 512 && g_key_down[k]; }
|
||||
int GetCharPressed(void) { return g_char_head < g_nchars ? g_chars[g_char_head++] : 0; }
|
||||
const char *GetClipboardText(void) { return ""; }
|
||||
bool CheckCollisionPointRec(Vector2 p, Rectangle r) { return p.x >= r.x && p.x < r.x + r.width && p.y >= r.y && p.y < r.y + r.height; }
|
||||
@@ -1,14 +0,0 @@
|
||||
/* Control del "raylib falso" que usa la prueba de humo de la interfaz (sin ventana ni GPU). */
|
||||
#ifndef MOCK_RAYLIB_H
|
||||
#define MOCK_RAYLIB_H
|
||||
|
||||
typedef enum { EV_MOVE, EV_DOWN, EV_UP, EV_KEY, EV_KEYDOWN, EV_KEYUP, EV_CHAR, EV_WHEEL } mock_ev_type;
|
||||
typedef struct { int frame; mock_ev_type type; float a, b; } mock_event;
|
||||
|
||||
/* Programa un guion de eventos (ordenado por frame) y el último frame; luego tgb_ui_run lo consume. */
|
||||
void mock_script(const mock_event *events, int count, int last_frame);
|
||||
int mock_frames_run(void);
|
||||
int mock_texture_updates(void);
|
||||
int mock_draw_calls(void);
|
||||
|
||||
#endif
|
||||
@@ -1,85 +0,0 @@
|
||||
/* Prueba de humo de la interfaz: ejecuta el bucle REAL de src/ui con un raylib falso y un guion de
|
||||
* ratón/teclado, y comprueba el resultado en el archivo .tgb guardado. NO prueba la compatibilidad
|
||||
* con raylib real: solo la lógica de la interfaz y su integración con el núcleo. */
|
||||
#include <stdio.h>
|
||||
#include "../tgb_test.h"
|
||||
#include "mock_raylib.h"
|
||||
#include "../../src/ui/ui_run.h"
|
||||
#include "../../src/ui/ui_app.h"
|
||||
|
||||
static tgb_context *g_ctx;
|
||||
static char g_root[200];
|
||||
|
||||
static mock_event mk(int f, mock_ev_type t, float a, float b) { mock_event e = {f, t, a, b}; return e; }
|
||||
#define CTRL_DOWN(f) mk(f, EV_KEYDOWN, 341, 0)
|
||||
#define CTRL_UP(f) mk(f, EV_KEYUP, 341, 0)
|
||||
#define KEY(f, k) mk(f, EV_KEY, (float)(k), 0)
|
||||
#define MOVE(f, x, y) mk(f, EV_MOVE, x, y)
|
||||
#define DOWN(f) mk(f, EV_DOWN, 0, 0)
|
||||
#define UP(f) mk(f, EV_UP, 0, 0)
|
||||
|
||||
int main(void) {
|
||||
/* El texto truncado nunca debe romper una secuencia UTF-8. */
|
||||
char clipped[64];
|
||||
ui_fit_text(clipped, sizeof clipped, "Rectángulo selección ñ", 100.0f, 14.0f);
|
||||
CHECK(strncmp(clipped, "Rect", 4) == 0);
|
||||
CHECK(strstr(clipped, "...") != NULL || strcmp(clipped, "Rectángulo selección ñ") == 0);
|
||||
g_ctx = tgb_test_context(g_root, sizeof g_root);
|
||||
tgb_recovery *rec = NULL;
|
||||
CHECK_OK(tgb_recovery_open(g_ctx, &rec));
|
||||
tgb_plugin_manager *pm = NULL;
|
||||
CHECK_OK(tgb_plugin_manager_create(g_ctx, &pm));
|
||||
char out[300]; snprintf(out, sizeof out, "%s/humo.tgb", g_root);
|
||||
|
||||
/* guion (ventana falsa de 1280x800: lienzo en x 0..980, y 104..606; "Nuevo" de la línea de tiempo en (45,625)) */
|
||||
static mock_event ev[400];
|
||||
int n = 0;
|
||||
#define E(x) (ev[n++] = (x))
|
||||
/* 1) trazo con el lápiz en el fotograma 1 */
|
||||
E(MOVE(5, 300, 300)); E(DOWN(6));
|
||||
for (int i = 0; i < 10; i++) E(MOVE(7 + i, 300 + 20 * (float)i, 300 + 5 * (float)i));
|
||||
E(UP(18));
|
||||
/* 2) deshacer y rehacer con atajos */
|
||||
E(CTRL_DOWN(20)); E(KEY(20, 90)); E(CTRL_UP(21)); /* Ctrl+Z */
|
||||
E(CTRL_DOWN(24)); E(KEY(24, 89)); E(CTRL_UP(25)); /* Ctrl+Y */
|
||||
/* 3) rectángulo con la tecla R, arrastrando */
|
||||
E(KEY(28, 82)); E(MOVE(29, 600, 200)); E(DOWN(30)); E(MOVE(31, 650, 250)); E(MOVE(32, 700, 300)); E(UP(33));
|
||||
/* 4) nuevo fotograma (botón "Nuevo" de la línea de tiempo) y un trazo en él */
|
||||
E(MOVE(36, 45, 625)); E(DOWN(37)); E(UP(38));
|
||||
E(KEY(40, 80)); /* P: lápiz */
|
||||
E(MOVE(41, 350, 350)); E(DOWN(42)); E(MOVE(43, 420, 380)); E(MOVE(44, 480, 360)); E(UP(45));
|
||||
/* 5) reproducir un rato (Enter) y pausar */
|
||||
E(KEY(50, 257)); E(KEY(120, 257));
|
||||
/* 6) abrir "Guardar como" (Ctrl+Shift+S), borrar el texto por defecto, escribir la ruta, aceptar */
|
||||
E(CTRL_DOWN(130)); E(mk(130, EV_KEYDOWN, 340, 0)); E(KEY(130, 83)); E(CTRL_UP(131)); E(mk(131, EV_KEYUP, 340, 0));
|
||||
for (int i = 0; i < 40; i++) E(KEY(135 + i, 259)); /* Backspace */
|
||||
for (const char *p = out; *p; p++) E(mk(180, EV_CHAR, (float)(unsigned char)*p, 0));
|
||||
E(KEY(185, 257)); /* Enter */
|
||||
#undef E
|
||||
mock_script(ev, n, 260);
|
||||
|
||||
int rc = tgb_ui_run(g_ctx, rec, pm, NULL);
|
||||
CHECK_EQ(rc, 0);
|
||||
CHECK(mock_frames_run() > 250);
|
||||
CHECK(mock_texture_updates() > 10); /* la vista se refrescó muchas veces */
|
||||
CHECK(mock_draw_calls() > 5000);
|
||||
|
||||
/* el archivo guardado desde la interfaz contiene lo dibujado */
|
||||
tgb_document *d = NULL;
|
||||
CHECK_OK(tgb_document_load(g_ctx, out, &d));
|
||||
if (d) {
|
||||
CHECK_EQ(tgb_frame_count(d), 2);
|
||||
CHECK(tgb_canvas_width(tgb_frame_cel(d, 0, 0)) == 960);
|
||||
size_t f0 = count_alpha(tgb_frame_cel(d, 0, 0)), f1 = count_alpha(tgb_frame_cel(d, 1, 0));
|
||||
printf(" (pixeles dibujados: fotograma 1 = %zu, fotograma 2 = %zu)\n", f0, f1);
|
||||
CHECK(f0 > 150); /* trazo + rectángulo */
|
||||
CHECK(f1 > 50);
|
||||
CHECK(!tgb_document_dirty(d));
|
||||
tgb_document_destroy(d);
|
||||
}
|
||||
tgb_plugin_manager_destroy(pm);
|
||||
tgb_recovery_close(rec, true);
|
||||
tgb_context_destroy(g_ctx);
|
||||
tgb_test_rmtree(g_root);
|
||||
TEST_SUMMARY();
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Busca dependencias circulares entre cabeceras públicas y entre módulos internos (por #include "...")."""
|
||||
import os, re, sys
|
||||
root = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
def includes(path):
|
||||
out = []
|
||||
for line in open(path, encoding="utf-8"):
|
||||
m = re.match(r'\s*#\s*include\s+"([^"]+)"', line)
|
||||
if m: out.append(m.group(1))
|
||||
return out
|
||||
|
||||
def find_cycle(graph):
|
||||
state, stack = {}, []
|
||||
def dfs(n):
|
||||
state[n] = 1; stack.append(n)
|
||||
for m in graph.get(n, []):
|
||||
if state.get(m) == 1: return stack[stack.index(m):] + [m]
|
||||
if state.get(m) is None:
|
||||
c = dfs(m)
|
||||
if c: return c
|
||||
stack.pop(); state[n] = 2
|
||||
return None
|
||||
for n in list(graph):
|
||||
if state.get(n) is None:
|
||||
c = dfs(n)
|
||||
if c: return c
|
||||
return None
|
||||
|
||||
# 1) cabeceras públicas
|
||||
pub = os.path.join(root, "include", "tahgibu")
|
||||
g = {}
|
||||
for f in sorted(os.listdir(pub)):
|
||||
g[f] = [i.split("/")[-1] for i in includes(os.path.join(pub, f)) if i.startswith("tahgibu/")]
|
||||
c = find_cycle(g)
|
||||
print("Cabeceras públicas:", "CICLO " + " -> ".join(c) if c else "sin ciclos (%d cabeceras)" % len(g))
|
||||
|
||||
# 2) módulos internos (carpetas de src/, sin ui/ ni platform/) por dependencias de archivos .c/.h
|
||||
src = os.path.join(root, "src")
|
||||
mods = {}
|
||||
for d, _, files in os.walk(src):
|
||||
for f in files:
|
||||
if not f.endswith((".c", ".h")): continue
|
||||
p = os.path.join(d, f)
|
||||
mod = os.path.relpath(d, src).split(os.sep)[0]
|
||||
for inc in includes(p):
|
||||
tgt = os.path.normpath(os.path.join(d, inc))
|
||||
if tgt.startswith(src) and os.path.exists(tgt):
|
||||
tmod = os.path.relpath(os.path.dirname(tgt), src).split(os.sep)[0]
|
||||
if tmod != mod: mods.setdefault(mod, set()).add(tmod)
|
||||
for m, deps in sorted(mods.items()): print(" %-12s -> %s" % (m, ", ".join(sorted(deps))))
|
||||
c = find_cycle({k: sorted(v) for k, v in mods.items()})
|
||||
print("Módulos internos:", "CICLO " + " -> ".join(c) if c else "sin ciclos")
|
||||
sys.exit(0)
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Comprueba SOLO la sintaxis y los tipos de src/ui y src/main.c contra una cabecera mínima de raylib
|
||||
# (tools/dev/raylib_stub). NO compila ni enlaza la aplicación: para eso hace falta raylib real (CMake).
|
||||
set -eu
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
for f in "$ROOT"/src/ui/*.c "$ROOT"/src/main.c; do
|
||||
echo "syntax-check $(basename "$f")"
|
||||
gcc -std=c11 -fsyntax-only -Wall -Wextra -Wshadow -Wpedantic -Werror -D_GNU_SOURCE -I"$ROOT/include" -I"$ROOT/tools/dev/raylib_stub" -I"$ROOT/src" "$f"
|
||||
done
|
||||
echo "OK: la interfaz es sintacticamente correcta frente a la cabecera de prueba (NO verificada contra raylib real)"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Mide qué líneas del núcleo ejecutan las pruebas (gcc + gcov). Resultado en /tmp/tgb-cov/resumen.txt
|
||||
set -eu
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${OUT:-/tmp/tgb-cov}
|
||||
rm -rf "$OUT"; mkdir -p "$OUT/obj" "$OUT/tests"
|
||||
FLAGS="-std=c11 -g -O0 --coverage -D_GNU_SOURCE -I$ROOT/include"
|
||||
for f in $(ls "$ROOT"/src/core/*.c "$ROOT"/src/document/*.c "$ROOT"/src/history/*.c "$ROOT"/src/editor/*.c "$ROOT"/src/editor/tools/*.c \
|
||||
"$ROOT"/src/animation/*.c "$ROOT"/src/rendering/*.c "$ROOT"/src/io/*.c "$ROOT"/src/recovery/*.c "$ROOT"/src/extensions/*.c "$ROOT"/src/platform/linux/*.c); do
|
||||
gcc $FLAGS -c "$f" -o "$OUT/obj/$(echo "${f#$ROOT/src/}" | tr '/' '_').o"
|
||||
done
|
||||
for f in "$ROOT"/tests/plugins/*.c; do gcc -std=c11 -fPIC -shared -I"$ROOT/include" "$f" -o "$OUT/tests/$(basename "$f" .c).so"; done
|
||||
for t in "$ROOT"/tests/test_*.c; do
|
||||
n=$(basename "$t" .c)
|
||||
gcc $FLAGS -DTGB_TEST_PLUGIN_DIR="\"$OUT/tests\"" "$t" "$OUT"/obj/*.o -o "$OUT/tests/$n" -lz -lm -ldl --coverage
|
||||
(cd "$OUT/obj" && "$OUT/tests/$n" > /dev/null 2>&1) || echo "AVISO: $n falló"
|
||||
done
|
||||
cd "$OUT/obj"
|
||||
: > "$OUT/resumen.txt"
|
||||
for o in *.o; do
|
||||
src=$(echo "$o" | sed 's/\.o$//')
|
||||
gcov -n -o . "$o" 2>/dev/null | awk -v file="$src" "/^File '.*\/src\//{f=\$2} /Lines executed/{ if (f != \"\") { split(\$2,a,\":\"); sub(\"%\",\"\",a[2]); print f, a[2], \$4; f=\"\" } }" >> "$OUT/resumen.txt" || true
|
||||
done
|
||||
sed -e "s|'||g" -e "s|$ROOT/||" "$OUT/resumen.txt" | sort -k2 -n > "$OUT/resumen2.txt"
|
||||
cat "$OUT/resumen2.txt"
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Compilación de DESARROLLO sin CMake: construye la biblioteca y las pruebas
|
||||
# con gcc directamente. Sirve para verificar el núcleo en máquinas sin CMake.
|
||||
# La compilación oficial del proyecto es CMake (ver docs/BUILD.md).
|
||||
# tools/dev-build.sh -> compila y ejecuta todas las pruebas
|
||||
# SAN=1 tools/dev-build.sh -> con AddressSanitizer + UBSan
|
||||
# OUT=/ruta tools/dev-build.sh -> carpeta de compilación (por defecto /tmp/tgb-dev)
|
||||
set -eu
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${OUT:-/tmp/tgb-dev}
|
||||
CC=${CC:-gcc}
|
||||
FLAGS="-std=c11 -g -O1 -Wall -Wextra -Wshadow -Wpedantic -Werror -D_GNU_SOURCE -I$ROOT/include"
|
||||
[ "${SAN:-0}" = "1" ] && FLAGS="$FLAGS -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all"
|
||||
mkdir -p "$OUT/obj" "$OUT/tests"
|
||||
echo "== Biblioteca (libtahgibu.a) =="
|
||||
OBJS=""
|
||||
for f in $(ls "$ROOT"/src/core/*.c "$ROOT"/src/document/*.c "$ROOT"/src/history/*.c "$ROOT"/src/editor/*.c \
|
||||
"$ROOT"/src/editor/tools/*.c "$ROOT"/src/animation/*.c "$ROOT"/src/rendering/*.c "$ROOT"/src/io/*.c \
|
||||
"$ROOT"/src/recovery/*.c "$ROOT"/src/extensions/*.c "$ROOT"/src/platform/linux/*.c 2>/dev/null); do
|
||||
o="$OUT/obj/$(echo "${f#$ROOT/src/}" | tr '/' '_').o"
|
||||
$CC $FLAGS -c "$f" -o "$o"
|
||||
OBJS="$OBJS $o"
|
||||
done
|
||||
rm -f "$OUT/libtahgibu.a"; ar rcs "$OUT/libtahgibu.a" $OBJS
|
||||
echo "== Extensiones de prueba =="
|
||||
for f in "$ROOT"/tests/plugins/*.c; do
|
||||
[ -e "$f" ] || continue
|
||||
n=$(basename "$f" .c)
|
||||
$CC $FLAGS -fPIC -shared "$f" -o "$OUT/tests/$n.so"
|
||||
done
|
||||
echo "== Pruebas =="
|
||||
FAIL=0
|
||||
for t in "$ROOT"/tests/test_*.c; do
|
||||
n=$(basename "$t" .c)
|
||||
$CC $FLAGS -DTGB_TEST_PLUGIN_DIR="\"$OUT/tests\"" "$t" "$OUT/libtahgibu.a" -o "$OUT/tests/$n" -lz -lm -ldl -lpthread
|
||||
echo "--- $n"
|
||||
if ! "$OUT/tests/$n"; then FAIL=1; fi
|
||||
done
|
||||
echo "--- ui_smoke (interfaz con raylib FALSO: no prueba raylib real)"
|
||||
if $CC $FLAGS -I"$ROOT/tools/dev/raylib_stub" -I"$ROOT/src" -I"$ROOT/tests" \
|
||||
"$ROOT"/tests/ui_smoke/ui_smoke.c "$ROOT"/tests/ui_smoke/mock_raylib.c "$ROOT"/src/ui/*.c \
|
||||
"$OUT/libtahgibu.a" -o "$OUT/tests/ui_smoke" -lz -lm -ldl -lpthread; then
|
||||
"$OUT/tests/ui_smoke" || FAIL=1
|
||||
else FAIL=1; fi
|
||||
[ $FAIL = 0 ] && echo "TODAS LAS PRUEBAS PASARON" || { echo "HAY PRUEBAS FALLIDAS"; exit 1; }
|
||||
@@ -1,80 +0,0 @@
|
||||
/* NO ES RAYLIB. Cabecera mínima escrita a mano con SOLO las declaraciones de la API de raylib 5.x
|
||||
* que usa la interfaz de Tahgibu. Sirve únicamente para comprobar la sintaxis y los tipos del
|
||||
* código de src/ui/ en máquinas sin raylib (gcc -fsyntax-only). No genera nada ejecutable.
|
||||
* Las firmas están escritas de memoria: la compilación REAL contra raylib sigue pendiente. */
|
||||
#ifndef RAYLIB_STUB_H
|
||||
#define RAYLIB_STUB_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef struct Vector2 { float x, y; } Vector2;
|
||||
typedef struct Rectangle { float x, y, width, height; } Rectangle;
|
||||
typedef struct Color { unsigned char r, g, b, a; } Color;
|
||||
typedef struct Image { void *data; int width, height, mipmaps, format; } Image;
|
||||
typedef struct Texture { unsigned int id; int width, height, mipmaps, format; } Texture;
|
||||
typedef Texture Texture2D;
|
||||
typedef struct GlyphInfo { int value, offsetX, offsetY, advanceX; Image image; } GlyphInfo;
|
||||
typedef struct Font { int baseSize, glyphCount, glyphPadding; Texture2D texture; Rectangle *recs; GlyphInfo *glyphs; } Font;
|
||||
|
||||
enum { FLAG_VSYNC_HINT = 0x40, FLAG_WINDOW_RESIZABLE = 0x04 };
|
||||
enum { MOUSE_BUTTON_LEFT = 0, MOUSE_BUTTON_RIGHT = 1, MOUSE_BUTTON_MIDDLE = 2 };
|
||||
enum { TEXTURE_FILTER_POINT = 0 };
|
||||
enum { PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 = 7 };
|
||||
enum {
|
||||
KEY_NULL = 0, KEY_SPACE = 32, KEY_COMMA = 44, KEY_PERIOD = 46,
|
||||
KEY_B = 66, KEY_C = 67, KEY_E = 69, KEY_F = 70, KEY_I = 73, KEY_L = 76, KEY_N = 78, KEY_O = 79, KEY_P = 80,
|
||||
KEY_R = 82, KEY_S = 83, KEY_V = 86, KEY_Y = 89, KEY_Z = 90,
|
||||
KEY_LEFT_BRACKET = 91, KEY_RIGHT_BRACKET = 93,
|
||||
KEY_ESCAPE = 256, KEY_ENTER = 257, KEY_BACKSPACE = 259, KEY_DELETE = 261,
|
||||
KEY_LEFT_SHIFT = 340, KEY_LEFT_CONTROL = 341, KEY_RIGHT_CONTROL = 345, KEY_RIGHT_SHIFT = 344
|
||||
};
|
||||
|
||||
void SetConfigFlags(unsigned int flags);
|
||||
void InitWindow(int width, int height, const char *title);
|
||||
void CloseWindow(void);
|
||||
bool WindowShouldClose(void);
|
||||
void SetWindowMinSize(int width, int height);
|
||||
void SetTargetFPS(int fps);
|
||||
void SetExitKey(int key);
|
||||
int GetScreenWidth(void);
|
||||
int GetScreenHeight(void);
|
||||
float GetFrameTime(void);
|
||||
double GetTime(void);
|
||||
void BeginDrawing(void);
|
||||
void EndDrawing(void);
|
||||
void ClearBackground(Color color);
|
||||
void BeginScissorMode(int x, int y, int width, int height);
|
||||
void EndScissorMode(void);
|
||||
|
||||
void DrawRectangle(int posX, int posY, int width, int height, Color color);
|
||||
void DrawRectangleRec(Rectangle rec, Color color);
|
||||
void DrawRectangleLines(int posX, int posY, int width, int height, Color color);
|
||||
void DrawRectangleLinesEx(Rectangle rec, float lineThick, Color color);
|
||||
void DrawCircleLines(int centerX, int centerY, float radius, Color color);
|
||||
void DrawText(const char *text, int posX, int posY, int fontSize, Color color);
|
||||
int MeasureText(const char *text, int fontSize);
|
||||
Font GetFontDefault(void);
|
||||
Font LoadFontEx(const char *fileName, int fontSize, int *codepoints, int codepointCount);
|
||||
void UnloadFont(Font font);
|
||||
void DrawTextEx(Font font, const char *text, Vector2 position, float fontSize, float spacing, Color tint);
|
||||
Vector2 MeasureTextEx(Font font, const char *text, float fontSize, float spacing);
|
||||
|
||||
Texture2D LoadTextureFromImage(Image image);
|
||||
void UpdateTexture(Texture2D texture, const void *pixels);
|
||||
void UnloadTexture(Texture2D texture);
|
||||
void SetTextureFilter(Texture2D texture, int filter);
|
||||
void DrawTexturePro(Texture2D texture, Rectangle source, Rectangle dest, Vector2 origin, float rotation, Color tint);
|
||||
|
||||
Vector2 GetMousePosition(void);
|
||||
bool IsMouseButtonPressed(int button);
|
||||
bool IsMouseButtonDown(int button);
|
||||
bool IsMouseButtonReleased(int button);
|
||||
float GetMouseWheelMove(void);
|
||||
bool IsKeyPressed(int key);
|
||||
bool IsKeyPressedRepeat(int key);
|
||||
bool IsKeyDown(int key);
|
||||
int GetCharPressed(void);
|
||||
const char *GetClipboardText(void);
|
||||
bool CheckCollisionPointRec(Vector2 point, Rectangle rec);
|
||||
|
||||
#endif
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Genera tests/fixtures_png.h con PNG producidos por una implementación
|
||||
INDEPENDIENTE (Pillow) y los píxeles RGBA esperados, para comprobar que nuestro
|
||||
decodificador entiende PNG reales y no solo los que escribe Tahgibu.
|
||||
Uso: python3 tools/gen_png_fixtures.py > tests/fixtures_png.h"""
|
||||
import io, struct, zlib
|
||||
from PIL import Image
|
||||
|
||||
W, H = 4, 3
|
||||
|
||||
def png_bytes(img, **kw):
|
||||
b = io.BytesIO(); img.save(b, "PNG", **kw); return b.getvalue()
|
||||
|
||||
def expected(img):
|
||||
rgba = img.convert("RGBA")
|
||||
out = []
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
r, g, b, a = rgba.getpixel((x, y))
|
||||
if a == 0: r = g = b = 0 # forma canónica de Tahgibu
|
||||
out += [r, g, b, a]
|
||||
return out
|
||||
|
||||
def pat(f):
|
||||
return [f(x, y) for y in range(H) for x in range(W)]
|
||||
|
||||
fixtures = []
|
||||
# RGBA 8
|
||||
im = Image.new("RGBA", (W, H)); im.putdata(pat(lambda x, y: (x * 60, y * 100, 200 - x * 10, 255 if (x + y) % 3 else (x + y) * 20)))
|
||||
fixtures.append(("rgba8", png_bytes(im), expected(im)))
|
||||
# RGB 8
|
||||
im = Image.new("RGB", (W, H)); im.putdata(pat(lambda x, y: (x * 50, y * 90, 17 * (x + y))))
|
||||
fixtures.append(("rgb8", png_bytes(im), expected(im)))
|
||||
# Gris 8
|
||||
im = Image.new("L", (W, H)); im.putdata(pat(lambda x, y: x * 40 + y * 10))
|
||||
fixtures.append(("gray8", png_bytes(im), expected(im)))
|
||||
# Gris + alfa
|
||||
im = Image.new("LA", (W, H)); im.putdata(pat(lambda x, y: (x * 30 + 5, 255 - y * 100)))
|
||||
fixtures.append(("graya8", png_bytes(im), expected(im)))
|
||||
# Paleta con transparencia (tRNS)
|
||||
im = Image.new("P", (W, H)); pal = []
|
||||
for i in range(4): pal += [i * 60, 255 - i * 60, i * 20]
|
||||
im.putpalette(pal + [0] * (768 - len(pal))); im.putdata(pat(lambda x, y: (x + y) % 4))
|
||||
fixtures.append(("palette_trns", png_bytes(im, transparency=bytes([255, 128, 0, 255])), None))
|
||||
pm = Image.open(io.BytesIO(fixtures[-1][1])).convert("RGBA")
|
||||
fixtures[-1] = (fixtures[-1][0], fixtures[-1][1], [v for y in range(H) for x in range(W) for v in ((0,0,0,0) if pm.getpixel((x,y))[3]==0 else pm.getpixel((x,y)))])
|
||||
# 1 bit
|
||||
im = Image.new("1", (W, H)); im.putdata(pat(lambda x, y: 255 if (x + y) % 2 else 0))
|
||||
fixtures.append(("bilevel", png_bytes(im), expected(im)))
|
||||
# Paleta de 2 bits (4 colores) con filtros variados
|
||||
im = Image.new("P", (W, H)); im.putpalette([255,0,0, 0,255,0, 0,0,255, 255,255,0] + [0]*756); im.putdata(pat(lambda x, y: (x * 2 + y) % 4))
|
||||
fixtures.append(("palette2bit", png_bytes(im, bits=2), None))
|
||||
pm = Image.open(io.BytesIO(fixtures[-1][1])).convert("RGBA")
|
||||
fixtures[-1] = (fixtures[-1][0], fixtures[-1][1], [v for y in range(H) for x in range(W) for v in pm.getpixel((x,y))])
|
||||
# Gris 16 bits
|
||||
im = Image.new("I;16", (W, H)); im.putdata(pat(lambda x, y: x * 4000 + y * 9000))
|
||||
data = png_bytes(im)
|
||||
exp = []
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
v = (x * 4000 + y * 9000) >> 8
|
||||
exp += [v, v, v, 255]
|
||||
fixtures.append(("gray16", data, exp))
|
||||
|
||||
print("/* GENERADO por tools/gen_png_fixtures.py con Pillow: no editar a mano. */")
|
||||
print("#ifndef TGB_FIXTURES_PNG_H\n#define TGB_FIXTURES_PNG_H\n#include <stddef.h>\n#include <stdint.h>")
|
||||
print("#define FIX_W %d\n#define FIX_H %d" % (W, H))
|
||||
for name, data, exp in fixtures:
|
||||
print("static const uint8_t fix_%s[] = {%s};" % (name, ",".join(str(b) for b in data)))
|
||||
print("static const uint8_t fix_%s_px[] = {%s};" % (name, ",".join(str(v) for v in exp)))
|
||||
print("typedef struct { const char *name; const uint8_t *png; size_t len; const uint8_t *px; } png_fixture;")
|
||||
print("static const png_fixture png_fixtures[] = {")
|
||||
for name, data, exp in fixtures:
|
||||
print(' {"%s", fix_%s, sizeof fix_%s, fix_%s_px},' % (name, name, name, name))
|
||||
print("};\n#endif")
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verifica con herramientas INDEPENDIENTES (Pillow, ffprobe) los archivos que escribe Tahgibu.
|
||||
Uso: mkdir -p /tmp/tgb-art && TGB_TEST_ARTIFACTS=/tmp/tgb-art <ruta>/test_io_files && python3 tools/verify-external.py /tmp/tgb-art"""
|
||||
import base64, io, json, subprocess, sys, shutil
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
d = sys.argv[1]
|
||||
ok = True
|
||||
def check(cond, msg):
|
||||
global ok
|
||||
print((" OK " if cond else " FALLO ") + msg)
|
||||
ok &= bool(cond)
|
||||
|
||||
png0 = Image.open(f"{d}/muestra_f0.png"); png0.load()
|
||||
check(png0.format == "PNG" and png0.mode == "RGBA" and png0.size == (48, 32), f"PNG exportado lo abre Pillow: {png0.format} {png0.mode} {png0.size}")
|
||||
|
||||
v2 = json.load(open(f"{d}/muestra_v2.tgb"))
|
||||
check(v2["format"] == "TAHGIBU" and v2["version"] == 2 and v2["fps"] == 12 and (v2["width"], v2["height"]) == (48, 32), "cabecera formato 2 coincide con el ejemplo histórico")
|
||||
v2_frames = [Image.open(io.BytesIO(base64.b64decode(f))).convert("RGBA") for f in v2["frames"]]
|
||||
check(len(v2_frames) == 3 and all(f.size == (48, 32) for f in v2_frames), "formato 2: 3 fotogramas PNG válidos en Base64 (decodificados con base64+Pillow)")
|
||||
check(list(png0.getdata()) == list(v2_frames[0].getdata()), "el PNG exportado == fotograma 0 del .tgb v2")
|
||||
|
||||
v3 = json.load(open(f"{d}/muestra_v3.tgb"))
|
||||
check(v3["version"] == 3 and "tgb3" in v3 and len(v3["frames"]) == 3, "formato 3: 'frames' aplanados + bloque 'tgb3'")
|
||||
v3_flat = [Image.open(io.BytesIO(base64.b64decode(f))).convert("RGBA") for f in v3["frames"]]
|
||||
check(all(list(a.getdata()) == list(b.getdata()) for a, b in zip(v3_flat, v2_frames)), "formato 3: los fotogramas aplanados == formato 2")
|
||||
imgs = [Image.open(io.BytesIO(base64.b64decode(s))) for s in v3["tgb3"]["images"]]
|
||||
check(all(i.size == (48, 32) and i.mode == "RGBA" for i in imgs), f"formato 3: {len(imgs)} imágenes de celda válidas")
|
||||
check([f["duration_ms"] for f in v3["tgb3"]["frames"]] == [100, 250, 40], "formato 3: duraciones por fotograma")
|
||||
|
||||
ap = Image.open(f"{d}/muestra.apng")
|
||||
check(getattr(ap, "is_animated", False) and ap.n_frames == 3, f"APNG: Pillow lo reconoce animado con {getattr(ap, 'n_frames', '?')} fotogramas")
|
||||
frames, durs = [], []
|
||||
for fr in ImageSequence.Iterator(ap):
|
||||
frames.append(fr.convert("RGBA").copy()); durs.append(fr.info.get("duration"))
|
||||
check(durs == [100, 250, 40], f"APNG: duraciones {durs}")
|
||||
check(all(list(a.getdata()) == list(b.getdata()) for a, b in zip(frames, v2_frames)), "APNG: cada fotograma == fotograma del proyecto (píxel a píxel)")
|
||||
|
||||
if shutil.which("ffprobe"):
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames,width,height,codec_name",
|
||||
"-of", "json", f"{d}/muestra.apng"], capture_output=True, text=True)
|
||||
info = json.loads(out.stdout or "{}").get("streams", [{}])[0]
|
||||
check(out.returncode == 0 and info.get("codec_name") == "apng" and int(info.get("nb_read_frames", 0)) == 3,
|
||||
f"ffprobe: codec={info.get('codec_name')} fotogramas={info.get('nb_read_frames')} {info.get('width')}x{info.get('height')}")
|
||||
else:
|
||||
print(" (ffprobe no disponible: omitido)")
|
||||
print("RESULTADO:", "TODO CORRECTO" if ok else "HAY FALLOS")
|
||||
sys.exit(0 if ok else 1)
|
||||
Reference in new issue
Block a user