From 4d9a339421368a84742eea5f2c9a56378ced38a5 Mon Sep 17 00:00:00 2001 From: Clinv Date: Sat, 24 Oct 2026 12:01:52 -0300 Subject: [PATCH] V2.0.0 --- .gitignore | 2 - CHANGELOG.md | 0 CMakeLists.txt | 112 +-- CMakePresets.json | 40 + LICENSE | 0 README.md | 92 --- build.bat | 2 - build.sh | 2 - cmake/BuildTargets.cmake | 78 ++ cmake/CompilerOptions.cmake | 16 + cmake/Dependencies.cmake | 28 + cmake/PlatformConfig.cmake | 13 + extensions/lua/ejemplo_diagonal.lua | 17 + extensions/native/ejemplo_cruz.c | 34 + include/tahgibu/animation.h | 59 ++ include/tahgibu/canvas.h | 62 ++ include/tahgibu/context.h | 35 + include/tahgibu/document.h | 60 ++ include/tahgibu/drawing.h | 68 ++ include/tahgibu/error.h | 44 ++ include/tahgibu/events.h | 50 ++ include/tahgibu/frame.h | 36 + include/tahgibu/history.h | 55 ++ include/tahgibu/io.h | 56 ++ include/tahgibu/layer.h | 31 + include/tahgibu/plugin.h | 121 +++ include/tahgibu/recovery.h | 78 ++ include/tahgibu/tahgibu.h | 20 + include/tahgibu/version.h | 23 + install.sh | 13 - requirements.txt | Bin 30 -> 0 bytes resources/palettes/default.hex | 22 + source/core/Constants.hpp | 25 - source/core/Frame.cpp | 28 - source/core/Frame.hpp | 28 - source/core/Project.cpp | 30 - source/core/Project.hpp | 39 - source/editor/EditorState.hpp | 15 - source/editor/Tool.hpp | 55 -- source/main.cpp | 14 - source/ui/Application.cpp | 242 ------ source/ui/Application.hpp | 35 - source/ui/Layout.cpp | 35 - source/ui/Layout.hpp | 18 - source/ui/Theme.hpp | 18 - src/animation/player.c | 109 +++ src/animation/render.c | 104 +++ src/core/context.c | 79 ++ src/core/error.c | 83 ++ src/core/internal.h | 66 ++ src/document/doc_internal.h | 104 +++ src/document/doc_ops.c | 372 +++++++++ src/document/document.c | 343 +++++++++ src/editor/edit.c | 193 +++++ src/editor/tools/tool_fill.c | 62 ++ src/editor/tools/tool_raster.c | 174 +++++ src/editor/tools/tool_region.c | 68 ++ src/extensions/ext_internal.h | 17 + src/extensions/lua_bridge.c | 248 ++++++ src/extensions/plugin_manager.c | 501 +++++++++++++ src/history/history.c | 406 ++++++++++ src/history/history_internal.h | 46 ++ src/io/image_io.c | 122 +++ src/io/io_internal.h | 59 ++ src/io/io_util.c | 136 ++++ src/io/json.c | 272 +++++++ src/io/png.c | 250 +++++++ src/io/tgb_format.c | 464 ++++++++++++ src/main.c | 46 ++ src/platform/linux/platform_linux.c | 217 ++++++ src/platform/platform.h | 48 ++ src/platform/windows/platform_windows.c | 195 +++++ src/recovery/autosave.c | 347 +++++++++ src/recovery/recovery_points.c | 170 +++++ src/rendering/canvas.c | 207 +++++ src/ui/ui_app.h | 125 ++++ src/ui/ui_canvas.c | 455 +++++++++++ src/ui/ui_main.c | 510 +++++++++++++ src/ui/ui_panel.c | 191 +++++ src/ui/ui_run.h | 11 + src/ui/ui_timeline.c | 89 +++ src/ui/ui_widgets.c | 181 +++++ tahgibu_animator.py | 955 ------------------------ tests/core_tests.cpp | 101 --- tests/fixtures_png.h | 35 + tests/plugins/plug_common.h | 14 + tests/plugins/plug_events.c | 14 + tests/plugins/plug_good.c | 57 ++ tests/plugins/plug_init_fails.c | 6 + tests/plugins/plug_needs_edit.c | 9 + tests/plugins/plug_no_entry.c | 3 + tests/plugins/plug_no_name.c | 5 + tests/plugins/plug_no_version.c | 4 + tests/plugins/plug_old_api.c | 5 + tests/plugins/plug_readonly.c | 12 + tests/test_animation.c | 180 +++++ tests/test_canvas_draw.c | 192 +++++ tests/test_document_history.c | 487 ++++++++++++ tests/test_io_files.c | 323 ++++++++ tests/test_io_formats.c | 450 +++++++++++ tests/test_plugins.c | 378 ++++++++++ tests/test_recovery.c | 307 ++++++++ tests/test_recovery_points.c | 122 +++ tests/tgb_test.h | 91 +++ tests/ui_smoke/mock_raylib.c | 113 +++ tests/ui_smoke/mock_raylib.h | 14 + tests/ui_smoke/ui_smoke.c | 85 +++ tools/check-includes.py | 54 ++ tools/check-ui-syntax.sh | 10 + tools/coverage.sh | 25 + tools/dev-build.sh | 45 ++ tools/dev/raylib_stub/raylib.h | 80 ++ tools/gen_png_fixtures.py | 75 ++ tools/verify-external.py | 48 ++ 114 files changed, 11383 insertions(+), 1837 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CMakePresets.json create mode 100644 LICENSE delete mode 100644 build.bat delete mode 100644 build.sh create mode 100644 cmake/BuildTargets.cmake create mode 100644 cmake/CompilerOptions.cmake create mode 100644 cmake/Dependencies.cmake create mode 100644 cmake/PlatformConfig.cmake create mode 100644 extensions/lua/ejemplo_diagonal.lua create mode 100644 extensions/native/ejemplo_cruz.c create mode 100644 include/tahgibu/animation.h create mode 100644 include/tahgibu/canvas.h create mode 100644 include/tahgibu/context.h create mode 100644 include/tahgibu/document.h create mode 100644 include/tahgibu/drawing.h create mode 100644 include/tahgibu/error.h create mode 100644 include/tahgibu/events.h create mode 100644 include/tahgibu/frame.h create mode 100644 include/tahgibu/history.h create mode 100644 include/tahgibu/io.h create mode 100644 include/tahgibu/layer.h create mode 100644 include/tahgibu/plugin.h create mode 100644 include/tahgibu/recovery.h create mode 100644 include/tahgibu/tahgibu.h create mode 100644 include/tahgibu/version.h delete mode 100755 install.sh delete mode 100644 requirements.txt create mode 100644 resources/palettes/default.hex delete mode 100644 source/core/Constants.hpp delete mode 100644 source/core/Frame.cpp delete mode 100644 source/core/Frame.hpp delete mode 100644 source/core/Project.cpp delete mode 100644 source/core/Project.hpp delete mode 100644 source/editor/EditorState.hpp delete mode 100644 source/editor/Tool.hpp delete mode 100644 source/main.cpp delete mode 100644 source/ui/Application.cpp delete mode 100644 source/ui/Application.hpp delete mode 100644 source/ui/Layout.cpp delete mode 100644 source/ui/Layout.hpp delete mode 100644 source/ui/Theme.hpp create mode 100644 src/animation/player.c create mode 100644 src/animation/render.c create mode 100644 src/core/context.c create mode 100644 src/core/error.c create mode 100644 src/core/internal.h create mode 100644 src/document/doc_internal.h create mode 100644 src/document/doc_ops.c create mode 100644 src/document/document.c create mode 100644 src/editor/edit.c create mode 100644 src/editor/tools/tool_fill.c create mode 100644 src/editor/tools/tool_raster.c create mode 100644 src/editor/tools/tool_region.c create mode 100644 src/extensions/ext_internal.h create mode 100644 src/extensions/lua_bridge.c create mode 100644 src/extensions/plugin_manager.c create mode 100644 src/history/history.c create mode 100644 src/history/history_internal.h create mode 100644 src/io/image_io.c create mode 100644 src/io/io_internal.h create mode 100644 src/io/io_util.c create mode 100644 src/io/json.c create mode 100644 src/io/png.c create mode 100644 src/io/tgb_format.c create mode 100644 src/main.c create mode 100644 src/platform/linux/platform_linux.c create mode 100644 src/platform/platform.h create mode 100644 src/platform/windows/platform_windows.c create mode 100644 src/recovery/autosave.c create mode 100644 src/recovery/recovery_points.c create mode 100644 src/rendering/canvas.c create mode 100644 src/ui/ui_app.h create mode 100644 src/ui/ui_canvas.c create mode 100644 src/ui/ui_main.c create mode 100644 src/ui/ui_panel.c create mode 100644 src/ui/ui_run.h create mode 100644 src/ui/ui_timeline.c create mode 100644 src/ui/ui_widgets.c delete mode 100755 tahgibu_animator.py delete mode 100644 tests/core_tests.cpp create mode 100644 tests/fixtures_png.h create mode 100644 tests/plugins/plug_common.h create mode 100644 tests/plugins/plug_events.c create mode 100644 tests/plugins/plug_good.c create mode 100644 tests/plugins/plug_init_fails.c create mode 100644 tests/plugins/plug_needs_edit.c create mode 100644 tests/plugins/plug_no_entry.c create mode 100644 tests/plugins/plug_no_name.c create mode 100644 tests/plugins/plug_no_version.c create mode 100644 tests/plugins/plug_old_api.c create mode 100644 tests/plugins/plug_readonly.c create mode 100644 tests/test_animation.c create mode 100644 tests/test_canvas_draw.c create mode 100644 tests/test_document_history.c create mode 100644 tests/test_io_files.c create mode 100644 tests/test_io_formats.c create mode 100644 tests/test_plugins.c create mode 100644 tests/test_recovery.c create mode 100644 tests/test_recovery_points.c create mode 100644 tests/tgb_test.h create mode 100644 tests/ui_smoke/mock_raylib.c create mode 100644 tests/ui_smoke/mock_raylib.h create mode 100644 tests/ui_smoke/ui_smoke.c create mode 100644 tools/check-includes.py create mode 100755 tools/check-ui-syntax.sh create mode 100755 tools/coverage.sh create mode 100755 tools/dev-build.sh create mode 100644 tools/dev/raylib_stub/raylib.h create mode 100644 tools/gen_png_fixtures.py create mode 100644 tools/verify-external.py diff --git a/.gitignore b/.gitignore index 902f3c7..567609b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ build/ -.cache/ -compile_commands.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/CMakeLists.txt b/CMakeLists.txt index 253ab07..c0a63d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,98 +1,34 @@ -cmake_minimum_required(VERSION 3.16) -project(Tahgibu VERSION 2.0.0 LANGUAGES C CXX) +cmake_minimum_required(VERSION 3.21) +project(Tahgibu VERSION 2.0.0 DESCRIPTION "Editor de animación 2D" LANGUAGES C) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) -set(CMAKE_EXPORT_COMPILE_COMMANDS 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 "Compilar el editor (requiere raylib)" ON) -option(TAHGIBU_BUILD_TESTS "Compilar las pruebas del núcleo" ON) -option(TAHGIBU_FETCH_RAYLIB "Descargar y compilar raylib si no se encuentra (requiere red y git)" OFF) +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) -# --------------------------------------------------------------------------- -# Núcleo: modelo de datos del proyecto. NO depende de raylib. -# --------------------------------------------------------------------------- -add_library(tahgibu_core STATIC - source/core/Frame.cpp - source/core/Project.cpp -) -target_include_directories(tahgibu_core PUBLIC source) -target_compile_definitions(tahgibu_core PUBLIC TAHGIBU_VERSION="${PROJECT_VERSION}") -if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - target_compile_options(tahgibu_core PUBLIC -Wall -Wextra) -endif() +list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake) +include(CompilerOptions) +include(PlatformConfig) +include(Dependencies) +include(BuildTargets) -# --------------------------------------------------------------------------- -# Lua: solo se detecta. No se enlaza hasta la etapa 10. -# --------------------------------------------------------------------------- -find_package(Lua QUIET) -if(LUA_FOUND) - message(STATUS "Lua ${LUA_VERSION_STRING} encontrado (se usará a partir de la etapa 10)") -else() - message(STATUS "Lua no encontrado. No hace falta todavía; será necesario en la etapa 10.") -endif() +message(STATUS "Tahgibu ${PROJECT_VERSION}: app=${TAHGIBU_BUILD_APP} tests=${TAHGIBU_BUILD_TESTS} lua=${TAHGIBU_WITH_LUA}") -# --------------------------------------------------------------------------- -# Editor (raylib) -# --------------------------------------------------------------------------- -if(TAHGIBU_BUILD_APP) - set(TAHGIBU_RAYLIB_TARGET "") - - find_package(raylib QUIET) - if(TARGET raylib) - set(TAHGIBU_RAYLIB_TARGET raylib) - else() - find_package(PkgConfig QUIET) - if(PKG_CONFIG_FOUND) - pkg_check_modules(RAYLIB QUIET IMPORTED_TARGET raylib) - if(RAYLIB_FOUND) - set(TAHGIBU_RAYLIB_TARGET PkgConfig::RAYLIB) - endif() - endif() - endif() - - if(NOT TAHGIBU_RAYLIB_TARGET AND TAHGIBU_FETCH_RAYLIB) - include(FetchContent) - set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - FetchContent_Declare(raylib - GIT_REPOSITORY https://github.com/raysan5/raylib.git - GIT_TAG 5.0 - GIT_SHALLOW ON) - FetchContent_MakeAvailable(raylib) - set(TAHGIBU_RAYLIB_TARGET raylib) - endif() - - if(NOT TAHGIBU_RAYLIB_TARGET) - message(FATAL_ERROR - "No se encontró raylib.\n" - "Opciones:\n" - " 1) Instalarla con el gestor de paquetes de tu distribución (ver README.md).\n" - " 2) Reconfigurar con -DTAHGIBU_FETCH_RAYLIB=ON (descarga y compila raylib).\n" - " 3) Compilar solo el núcleo y las pruebas con -DTAHGIBU_BUILD_APP=OFF.") - endif() - message(STATUS "raylib: ${TAHGIBU_RAYLIB_TARGET}") - - add_executable(tahgibu - source/main.cpp - source/ui/Application.cpp - source/ui/Layout.cpp - ) - target_link_libraries(tahgibu PRIVATE tahgibu_core ${TAHGIBU_RAYLIB_TARGET}) -endif() - -# --------------------------------------------------------------------------- -# Pruebas del núcleo (no necesitan raylib ni pantalla) -# --------------------------------------------------------------------------- -if(TAHGIBU_BUILD_TESTS) - enable_testing() - add_executable(tahgibu_core_tests tests/core_tests.cpp) - target_link_libraries(tahgibu_core_tests PRIVATE tahgibu_core) - add_test(NAME core COMMAND tahgibu_core_tests) -endif() +# 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) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..d5e1bd9 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,40 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "release", + "displayName": "Release (aplicación + pruebas)", + "binaryDir": "${sourceDir}/build/release", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "dev", + "displayName": "Desarrollo: Debug + sanitizers + avisos como errores", + "binaryDir": "${sourceDir}/build/dev", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "TAHGIBU_SANITIZE": "ON", "TAHGIBU_WARNINGS_AS_ERRORS": "ON" } + }, + { + "name": "core-only", + "displayName": "Solo núcleo y pruebas (sin raylib)", + "binaryDir": "${sourceDir}/build/core-only", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "TAHGIBU_BUILD_APP": "OFF" } + }, + { + "name": "release-fetch", + "displayName": "Release descargando raylib", + "inherits": "release", + "binaryDir": "${sourceDir}/build/release-fetch", + "cacheVariables": { "TAHGIBU_FETCH_DEPS": "ON" } + } + ], + "buildPresets": [ + { "name": "release", "configurePreset": "release" }, + { "name": "dev", "configurePreset": "dev" }, + { "name": "core-only", "configurePreset": "core-only" } + ], + "testPresets": [ + { "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } }, + { "name": "core-only", "configurePreset": "core-only", "output": { "outputOnFailure": true } } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 0e3012d..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,92 +0,0 @@ -# Tahgibu 2.0.0 - -Editor de animación 2D ligero y nativo para Linux. -C++ · C · Lua · raylib · CMake. **Sin Python.** - -## Estado actual: Etapa 1 (base) - -Hecho y verificado: - -- Sistema de compilación con CMake (C, C++, raylib; Lua solo se detecta). -- Núcleo sin dependencias gráficas (`source/core`): `Frame`, `Project`, límite de 60 FPS, - duración real por frame. Con pruebas automáticas. -- Ventana de raylib con el reparto de zonas: menús, herramientas, canvas, propiedades, timeline. -- Registro de herramientas (`source/editor/Tool.hpp`): la barra sale de una tabla. - -Todavía **no** hace nada más que eso: - -- Los menús se dibujan pero no abren nada. -- Las herramientas se pueden seleccionar (queda resaltada), pero no editan nada. -- La timeline es un esqueleto vacío (etapa 3). - -## Dependencias - -Necesarias hoy: compilador C++17, CMake >= 3.16 y raylib >= 4.0. - -| Distro | raylib | -|---|---| -| Arch | `sudo pacman -S raylib` | -| Fedora | `sudo dnf install raylib-devel` | -| Debian/Ubuntu | `libraylib-dev` si tu versión lo trae | - -Si tu distro no la trae, dos opciones: - -1. Compilarla desde https://github.com/raysan5/raylib (en Debian/Ubuntu necesita - `libasound2-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libglu1-mesa-dev - libxcursor-dev libxinerama-dev libwayland-dev libxkbcommon-dev`). -2. Dejar que CMake la descargue y compile: `cmake -S . -B build -DTAHGIBU_FETCH_RAYLIB=ON` - (requiere red y `git`). - -Lua (`liblua5.4-dev` en Debian/Ubuntu, `lua` en Arch, `lua-devel` en Fedora) no hace falta -hasta la etapa 10. - -## Compilar y ejecutar - -```bash -cmake -S . -B build -cmake --build build -./build/tahgibu -``` - -Solo núcleo + pruebas, sin raylib: - -```bash -cmake -S . -B build -DTAHGIBU_BUILD_APP=OFF -cmake --build build -ctest --test-dir build --output-on-failure -``` - -## Estructura - -```text -source/ -├── main.cpp -├── core/ modelo de datos (sin raylib): Frame, Project, constantes -├── editor/ estado del editor y registro de herramientas -└── ui/ ventana, layout y dibujo con raylib -tests/ pruebas del núcleo -``` - -Los `.hpp` viven junto a su `.cpp`; `source/` es la raíz de includes -(`#include "core/Project.hpp"`). Las carpetas `canvas/`, `timeline/`, `animation/`, -`project/`, `media/`, `export/` y `lua/` se crean cuando llega su etapa. - -## Decisiones de diseño - -- **FPS por frame = override opcional.** Un frame usa el FPS base del proyecto salvo que - tenga FPS propios. Cambiar el FPS base afecta a los frames sin override. -- **Límite 60 FPS** aplicado en un único punto (`Frame::clampFps`) para el base y para cada frame. -- **Mínimo 0.1 FPS** (un frame puede durar hasta 10 s) para hacer pausas largas sin duplicar frames. - Se cambia en `core/Constants.hpp`. -- **El FPS de la interfaz (60) es independiente** del FPS de la animación. - -## Limitaciones conocidas - -- El texto usa la fuente por defecto de raylib. Si las tildes salen como `?`, hará falta - cargar una fuente propia. -- Esc no cierra la ventana (a propósito, para no perder trabajo); se cierra con el botón de la ventana. - -## Hoja de ruta - -1 Base · 2 Editor · 3 Frames · 4 Sprites · 5 Fondos · 6 FPS · 7 Audio · 8 Vértices · -9 Proyecto · 10 Lua · 11 Exportación diff --git a/build.bat b/build.bat deleted file mode 100644 index 0bb2bb4..0000000 --- a/build.bat +++ /dev/null @@ -1,2 +0,0 @@ -python -m pip install pyinstaller -python pyinstaller .\tahgibu_animator.py \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100644 index 0bb2bb4..0000000 --- a/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -python -m pip install pyinstaller -python pyinstaller .\tahgibu_animator.py \ No newline at end of file diff --git a/cmake/BuildTargets.cmake b/cmake/BuildTargets.cmake new file mode 100644 index 0000000..2599f67 --- /dev/null +++ b/cmake/BuildTargets.cmake @@ -0,0 +1,78 @@ +# Destinos: biblioteca del núcleo, aplicación, pruebas y ejemplos. +set(TAHGIBU_CORE_SOURCES + src/core/error.c src/core/context.c + src/document/document.c src/document/doc_ops.c + src/history/history.c + src/editor/edit.c src/editor/tools/tool_raster.c src/editor/tools/tool_fill.c src/editor/tools/tool_region.c + src/animation/render.c src/animation/player.c + src/rendering/canvas.c + src/io/io_util.c src/io/json.c src/io/png.c src/io/tgb_format.c src/io/image_io.c + src/recovery/recovery_points.c src/recovery/autosave.c + src/extensions/plugin_manager.c src/extensions/lua_bridge.c + ${TAHGIBU_PLATFORM_SOURCES}) + +add_library(tahgibu_core STATIC ${TAHGIBU_CORE_SOURCES}) +target_include_directories(tahgibu_core PUBLIC include) +target_link_libraries(tahgibu_core PUBLIC ZLIB::ZLIB ${TAHGIBU_PLATFORM_LIBS}) +if(UNIX) + target_compile_definitions(tahgibu_core PUBLIC _GNU_SOURCE) +endif() +tahgibu_apply_warnings(tahgibu_core) +if(TAHGIBU_WITH_LUA) + target_compile_definitions(tahgibu_core PRIVATE TAHGIBU_WITH_LUA) + target_include_directories(tahgibu_core PRIVATE ${LUA_INCLUDE_DIR}) + target_link_libraries(tahgibu_core PUBLIC ${LUA_LIBRARIES}) +endif() + +if(TAHGIBU_BUILD_APP) + add_executable(tahgibu src/main.c src/ui/ui_main.c src/ui/ui_canvas.c src/ui/ui_panel.c src/ui/ui_timeline.c src/ui/ui_widgets.c) + target_include_directories(tahgibu PRIVATE src) + target_link_libraries(tahgibu PRIVATE tahgibu_core raylib) + tahgibu_apply_warnings(tahgibu) + if(WIN32) + set_target_properties(tahgibu PROPERTIES WIN32_EXECUTABLE ON) + endif() + install(TARGETS tahgibu RUNTIME DESTINATION bin) + install(DIRECTORY resources/palettes DESTINATION share/tahgibu) + install(FILES README.md LICENSE CHANGELOG.md DESTINATION share/doc/tahgibu) + install(DIRECTORY docs/ DESTINATION share/doc/tahgibu/docs) +endif() +install(DIRECTORY include/tahgibu DESTINATION include) +install(TARGETS tahgibu_core ARCHIVE DESTINATION lib) + +if(TAHGIBU_BUILD_EXAMPLES) + add_library(ejemplo_cruz MODULE extensions/native/ejemplo_cruz.c) + target_include_directories(ejemplo_cruz PRIVATE include) + set_target_properties(ejemplo_cruz PROPERTIES PREFIX "") + tahgibu_apply_warnings(ejemplo_cruz) +endif() + +if(TAHGIBU_BUILD_TESTS) + enable_testing() + if(UNIX) # las pruebas usan POSIX (fork, mkdtemp, rlimit): aún no son portables a Windows + set(TAHGIBU_PLUGIN_TEST_DIR ${CMAKE_BINARY_DIR}/tests/plugins) + set(TAHGIBU_TEST_PLUGINS plug_good plug_readonly plug_needs_edit plug_init_fails plug_no_entry plug_old_api plug_no_version plug_no_name plug_events) + foreach(p ${TAHGIBU_TEST_PLUGINS}) + add_library(${p} MODULE tests/plugins/${p}.c) + target_include_directories(${p} PRIVATE include) + set_target_properties(${p} PROPERTIES PREFIX "" LIBRARY_OUTPUT_DIRECTORY ${TAHGIBU_PLUGIN_TEST_DIR}) + tahgibu_apply_warnings(${p}) + endforeach() + foreach(t test_canvas_draw test_document_history test_animation test_recovery_points test_io_formats test_io_files test_recovery test_plugins) + add_executable(${t} tests/${t}.c) + target_include_directories(${t} PRIVATE tests) + target_link_libraries(${t} PRIVATE tahgibu_core) + tahgibu_apply_warnings(${t}) + add_test(NAME ${t} COMMAND ${t}) + endforeach() + target_compile_definitions(test_plugins PRIVATE TGB_TEST_PLUGIN_DIR="${TAHGIBU_PLUGIN_TEST_DIR}") + add_dependencies(test_plugins ${TAHGIBU_TEST_PLUGINS}) + if(TAHGIBU_UI_SMOKE) # prueba de la interfaz con un raylib FALSO (desarrollo) + add_executable(ui_smoke tests/ui_smoke/ui_smoke.c tests/ui_smoke/mock_raylib.c + src/ui/ui_main.c src/ui/ui_canvas.c src/ui/ui_panel.c src/ui/ui_timeline.c src/ui/ui_widgets.c) + target_include_directories(ui_smoke PRIVATE tests tools/dev/raylib_stub src) + target_link_libraries(ui_smoke PRIVATE tahgibu_core) + add_test(NAME ui_smoke COMMAND ui_smoke) + endif() + endif() +endif() diff --git a/cmake/CompilerOptions.cmake b/cmake/CompilerOptions.cmake new file mode 100644 index 0000000..90baf0c --- /dev/null +++ b/cmake/CompilerOptions.cmake @@ -0,0 +1,16 @@ +# Opciones del compilador comunes a todos los destinos. +function(tahgibu_apply_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /utf-8) + target_compile_definitions(${target} PRIVATE _CRT_SECURE_NO_WARNINGS) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra -Wshadow -Wpedantic) + if(TAHGIBU_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE -Werror) + endif() + if(TAHGIBU_SANITIZE) + target_compile_options(${target} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all) + target_link_options(${target} PRIVATE -fsanitize=address,undefined) + endif() + endif() +endfunction() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000..1fa34cb --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,28 @@ +# Dependencias. Licencias y justificación: docs/THIRD_PARTY.md +# - zlib (obligatoria): DEFLATE y CRC32 para leer/escribir PNG. Licencia zlib. +# - raylib (solo la aplicación gráfica): ventana, dibujo y entrada. Licencia zlib/libpng. +# - Lua 5.4 (opcional): extensiones Lua. Licencia MIT. +find_package(ZLIB REQUIRED) + +if(TAHGIBU_BUILD_APP) + find_package(raylib 5.0 QUIET) + if(NOT raylib_FOUND) + if(TAHGIBU_FETCH_DEPS) + include(FetchContent) + set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + FetchContent_Declare(raylib GIT_REPOSITORY https://github.com/raysan5/raylib.git GIT_TAG 5.5 GIT_SHALLOW TRUE) + FetchContent_MakeAvailable(raylib) + else() + message(FATAL_ERROR + "No se encontró raylib 5.x. Opciones:\n" + " - Linux (Arch/CachyOS): sudo pacman -S raylib\n" + " - Linux (Debian/Ubuntu): sudo apt install libraylib-dev (si es la versión 5)\n" + " - o configurar con -DTAHGIBU_FETCH_DEPS=ON para que CMake la descargue (necesita Internet)\n" + " - o construir solo el núcleo y las pruebas con -DTAHGIBU_BUILD_APP=OFF") + endif() + endif() +endif() + +if(TAHGIBU_WITH_LUA) + find_package(Lua 5.4 REQUIRED) +endif() diff --git a/cmake/PlatformConfig.cmake b/cmake/PlatformConfig.cmake new file mode 100644 index 0000000..ccdf6fc --- /dev/null +++ b/cmake/PlatformConfig.cmake @@ -0,0 +1,13 @@ +# Diferencias entre sistemas operativos. +if(WIN32) + set(TAHGIBU_PLATFORM_SOURCES src/platform/windows/platform_windows.c) + set(TAHGIBU_PLATFORM_LIBS "") +elseif(UNIX) + set(TAHGIBU_PLATFORM_SOURCES src/platform/linux/platform_linux.c) + set(TAHGIBU_PLATFORM_LIBS ${CMAKE_DL_LIBS} m) +else() + message(FATAL_ERROR "Sistema operativo no soportado: Tahgibu 2.0.0 apunta a Linux y Windows de 64 bits.") +endif() +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(WARNING "Tahgibu se ha probado solo en 64 bits.") +endif() diff --git a/extensions/lua/ejemplo_diagonal.lua b/extensions/lua/ejemplo_diagonal.lua new file mode 100644 index 0000000..1d683c9 --- /dev/null +++ b/extensions/lua/ejemplo_diagonal.lua @@ -0,0 +1,17 @@ +-- Extensión LUA de ejemplo: dibuja una diagonal azul en el fotograma 1, capa 1. +-- NOTA: el soporte de Lua se compila con -DTAHGIBU_WITH_LUA=ON y este script NO se ha podido +-- probar todavía (ver docs/STATUS.md). Instalar: panel Extensiones > "Instalar extension" > ruta del .lua. +-- +-- Entorno: solo base (sin dofile/load/require), table, string, math y utf8. Las funciones tgb.* +-- que necesitan permisos solo existen si el usuario los concede al habilitar la extensión. + +info = { name = "Diagonal de ejemplo", version = "1.0.0", caps = { "read", "edit" } } + +tgb.register_command("diagonal-azul", function() + local w, h = tgb.doc_width(), tgb.doc_height() + tgb.edit_begin(0, 0) -- fotograma 0, capa 0 + for i = 0, math.min(w, h) - 1 do + tgb.fill_rect(i, i, 3, 3, 0, 0, 255, 255) -- x, y, ancho, alto, r, g, b, a + end + tgb.edit_end() -- una sola operación deshacible +end) diff --git a/extensions/native/ejemplo_cruz.c b/extensions/native/ejemplo_cruz.c new file mode 100644 index 0000000..2e98600 --- /dev/null +++ b/extensions/native/ejemplo_cruz.c @@ -0,0 +1,34 @@ +/* Extensión NATIVA de ejemplo: dibuja una cruz roja en el centro del fotograma 1, capa 1. + * + * Compilar (Linux): gcc -shared -fPIC -Iinclude extensions/native/ejemplo_cruz.c -o ejemplo_cruz.so + * (o activar -DTAHGIBU_BUILD_EXAMPLES=ON en CMake). Luego en Tahgibu: panel Extensiones > + * "Instalar extension" > ruta del .so > "Habilitar" y conceder "Leer" y "Modificar el documento". + * Recuerda: una extensión nativa corre DENTRO de Tahgibu con tus permisos. */ +#include "tahgibu/plugin.h" + +TGB_PLUGIN_DECLARE_API_VERSION; + +static const tgb_host_api *host; + +static tgb_status dibujar_cruz(tgb_plugin_handle *self, void *user) { + (void)user; + tgb_status s = host->edit_begin(self, 0, 0); /* fotograma 0, capa 0 */ + if (s < 0) return s; + const tgb_color rojo = {255, 0, 0, 255}; + int cx = host->doc_width(self) / 2, cy = host->doc_height(self) / 2; + for (int i = -20; i <= 20; i++) { + host->fill_rect(self, (tgb_rect){cx + i - 1, cy - 1, 3, 3}, rojo); + host->fill_rect(self, (tgb_rect){cx - 1, cy + i - 1, 3, 3}, rojo); + } + return host->edit_end(self); /* una sola operación deshacible */ +} + +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *h, tgb_plugin_handle *self, tgb_plugin_info *info) { + host = h; + tgb_status s = h->register_command(self, "dibujar-cruz", dibujar_cruz, NULL); + if (s < 0) return s; + info->name = "Cruz de ejemplo"; + info->version = "1.0.0"; + info->required_caps = TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT; + return TGB_OK; +} diff --git a/include/tahgibu/animation.h b/include/tahgibu/animation.h new file mode 100644 index 0000000..098ba0d --- /dev/null +++ b/include/tahgibu/animation.h @@ -0,0 +1,59 @@ +/* Composición, Onion Skin y reproducción. */ +#ifndef TAHGIBU_ANIMATION_H +#define TAHGIBU_ANIMATION_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct tgb_onion_settings { + bool enabled; + int frames_before; /* 0..8 */ + int frames_after; /* 0..8 */ + float opacity_before; /* 0..1 opacidad del fotograma más cercano */ + float opacity_after; + float falloff; /* 0..1: cuánto se atenúa cada fotograma más lejano */ + bool tint; /* teñir anteriores/posteriores */ + tgb_color tint_before; /* por defecto rojizo */ + tgb_color tint_after; /* por defecto azulado */ +} tgb_onion_settings; + +void tgb_onion_defaults(tgb_onion_settings *s); +tgb_status tgb_onion_validate(tgb_onion_settings *s); /* recorta a rangos válidos */ + +/* Aplana las capas visibles (con su opacidad) del fotograma en 'out' + * (mismas dimensiones que el documento; se sobrescribe). */ +tgb_status tgb_render_flatten(const tgb_document *doc, size_t frame_index, tgb_canvas *out); + +/* Vista de edición: fondo + Onion Skin + fotograma actual. + * background.a == 0 deja el fondo transparente. */ +tgb_status tgb_render_view(const tgb_document *doc, size_t frame_index, + const tgb_onion_settings *onion, tgb_color background, + tgb_canvas *out); + +typedef enum tgb_play_mode { TGB_PLAY_ONCE = 0, TGB_PLAY_LOOP, TGB_PLAY_PINGPONG } tgb_play_mode; + +/* Reproductor: lógica pura de tiempo, respeta la duración de cada fotograma. + * El documento debe sobrevivir al reproductor. */ +typedef struct tgb_player tgb_player; +tgb_player *tgb_player_create(const tgb_document *doc); +void tgb_player_destroy(tgb_player *p); +void tgb_player_play(tgb_player *p); +void tgb_player_pause(tgb_player *p); +void tgb_player_stop(tgb_player *p); /* pausa y vuelve al inicio del rango */ +bool tgb_player_is_playing(const tgb_player *p); +void tgb_player_set_mode(tgb_player *p, tgb_play_mode mode); +void tgb_player_set_speed(tgb_player *p, float speed); /* 0.1 .. 8 */ +tgb_status tgb_player_set_range(tgb_player *p, size_t first, size_t last); +void tgb_player_clear_range(tgb_player *p); +tgb_status tgb_player_seek(tgb_player *p, size_t frame_index); +size_t tgb_player_frame(const tgb_player *p); +/* Avanza dt_ms milisegundos; devuelve el fotograma actual. */ +size_t tgb_player_update(tgb_player *p, double dt_ms); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/canvas.h b/include/tahgibu/canvas.h new file mode 100644 index 0000000..744ef7f --- /dev/null +++ b/include/tahgibu/canvas.h @@ -0,0 +1,62 @@ +/* Lienzo de píxeles RGBA de 8 bits (NO premultiplicado), sin dependencias + * de raylib ni del sistema operativo. + * Propiedad: tgb_canvas_create/clone devuelven un objeto con 1 referencia; + * quien lo recibe debe llamar a tgb_canvas_release una vez. */ +#ifndef TAHGIBU_CANVAS_H +#define TAHGIBU_CANVAS_H + +#include +#include +#include +#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 diff --git a/include/tahgibu/context.h b/include/tahgibu/context.h new file mode 100644 index 0000000..8efecc1 --- /dev/null +++ b/include/tahgibu/context.h @@ -0,0 +1,35 @@ +/* Contexto de la aplicación: dueño de la configuración, rutas de usuario, + * bus de eventos y portapapeles de fotogramas. + * Orden de destrucción: destruye primero todos los tgb_document y + * gestores (recuperación, extensiones) y al final el contexto. */ +#ifndef TAHGIBU_CONTEXT_H +#define TAHGIBU_CONTEXT_H + +#include "tahgibu/error.h" +#include "tahgibu/events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct tgb_context_config { + /* NULL = ubicación estándar del sistema (ver docs/ARCHITECTURE.md). */ + const char *config_dir; /* configuración y registro de extensiones */ + const char *data_dir; /* datos del usuario (extensiones instaladas, paletas) */ + const char *cache_dir; /* caché y temporales de historial */ + const char *state_dir; /* registros y archivos de recuperación */ +} tgb_context_config; + +/* config puede ser NULL. Crea las carpetas si no existen. */ +tgb_status tgb_context_create(const tgb_context_config *config, tgb_context **out); +void tgb_context_destroy(tgb_context *ctx); /* acepta NULL */ + +const char *tgb_context_config_dir(const tgb_context *ctx); +const char *tgb_context_data_dir(const tgb_context *ctx); +const char *tgb_context_cache_dir(const tgb_context *ctx); +const char *tgb_context_state_dir(const tgb_context *ctx); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/document.h b/include/tahgibu/document.h new file mode 100644 index 0000000..32e0a00 --- /dev/null +++ b/include/tahgibu/document.h @@ -0,0 +1,60 @@ +/* Documento de animación: dimensiones, fps, capas y fotogramas. + * Modelo: las CAPAS son del documento; cada FOTOGRAMA tiene una "celda" + * (lienzo) por capa. Las celdas vacías no ocupan memoria. Los índices + * empiezan en 0; la capa 0 es la de más abajo. + * Propiedad: tgb_document_create devuelve un objeto que debes destruir + * con tgb_document_destroy ANTES de destruir el contexto. */ +#ifndef TAHGIBU_DOCUMENT_H +#define TAHGIBU_DOCUMENT_H + +#include +#include +#include +#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 diff --git a/include/tahgibu/drawing.h b/include/tahgibu/drawing.h new file mode 100644 index 0000000..a5f00bd --- /dev/null +++ b/include/tahgibu/drawing.h @@ -0,0 +1,68 @@ +/* Herramientas de dibujo. + * Capa 1 (primitivas): funciones sobre un tgb_canvas, sin historial. + * Capa 2 (sesión de edición): tgb_edit_* conecta una celda del documento + * con el historial: todo lo dibujado entre begin y end es UNA operación + * que se puede deshacer. Solo puede haber una sesión abierta por documento. */ +#ifndef TAHGIBU_DRAWING_H +#define TAHGIBU_DRAWING_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum tgb_brush_mode { TGB_BRUSH_PAINT = 0, TGB_BRUSH_ERASE = 1 } tgb_brush_mode; + +typedef struct tgb_brush { + float size; /* diámetro en píxeles (1..512) */ + float hardness; /* 0 (muy suave) .. 1 (borde duro) */ + bool antialias; /* false = lápiz de píxeles exactos */ + tgb_color color; + tgb_brush_mode mode; +} tgb_brush; + +tgb_brush tgb_brush_pencil(tgb_color color, int size); /* sin suavizado */ +tgb_brush tgb_brush_soft(tgb_color color, float size, float hardness); +tgb_brush tgb_brush_eraser(float size, float hardness); + +/* ---- Primitivas (marcan la zona modificada del lienzo) ---- */ +void tgb_draw_dab(tgb_canvas *c, const tgb_brush *b, float x, float y); +void tgb_draw_line(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1); +void tgb_draw_rect(tgb_canvas *c, const tgb_brush *b, tgb_rect r, bool filled); +void tgb_draw_ellipse(tgb_canvas *c, const tgb_brush *b, tgb_rect bounds, bool filled); +/* Curva de Bézier cúbica con puntos de control p1 y p2. */ +void tgb_draw_bezier(tgb_canvas *c, const tgb_brush *b, float x0, float y0, float x1, float y1, + float x2, float y2, float x3, float y3); +/* Relleno por inundación; tolerance 0..255 (por canal). Devuelve píxeles cambiados. */ +size_t tgb_flood_fill(tgb_canvas *c, int x, int y, tgb_color fill, int tolerance); +tgb_color tgb_eyedropper(const tgb_canvas *c, int x, int y); + +/* Transformaciones de una región (selección rectangular). */ +tgb_status tgb_region_move(tgb_canvas *c, tgb_rect src, int dx, int dy); /* mueve y deja hueco */ +tgb_status tgb_region_flip(tgb_canvas *c, tgb_rect r, bool horizontal); +tgb_status tgb_region_scale(tgb_canvas *c, tgb_rect r, int new_w, int new_h); /* vecino más cercano, ancla arriba-izquierda */ +tgb_status tgb_region_clear(tgb_canvas *c, tgb_rect r); + +/* ---- Sesión de edición ligada al documento ---- */ + +/* Abre la sesión sobre la celda (frame_id, layer_id). Crea la celda si estaba + * vacía. Errores: TGB_ERR_LOCKED (capa bloqueada), TGB_ERR_STATE (ya hay + * sesión), TGB_ERR_NOT_FOUND. */ +tgb_status tgb_edit_begin(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, + const char *label); +/* Lienzo editable de la sesión (NULL si no hay sesión). NO lo liberes. */ +tgb_canvas *tgb_edit_canvas(tgb_document *doc); +/* Vuelve al estado del principio de la sesión (útil para previsualizar formas). */ +tgb_status tgb_edit_reset(tgb_document *doc); +/* Cierra y registra UNA operación en el historial si hubo cambios reales. + * Devuelve TGB_WARN_HISTORY_LOST si el cambio se aplicó pero no cabe en el + * historial. Si falla por memoria, deja la celda como estaba. */ +tgb_status tgb_edit_end(tgb_document *doc); +tgb_status tgb_edit_cancel(tgb_document *doc); /* descarta los cambios */ +bool tgb_edit_active(const tgb_document *doc); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/error.h b/include/tahgibu/error.h new file mode 100644 index 0000000..77ebae0 --- /dev/null +++ b/include/tahgibu/error.h @@ -0,0 +1,44 @@ +/* Códigos de error comunes de toda la API pública. + * Regla: valor < 0 = fallo, 0 = éxito, > 0 = éxito con aviso. */ +#ifndef TAHGIBU_ERROR_H +#define TAHGIBU_ERROR_H + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum tgb_status { + TGB_WARN_HISTORY_LOST = 1, /* la operación se hizo pero no se pudo guardar para deshacer */ + TGB_OK = 0, + TGB_ERR_INVALID_ARG = -1, /* argumento NULL o inválido */ + TGB_ERR_NOMEM = -2, /* sin memoria */ + TGB_ERR_RANGE = -3, /* índice o valor fuera de rango */ + TGB_ERR_NOT_FOUND = -4, /* id/archivo/elemento inexistente */ + TGB_ERR_EXISTS = -5, /* ya existe (p. ej. nombre repetido) */ + TGB_ERR_IO = -6, /* error de lectura/escritura del sistema */ + TGB_ERR_FORMAT = -7, /* el archivo no tiene el formato esperado */ + TGB_ERR_CORRUPT = -8, /* datos dañados (Base64, PNG, CRC...) */ + TGB_ERR_UNSUPPORTED = -9, /* válido pero no soportado todavía */ + TGB_ERR_LIMIT = -10, /* supera un límite de seguridad */ + TGB_ERR_STATE = -11, /* operación no permitida en el estado actual */ + TGB_ERR_LOCKED = -12, /* capa bloqueada */ + TGB_ERR_NOTHING = -13, /* nada que deshacer/rehacer */ + TGB_ERR_PLUGIN = -14, /* fallo de una extensión */ + TGB_ERR_INCOMPATIBLE = -15 /* versión de formato más nueva o desconocida */ +} tgb_status; + +#define TGB_FAILED(s) ((s) < 0) +#define TGB_SUCCEEDED(s) ((s) >= 0) + +/* Nombre corto del código (para registros), p. ej. "TGB_ERR_IO". */ +const char *tgb_status_name(tgb_status status); + +/* Mensaje detallado en español del último fallo en ESTE hilo. + * Nunca devuelve NULL. El puntero es válido hasta la siguiente llamada + * a la API desde el mismo hilo; copia el texto si lo necesitas más tiempo. */ +const char *tgb_last_error(void); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/events.h b/include/tahgibu/events.h new file mode 100644 index 0000000..9ef7257 --- /dev/null +++ b/include/tahgibu/events.h @@ -0,0 +1,50 @@ +/* Bus de eventos sencillo. Las llamadas a los suscriptores son SÍNCRONAS, + * en el mismo hilo que provocó el cambio. Un suscriptor no debe + * suscribirse/desuscribirse ni modificar el documento dentro de su + * callback. */ +#ifndef TAHGIBU_EVENTS_H +#define TAHGIBU_EVENTS_H + +#include +#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 diff --git a/include/tahgibu/frame.h b/include/tahgibu/frame.h new file mode 100644 index 0000000..0c11a7e --- /dev/null +++ b/include/tahgibu/frame.h @@ -0,0 +1,36 @@ +/* Fotogramas. Todas las operaciones que modifican se pueden deshacer y + * fallan con TGB_ERR_STATE si hay una sesión de edición abierta. */ +#ifndef TAHGIBU_FRAME_H +#define TAHGIBU_FRAME_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +size_t tgb_frame_count(const tgb_document *doc); +uint32_t tgb_frame_id(const tgb_document *doc, size_t index); /* 0 si fuera de rango */ +tgb_status tgb_frame_index_of(const tgb_document *doc, uint32_t id, size_t *out_index); +uint32_t tgb_frame_duration_ms(const tgb_document *doc, size_t index); + +/* index == count añade al final. Devuelve el id del nuevo fotograma. */ +tgb_status tgb_frame_add(tgb_document *doc, size_t index, uint32_t *out_id); +/* Duplica y coloca la copia justo después (comparte memoria hasta editarla). */ +tgb_status tgb_frame_duplicate(tgb_document *doc, size_t index, uint32_t *out_id); +tgb_status tgb_frame_delete(tgb_document *doc, size_t index); /* nunca el último */ +tgb_status tgb_frame_move(tgb_document *doc, size_t from, size_t to); +tgb_status tgb_frame_set_duration(tgb_document *doc, size_t index, uint32_t ms); + +/* Portapapeles de fotogramas (vive en el contexto). Pegar exige mismas + * dimensiones; las capas se emparejan por posición (las sobrantes se ignoran). */ +tgb_status tgb_frame_copy(tgb_document *doc, size_t index); +tgb_status tgb_frame_paste(tgb_document *doc, size_t index, uint32_t *out_id); + +/* Celda de un fotograma/capa. NULL = celda vacía (transparente). Solo lectura. */ +const tgb_canvas *tgb_frame_cel(const tgb_document *doc, size_t frame_index, size_t layer_index); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/history.h b/include/tahgibu/history.h new file mode 100644 index 0000000..4e47fa7 --- /dev/null +++ b/include/tahgibu/history.h @@ -0,0 +1,55 @@ +/* Historial unificado (deshacer/rehacer) de un documento. + * - Edición de píxeles, fotogramas, capas y propiedades usan el mismo historial. + * - Operaciones agrupadas: begin_group ... end_group = UNA entrada. + * - Límites adaptativos: memoria, nº de operaciones y almacenamiento temporal. + * Al superarlos, las operaciones más antiguas de píxeles se pasan a disco + * (si hay carpeta temporal y espacio) o, si no, se descartan (la más + * antigua primero). Nunca se descarta lo ya aplicado al documento. */ +#ifndef TAHGIBU_HISTORY_H +#define TAHGIBU_HISTORY_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +tgb_status tgb_history_undo(tgb_document *doc); /* TGB_ERR_NOTHING si no hay nada */ +tgb_status tgb_history_redo(tgb_document *doc); +bool tgb_history_can_undo(const tgb_document *doc); +bool tgb_history_can_redo(const tgb_document *doc); +const char *tgb_history_undo_label(const tgb_document *doc); /* NULL si no hay */ +const char *tgb_history_redo_label(const tgb_document *doc); + +/* Grupos anidables (hasta 8 niveles). Mientras hay un grupo abierto no se + * puede deshacer/rehacer. abort_group deshace lo hecho dentro del grupo y + * lo descarta (lo usan las extensiones al fallar). */ +tgb_status tgb_history_begin_group(tgb_document *doc, const char *label); +tgb_status tgb_history_end_group(tgb_document *doc); +tgb_status tgb_history_abort_group(tgb_document *doc); + +typedef struct tgb_history_limits { + size_t max_memory_bytes; /* memoria para datos del historial */ + uint32_t max_operations; /* nº máximo de operaciones deshacibles */ + size_t max_temp_bytes; /* disco temporal permitido (0 = no usar disco) */ + char temp_dir[512]; /* carpeta temporal ("" = no usar disco) */ +} tgb_history_limits; + +tgb_status tgb_history_set_limits(tgb_document *doc, const tgb_history_limits *limits); +void tgb_history_get_limits(const tgb_document *doc, tgb_history_limits *out); +/* Recalcula los límites según la RAM libre, el tamaño del documento y el + * espacio en disco. Llámalo de vez en cuando (p. ej. cada minuto). */ +tgb_status tgb_history_adapt(tgb_document *doc); + +typedef struct tgb_history_stats { + size_t undo_count, redo_count; + size_t memory_bytes, temp_bytes; + uint64_t dropped_ops, spilled_ops; +} tgb_history_stats; +void tgb_history_get_stats(const tgb_document *doc, tgb_history_stats *out); +void tgb_history_clear(tgb_document *doc); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/io.h b/include/tahgibu/io.h new file mode 100644 index 0000000..78be3d7 --- /dev/null +++ b/include/tahgibu/io.h @@ -0,0 +1,56 @@ +/* Archivos: proyectos .tgb, importación de imágenes y exportación. + * Toda lectura valida el contenido y devuelve errores con mensaje claro + * (tgb_last_error). Todo guardado es atómico: se escribe un temporal en la + * misma carpeta y se reemplaza el destino solo si todo salió bien. */ +#ifndef TAHGIBU_IO_H +#define TAHGIBU_IO_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define TGB_MAX_FILE_BYTES (512u * 1024u * 1024u) /* tamaño máximo de un .tgb */ +#define TGB_MAX_PIXEL_MEMORY ((size_t)2048u * 1024u * 1024u) /* memoria máxima de píxeles al cargar */ + +tgb_status tgb_document_load(tgb_context *ctx, const char *path, tgb_document **out); +tgb_status tgb_document_load_memory(tgb_context *ctx, const uint8_t *data, size_t len, + tgb_document **out); + +typedef struct tgb_save_options { + bool include_flat_frames; /* true: añade "frames" aplanados para lectores del formato 2 */ + bool legacy_v2; /* true: escribe formato 2 (una imagen por fotograma, SIN capas + ni puntos de recuperación: se pierde información) */ +} tgb_save_options; +void tgb_save_options_defaults(tgb_save_options *opts); /* include_flat_frames=true */ + +/* path NULL = usar la ruta asociada al documento. Tras guardar bien, asocia + * la ruta y marca el documento como guardado (salvo en modo legacy_v2). */ +tgb_status tgb_document_save(tgb_document *doc, const char *path, const tgb_save_options *opts); +/* Serializa a memoria; libera *out_data con tgb_free. No cambia el estado del documento. */ +tgb_status tgb_document_save_memory(const tgb_document *doc, const tgb_save_options *opts, + uint8_t **out_data, size_t *out_len); +void tgb_free(void *p); + +/* PNG (8 bits RGBA al escribir; al leer: 8/16 bits, gris/RGB/paleta/+alfa, + * sin entrelazado). */ +tgb_status tgb_png_decode_memory(const uint8_t *data, size_t len, tgb_canvas **out); +tgb_status tgb_png_encode_memory(const tgb_canvas *canvas, uint8_t **out_data, size_t *out_len); + +/* Importa un PNG como NUEVO fotograma en frame_index, dibujado en la capa + * layer_index (centrado; se recorta si es mayor que el documento). Una sola + * operación deshacible. */ +tgb_status tgb_import_png(tgb_document *doc, const char *path, size_t frame_index, + size_t layer_index, uint32_t *out_frame_id); +/* Exporta un fotograma aplanado a PNG. */ +tgb_status tgb_export_png(const tgb_document *doc, size_t frame_index, const char *path); +/* Exporta todos los fotogramas: /_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 diff --git a/include/tahgibu/layer.h b/include/tahgibu/layer.h new file mode 100644 index 0000000..cd8326c --- /dev/null +++ b/include/tahgibu/layer.h @@ -0,0 +1,31 @@ +/* Capas. Opacidad 0..255. Una capa bloqueada rechaza ediciones de píxeles. */ +#ifndef TAHGIBU_LAYER_H +#define TAHGIBU_LAYER_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +size_t tgb_layer_count(const tgb_document *doc); +uint32_t tgb_layer_id(const tgb_document *doc, size_t index); /* 0 si fuera de rango */ +tgb_status tgb_layer_index_of(const tgb_document *doc, uint32_t id, size_t *out_index); +/* El puntero devuelto es válido hasta el siguiente cambio de capas. */ +const char *tgb_layer_name(const tgb_document *doc, size_t index); +bool tgb_layer_visible(const tgb_document *doc, size_t index); +bool tgb_layer_locked(const tgb_document *doc, size_t index); +uint8_t tgb_layer_opacity(const tgb_document *doc, size_t index); + +tgb_status tgb_layer_add(tgb_document *doc, size_t index, const char *name, uint32_t *out_id); +tgb_status tgb_layer_delete(tgb_document *doc, size_t index); /* nunca la última */ +tgb_status tgb_layer_move(tgb_document *doc, size_t from, size_t to); +tgb_status tgb_layer_rename(tgb_document *doc, size_t index, const char *name); +tgb_status tgb_layer_set_visible(tgb_document *doc, size_t index, bool visible); +tgb_status tgb_layer_set_locked(tgb_document *doc, size_t index, bool locked); +tgb_status tgb_layer_set_opacity(tgb_document *doc, size_t index, uint8_t opacity); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/plugin.h b/include/tahgibu/plugin.h new file mode 100644 index 0000000..46c3a70 --- /dev/null +++ b/include/tahgibu/plugin.h @@ -0,0 +1,121 @@ +/* Extensiones nativas en C (y base de las de Lua). + * + * ADVERTENCIA DE SEGURIDAD: una extensión nativa es código que se ejecuta + * DENTRO del proceso de Tahgibu con sus mismos permisos. Los permisos + * (capabilities) de abajo controlan qué funciones del host se le ofrecen, + * pero NO pueden impedir que el código nativo lea/escriba memoria o + * archivos por su cuenta. Instala solo extensiones de origen confiable. + * + * Una extensión nativa es una biblioteca compartida (.so/.dll) que exporta: + * const uint32_t tgb_plugin_api_version; (usa TGB_PLUGIN_DECLARE_API_VERSION) + * tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, + * tgb_plugin_info *info); + * void tgb_plugin_shutdown(tgb_plugin_handle *self); (opcional) + * + * Las funciones de lectura/edición del host solo funcionan DENTRO de un + * comando (mientras Tahgibu ejecuta tgb_plugin_command_fn), nunca en init. */ +#ifndef TAHGIBU_PLUGIN_H +#define TAHGIBU_PLUGIN_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define TGB_PLUGIN_API_VERSION 1 + +#if defined(_WIN32) +#define TGB_PLUGIN_EXPORT __declspec(dllexport) +#else +#define TGB_PLUGIN_EXPORT __attribute__((visibility("default"))) +#endif + +/* Declara la versión de API con la que se compiló la extensión. Sin ella (o con otra) no se carga. */ +#define TGB_PLUGIN_DECLARE_API_VERSION TGB_PLUGIN_EXPORT const uint32_t tgb_plugin_api_version = TGB_PLUGIN_API_VERSION + +typedef enum tgb_capability { + TGB_CAP_READ_DOCUMENT = 1u << 0, /* leer dimensiones, fotogramas, píxeles */ + TGB_CAP_EDIT_DOCUMENT = 1u << 1, /* modificar el documento (siempre con historial) */ + TGB_CAP_EVENTS = 1u << 2 /* recibir eventos */ +} tgb_capability; + +typedef struct tgb_plugin_handle tgb_plugin_handle; /* identidad de la extensión (opaco) */ + +typedef struct tgb_plugin_info { + const char *name; /* nombre legible */ + const char *version; /* p. ej. "1.0.0" */ + uint32_t required_caps; /* combinación de tgb_capability */ +} tgb_plugin_info; + +/* Un comando recibe el documento activo a través del host (no directamente). */ +typedef tgb_status (*tgb_plugin_command_fn)(tgb_plugin_handle *self, void *user); + +typedef struct tgb_host_api { + uint32_t api_version; /* = TGB_PLUGIN_API_VERSION */ + uint32_t struct_size; /* sizeof(tgb_host_api) del host */ + uint32_t (*granted_caps)(tgb_plugin_handle *self); + void (*log)(tgb_plugin_handle *self, const char *message); + tgb_status (*register_command)(tgb_plugin_handle *self, const char *name, + tgb_plugin_command_fn fn, void *user); + /* TGB_CAP_EVENTS */ + tgb_status (*subscribe)(tgb_plugin_handle *self, tgb_event_fn fn, void *user); + /* TGB_CAP_READ_DOCUMENT */ + int (*doc_width)(tgb_plugin_handle *self); + int (*doc_height)(tgb_plugin_handle *self); + size_t (*doc_frame_count)(tgb_plugin_handle *self); + size_t (*doc_layer_count)(tgb_plugin_handle *self); + tgb_color (*get_pixel)(tgb_plugin_handle *self, size_t frame, size_t layer, int x, int y); + /* TGB_CAP_EDIT_DOCUMENT: todo lo editado entre begin y end es una operación deshacible */ + tgb_status (*edit_begin)(tgb_plugin_handle *self, size_t frame, size_t layer); + tgb_status (*put_pixel)(tgb_plugin_handle *self, int x, int y, tgb_color color); + tgb_status (*fill_rect)(tgb_plugin_handle *self, tgb_rect r, tgb_color color); + tgb_status (*edit_end)(tgb_plugin_handle *self); + tgb_status (*add_frame)(tgb_plugin_handle *self, size_t index); +} tgb_host_api; + +/* ---------------- Gestor de extensiones (lado de la aplicación) ---------------- */ +typedef struct tgb_plugin_manager tgb_plugin_manager; + +typedef struct tgb_plugin_entry_info { + char id[64]; + char kind[8]; /* "native" o "lua" */ + char file[256]; + char name[64]; + char version[32]; + bool enabled; + bool loaded; + uint32_t granted_caps; + uint32_t required_caps; + char last_error[160]; +} tgb_plugin_entry_info; + +/* El registro vive en /extensions.json; los archivos instalados + * en /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 diff --git a/include/tahgibu/recovery.h b/include/tahgibu/recovery.h new file mode 100644 index 0000000..d348deb --- /dev/null +++ b/include/tahgibu/recovery.h @@ -0,0 +1,78 @@ +/* Recuperación. Hay CUATRO cosas distintas (ver docs/RECOVERY.md): + * 1. El proyecto guardado por el usuario (.tgb). + * 2. El autoguardado: copia temporal en la carpeta de estado. + * 3. La sesión: marcador que permite detectar un cierre inesperado. + * 4. Los puntos de recuperación NOMBRADOS: viven dentro del proyecto. */ +#ifndef TAHGIBU_RECOVERY_H +#define TAHGIBU_RECOVERY_H + +#include "tahgibu/document.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---------- Puntos de recuperación nombrados ---------- */ +#define TGB_MAX_RECOVERY_POINTS 64 +#define TGB_RECOVERY_POINT_NAME_MAX 128 + +typedef struct tgb_recovery_point_info { + uint32_t id; + char name[TGB_RECOVERY_POINT_NAME_MAX]; + int64_t created_unix; + bool compatible; /* false: guardado con un formato que no se puede restaurar */ +} tgb_recovery_point_info; + +size_t tgb_recovery_point_count(const tgb_document *doc); +tgb_status tgb_recovery_point_info_at(const tgb_document *doc, size_t index, + tgb_recovery_point_info *out); +/* Nombre único, no vacío, UTF-8 válido. Comparte memoria con el documento + * (copy-on-write): solo cuesta lo que luego se edite. */ +tgb_status tgb_recovery_point_create(tgb_document *doc, const char *name, uint32_t *out_id); +/* Antes de restaurar crea el punto "Antes de restaurar «nombre»" y vacía el + * historial ordinario (queda el punto automático para volver). */ +tgb_status tgb_recovery_point_restore(tgb_document *doc, uint32_t id); +tgb_status tgb_recovery_point_delete(tgb_document *doc, uint32_t id); +/* Memoria extra retenida por los puntos (celdas que ya no están en el documento). */ +size_t tgb_recovery_points_extra_bytes(const tgb_document *doc); + +/* ---------- Autoguardado y recuperación de sesión ---------- */ +typedef struct tgb_recovery tgb_recovery; + +typedef struct tgb_recovery_candidate { + char autosave_path[512]; + char original_path[512]; /* "" si el documento nunca se guardó */ + char title[128]; + int64_t saved_unix; +} tgb_recovery_candidate; + +/* Abre la gestión de sesión: busca restos de sesiones que terminaron mal y + * registra la sesión actual. Los archivos originales nunca se tocan. */ +tgb_status tgb_recovery_open(tgb_context *ctx, tgb_recovery **out); +/* clean_exit=true: borra marcador y autoguardados de esta sesión. + * clean_exit=false: libera memoria pero deja los archivos (simula un cierre inesperado en pruebas). */ +void tgb_recovery_close(tgb_recovery *rec, bool clean_exit); + +size_t tgb_recovery_candidate_count(const tgb_recovery *rec); +tgb_status tgb_recovery_candidate_at(const tgb_recovery *rec, size_t index, + tgb_recovery_candidate *out); +/* Carga el autoguardado en un documento NUEVO (el original no se modifica). */ +tgb_status tgb_recovery_candidate_load(tgb_recovery *rec, size_t index, tgb_document **out); +/* Borra el autoguardado (solo si el usuario lo decide). */ +tgb_status tgb_recovery_candidate_discard(tgb_recovery *rec, size_t index); + +tgb_status tgb_recovery_attach(tgb_recovery *rec, tgb_document *doc, const char *title); +tgb_status tgb_recovery_detach(tgb_recovery *rec, tgb_document *doc); /* borra su autoguardado */ +void tgb_recovery_set_interval(tgb_recovery *rec, double seconds); /* por defecto 300 (5 min) */ +/* Llamar a menudo con un reloj monotónico en segundos. Autoguarda los + * documentos con cambios cuando toca. Si falla (p. ej. disco lleno) devuelve + * el error y CONSERVA el autoguardado anterior. */ +tgb_status tgb_recovery_tick(tgb_recovery *rec, double now_seconds); +tgb_status tgb_recovery_save_now(tgb_recovery *rec, tgb_document *doc); +/* Avisa de que el usuario guardó el proyecto: el autoguardado ya no hace falta. */ +void tgb_recovery_notify_saved(tgb_recovery *rec, tgb_document *doc); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/tahgibu/tahgibu.h b/include/tahgibu/tahgibu.h new file mode 100644 index 0000000..9a5526f --- /dev/null +++ b/include/tahgibu/tahgibu.h @@ -0,0 +1,20 @@ +/* Cabecera "paraguas" de la API pública de Tahgibu. */ +#ifndef TAHGIBU_H +#define TAHGIBU_H + +#include "tahgibu/version.h" +#include "tahgibu/error.h" +#include "tahgibu/canvas.h" +#include "tahgibu/events.h" +#include "tahgibu/context.h" +#include "tahgibu/document.h" +#include "tahgibu/frame.h" +#include "tahgibu/layer.h" +#include "tahgibu/drawing.h" +#include "tahgibu/animation.h" +#include "tahgibu/history.h" +#include "tahgibu/recovery.h" +#include "tahgibu/io.h" +#include "tahgibu/plugin.h" + +#endif diff --git a/include/tahgibu/version.h b/include/tahgibu/version.h new file mode 100644 index 0000000..1608350 --- /dev/null +++ b/include/tahgibu/version.h @@ -0,0 +1,23 @@ +/* Versiones de Tahgibu. Son TRES cosas distintas, no las confundas: + * - versión de la aplicación (2.0.0) + * - versión de la API pública en C (TGB_API_VERSION) + * - versión del formato de archivo .tgb (TGB_FORMAT_VERSION_*) */ +#ifndef TAHGIBU_VERSION_H +#define TAHGIBU_VERSION_H + +#define TGB_VERSION_MAJOR 2 +#define TGB_VERSION_MINOR 0 +#define TGB_VERSION_PATCH 0 +#define TGB_VERSION_STRING "2.0.0" +#define TGB_APP_NAME "Tahgibu" + +/* API pública: sube cuando cambian los contratos. NO es estable todavía + * (ver docs/API.md): no hay política de compatibilidad de ABI. */ +#define TGB_API_VERSION 1 + +/* Formato .tgb: 2 = formato histórico (un PNG por fotograma, sin capas), + * 3 = formato de Tahgibu 2.0.0 (capas, duraciones, puntos). */ +#define TGB_FORMAT_VERSION_LEGACY 2 +#define TGB_FORMAT_VERSION_CURRENT 3 + +#endif diff --git a/install.sh b/install.sh deleted file mode 100755 index b576a9f..0000000 --- a/install.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -e -APP_DIR="$HOME/.local/share/tahgibu-animator" -mkdir -p "$APP_DIR" "$HOME/.local/share/applications" - -cp tahgibu_animator.py "$APP_DIR/tahgibu_animator.py" -chmod +x "$APP_DIR/tahgibu_animator.py" -cp tahgibu-animator.desktop "$HOME/.local/share/applications/tahgibu-animator.desktop" - -echo "Tahgibu Animator instalado." -echo "Dependencias necesarias: python3 y python-pillow." -echo "Para pegar imágenes con Ctrl+V en KDE/Wayland: sudo pacman -S wl-clipboard" -echo "Abrilo desde el menú de aplicaciones como: Tahgibu Animator" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 67b7068fd81bb301a8d6b6328d97df2fe8a5b63b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30 lcmezWuYe(wA%`J{A)ld~!Ir_6!H~g-L65 -#include - -namespace tahgibu { - -float Frame::clampFps(float fps) noexcept { - if (!std::isfinite(fps)) { - return kDefaultFps; - } - return std::clamp(fps, kMinFps, kMaxFps); -} - -void Frame::setFpsOverride(float fps) noexcept { - fpsOverride_ = clampFps(fps); -} - -float Frame::effectiveFps(float baseFps) const noexcept { - return fpsOverride_ ? *fpsOverride_ : clampFps(baseFps); -} - -double Frame::durationSeconds(float baseFps) const noexcept { - // effectiveFps() >= kMinFps, así que nunca se divide por cero. - return 1.0 / static_cast(effectiveFps(baseFps)); -} - -} // namespace tahgibu diff --git a/source/core/Frame.hpp b/source/core/Frame.hpp deleted file mode 100644 index e17db9a..0000000 --- a/source/core/Frame.hpp +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include - -#include "core/Constants.hpp" - -namespace tahgibu { - -// Un frame de la animación. Solo guarda cuánto dura: usa los FPS base del -// proyecto, salvo que tenga FPS propios (override). -class Frame { -public: - // Fuerza el valor al rango [kMinFps, kMaxFps]. Valores no finitos (NaN, inf) - // devuelven kDefaultFps. - static float clampFps(float fps) noexcept; - - bool hasFpsOverride() const noexcept { return fpsOverride_.has_value(); } - void setFpsOverride(float fps) noexcept; // aplica clampFps - void clearFpsOverride() noexcept { fpsOverride_.reset(); } - - float effectiveFps(float baseFps) const noexcept; - double durationSeconds(float baseFps) const noexcept; - -private: - std::optional fpsOverride_; -}; - -} // namespace tahgibu diff --git a/source/core/Project.cpp b/source/core/Project.cpp deleted file mode 100644 index cf15ec8..0000000 --- a/source/core/Project.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "core/Project.hpp" - -namespace tahgibu { - -Project::Project() : name_("Sin título") { - frames_.emplace_back(); -} - -void Project::setBaseFps(float fps) noexcept { - baseFps_ = Frame::clampFps(fps); -} - -std::size_t Project::addFrame() { - frames_.emplace_back(); - return frames_.size() - 1; -} - -double Project::frameDurationSeconds(std::size_t index) const { - return frames_.at(index).durationSeconds(baseFps_); -} - -double Project::totalDurationSeconds() const { - double total = 0.0; - for (const Frame& frame : frames_) { - total += frame.durationSeconds(baseFps_); - } - return total; -} - -} // namespace tahgibu diff --git a/source/core/Project.hpp b/source/core/Project.hpp deleted file mode 100644 index 75fce26..0000000 --- a/source/core/Project.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "core/Frame.hpp" - -namespace tahgibu { - -// Modelo de datos del proyecto. No conoce raylib ni la interfaz. -class Project { -public: - Project(); // 1280x720, FPS base kDefaultFps, un frame inicial - - const std::string& name() const noexcept { return name_; } - int width() const noexcept { return width_; } - int height() const noexcept { return height_; } - - float baseFps() const noexcept { return baseFps_; } - void setBaseFps(float fps) noexcept; // aplica Frame::clampFps - - std::size_t frameCount() const noexcept { return frames_.size(); } - const Frame& frame(std::size_t index) const { return frames_.at(index); } - Frame& frame(std::size_t index) { return frames_.at(index); } - std::size_t addFrame(); // devuelve el índice del frame nuevo - - double frameDurationSeconds(std::size_t index) const; - double totalDurationSeconds() const; - -private: - std::string name_; - int width_ = 1280; - int height_ = 720; - float baseFps_ = kDefaultFps; - std::vector frames_; -}; - -} // namespace tahgibu diff --git a/source/editor/EditorState.hpp b/source/editor/EditorState.hpp deleted file mode 100644 index ded11de..0000000 --- a/source/editor/EditorState.hpp +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include - -#include "editor/Tool.hpp" - -namespace tahgibu { - -// Estado del editor que NO forma parte del proyecto guardado. -struct EditorState { - Tool activeTool = Tool::Select; - std::size_t currentFrame = 0; -}; - -} // namespace tahgibu diff --git a/source/editor/Tool.hpp b/source/editor/Tool.hpp deleted file mode 100644 index 20e3779..0000000 --- a/source/editor/Tool.hpp +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include - -namespace tahgibu { - -enum class Tool { - Select, - Move, - Scale, - Rotate, - Transform, - Vertices, - Draw, - Eraser, - Sprite, - ColorPicker, - Zoom, - Pan, -}; - -struct ToolInfo { - Tool id; - const char* label; -}; - -// Registro de herramientas. La barra superior se genera a partir de esta -// tabla: agregar una herramienta nueva = agregar un valor al enum y una línea aquí. -inline constexpr ToolInfo kToolInfos[] = { - {Tool::Select, "Seleccionar"}, - {Tool::Move, "Mover"}, - {Tool::Scale, "Escalar"}, - {Tool::Rotate, "Rotar"}, - {Tool::Transform, "Transformar"}, - {Tool::Vertices, "Vértices"}, - {Tool::Draw, "Dibujar"}, - {Tool::Eraser, "Borrador"}, - {Tool::Sprite, "Sprite"}, - {Tool::ColorPicker, "Color"}, - {Tool::Zoom, "Zoom"}, - {Tool::Pan, "Mano"}, -}; - -inline constexpr std::size_t kToolCount = sizeof(kToolInfos) / sizeof(kToolInfos[0]); - -inline const char* toolLabel(Tool tool) noexcept { - for (const ToolInfo& info : kToolInfos) { - if (info.id == tool) { - return info.label; - } - } - return "?"; -} - -} // namespace tahgibu diff --git a/source/main.cpp b/source/main.cpp deleted file mode 100644 index 3b4bbd9..0000000 --- a/source/main.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include -#include - -#include "ui/Application.hpp" - -int main() { - try { - tahgibu::Application app; - return app.run(); - } catch (const std::exception& error) { - std::fprintf(stderr, "Error fatal: %s\n", error.what()); - return 1; - } -} diff --git a/source/ui/Application.cpp b/source/ui/Application.cpp deleted file mode 100644 index ac0ccff..0000000 --- a/source/ui/Application.cpp +++ /dev/null @@ -1,242 +0,0 @@ -#include "ui/Application.hpp" - -#include - -#include -#include -#include -#include -#include - -#include "core/Constants.hpp" -#include "editor/Tool.hpp" -#include "ui/Theme.hpp" - -namespace tahgibu { -namespace { - -constexpr int kInitialWidth = 1280; -constexpr int kInitialHeight = 800; -constexpr int kMinWidth = 1000; -constexpr int kMinHeight = 640; -constexpr int kUiTargetFps = 60; // FPS de la interfaz; no tiene relación con los FPS de la animación - -constexpr int kFontSize = 16; -constexpr int kSmallFontSize = 14; -constexpr int kButtonPadding = 10; -constexpr float kButtonGap = 4.0f; -constexpr float kPanelHeaderHeight = 24.0f; - -constexpr std::array kMenus = { - "Archivo", "Editar", "Animación", "Sprite", "Audio", "Ver", "Lua"}; - -int px(float value) { - return static_cast(value); -} - -void drawTextVCentered(const char* text, float x, const Rectangle& area, int size, Color color) { - DrawText(text, px(x), px(area.y + (area.height - static_cast(size)) / 2.0f), size, color); -} - -void drawPanelHeader(const Rectangle& panel, const char* title) { - DrawRectangle(px(panel.x), px(panel.y), px(panel.width), px(kPanelHeaderHeight), theme::kPanelHeader); - DrawText(title, px(panel.x + 12.0f), px(panel.y + 4.0f), kFontSize, theme::kText); -} - -std::array computeToolButtons(const Rectangle& bar) { - std::array rects{}; - float x = bar.x + 8.0f; - for (std::size_t i = 0; i < kToolCount; ++i) { - const float w = static_cast(MeasureText(kToolInfos[i].label, kFontSize) + 2 * kButtonPadding); - rects[i] = {x, bar.y + 6.0f, w, bar.height - 12.0f}; - x += w + kButtonGap; - } - return rects; -} - -std::array computeMenuRects(const Rectangle& bar) { - std::array rects{}; - float x = bar.x + 6.0f; - for (std::size_t i = 0; i < kMenus.size(); ++i) { - const float w = static_cast(MeasureText(kMenus[i], kFontSize) + 2 * kButtonPadding); - rects[i] = {x, bar.y + 2.0f, w, bar.height - 4.0f}; - x += w + 2.0f; - } - return rects; -} - -} // namespace - -Application::Application() { - SetConfigFlags(FLAG_WINDOW_RESIZABLE); - const std::string title = std::string(kAppName) + " " + kAppVersion; - InitWindow(kInitialWidth, kInitialHeight, title.c_str()); - if (!IsWindowReady()) { - return; - } - SetWindowMinSize(kMinWidth, kMinHeight); - SetExitKey(KEY_NULL); // Esc no debe cerrar el editor - SetTargetFPS(kUiTargetFps); - layout_ = Layout::compute(GetScreenWidth(), GetScreenHeight()); -} - -Application::~Application() { - if (IsWindowReady()) { - CloseWindow(); - } -} - -int Application::run() { - if (!IsWindowReady()) { - std::fprintf(stderr, "No se pudo crear la ventana (¿hay entorno gráfico y OpenGL disponible?).\n"); - return 1; - } - while (!WindowShouldClose()) { - update(); - draw(); - } - return 0; -} - -void Application::update() { - layout_ = Layout::compute(GetScreenWidth(), GetScreenHeight()); - - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { - const Vector2 mouse = GetMousePosition(); - const auto rects = computeToolButtons(layout_.toolBar); - for (std::size_t i = 0; i < kToolCount; ++i) { - if (CheckCollisionPointRec(mouse, rects[i])) { - state_.activeTool = kToolInfos[i].id; - break; - } - } - } -} - -void Application::draw() const { - BeginDrawing(); - ClearBackground(theme::kWindowBg); - drawCanvas(); - drawProperties(); - drawTimeline(); - drawToolBar(); - drawMenuBar(); - EndDrawing(); -} - -void Application::drawMenuBar() const { - const Rectangle& bar = layout_.menuBar; - DrawRectangleRec(bar, theme::kPanelHeader); - - const Vector2 mouse = GetMousePosition(); - const auto rects = computeMenuRects(bar); - for (std::size_t i = 0; i < kMenus.size(); ++i) { - if (CheckCollisionPointRec(mouse, rects[i])) { - DrawRectangleRec(rects[i], theme::kHover); - } - drawTextVCentered(kMenus[i], rects[i].x + static_cast(kButtonPadding), rects[i], kFontSize, - theme::kText); - } -} - -void Application::drawToolBar() const { - const Rectangle& bar = layout_.toolBar; - DrawRectangleRec(bar, theme::kPanelBg); - DrawRectangle(px(bar.x), px(bar.y + bar.height - 1.0f), px(bar.width), 1, theme::kBorder); - - const Vector2 mouse = GetMousePosition(); - const auto rects = computeToolButtons(bar); - for (std::size_t i = 0; i < kToolCount; ++i) { - const bool active = state_.activeTool == kToolInfos[i].id; - const bool hover = CheckCollisionPointRec(mouse, rects[i]); - const Color background = active ? theme::kAccent : (hover ? theme::kHover : theme::kPanelHeader); - DrawRectangleRec(rects[i], background); - drawTextVCentered(kToolInfos[i].label, rects[i].x + static_cast(kButtonPadding), rects[i], - kFontSize, active ? theme::kWindowBg : theme::kText); - } -} - -void Application::drawCanvas() const { - const Rectangle& area = layout_.canvas; - DrawRectangleRec(area, theme::kCanvasBg); - - // Escenario del tamaño del proyecto, ajustado al área disponible. - constexpr float kMargin = 32.0f; - const float projectW = static_cast(project_.width()); - const float projectH = static_cast(project_.height()); - const float availableW = std::max(1.0f, area.width - 2.0f * kMargin); - const float availableH = std::max(1.0f, area.height - 2.0f * kMargin); - const float scale = std::min(availableW / projectW, availableH / projectH); - const float w = projectW * scale; - const float h = projectH * scale; - const Rectangle stage{area.x + (area.width - w) / 2.0f, area.y + (area.height - h) / 2.0f, w, h}; - - DrawRectangleRec(stage, theme::kStage); - DrawRectangleLinesEx(stage, 1.0f, theme::kBorder); - - char caption[96]; - std::snprintf(caption, sizeof caption, "Escenario %d x %d", project_.width(), project_.height()); - DrawText(caption, px(area.x + 10.0f), px(area.y + 8.0f), kSmallFontSize, theme::kTextDim); -} - -void Application::drawProperties() const { - const Rectangle& panel = layout_.properties; - DrawRectangleRec(panel, theme::kPanelBg); - drawPanelHeader(panel, "Propiedades"); - DrawRectangle(px(panel.x), px(panel.y), 1, px(panel.height), theme::kBorder); - - const std::size_t index = std::min(state_.currentFrame, project_.frameCount() - 1); - const Frame& frame = project_.frame(index); - - float y = panel.y + kPanelHeaderHeight + 12.0f; - const float x = panel.x + 12.0f; - char line[128]; - auto put = [&](Color color) { - DrawText(line, px(x), px(y), kFontSize, color); - y += 24.0f; - }; - - std::snprintf(line, sizeof line, "Proyecto: %s", project_.name().c_str()); - put(theme::kText); - std::snprintf(line, sizeof line, "Resolución: %d x %d", project_.width(), project_.height()); - put(theme::kText); - std::snprintf(line, sizeof line, "FPS base: %.1f (máx. %.0f)", static_cast(project_.baseFps()), - static_cast(kMaxFps)); - put(theme::kText); - std::snprintf(line, sizeof line, "Frame: %zu / %zu", index + 1, project_.frameCount()); - put(theme::kText); - std::snprintf(line, sizeof line, "FPS del frame: %.1f %s", - static_cast(frame.effectiveFps(project_.baseFps())), - frame.hasFpsOverride() ? "(propio)" : "(base)"); - put(theme::kText); - std::snprintf(line, sizeof line, "Herramienta: %s", toolLabel(state_.activeTool)); - put(theme::kText); - - y += 8.0f; - std::snprintf(line, sizeof line, "Sprites y vértices: etapas 4 y 8"); - put(theme::kTextDim); -} - -void Application::drawTimeline() const { - const Rectangle& panel = layout_.timeline; - DrawRectangleRec(panel, theme::kPanelBg); - drawPanelHeader(panel, "Timeline"); - DrawRectangle(px(panel.x), px(panel.y), px(panel.width), 1, theme::kBorder); - - constexpr std::array kTracks = {"Fondo", "Sprite", "Audio"}; - constexpr float kLaneHeight = 32.0f; - constexpr float kLabelWidth = 90.0f; - - float y = panel.y + kPanelHeaderHeight + 12.0f; - for (const char* name : kTracks) { - DrawText(name, px(panel.x + 12.0f), px(y + (kLaneHeight - static_cast(kFontSize)) / 2.0f), - kFontSize, theme::kText); - const Rectangle lane{panel.x + kLabelWidth, y, panel.width - kLabelWidth - 12.0f, kLaneHeight - 4.0f}; - DrawRectangleRec(lane, theme::kPanelHeader); - y += kLaneHeight; - } - DrawText("La timeline se implementa en la etapa 3", px(panel.x + 12.0f), px(y + 8.0f), kSmallFontSize, - theme::kTextDim); -} - -} // namespace tahgibu diff --git a/source/ui/Application.hpp b/source/ui/Application.hpp deleted file mode 100644 index c29de5d..0000000 --- a/source/ui/Application.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "core/Project.hpp" -#include "editor/EditorState.hpp" -#include "ui/Layout.hpp" - -namespace tahgibu { - -// Ventana principal: crea la ventana de raylib, ejecuta el bucle y dibuja las zonas. -// La lógica del programa vive en core/ y editor/, no aquí. -class Application { -public: - Application(); - ~Application(); - Application(const Application&) = delete; - Application& operator=(const Application&) = delete; - - // Devuelve el código de salida del proceso. - int run(); - -private: - void update(); - void draw() const; - void drawMenuBar() const; - void drawToolBar() const; - void drawCanvas() const; - void drawProperties() const; - void drawTimeline() const; - - Project project_; - EditorState state_; - Layout layout_; -}; - -} // namespace tahgibu diff --git a/source/ui/Layout.cpp b/source/ui/Layout.cpp deleted file mode 100644 index e674768..0000000 --- a/source/ui/Layout.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "ui/Layout.hpp" - -#include - -namespace tahgibu { -namespace { - -constexpr float kMenuBarHeight = 26.0f; -constexpr float kToolBarHeight = 40.0f; -constexpr float kPropertiesWidth = 280.0f; -constexpr float kTimelineHeight = 200.0f; -constexpr float kMinCanvasWidth = 400.0f; -constexpr float kMinCanvasHeight = 200.0f; - -} // namespace - -Layout Layout::compute(int windowWidth, int windowHeight) { - const float w = static_cast(windowWidth); - const float h = static_cast(windowHeight); - const float top = kMenuBarHeight + kToolBarHeight; - - const float timelineH = std::min(kTimelineHeight, std::max(0.0f, h - top - kMinCanvasHeight)); - const float propertiesW = std::min(kPropertiesWidth, std::max(0.0f, w - kMinCanvasWidth)); - const float middleH = std::max(0.0f, h - top - timelineH); - - Layout layout; - layout.menuBar = {0.0f, 0.0f, w, kMenuBarHeight}; - layout.toolBar = {0.0f, kMenuBarHeight, w, kToolBarHeight}; - layout.canvas = {0.0f, top, w - propertiesW, middleH}; - layout.properties = {w - propertiesW, top, propertiesW, middleH}; - layout.timeline = {0.0f, top + middleH, w, timelineH}; - return layout; -} - -} // namespace tahgibu diff --git a/source/ui/Layout.hpp b/source/ui/Layout.hpp deleted file mode 100644 index fc62dc2..0000000 --- a/source/ui/Layout.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include - -namespace tahgibu { - -// Reparto de la ventana en zonas. Función pura del tamaño de la ventana. -struct Layout { - Rectangle menuBar{}; - Rectangle toolBar{}; - Rectangle canvas{}; - Rectangle properties{}; - Rectangle timeline{}; - - static Layout compute(int windowWidth, int windowHeight); -}; - -} // namespace tahgibu diff --git a/source/ui/Theme.hpp b/source/ui/Theme.hpp deleted file mode 100644 index 8fe6b91..0000000 --- a/source/ui/Theme.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include - -namespace tahgibu::theme { - -inline constexpr Color kWindowBg{24, 24, 28, 255}; -inline constexpr Color kPanelBg{34, 34, 40, 255}; -inline constexpr Color kPanelHeader{44, 44, 52, 255}; -inline constexpr Color kBorder{14, 14, 18, 255}; -inline constexpr Color kHover{62, 62, 74, 255}; -inline constexpr Color kText{225, 225, 230, 255}; -inline constexpr Color kTextDim{140, 140, 152, 255}; -inline constexpr Color kAccent{255, 140, 60, 255}; -inline constexpr Color kCanvasBg{18, 18, 22, 255}; -inline constexpr Color kStage{245, 245, 245, 255}; - -} // namespace tahgibu::theme diff --git a/src/animation/player.c b/src/animation/player.c new file mode 100644 index 0000000..c4da5de --- /dev/null +++ b/src/animation/player.c @@ -0,0 +1,109 @@ +/* Reproductor: lógica pura de tiempo. No dibuja nada. */ +#include "../document/doc_internal.h" + +struct tgb_player { + const tgb_document *doc; + bool playing; + tgb_play_mode mode; + float speed; + bool has_range; + size_t first, last; + size_t frame; + double elapsed_ms; /* tiempo transcurrido dentro del fotograma actual */ + int dir; /* +1 / -1 (ping-pong) */ +}; + +tgb_player *tgb_player_create(const tgb_document *doc) { + if (!doc) { tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo"); return NULL; } + tgb_player *p = calloc(1, sizeof *p); + if (!p) { tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); return NULL; } + p->doc = doc; + p->mode = TGB_PLAY_LOOP; + p->speed = 1.0f; + p->dir = 1; + return p; +} +void tgb_player_destroy(tgb_player *p) { free(p); } + +static void bounds(const tgb_player *p, size_t *lo, size_t *hi) { + size_t n = p->doc->nframes; + *lo = 0; *hi = n ? n - 1 : 0; + if (p->has_range) { + if (p->first < n) *lo = p->first; + if (p->last < n) *hi = p->last; + if (*hi < *lo) *hi = *lo; + } +} + +void tgb_player_play(tgb_player *p) { + if (!p) return; + size_t lo, hi; + bounds(p, &lo, &hi); + if (p->frame < lo || p->frame > hi) { p->frame = lo; p->elapsed_ms = 0; } + else if (p->mode == TGB_PLAY_ONCE && p->frame == hi) { p->frame = lo; p->elapsed_ms = 0; } + p->playing = true; +} +void tgb_player_pause(tgb_player *p) { if (p) p->playing = false; } +void tgb_player_stop(tgb_player *p) { + if (!p) return; + size_t lo, hi; + bounds(p, &lo, &hi); + p->playing = false; + p->frame = lo; + p->elapsed_ms = 0; + p->dir = 1; +} +bool tgb_player_is_playing(const tgb_player *p) { return p && p->playing; } +void tgb_player_set_mode(tgb_player *p, tgb_play_mode m) { if (p) { p->mode = m; p->dir = 1; } } +void tgb_player_set_speed(tgb_player *p, float s) { + if (!p) return; + if (!(s >= 0.1f)) s = 0.1f; + if (s > 8.0f) s = 8.0f; + p->speed = s; +} +tgb_status tgb_player_set_range(tgb_player *p, size_t first, size_t last) { + if (!p) return tgb_fail(TGB_ERR_INVALID_ARG, "Reproductor nulo"); + if (first > last || last >= p->doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Rango de reproducción no válido"); + p->has_range = true; p->first = first; p->last = last; + return TGB_OK; +} +void tgb_player_clear_range(tgb_player *p) { if (p) p->has_range = false; } +tgb_status tgb_player_seek(tgb_player *p, size_t frame) { + if (!p) return tgb_fail(TGB_ERR_INVALID_ARG, "Reproductor nulo"); + if (frame >= p->doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Fotograma %zu fuera de rango", frame); + p->frame = frame; p->elapsed_ms = 0; + return TGB_OK; +} +size_t tgb_player_frame(const tgb_player *p) { return p ? p->frame : 0; } + +size_t tgb_player_update(tgb_player *p, double dt_ms) { + if (!p) return 0; + size_t lo, hi; + bounds(p, &lo, &hi); + if (p->frame >= p->doc->nframes) p->frame = lo; /* el documento cambió */ + if (!p->playing || !(dt_ms > 0)) return p->frame; + p->elapsed_ms += dt_ms * (double)p->speed; + /* tiempo total de un ciclo: evita bucles largos si dt es enorme */ + double cycle = 0; + for (size_t i = lo; i <= hi; i++) cycle += (double)p->doc->frames[i].duration_ms; + if (p->mode == TGB_PLAY_PINGPONG && hi > lo) cycle *= 2; + if (p->mode != TGB_PLAY_ONCE && cycle > 0 && p->elapsed_ms > cycle * 2) { + double extra = p->elapsed_ms - cycle; + p->elapsed_ms -= cycle * (double)(long long)(extra / cycle); + } + for (;;) { + double dur = (double)p->doc->frames[p->frame].duration_ms; + if (p->elapsed_ms < dur) break; + p->elapsed_ms -= dur; + if (p->dir > 0) { + if (p->frame < hi) p->frame++; + else if (p->mode == TGB_PLAY_LOOP) p->frame = lo; + else if (p->mode == TGB_PLAY_PINGPONG && hi > lo) { p->dir = -1; p->frame--; } + else { p->playing = false; p->elapsed_ms = 0; break; } /* ONCE: se queda en el último */ + } else { + if (p->frame > lo) p->frame--; + else { p->dir = 1; if (hi > lo) p->frame++; } + } + } + return p->frame; +} diff --git a/src/animation/render.c b/src/animation/render.c new file mode 100644 index 0000000..adf4790 --- /dev/null +++ b/src/animation/render.c @@ -0,0 +1,104 @@ +/* Composición de capas y Onion Skin. */ +#include +#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; +} diff --git a/src/core/context.c b/src/core/context.c new file mode 100644 index 0000000..91a2e51 --- /dev/null +++ b/src/core/context.c @@ -0,0 +1,79 @@ +/* Contexto de la aplicación y bus de eventos. */ +#include "internal.h" +#include "../platform/platform.h" + +tgb_status tgb_context_create(const tgb_context_config *config, tgb_context **out) { + if (!out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_context_create: 'out' es nulo"); + *out = NULL; + tgb_context *ctx = calloc(1, sizeof *ctx); + if (!ctx) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el contexto"); + char dc[512], dd[512], dk[512], ds[512]; + tgb_status s = tgb_plat_default_dirs(dc, dd, dk, ds, sizeof dc); + const char *cfg[4] = {config ? config->config_dir : NULL, config ? config->data_dir : NULL, + config ? config->cache_dir : NULL, config ? config->state_dir : NULL}; + const char *def[4] = {dc, dd, dk, ds}; + char *dst[4] = {ctx->config_dir, ctx->data_dir, ctx->cache_dir, ctx->state_dir}; + for (int i = 0; i < 4; i++) { + if (cfg[i] && cfg[i][0]) { + if (strlen(cfg[i]) >= 512) { free(ctx); return tgb_fail(TGB_ERR_LIMIT, "Ruta de carpeta demasiado larga"); } + tgb_strlcpy(dst[i], cfg[i], 512); + } else { + if (TGB_FAILED(s)) { free(ctx); return s; } + tgb_strlcpy(dst[i], def[i], 512); + } + tgb_status m = tgb_plat_mkdirs(dst[i]); + if (TGB_FAILED(m)) { free(ctx); return m; } + } + ctx->next_sub_id = 1; + *out = ctx; + return TGB_OK; +} + +void tgb_context_clear_clipboard(tgb_context *ctx) { + if (!ctx || !ctx->clip.valid) return; + for (size_t i = 0; i < ctx->clip.nlayers; i++) tgb_canvas_release(ctx->clip.cels[i]); + free(ctx->clip.cels); + memset(&ctx->clip, 0, sizeof ctx->clip); +} + +void tgb_context_destroy(tgb_context *ctx) { + if (!ctx) return; + tgb_context_clear_clipboard(ctx); + free(ctx); +} + +const char *tgb_context_config_dir(const tgb_context *ctx) { return ctx ? ctx->config_dir : ""; } +const char *tgb_context_data_dir(const tgb_context *ctx) { return ctx ? ctx->data_dir : ""; } +const char *tgb_context_cache_dir(const tgb_context *ctx) { return ctx ? ctx->cache_dir : ""; } +const char *tgb_context_state_dir(const tgb_context *ctx) { return ctx ? ctx->state_dir : ""; } + +/* ---- eventos ---- */ +tgb_status tgb_events_subscribe(tgb_context *ctx, tgb_event_fn fn, void *user, uint32_t *out_id) { + if (!ctx || !fn) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_events_subscribe: argumento nulo"); + if (ctx->nsubs >= TGB_MAX_SUBSCRIBERS) return tgb_fail(TGB_ERR_LIMIT, "Demasiados suscriptores de eventos (máximo %d)", TGB_MAX_SUBSCRIBERS); + uint32_t id = ctx->next_sub_id++; + ctx->subs[ctx->nsubs].id = id; + ctx->subs[ctx->nsubs].fn = fn; + ctx->subs[ctx->nsubs].user = user; + ctx->nsubs++; + if (out_id) *out_id = id; + return TGB_OK; +} + +tgb_status tgb_events_unsubscribe(tgb_context *ctx, uint32_t id) { + if (!ctx) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_events_unsubscribe: contexto nulo"); + for (size_t i = 0; i < ctx->nsubs; i++) { + if (ctx->subs[i].id == id) { + memmove(&ctx->subs[i], &ctx->subs[i + 1], (ctx->nsubs - i - 1) * sizeof ctx->subs[0]); + ctx->nsubs--; + return TGB_OK; + } + } + return tgb_fail(TGB_ERR_NOT_FOUND, "Suscripción %u inexistente", (unsigned)id); +} + +void tgb_emit(tgb_context *ctx, const tgb_event *ev) { + if (!ctx || !ev) return; + size_t n = ctx->nsubs; /* copia: los suscriptores no deben modificar la lista */ + for (size_t i = 0; i < n && i < ctx->nsubs; i++) ctx->subs[i].fn(ev, ctx->subs[i].user); +} diff --git a/src/core/error.c b/src/core/error.c new file mode 100644 index 0000000..db789ce --- /dev/null +++ b/src/core/error.c @@ -0,0 +1,83 @@ +#include +#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; +} diff --git a/src/core/internal.h b/src/core/internal.h new file mode 100644 index 0000000..ab02c56 --- /dev/null +++ b/src/core/internal.h @@ -0,0 +1,66 @@ +/* Cabecera INTERNA (no es API pública). Utilidades comunes y estructuras + * compartidas entre módulos. */ +#ifndef TAHGIBU_INTERNAL_H +#define TAHGIBU_INTERNAL_H + +#include +#include +#include +#include +#include +#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 diff --git a/src/document/doc_internal.h b/src/document/doc_internal.h new file mode 100644 index 0000000..1043e39 --- /dev/null +++ b/src/document/doc_internal.h @@ -0,0 +1,104 @@ +/* INTERNO: estructura del documento y mutadores de bajo nivel (sin historial). + * Solo los módulos document/, history/, editor/, io/ y recovery/ la usan. */ +#ifndef TAHGIBU_DOC_INTERNAL_H +#define TAHGIBU_DOC_INTERNAL_H + +#include "../core/internal.h" + +#define NPOS ((size_t)-1) + +typedef struct tgb_layer { + uint32_t id; + char name[TGB_NAME_MAX]; + bool visible, locked; + uint8_t opacity; +} tgb_layer; + +typedef struct tgb_frame { + uint32_t id; + uint32_t duration_ms; + tgb_canvas **cels; /* un puntero por capa (NULL = vacía); longitud = nº de capas */ +} tgb_frame; + +/* Instantánea de la estructura completa (comparte las celdas por referencia). */ +typedef struct tgb_state { + tgb_layer *layers; + size_t nlayers; + tgb_frame *frames; + size_t nframes; + int fps; +} tgb_state; + +typedef struct tgb_rpoint { + uint32_t id; + char name[TGB_RECOVERY_POINT_NAME_MAX]; + int64_t created; + bool compatible; + tgb_state state; /* válido si compatible */ + char *raw_json; /* si !compatible: JSON original, se reescribe tal cual */ +} tgb_rpoint; + +struct tgb_history; + +struct tgb_document { + tgb_context *ctx; + int width, height, fps; + tgb_layer *layers; + size_t nlayers, caplayers; + tgb_frame *frames; + size_t nframes, capframes; + uint32_t next_id; + uint64_t revision, saved_revision; + char *path; + bool inconsistent; + struct tgb_history *history; + struct { + bool active; + uint32_t frame_id, layer_id; + tgb_canvas *canvas; /* celda editada (propiedad del documento) */ + tgb_canvas *base; /* copia del estado inicial (propiedad de la sesión) */ + char label[64]; + } edit; + tgb_rpoint *rpoints; + size_t nrpoints, caprpoints; + uint32_t next_rpoint_id; + struct { char *key; char *raw; } *extra; /* campos desconocidos de .tgb */ + size_t nextra; +}; + +/* ---- búsqueda ---- */ +size_t doc_find_frame(const tgb_document *doc, uint32_t id); +size_t doc_find_layer(const tgb_document *doc, uint32_t id); +uint32_t doc_new_id(tgb_document *doc); +uint32_t doc_default_duration_ms(int fps); +void doc_touch(tgb_document *doc); /* revision++ y evento DOCUMENT_CHANGED */ + +/* ---- mutadores de bajo nivel: NO registran historial ---- */ +void doc_frame_free_cels(tgb_canvas **cels, size_t n); +/* Toma posesión de f->cels (debe tener doc->nlayers entradas). */ +tgb_status doc_frame_insert(tgb_document *doc, size_t index, tgb_frame *f); +void doc_frame_remove(tgb_document *doc, size_t index, tgb_frame *out); +void doc_frame_move(tgb_document *doc, size_t from, size_t to); +/* 'cels' tiene doc->nframes entradas; las referencias pasan al documento. */ +tgb_status doc_layer_insert(tgb_document *doc, size_t index, const tgb_layer *layer, tgb_canvas **cels); +/* 'cels_out' debe tener sitio para doc->nframes punteros; recibe las referencias. */ +void doc_layer_remove(tgb_document *doc, size_t index, tgb_layer *out, tgb_canvas **cels_out); +void doc_layer_move(tgb_document *doc, size_t from, size_t to); + +/* Celda editable: la crea si no existe y create=true; si está compartida + * (copy-on-write con instantáneas/historial) la copia antes. */ +tgb_canvas *doc_cel_for_write(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, bool create, + bool *created); + +/* ---- instantáneas ---- */ +tgb_status state_capture(const tgb_document *doc, tgb_state *out); +void state_free(tgb_state *st); +tgb_status state_apply(tgb_document *doc, const tgb_state *st); /* sustituye la estructura */ +/* Bytes de celdas de 'st' que NO están en el documento actual (memoria incremental). */ +size_t state_extra_bytes(const tgb_state *st, const tgb_document *doc); + +/* Comprobación común: ¿se puede modificar el documento ahora? */ +tgb_status doc_check_mutable(const tgb_document *doc); +void doc_clear_extra(tgb_document *doc); + +#endif diff --git a/src/document/doc_ops.c b/src/document/doc_ops.c new file mode 100644 index 0000000..04f359d --- /dev/null +++ b/src/document/doc_ops.c @@ -0,0 +1,372 @@ +#include +/* 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; +} diff --git a/src/document/document.c b/src/document/document.c new file mode 100644 index 0000000..6754252 --- /dev/null +++ b/src/document/document.c @@ -0,0 +1,343 @@ +/* Modelo de documento: creación, destrucción, consultas y mutadores de bajo nivel. */ +#include "doc_internal.h" +#include "../history/history_internal.h" + +uint32_t doc_default_duration_ms(int fps) { + uint32_t ms = (uint32_t)((1000 + fps / 2) / fps); + if (ms < TGB_MIN_FRAME_MS) ms = TGB_MIN_FRAME_MS; + return ms; +} + +uint32_t doc_new_id(tgb_document *doc) { return doc->next_id++; } + +void tgb_emit_simple(tgb_document *doc, tgb_event_type type) { + if (!doc || !doc->ctx) return; + tgb_event ev = {type, doc, 0, 0, {0, 0, 0, 0}, NULL}; + tgb_emit(doc->ctx, &ev); +} + +void doc_touch(tgb_document *doc) { + doc->revision++; + tgb_emit_simple(doc, TGB_EVENT_DOCUMENT_CHANGED); +} + +tgb_status tgb_document_create(tgb_context *ctx, int width, int height, int fps, tgb_document **out) { + if (!ctx || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_document_create: argumento nulo"); + *out = NULL; + if (width < 1 || height < 1 || width > TGB_MAX_DIMENSION || height > TGB_MAX_DIMENSION) + return tgb_fail(TGB_ERR_RANGE, "Dimensiones no válidas %dx%d (1..%d)", width, height, TGB_MAX_DIMENSION); + if (fps < TGB_MIN_FPS || fps > TGB_MAX_FPS) + return tgb_fail(TGB_ERR_RANGE, "fps %d fuera de rango (%d..%d)", fps, TGB_MIN_FPS, TGB_MAX_FPS); + tgb_document *doc = calloc(1, sizeof *doc); + if (!doc) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el documento"); + doc->ctx = ctx; + doc->width = width; + doc->height = height; + doc->fps = fps; + doc->next_id = 1; + doc->next_rpoint_id = 1; + doc->history = hist_create(); + doc->layers = calloc(4, sizeof *doc->layers); + doc->frames = calloc(4, sizeof *doc->frames); + tgb_canvas **cels = calloc(1, sizeof *cels); + if (!doc->history || !doc->layers || !doc->frames || !cels) { + free(cels); + tgb_document_destroy(doc); + return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el documento"); + } + doc->caplayers = 4; + doc->capframes = 4; + doc->layers[0].id = doc_new_id(doc); + tgb_strlcpy(doc->layers[0].name, "Capa 1", TGB_NAME_MAX); + doc->layers[0].visible = true; + doc->layers[0].opacity = 255; + doc->nlayers = 1; + doc->frames[0].id = doc_new_id(doc); + doc->frames[0].duration_ms = doc_default_duration_ms(fps); + doc->frames[0].cels = cels; + doc->nframes = 1; + tgb_history_adapt(doc); /* límites iniciales según la máquina (no es fatal si falla) */ + *out = doc; + return TGB_OK; +} + +void doc_clear_extra(tgb_document *doc) { + for (size_t i = 0; i < doc->nextra; i++) { free(doc->extra[i].key); free(doc->extra[i].raw); } + free(doc->extra); + doc->extra = NULL; + doc->nextra = 0; +} + +void doc_frame_free_cels(tgb_canvas **cels, size_t n) { + if (!cels) return; + for (size_t i = 0; i < n; i++) tgb_canvas_release(cels[i]); + free(cels); +} + +void tgb_document_destroy(tgb_document *doc) { + if (!doc) return; + if (doc->edit.active) { tgb_canvas_release(doc->edit.base); } + hist_destroy(doc->history); /* antes que las celdas: las operaciones pueden retener celdas */ + for (size_t i = 0; i < doc->nframes; i++) doc_frame_free_cels(doc->frames[i].cels, doc->nlayers); + free(doc->frames); + free(doc->layers); + for (size_t i = 0; i < doc->nrpoints; i++) { + if (doc->rpoints[i].compatible) state_free(&doc->rpoints[i].state); + free(doc->rpoints[i].raw_json); + } + free(doc->rpoints); + doc_clear_extra(doc); + free(doc->path); + free(doc); +} + +/* ---- consultas ---- */ +tgb_context *tgb_document_context(const tgb_document *d) { return d ? d->ctx : NULL; } +int tgb_document_width(const tgb_document *d) { return d ? d->width : 0; } +int tgb_document_height(const tgb_document *d) { return d ? d->height : 0; } +int tgb_document_fps(const tgb_document *d) { return d ? d->fps : 0; } +uint64_t tgb_document_revision(const tgb_document *d) { return d ? d->revision : 0; } +bool tgb_document_dirty(const tgb_document *d) { return d && d->revision != d->saved_revision; } +void tgb_document_mark_saved(tgb_document *d) { if (d) d->saved_revision = d->revision; } +const char *tgb_document_path(const tgb_document *d) { return d ? d->path : NULL; } +bool tgb_document_is_consistent(const tgb_document *d) { return d && !d->inconsistent; } + +tgb_status tgb_document_set_path(tgb_document *doc, const char *path) { + if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo"); + char *copy = NULL; + if (path) { copy = tgb_strdup(path); if (!copy) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria"); } + free(doc->path); + doc->path = copy; + return TGB_OK; +} + +const char *tgb_document_extra_field(const tgb_document *doc, const char *key) { + if (!doc || !key) return NULL; + for (size_t i = 0; i < doc->nextra; i++) if (!strcmp(doc->extra[i].key, key)) return doc->extra[i].raw; + return NULL; +} + +size_t tgb_frame_count(const tgb_document *d) { return d ? d->nframes : 0; } +uint32_t tgb_frame_id(const tgb_document *d, size_t i) { return (d && i < d->nframes) ? d->frames[i].id : 0; } +uint32_t tgb_frame_duration_ms(const tgb_document *d, size_t i) { return (d && i < d->nframes) ? d->frames[i].duration_ms : 0; } +size_t tgb_layer_count(const tgb_document *d) { return d ? d->nlayers : 0; } +uint32_t tgb_layer_id(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].id : 0; } +const char *tgb_layer_name(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].name : ""; } +bool tgb_layer_visible(const tgb_document *d, size_t i) { return d && i < d->nlayers && d->layers[i].visible; } +bool tgb_layer_locked(const tgb_document *d, size_t i) { return d && i < d->nlayers && d->layers[i].locked; } +uint8_t tgb_layer_opacity(const tgb_document *d, size_t i) { return (d && i < d->nlayers) ? d->layers[i].opacity : 0; } + +size_t doc_find_frame(const tgb_document *d, uint32_t id) { + for (size_t i = 0; i < d->nframes; i++) if (d->frames[i].id == id) return i; + return NPOS; +} +size_t doc_find_layer(const tgb_document *d, uint32_t id) { + for (size_t i = 0; i < d->nlayers; i++) if (d->layers[i].id == id) return i; + return NPOS; +} + +tgb_status tgb_frame_index_of(const tgb_document *d, uint32_t id, size_t *out) { + if (!d || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo"); + size_t i = doc_find_frame(d, id); + if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe el fotograma con id %u", (unsigned)id); + *out = i; + return TGB_OK; +} +tgb_status tgb_layer_index_of(const tgb_document *d, uint32_t id, size_t *out) { + if (!d || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "Argumento nulo"); + size_t i = doc_find_layer(d, id); + if (i == NPOS) return tgb_fail(TGB_ERR_NOT_FOUND, "No existe la capa con id %u", (unsigned)id); + *out = i; + return TGB_OK; +} +const tgb_canvas *tgb_frame_cel(const tgb_document *d, size_t f, size_t l) { + if (!d || f >= d->nframes || l >= d->nlayers) return NULL; + return d->frames[f].cels[l]; +} + +tgb_status doc_check_mutable(const tgb_document *doc) { + if (!doc) return tgb_fail(TGB_ERR_INVALID_ARG, "Documento nulo"); + if (doc->edit.active) return tgb_fail(TGB_ERR_STATE, "Hay una sesión de edición abierta: ciérrala antes de cambiar la estructura"); + return TGB_OK; +} + +/* ---- mutadores de bajo nivel ---- */ +tgb_status doc_frame_insert(tgb_document *doc, size_t index, tgb_frame *f) { + if (index > doc->nframes) return tgb_fail(TGB_ERR_RANGE, "Índice de fotograma %zu fuera de rango", index); + if (doc->nframes == doc->capframes) { + size_t nc = doc->capframes ? doc->capframes * 2 : 4; + tgb_frame *nf = realloc(doc->frames, nc * sizeof *nf); + if (!nf) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para más fotogramas"); + doc->frames = nf; + doc->capframes = nc; + } + memmove(&doc->frames[index + 1], &doc->frames[index], (doc->nframes - index) * sizeof doc->frames[0]); + doc->frames[index] = *f; + doc->nframes++; + f->cels = NULL; + return TGB_OK; +} + +void doc_frame_remove(tgb_document *doc, size_t index, tgb_frame *out) { + *out = doc->frames[index]; + memmove(&doc->frames[index], &doc->frames[index + 1], (doc->nframes - index - 1) * sizeof doc->frames[0]); + doc->nframes--; +} + +void doc_frame_move(tgb_document *doc, size_t from, size_t to) { + if (from == to) return; + tgb_frame tmp = doc->frames[from]; + if (from < to) memmove(&doc->frames[from], &doc->frames[from + 1], (to - from) * sizeof tmp); + else memmove(&doc->frames[to + 1], &doc->frames[to], (from - to) * sizeof tmp); + doc->frames[to] = tmp; +} + +tgb_status doc_layer_insert(tgb_document *doc, size_t index, const tgb_layer *layer, tgb_canvas **cels) { + if (index > doc->nlayers) return tgb_fail(TGB_ERR_RANGE, "Índice de capa %zu fuera de rango", index); + /* 1) reservar todo antes de tocar nada */ + if (doc->nlayers == doc->caplayers) { + size_t nc = doc->caplayers ? doc->caplayers * 2 : 4; + tgb_layer *nl = realloc(doc->layers, nc * sizeof *nl); + if (!nl) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para más capas"); + doc->layers = nl; + doc->caplayers = nc; + } + for (size_t i = 0; i < doc->nframes; i++) { + tgb_canvas **na = realloc(doc->frames[i].cels, (doc->nlayers + 1) * sizeof *na); + if (!na) return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para añadir la capa"); + doc->frames[i].cels = na; /* hueco extra inofensivo si luego falla otro fotograma */ + } + /* 2) confirmar */ + for (size_t i = 0; i < doc->nframes; i++) { + tgb_canvas **a = doc->frames[i].cels; + memmove(&a[index + 1], &a[index], (doc->nlayers - index) * sizeof *a); + a[index] = cels ? cels[i] : NULL; + } + memmove(&doc->layers[index + 1], &doc->layers[index], (doc->nlayers - index) * sizeof doc->layers[0]); + doc->layers[index] = *layer; + doc->nlayers++; + return TGB_OK; +} + +void doc_layer_remove(tgb_document *doc, size_t index, tgb_layer *out, tgb_canvas **cels_out) { + for (size_t i = 0; i < doc->nframes; i++) { + tgb_canvas **a = doc->frames[i].cels; + if (cels_out) cels_out[i] = a[index]; else tgb_canvas_release(a[index]); + memmove(&a[index], &a[index + 1], (doc->nlayers - index - 1) * sizeof *a); + } + *out = doc->layers[index]; + memmove(&doc->layers[index], &doc->layers[index + 1], (doc->nlayers - index - 1) * sizeof doc->layers[0]); + doc->nlayers--; +} + +void doc_layer_move(tgb_document *doc, size_t from, size_t to) { + if (from == to) return; + for (size_t i = 0; i < doc->nframes; i++) { + tgb_canvas **a = doc->frames[i].cels; + tgb_canvas *tmp = a[from]; + if (from < to) memmove(&a[from], &a[from + 1], (to - from) * sizeof tmp); + else memmove(&a[to + 1], &a[to], (from - to) * sizeof tmp); + a[to] = tmp; + } + tgb_layer tl = doc->layers[from]; + if (from < to) memmove(&doc->layers[from], &doc->layers[from + 1], (to - from) * sizeof tl); + else memmove(&doc->layers[to + 1], &doc->layers[to], (from - to) * sizeof tl); + doc->layers[to] = tl; +} + +tgb_canvas *doc_cel_for_write(tgb_document *doc, uint32_t frame_id, uint32_t layer_id, bool create, bool *created) { + size_t fi = doc_find_frame(doc, frame_id), li = doc_find_layer(doc, layer_id); + if (created) *created = false; + if (fi == NPOS || li == NPOS) { tgb_fail(TGB_ERR_NOT_FOUND, "Celda inexistente (fotograma %u, capa %u)", (unsigned)frame_id, (unsigned)layer_id); return NULL; } + tgb_canvas **slot = &doc->frames[fi].cels[li]; + if (!*slot) { + if (!create) return NULL; + *slot = tgb_canvas_create(doc->width, doc->height); + if (!*slot) return NULL; + if (created) *created = true; + } else if (tgb_canvas_refs(*slot) > 1) { /* copy-on-write */ + tgb_canvas *copy = tgb_canvas_clone(*slot); + if (!copy) return NULL; + tgb_canvas_release(*slot); + *slot = copy; + } + return *slot; +} + +/* ---- instantáneas ---- */ +void state_free(tgb_state *st) { + if (!st) return; + for (size_t i = 0; i < st->nframes; i++) doc_frame_free_cels(st->frames[i].cels, st->nlayers); + free(st->frames); + free(st->layers); + memset(st, 0, sizeof *st); +} + +static tgb_status copy_structure(const tgb_layer *layers, size_t nl, const tgb_frame *frames, size_t nf, + tgb_layer **out_l, tgb_frame **out_f) { + tgb_layer *l = malloc((nl ? nl : 1) * sizeof *l); + tgb_frame *f = calloc(nf ? nf : 1, sizeof *f); + if (!l || !f) { free(l); free(f); return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para la instantánea"); } + memcpy(l, layers, nl * sizeof *l); + for (size_t i = 0; i < nf; i++) { + f[i].id = frames[i].id; + f[i].duration_ms = frames[i].duration_ms; + f[i].cels = calloc(nl ? nl : 1, sizeof *f[i].cels); + if (!f[i].cels) { + for (size_t k = 0; k < i; k++) doc_frame_free_cels(f[k].cels, nl); + free(l); free(f); + return tgb_fail(TGB_ERR_NOMEM, "Sin memoria para la instantánea"); + } + for (size_t j = 0; j < nl; j++) f[i].cels[j] = tgb_canvas_retain(frames[i].cels[j]); + } + *out_l = l; + *out_f = f; + return TGB_OK; +} + +tgb_status state_capture(const tgb_document *doc, tgb_state *out) { + memset(out, 0, sizeof *out); + tgb_status s = copy_structure(doc->layers, doc->nlayers, doc->frames, doc->nframes, &out->layers, &out->frames); + if (TGB_FAILED(s)) return s; + out->nlayers = doc->nlayers; + out->nframes = doc->nframes; + out->fps = doc->fps; + return TGB_OK; +} + +tgb_status state_apply(tgb_document *doc, const tgb_state *st) { + tgb_layer *nl; tgb_frame *nf; + tgb_status s = copy_structure(st->layers, st->nlayers, st->frames, st->nframes, &nl, &nf); + if (TGB_FAILED(s)) return s; + for (size_t i = 0; i < doc->nframes; i++) doc_frame_free_cels(doc->frames[i].cels, doc->nlayers); + free(doc->frames); + free(doc->layers); + doc->layers = nl; doc->nlayers = st->nlayers; doc->caplayers = st->nlayers ? st->nlayers : 1; + doc->frames = nf; doc->nframes = st->nframes; doc->capframes = st->nframes ? st->nframes : 1; + doc->fps = st->fps; + return TGB_OK; +} + +static int cmp_ptr(const void *a, const void *b) { + const void *x = *(const void *const *)a, *y = *(const void *const *)b; + return (x > y) - (x < y); +} + +size_t state_extra_bytes(const tgb_state *st, const tgb_document *doc) { + size_t n = 0; + for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j]) n++; + const void **cur = malloc((n ? n : 1) * sizeof *cur); + if (!cur) return 0; + size_t k = 0; + for (size_t i = 0; i < doc->nframes; i++) for (size_t j = 0; j < doc->nlayers; j++) if (doc->frames[i].cels[j]) cur[k++] = doc->frames[i].cels[j]; + qsort(cur, n, sizeof *cur, cmp_ptr); + size_t total = 0; + for (size_t i = 0; i < st->nframes; i++) for (size_t j = 0; j < st->nlayers; j++) { + tgb_canvas *c = st->frames[i].cels[j]; + if (c && !bsearch(&c, cur, n, sizeof *cur, cmp_ptr)) total += tgb_canvas_bytes(c); + } + free(cur); + return total; +} + +size_t cel_exclusive_bytes(tgb_canvas *c) { return (c && tgb_canvas_refs(c) == 1) ? tgb_canvas_bytes(c) : 0; } diff --git a/src/editor/edit.c b/src/editor/edit.c new file mode 100644 index 0000000..2fb54f8 --- /dev/null +++ b/src/editor/edit.c @@ -0,0 +1,193 @@ +/* Sesión de edición: conecta una celda del documento con el historial. + * Mientras dura la sesión se dibuja directamente sobre la celda; al cerrar + * se calcula el rectángulo realmente modificado y se guarda UNA operación + * con los píxeles antes/después de ese rectángulo. */ +#include +#include +#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; +} diff --git a/src/editor/tools/tool_fill.c b/src/editor/tools/tool_fill.c new file mode 100644 index 0000000..ac88f6a --- /dev/null +++ b/src/editor/tools/tool_fill.c @@ -0,0 +1,62 @@ +/* Relleno por inundación (por líneas de barrido) y cuentagotas. */ +#include "../../core/internal.h" + +typedef struct { int x, y; } pt; + +static bool matches(const tgb_canvas *c, int x, int y, tgb_color seed, int tol) { + tgb_color p = tgb_canvas_get(c, x, y); + return abs((int)p.r - seed.r) <= tol && abs((int)p.g - seed.g) <= tol && abs((int)p.b - seed.b) <= tol && + abs((int)p.a - seed.a) <= tol; +} + +tgb_color tgb_eyedropper(const tgb_canvas *c, int x, int y) { return tgb_canvas_get(c, x, y); } + +size_t tgb_flood_fill(tgb_canvas *c, int x, int y, tgb_color fill, int tolerance) { + if (!c) return 0; + int w = tgb_canvas_width(c), h = tgb_canvas_height(c); + if (x < 0 || y < 0 || x >= w || y >= h) return 0; + if (tolerance < 0) tolerance = 0; + if (tolerance > 255) tolerance = 255; + tgb_color seed = tgb_canvas_get(c, x, y); + if (tolerance == 0 && seed.r == fill.r && seed.g == fill.g && seed.b == fill.b && seed.a == fill.a) + return 0; /* ya es de ese color: nada que hacer */ + uint8_t *seen = calloc((size_t)w * (size_t)h, 1); + size_t cap = 256, n = 0, changed = 0; + pt *stack = malloc(cap * sizeof *stack); + if (!seen || !stack) { free(seen); free(stack); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el relleno"); return 0; } + stack[n++] = (pt){x, y}; + while (n > 0) { + pt p = stack[--n]; + if (seen[(size_t)p.y * (size_t)w + (size_t)p.x] || !matches(c, p.x, p.y, seed, tolerance)) continue; + int lx = p.x, rx = p.x; + while (lx > 0 && !seen[(size_t)p.y * (size_t)w + (size_t)(lx - 1)] && matches(c, lx - 1, p.y, seed, tolerance)) lx--; + while (rx < w - 1 && !seen[(size_t)p.y * (size_t)w + (size_t)(rx + 1)] && matches(c, rx + 1, p.y, seed, tolerance)) rx++; + for (int i = lx; i <= rx; i++) seen[(size_t)p.y * (size_t)w + (size_t)i] = 1; + for (int dy = -1; dy <= 1; dy += 2) { + int ny = p.y + dy; + if (ny < 0 || ny >= h) continue; + bool in_run = false; + for (int i = lx; i <= rx; i++) { + bool ok = !seen[(size_t)ny * (size_t)w + (size_t)i] && matches(c, i, ny, seed, tolerance); + if (ok && !in_run) { + if (n == cap) { + cap *= 2; + pt *ns = realloc(stack, cap * sizeof *stack); + if (!ns) { free(seen); free(stack); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el relleno"); return changed; } + stack = ns; + } + stack[n++] = (pt){i, ny}; + } + in_run = ok; + } + } + for (int i = lx; i <= rx; i++) { + tgb_color old = tgb_canvas_get(c, i, p.y); + if (old.r != fill.r || old.g != fill.g || old.b != fill.b || old.a != fill.a) changed++; /* cuenta solo cambios reales */ + tgb_canvas_set(c, i, p.y, fill); + } + } + free(seen); + free(stack); + return changed; +} diff --git a/src/editor/tools/tool_raster.c b/src/editor/tools/tool_raster.c new file mode 100644 index 0000000..0c75663 --- /dev/null +++ b/src/editor/tools/tool_raster.c @@ -0,0 +1,174 @@ +/* Primitivas de dibujo sobre un lienzo: pincel, línea, formas, curvas. */ +#include +#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; + } +} diff --git a/src/editor/tools/tool_region.c b/src/editor/tools/tool_region.c new file mode 100644 index 0000000..fbae268 --- /dev/null +++ b/src/editor/tools/tool_region.c @@ -0,0 +1,68 @@ +/* Selección rectangular y transformaciones de una región. */ +#include "../../core/internal.h" + +static tgb_status clip_region(const tgb_canvas *c, tgb_rect *r) { + tgb_rect bounds = {0, 0, tgb_canvas_width(c), tgb_canvas_height(c)}; + *r = tgb_rect_intersect(*r, bounds); + if (r->w <= 0 || r->h <= 0) return tgb_fail(TGB_ERR_RANGE, "La selección queda fuera del lienzo"); + return TGB_OK; +} + +tgb_status tgb_region_clear(tgb_canvas *c, tgb_rect r) { + if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo"); + tgb_status s = clip_region(c, &r); + if (TGB_FAILED(s)) return s; + tgb_color none = {0, 0, 0, 0}; + tgb_canvas_fill_rect(c, r, none, false); + return TGB_OK; +} + +tgb_status tgb_region_move(tgb_canvas *c, tgb_rect src, int dx, int dy) { + if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo"); + tgb_status s = clip_region(c, &src); + if (TGB_FAILED(s)) return s; + if (dx == 0 && dy == 0) return TGB_OK; + tgb_canvas *tmp; + s = tgb_canvas_crop(c, src, &tmp); + if (TGB_FAILED(s)) return s; + tgb_region_clear(c, src); + tgb_canvas_blit(c, tmp, src.x + dx, src.y + dy, 255, true); /* lo que sale del lienzo se pierde */ + tgb_canvas_release(tmp); + return TGB_OK; +} + +tgb_status tgb_region_flip(tgb_canvas *c, tgb_rect r, bool horizontal) { + if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo"); + tgb_status s = clip_region(c, &r); + if (TGB_FAILED(s)) return s; + tgb_canvas *tmp; + s = tgb_canvas_crop(c, r, &tmp); + if (TGB_FAILED(s)) return s; + for (int y = 0; y < r.h; y++) + for (int x = 0; x < r.w; x++) { + int sx = horizontal ? r.w - 1 - x : x, sy = horizontal ? y : r.h - 1 - y; + tgb_canvas_set(c, r.x + x, r.y + y, tgb_canvas_get(tmp, sx, sy)); + } + tgb_canvas_release(tmp); + return TGB_OK; +} + +tgb_status tgb_region_scale(tgb_canvas *c, tgb_rect r, int new_w, int new_h) { + if (!c) return tgb_fail(TGB_ERR_INVALID_ARG, "Lienzo nulo"); + if (new_w < 1 || new_h < 1 || new_w > TGB_MAX_DIMENSION || new_h > TGB_MAX_DIMENSION) + return tgb_fail(TGB_ERR_RANGE, "Tamaño de escala no válido %dx%d", new_w, new_h); + tgb_status s = clip_region(c, &r); + if (TGB_FAILED(s)) return s; + tgb_canvas *src, *dst = tgb_canvas_create(new_w, new_h); + if (!dst) return TGB_ERR_NOMEM; + s = tgb_canvas_crop(c, r, &src); + if (TGB_FAILED(s)) { tgb_canvas_release(dst); return s; } + for (int y = 0; y < new_h; y++) + for (int x = 0; x < new_w; x++) + tgb_canvas_set(dst, x, y, tgb_canvas_get(src, (int)((int64_t)x * r.w / new_w), (int)((int64_t)y * r.h / new_h))); + tgb_region_clear(c, r); + tgb_canvas_blit(c, dst, r.x, r.y, 255, true); + tgb_canvas_release(src); + tgb_canvas_release(dst); + return TGB_OK; +} diff --git a/src/extensions/ext_internal.h b/src/extensions/ext_internal.h new file mode 100644 index 0000000..34fde69 --- /dev/null +++ b/src/extensions/ext_internal.h @@ -0,0 +1,17 @@ +/* INTERNO: puente entre el gestor de extensiones y el intérprete de Lua. */ +#ifndef TAHGIBU_EXT_INTERNAL_H +#define TAHGIBU_EXT_INTERNAL_H + +#include "../core/internal.h" + +typedef struct tgb_lua_ext tgb_lua_ext; + +/* true si esta compilación incluye Lua (-DTAHGIBU_WITH_LUA). */ +bool tgb_lua_available(void); +/* Carga y ejecuta el script en un intérprete aislado. Los nombres de info se copian a los búferes. */ +tgb_status tgb_lua_ext_load(const char *path, tgb_plugin_handle *self, const tgb_host_api *api, + tgb_lua_ext **out, char *name, size_t name_cap, char *version, size_t ver_cap, + uint32_t *required_caps); +void tgb_lua_ext_unload(tgb_lua_ext *ext); + +#endif diff --git a/src/extensions/lua_bridge.c b/src/extensions/lua_bridge.c new file mode 100644 index 0000000..4323fd1 --- /dev/null +++ b/src/extensions/lua_bridge.c @@ -0,0 +1,248 @@ +/* Extensiones Lua. SOLO se compila con -DTAHGIBU_WITH_LUA (necesita Lua 5.4). + * + * ESTADO: escrito, NO compilado ni probado en el entorno de desarrollo del + * proyecto (no había cabeceras de Lua). Ver docs/STATUS.md. + * + * Seguridad (esto SÍ se puede garantizar en Lua, a diferencia del código nativo): + * - el intérprete solo abre base (sin dofile/loadfile/load), table, string, math y utf8: + * no hay io, os, package ni debug; + * - las funciones tgb.* que necesitan un permiso NO EXISTEN si no se concedió; + * - hay un límite de instrucciones por llamada y un límite de memoria. */ +#include "ext_internal.h" + +#ifndef TAHGIBU_WITH_LUA + +bool tgb_lua_available(void) { return false; } +tgb_status tgb_lua_ext_load(const char *path, tgb_plugin_handle *self, const tgb_host_api *api, tgb_lua_ext **out, + char *name, size_t name_cap, char *version, size_t ver_cap, uint32_t *required_caps) { + (void)path; (void)self; (void)api; (void)out; (void)name; (void)name_cap; (void)version; (void)ver_cap; (void)required_caps; + return tgb_fail(TGB_ERR_UNSUPPORTED, "Esta compilación de Tahgibu no incluye soporte de Lua (compila con -DTAHGIBU_WITH_LUA=ON)"); +} +void tgb_lua_ext_unload(tgb_lua_ext *ext) { (void)ext; } + +#else /* TAHGIBU_WITH_LUA */ + +#include +#include +#include + +#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 */ diff --git a/src/extensions/plugin_manager.c b/src/extensions/plugin_manager.c new file mode 100644 index 0000000..f539ab1 --- /dev/null +++ b/src/extensions/plugin_manager.c @@ -0,0 +1,501 @@ +/* Gestor de extensiones: instalar, habilitar, deshabilitar, eliminar y ejecutar comandos. + * + * Reglas de seguridad de diseño: + * - NUNCA se carga un archivo solo por estar en la carpeta: debe estar en el registro + * (extensions.json) y habilitado explícitamente por el usuario; + * - los permisos concedidos limitan qué funciones del host se ofrecen (en Lua es una + * garantía real; en código nativo es solo una convención: ver plugin.h); + * - un comando de extensión se ejecuta dentro de un grupo del historial: se deshace de + * una vez, y si falla se revierte todo lo que hizo; + * - TGB_SAFE_MODE=1 en el entorno arranca sin cargar ninguna extensión. */ +#include +#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); +} diff --git a/src/history/history.c b/src/history/history.c new file mode 100644 index 0000000..07b177e --- /dev/null +++ b/src/history/history.c @@ -0,0 +1,406 @@ +/* Historial unificado: pilas de deshacer/rehacer, grupos y límites adaptativos. */ +#include +#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; +} diff --git a/src/history/history_internal.h b/src/history/history_internal.h new file mode 100644 index 0000000..a970016 --- /dev/null +++ b/src/history/history_internal.h @@ -0,0 +1,46 @@ +/* INTERNO: operaciones del historial y funciones para los demás módulos. */ +#ifndef TAHGIBU_HISTORY_INTERNAL_H +#define TAHGIBU_HISTORY_INTERNAL_H + +#include "../document/doc_internal.h" + +#define TGB_NOTIFY_PIXELS 1u +#define TGB_NOTIFY_FRAMES 2u +#define TGB_NOTIFY_LAYERS 4u +#define TGB_MAX_GROUP_DEPTH 8 + +typedef struct tgb_op tgb_op; +typedef struct tgb_spill_ctx { + const char *dir; + uint64_t serial; + size_t room; /* disco aún disponible */ +} tgb_spill_ctx; + +typedef struct tgb_op_vt { + tgb_status (*undo)(tgb_op *op, tgb_document *doc); /* si falla, el documento no cambia */ + tgb_status (*redo)(tgb_op *op, tgb_document *doc); /* aplica (también la 1ª vez) */ + void (*destroy)(tgb_op *op); + /* Pasa datos a disco; devuelve la memoria liberada (0 si no puede). Opcional. */ + size_t (*spill)(tgb_op *op, tgb_spill_ctx *ctx); +} tgb_op_vt; + +struct tgb_op { + const tgb_op_vt *vt; + char label[64]; + size_t mem_bytes; /* memoria estimada en uso */ + size_t disk_bytes; /* disco temporal en uso */ + unsigned notify; /* qué eventos emitir al deshacer/rehacer */ +}; + +struct tgb_history *hist_create(void); +void hist_destroy(struct tgb_history *h); +tgb_status hist_reserve(tgb_document *doc); /* garantiza sitio para 1 operación más */ +tgb_status hist_push(tgb_document *doc, tgb_op *op); /* toma posesión; solo tras hist_reserve */ +/* Reserva, aplica (vt->redo) y registra. Si falla destruye 'op'. */ +tgb_status hist_run(tgb_document *doc, tgb_op *op); +/* Utilidad: inicializa campos comunes de una operación. */ +void op_init(tgb_op *op, const tgb_op_vt *vt, const char *label, unsigned notify); +/* Bytes de una celda solo si está retenida únicamente por quien la consulta. */ +size_t cel_exclusive_bytes(tgb_canvas *c); + +#endif diff --git a/src/io/image_io.c b/src/io/image_io.c new file mode 100644 index 0000000..6e35ff5 --- /dev/null +++ b/src/io/image_io.c @@ -0,0 +1,122 @@ +/* Importación de imágenes y exportación (PNG, secuencia PNG, APNG). */ +#include +#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; +} diff --git a/src/io/io_internal.h b/src/io/io_internal.h new file mode 100644 index 0000000..cb77a6f --- /dev/null +++ b/src/io/io_internal.h @@ -0,0 +1,59 @@ +/* INTERNO: utilidades de entrada/salida (búfer de texto, Base64, JSON, PNG). */ +#ifndef TAHGIBU_IO_INTERNAL_H +#define TAHGIBU_IO_INTERNAL_H + +#include "../core/internal.h" + +/* ---------- búfer de texto/bytes creciente ---------- */ +typedef struct tgb_sb { + char *data; + size_t len, cap; + bool oom; /* true si alguna operación falló por memoria */ +} tgb_sb; +void sb_init(tgb_sb *sb); +void sb_free(tgb_sb *sb); +bool sb_append(tgb_sb *sb, const void *data, size_t n); +bool sb_puts(tgb_sb *sb, const char *s); +bool sb_printf(tgb_sb *sb, const char *fmt, ...) TGB_PRINTF(2, 3); +bool sb_json_string(tgb_sb *sb, const char *s, size_t n); /* con comillas y escapes */ +/* Entrega la propiedad del contenido (liberar con free). */ +char *sb_take(tgb_sb *sb, size_t *len); + +/* ---------- Base64 estándar con relleno ---------- */ +size_t tgb_b64_encoded_len(size_t n); +void tgb_b64_encode(const uint8_t *in, size_t n, char *out); /* escribe tgb_b64_encoded_len + NUL */ +/* Estricto: solo el alfabeto estándar, longitud múltiplo de 4, relleno solo al final. + * Acepta el prefijo exacto "data:image/png;base64,". */ +tgb_status tgb_b64_decode(const char *s, size_t len, size_t max_out, uint8_t **out, size_t *out_len); + +/* ---------- JSON ---------- */ +typedef enum { J_NULL = 0, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ } jtype; +typedef struct jnode { + jtype t; + bool b; + double num; + const char *s; /* J_STR: NO terminado en NUL; apunta al texto original o a una copia */ + size_t slen; + bool s_owned; + struct jnode *items; /* J_ARR / J_OBJ */ + char **keys; /* J_OBJ: claves terminadas en NUL */ + size_t n; + size_t start, end; /* posición del valor en el texto original */ +} jnode; +#define JSON_MAX_DEPTH 24 +#define JSON_MAX_NODES 4000000u +/* El texto debe seguir vivo mientras se use el árbol. Devuelve TGB_ERR_FORMAT con mensaje si no es JSON válido. */ +tgb_status json_parse(const char *src, size_t len, jnode **root); +void json_free(jnode *root); +const jnode *json_get(const jnode *obj, const char *key); +bool json_is_int(const jnode *n, long long lo, long long hi, long long *out); +bool json_str_eq(const jnode *n, const char *lit); + +/* ---------- PNG (interno) ---------- */ +/* Filtra y comprime (zlib) un lienzo RGBA 8 bits: datos listos para IDAT. */ +tgb_status png_compress_image(const tgb_canvas *c, uint8_t **out, size_t *len); +void png_put_chunk(tgb_sb *sb, const char type[4], const uint8_t *data, size_t len); +void png_put_signature(tgb_sb *sb); +void png_put_u32(uint8_t *p, uint32_t v); + +#endif diff --git a/src/io/io_util.c b/src/io/io_util.c new file mode 100644 index 0000000..11b9cb0 --- /dev/null +++ b/src/io/io_util.c @@ -0,0 +1,136 @@ +/* Búfer de texto y Base64. */ +#include +#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; +} diff --git a/src/io/json.c b/src/io/json.c new file mode 100644 index 0000000..99049f3 --- /dev/null +++ b/src/io/json.c @@ -0,0 +1,272 @@ +/* Analizador JSON estricto (RFC 8259) con límites de profundidad y de nodos. + * Cada valor recuerda su posición en el texto para poder conservar campos + * desconocidos tal cual. */ +#include +#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; +} diff --git a/src/io/png.c b/src/io/png.c new file mode 100644 index 0000000..659c5eb --- /dev/null +++ b/src/io/png.c @@ -0,0 +1,250 @@ +/* Lector y escritor de PNG (usa zlib solo para DEFLATE/CRC). */ +#include +#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); } diff --git a/src/io/tgb_format.c b/src/io/tgb_format.c new file mode 100644 index 0000000..945985d --- /dev/null +++ b/src/io/tgb_format.c @@ -0,0 +1,464 @@ +/* Lectura y escritura de proyectos .tgb (formatos 2 y 3). Ver docs/FORMAT.md. */ +#include +#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; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..3d98eb3 --- /dev/null +++ b/src/main.c @@ -0,0 +1,46 @@ +/* Tahgibu 2.0.0: punto de entrada. + * Uso: tahgibu [--version] [--safe-mode] [archivo.tgb] */ +#include +#include +#include +#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; +} diff --git a/src/platform/linux/platform_linux.c b/src/platform/linux/platform_linux.c new file mode 100644 index 0000000..9622afb --- /dev/null +++ b/src/platform/linux/platform_linux.c @@ -0,0 +1,217 @@ +/* Adaptación a Linux (POSIX). */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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"; } diff --git a/src/platform/platform.h b/src/platform/platform.h new file mode 100644 index 0000000..b66d57d --- /dev/null +++ b/src/platform/platform.h @@ -0,0 +1,48 @@ +/* Capa de adaptación al sistema operativo (INTERNA). Hay una implementación + * para Linux (probada) y otra para Windows (escrita, sin compilar ni probar + * todavía: ver docs/STATUS.md). */ +#ifndef TAHGIBU_PLATFORM_H +#define TAHGIBU_PLATFORM_H + +#include +#include +#include +#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 diff --git a/src/platform/windows/platform_windows.c b/src/platform/windows/platform_windows.c new file mode 100644 index 0000000..ea64ecc --- /dev/null +++ b/src/platform/windows/platform_windows.c @@ -0,0 +1,195 @@ +/* Adaptación a Windows (Win32). + * + * ESTADO: escrito, NO compilado ni probado (el entorno de desarrollo era Linux y no había + * compilador para Windows). Hay que compilarlo y pasar las pruebas en Windows antes de + * distribuirlo. Ver docs/STATUS.md. + * + * Todas las rutas de la API pública son UTF-8; aquí se convierten a UTF-16 para las funciones W. */ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include +#include +#include +#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"; } diff --git a/src/recovery/autosave.c b/src/recovery/autosave.c new file mode 100644 index 0000000..56a8f42 --- /dev/null +++ b/src/recovery/autosave.c @@ -0,0 +1,347 @@ +/* Autoguardado y recuperación de sesiones. + * + * Carpeta: /recovery/ + * session-.lock marcador de una sesión en marcha (id = pid-inicio) + * autosave--.tgb copia temporal del documento n de esa sesión + * autosave--.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 +#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--.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); +} diff --git a/src/recovery/recovery_points.c b/src/recovery/recovery_points.c new file mode 100644 index 0000000..1eb5df6 --- /dev/null +++ b/src/recovery/recovery_points.c @@ -0,0 +1,170 @@ +/* Puntos de recuperación nombrados (viven dentro del documento y del .tgb). + * Son instantáneas de la estructura que COMPARTEN las celdas con el + * documento (copy-on-write): crear uno es barato; cuesta memoria solo + * lo que después se edita. */ +#include +#include +#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; +} diff --git a/src/rendering/canvas.c b/src/rendering/canvas.c new file mode 100644 index 0000000..19b3f42 --- /dev/null +++ b/src/rendering/canvas.c @@ -0,0 +1,207 @@ +/* Lienzo RGBA8 no premultiplicado con seguimiento de zona modificada. */ +#include "../core/internal.h" + +struct tgb_canvas { + int refs; + int w, h; + int dx0, dy0, dx1, dy1; /* zona modificada, [x0,x1) x [y0,y1); vacía si x0>=x1 */ + uint8_t *px; +}; + +tgb_rect tgb_rect_intersect(tgb_rect a, tgb_rect b) { + int x0 = a.x > b.x ? a.x : b.x; + int y0 = a.y > b.y ? a.y : b.y; + int x1 = (a.x + a.w) < (b.x + b.w) ? (a.x + a.w) : (b.x + b.w); + int y1 = (a.y + a.h) < (b.y + b.h) ? (a.y + a.h) : (b.y + b.h); + tgb_rect r = {x0, y0, x1 - x0, y1 - y0}; + if (r.w <= 0 || r.h <= 0) { r.w = 0; r.h = 0; } + return r; +} + +tgb_rect tgb_rect_union(tgb_rect a, tgb_rect b) { + if (a.w <= 0 || a.h <= 0) return b; + if (b.w <= 0 || b.h <= 0) return a; + int x0 = a.x < b.x ? a.x : b.x; + int y0 = a.y < b.y ? a.y : b.y; + int x1 = (a.x + a.w) > (b.x + b.w) ? (a.x + a.w) : (b.x + b.w); + int y1 = (a.y + a.h) > (b.y + b.h) ? (a.y + a.h) : (b.y + b.h); + tgb_rect r = {x0, y0, x1 - x0, y1 - y0}; + return r; +} + +tgb_canvas *tgb_canvas_create(int width, int height) { + if (width < 1 || height < 1 || width > TGB_MAX_DIMENSION || height > TGB_MAX_DIMENSION) { + tgb_fail(TGB_ERR_RANGE, "Dimensiones de lienzo no válidas: %dx%d (máximo %d)", width, height, + TGB_MAX_DIMENSION); + return NULL; + } + tgb_canvas *c = calloc(1, sizeof *c); + if (!c) { tgb_fail(TGB_ERR_NOMEM, "Sin memoria para el lienzo"); return NULL; } + c->px = calloc((size_t)width * (size_t)height, 4); + if (!c->px) { free(c); tgb_fail(TGB_ERR_NOMEM, "Sin memoria para %dx%d píxeles", width, height); return NULL; } + c->refs = 1; + c->w = width; + c->h = height; + return c; +} + +tgb_canvas *tgb_canvas_clone(const tgb_canvas *src) { + if (!src) return NULL; + tgb_canvas *c = tgb_canvas_create(src->w, src->h); + if (!c) return NULL; + memcpy(c->px, src->px, tgb_canvas_bytes(src)); + return c; +} + +tgb_canvas *tgb_canvas_retain(tgb_canvas *c) { if (c) c->refs++; return c; } + +void tgb_canvas_release(tgb_canvas *c) { + if (!c) return; + if (--c->refs <= 0) { free(c->px); free(c); } +} + +int tgb_canvas_refs(const tgb_canvas *c) { return c ? c->refs : 0; } +int tgb_canvas_width(const tgb_canvas *c) { return c ? c->w : 0; } +int tgb_canvas_height(const tgb_canvas *c) { return c ? c->h : 0; } +size_t tgb_canvas_bytes(const tgb_canvas *c) { return c ? (size_t)c->w * (size_t)c->h * 4u : 0; } +const uint8_t *tgb_canvas_pixels(const tgb_canvas *c) { return c ? c->px : NULL; } +uint8_t *tgb_canvas_pixels_mut(tgb_canvas *c) { return c ? c->px : NULL; } + +static inline void mark_px(tgb_canvas *c, int x, int y) { + if (c->dx0 >= c->dx1) { c->dx0 = x; c->dx1 = x + 1; c->dy0 = y; c->dy1 = y + 1; return; } + if (x < c->dx0) c->dx0 = x; + if (x >= c->dx1) c->dx1 = x + 1; + if (y < c->dy0) c->dy0 = y; + if (y >= c->dy1) c->dy1 = y + 1; +} + +tgb_rect tgb_canvas_dirty(const tgb_canvas *c) { + tgb_rect r = {0, 0, 0, 0}; + if (c && c->dx0 < c->dx1) { r.x = c->dx0; r.y = c->dy0; r.w = c->dx1 - c->dx0; r.h = c->dy1 - c->dy0; } + return r; +} +void tgb_canvas_reset_dirty(tgb_canvas *c) { if (c) c->dx0 = c->dy0 = c->dx1 = c->dy1 = 0; } +void tgb_canvas_mark_dirty(tgb_canvas *c, tgb_rect r) { + if (!c) return; + tgb_rect bounds = {0, 0, c->w, c->h}; + r = tgb_rect_intersect(r, bounds); + if (r.w <= 0) return; + mark_px(c, r.x, r.y); + mark_px(c, r.x + r.w - 1, r.y + r.h - 1); +} + +tgb_color tgb_canvas_get(const tgb_canvas *c, int x, int y) { + tgb_color z = {0, 0, 0, 0}; + if (!c || x < 0 || y < 0 || x >= c->w || y >= c->h) return z; + const uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u; + tgb_color r = {p[0], p[1], p[2], p[3]}; + return r; +} + +void tgb_canvas_set(tgb_canvas *c, int x, int y, tgb_color col) { + if (!c || x < 0 || y < 0 || x >= c->w || y >= c->h) return; + uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u; + if (col.a == 0) { p[0] = p[1] = p[2] = p[3] = 0; } /* forma canónica: transparente = 0,0,0,0 */ + else { p[0] = col.r; p[1] = col.g; p[2] = col.b; p[3] = col.a; } + mark_px(c, x, y); +} + +static inline void blend_into(uint8_t *d, uint8_t sr, uint8_t sg, uint8_t sb, unsigned sa) { + if (sa == 0) return; + unsigned da = d[3]; + if (sa == 255 || da == 0) { d[0] = sr; d[1] = sg; d[2] = sb; d[3] = (uint8_t)sa; return; } + unsigned inv = 255u - sa; + unsigned oa = sa + (da * inv + 127u) / 255u; + unsigned den = oa * 255u; + d[0] = (uint8_t)((sr * sa * 255u + d[0] * da * inv + den / 2u) / den); + d[1] = (uint8_t)((sg * sa * 255u + d[1] * da * inv + den / 2u) / den); + d[2] = (uint8_t)((sb * sa * 255u + d[2] * da * inv + den / 2u) / den); + d[3] = (uint8_t)oa; +} + +void tgb_canvas_blend(tgb_canvas *c, int x, int y, tgb_color col) { + if (!c || col.a == 0 || x < 0 || y < 0 || x >= c->w || y >= c->h) return; + blend_into(c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u, col.r, col.g, col.b, col.a); + mark_px(c, x, y); +} + +void tgb_canvas_erase_px(tgb_canvas *c, int x, int y, uint8_t amount) { + if (!c || amount == 0 || x < 0 || y < 0 || x >= c->w || y >= c->h) return; + uint8_t *p = c->px + ((size_t)y * (size_t)c->w + (size_t)x) * 4u; + if (p[3] == 0) return; + unsigned na = (p[3] * (255u - amount) + 127u) / 255u; + if (na == 0) p[0] = p[1] = p[2] = p[3] = 0; else p[3] = (uint8_t)na; + mark_px(c, x, y); +} + +void tgb_canvas_clear(tgb_canvas *c) { + if (!c) return; + memset(c->px, 0, tgb_canvas_bytes(c)); + tgb_rect all = {0, 0, c->w, c->h}; + tgb_canvas_mark_dirty(c, all); +} + +void tgb_canvas_fill_rect(tgb_canvas *c, tgb_rect r, tgb_color col, bool blend) { + if (!c) return; + tgb_rect bounds = {0, 0, c->w, c->h}; + r = tgb_rect_intersect(r, bounds); + for (int y = r.y; y < r.y + r.h; y++) + for (int x = r.x; x < r.x + r.w; x++) { + if (blend) tgb_canvas_blend(c, x, y, col); else tgb_canvas_set(c, x, y, col); + } +} + +void tgb_canvas_blit(tgb_canvas *dst, const tgb_canvas *src, int dx, int dy, uint8_t opacity, bool blend) { + if (!dst || !src || opacity == 0 || dst == src) return; + tgb_rect bounds = {0, 0, dst->w, dst->h}; + tgb_rect area = {dx, dy, src->w, src->h}; + area = tgb_rect_intersect(area, bounds); + for (int y = area.y; y < area.y + area.h; y++) { + const uint8_t *s = src->px + ((size_t)(y - dy) * (size_t)src->w + (size_t)(area.x - dx)) * 4u; + uint8_t *d = dst->px + ((size_t)y * (size_t)dst->w + (size_t)area.x) * 4u; + for (int x = 0; x < area.w; x++, s += 4, d += 4) { + unsigned sa = opacity == 255 ? s[3] : (s[3] * (unsigned)opacity + 127u) / 255u; + if (blend) { + if (sa == 0) continue; + blend_into(d, s[0], s[1], s[2], sa); + } else if (sa == 0) { d[0] = d[1] = d[2] = d[3] = 0; } + else { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = (uint8_t)sa; } + mark_px(dst, area.x + x, y); + } + } +} + +tgb_status tgb_canvas_crop(const tgb_canvas *src, tgb_rect r, tgb_canvas **out) { + if (!src || !out) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_canvas_crop: argumento nulo"); + if (r.w < 1 || r.h < 1 || r.x < 0 || r.y < 0 || r.x + r.w > src->w || r.y + r.h > src->h) + return tgb_fail(TGB_ERR_RANGE, "Recorte fuera del lienzo"); + tgb_canvas *c = tgb_canvas_create(r.w, r.h); + if (!c) return TGB_ERR_NOMEM; + for (int y = 0; y < r.h; y++) + memcpy(c->px + (size_t)y * (size_t)r.w * 4u, + src->px + ((size_t)(r.y + y) * (size_t)src->w + (size_t)r.x) * 4u, (size_t)r.w * 4u); + *out = c; + return TGB_OK; +} + +bool tgb_canvas_is_empty(const tgb_canvas *c) { + if (!c) return true; + size_t n = (size_t)c->w * (size_t)c->h; + for (size_t i = 0; i < n; i++) if (c->px[i * 4u + 3u]) return false; + return true; +} + +bool tgb_canvas_equal(const tgb_canvas *a, const tgb_canvas *b) { + if (a == b) return true; + if (!a || !b || a->w != b->w || a->h != b->h) return false; + return memcmp(a->px, b->px, tgb_canvas_bytes(a)) == 0; +} + +tgb_status tgb_canvas_copy_pixels(tgb_canvas *dst, const tgb_canvas *src) { + if (!dst || !src) return tgb_fail(TGB_ERR_INVALID_ARG, "tgb_canvas_copy_pixels: argumento nulo"); + if (dst->w != src->w || dst->h != src->h) return tgb_fail(TGB_ERR_RANGE, "Los lienzos tienen distinto tamaño"); + memcpy(dst->px, src->px, tgb_canvas_bytes(src)); + tgb_rect all = {0, 0, dst->w, dst->h}; + tgb_canvas_mark_dirty(dst, all); + return TGB_OK; +} diff --git a/src/ui/ui_app.h b/src/ui/ui_app.h new file mode 100644 index 0000000..49fdeda --- /dev/null +++ b/src/ui/ui_app.h @@ -0,0 +1,125 @@ +/* INTERNO de la interfaz gráfica (raylib). Estado de la aplicación y funciones compartidas. + * La interfaz SOLO usa la API pública (include/tahgibu): no toca estructuras internas. */ +#ifndef TAHGIBU_UI_APP_H +#define TAHGIBU_UI_APP_H + +#include +#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 diff --git a/src/ui/ui_canvas.c b/src/ui/ui_canvas.c new file mode 100644 index 0000000..29806ab --- /dev/null +++ b/src/ui/ui_canvas.c @@ -0,0 +1,455 @@ +#if defined(__linux__) +#define _POSIX_C_SOURCE 200809L +#endif +/* Lienzo central: zoom, desplazamiento y herramientas de dibujo. + * Todo lo que se dibuja pasa por tgb_edit_* (sesión de edición): nada se + * pinta "solo en pantalla"; cada trazo queda en el modelo y en el historial. */ +#include +#include +#include +#include +#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)); +} diff --git a/src/ui/ui_main.c b/src/ui/ui_main.c new file mode 100644 index 0000000..76889af --- /dev/null +++ b/src/ui/ui_main.c @@ -0,0 +1,510 @@ +/* Ventana principal: barra superior, diálogos, atajos y bucle de la aplicación. */ +#include +#include +#include +#include +#include +#include +#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; +} diff --git a/src/ui/ui_panel.c b/src/ui/ui_panel.c new file mode 100644 index 0000000..6f9ca6f --- /dev/null +++ b/src/ui/ui_panel.c @@ -0,0 +1,191 @@ +/* Panel de edición (derecha): opciones de herramienta, color, capas, Onion Skin, + * puntos de recuperación y extensiones. */ +#include +#include +#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}); + } +} diff --git a/src/ui/ui_run.h b/src/ui/ui_run.h new file mode 100644 index 0000000..0dc532d --- /dev/null +++ b/src/ui/ui_run.h @@ -0,0 +1,11 @@ +/* Punto de entrada de la interfaz gráfica. */ +#ifndef TAHGIBU_UI_RUN_H +#define TAHGIBU_UI_RUN_H + +#include "tahgibu/tahgibu.h" + +/* Abre la ventana y ejecuta el editor hasta que el usuario sale. open_path puede ser NULL. + * recovery/plugins pueden ser NULL (se desactivan esas funciones). Devuelve 0 si todo fue bien. */ +int tgb_ui_run(tgb_context *ctx, tgb_recovery *recovery, tgb_plugin_manager *plugins, const char *open_path); + +#endif diff --git a/src/ui/ui_timeline.c b/src/ui/ui_timeline.c new file mode 100644 index 0000000..4beb066 --- /dev/null +++ b/src/ui/ui_timeline.c @@ -0,0 +1,89 @@ +/* Línea de tiempo: representa los fotogramas REALES del documento. */ +#include +#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(); +} diff --git a/src/ui/ui_widgets.c b/src/ui/ui_widgets.c new file mode 100644 index 0000000..c92fb80 --- /dev/null +++ b/src/ui/ui_widgets.c @@ -0,0 +1,181 @@ +/* Widgets mínimos de interfaz de estilo inmediato sobre raylib. */ +#include +#include +#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; +} diff --git a/tahgibu_animator.py b/tahgibu_animator.py deleted file mode 100755 index e115524..0000000 --- a/tahgibu_animator.py +++ /dev/null @@ -1,955 +0,0 @@ -#!/usr/bin/env python3 -""" -Tahgibu Animator -Animación 2D cuadro por cuadro, simple y sin marca de agua. -Pensado para andar liviano en equipos modestos (Linux). -""" -import tkinter as tk -from tkinter import ttk, filedialog, messagebox, colorchooser -import os, json, base64, io, subprocess -from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageChops - -APP_NAME = "Tahgibu Animator" -VERSION = "1.0.0" -FORMAT_NAME = "TAHGIBU" -LEGACY_FORMAT_NAMES = ("TAHGIBU", "ASM") # ASM = formato del proyecto original, se puede seguir abriendo -FILE_EXT = ".tgb" -UNDO_LIMIT = 40 -THUMB_W, THUMB_H = 34, 34 -SLOT_W = 44 - - -class TahgibuAnimator: - def __init__(self, root): - self.root = root - self.root.title(f"{APP_NAME} {VERSION}") - self.root.geometry("1280x800") - self.root.minsize(980, 650) - - # --- paleta --- - self.bg_color = "#171a20" - self.panel = "#20242b" - self.panel2 = "#292f38" - self.accent = "#6c7cff" - self.text = "#eef1f6" - self.muted = "#9ca6b5" - self.root.configure(bg=self.bg_color) - - # --- estado de la animación --- - self.W, self.H = 960, 540 - self.fps = 12 - self.current = 0 - self.frames = [Image.new("RGBA", (self.W, self.H), (0, 0, 0, 0))] - self.background = None # imagen original (para guardar) - self._bg_scaled = None # versión ya escalada (cache, no se recalcula en cada render) - self._bg_pos = (0, 0) - self.sprites = [] # lista de (nombre, imagen) - - # --- herramientas --- - self.color = "#111111" - self.brush_size = 7 - self.tool = "solid" - self.stamp_mode = False - self.pending_sprite = None - - # --- reproducción --- - self.playing = False - self.play_after = None - - # --- deshacer / rehacer (por acción, no copia toda la animación) --- - self.undo_stack = [] - self.redo_stack = [] - - # --- varios --- - self.tk_image = None - self.last = None - self.dirty = False - self.current_path = None - self._thumb_cache = {} - self._onion_index = None - self._onion_valid = False - self._onion_prev = None - self._onion_next = None - - self.make_ui() - self.root.protocol("WM_DELETE_WINDOW", self.on_close) - self.root.update_idletasks() # asegura tamaño real del canvas antes del primer dibujo - self.render() - - # ---------------------------------------------------------------- UI --- - def make_ui(self): - top = tk.Frame(self.root, bg=self.panel, height=54) - top.pack(fill="x") - top.pack_propagate(False) - - tk.Label(top, text="Tahgibu", bg=self.panel, fg=self.accent, - font=("DejaVu Sans", 16, "bold")).pack(side="left", padx=(16, 3)) - tk.Label(top, text="ANIMATOR", bg=self.panel, fg=self.text, - font=("DejaVu Sans", 11, "bold")).pack(side="left", padx=(0, 18)) - - def top_btn(txt, cmd): - return tk.Button(top, text=txt, command=cmd, bg=self.panel2, fg=self.text, - activebackground="#3a414d", activeforeground="white", - relief="flat", padx=10) - - for txt, cmd in [ - ("Nuevo", self.new_project), - ("Abrir", self.open_asm), - ("Guardar", self.save_asm), - ("Guardar como…", self.save_asm_as), - ("Importar", self.import_asm), - ("Pegar imagen", self.paste_image), - ]: - top_btn(txt, cmd).pack(side="left", padx=3, pady=10) - - tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6) - - top_btn("↶ Deshacer", self.undo).pack(side="left", padx=3, pady=10) - top_btn("↷ Rehacer", self.redo).pack(side="left", padx=3, pady=10) - - tk.Frame(top, bg="#3a414d", width=1).pack(side="left", fill="y", pady=12, padx=6) - - self.export_menu_btn = tk.Menubutton(top, text="Exportar ▾", bg=self.panel2, fg=self.text, - activebackground="#3a414d", relief="flat", padx=10) - export_menu = tk.Menu(self.export_menu_btn, tearoff=0) - export_menu.add_command(label="GIF animado…", command=self.export_gif) - export_menu.add_command(label="Secuencia de PNG…", command=self.export_png_sequence) - self.export_menu_btn.configure(menu=export_menu) - self.export_menu_btn.pack(side="left", padx=3, pady=10) - - top_btn("❓ Ayuda", self.show_help).pack(side="left", padx=3, pady=10) - - tk.Button(top, text="▶", command=self.toggle_play, bg=self.accent, fg="white", - relief="flat", width=4).pack(side="right", padx=(3, 16), pady=10) - self.fps_var = tk.IntVar(value=self.fps) - tk.Spinbox(top, from_=1, to=60, textvariable=self.fps_var, width=4, - bg=self.panel2, fg=self.text, buttonbackground=self.panel2, - relief="flat").pack(side="right", padx=4) - tk.Label(top, text="FPS", bg=self.panel, fg=self.muted).pack(side="right") - - body = tk.Frame(self.root, bg=self.bg_color) - body.pack(fill="both", expand=True) - - # ------------------------------------------------------ panel izq --- - tools = tk.Frame(body, bg=self.panel, width=210) - tools.pack(side="left", fill="y") - tools.pack_propagate(False) - - tk.Label(tools, text="HERRAMIENTAS", bg=self.panel, fg=self.muted, - font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 8)) - - self.tool_buttons = {} - for key, label in [ - ("soft", "◌ Lápiz suave"), - ("solid", "● Lápiz sólido"), - ("eraser", "⌫ Borrador"), - ]: - b = tk.Button(tools, text=label, command=lambda k=key: self.set_tool(k), - bg=self.panel2, fg=self.text, activebackground=self.accent, - activeforeground="white", relief="flat", anchor="w", padx=12) - b.pack(fill="x", padx=10, pady=3) - self.tool_buttons[key] = b - - tk.Button(tools, text="🎨 Elegir color", command=self.choose_color, - bg=self.panel2, fg=self.text, activebackground="#3a414d", - relief="flat", anchor="w", padx=12).pack(fill="x", padx=10, pady=(14, 4)) - self.color_preview = tk.Label(tools, text=" ", bg=self.color, relief="solid", bd=1) - self.color_preview.pack(anchor="w", padx=14, pady=3) - - size_row = tk.Frame(tools, bg=self.panel) - size_row.pack(fill="x", padx=14, pady=(14, 0)) - tk.Label(size_row, text="Tamaño", bg=self.panel, fg=self.muted).pack(side="left") - self.size_label = tk.Label(size_row, text=str(self.brush_size), bg=self.panel, fg=self.text) - self.size_label.pack(side="right") - self.size_scale = tk.Scale(tools, from_=1, to=60, orient="horizontal", - command=self.on_size_change, - bg=self.panel, fg=self.text, troughcolor="#363d48", - highlightthickness=0) - self.size_scale.set(self.brush_size) - self.size_scale.pack(fill="x", padx=8) - - self.onion_var = tk.BooleanVar(value=True) - tk.Checkbutton(tools, text="Onion Skin", variable=self.onion_var, - command=self.render, bg=self.panel, fg=self.text, - selectcolor=self.panel2, activebackground=self.panel, - activeforeground=self.text).pack(anchor="w", padx=10, pady=(12, 2)) - - tk.Button(tools, text="🗑 Vaciar frame actual", command=self.clear_frame, - bg=self.panel2, fg=self.text, relief="flat", anchor="w", - padx=12).pack(fill="x", padx=10, pady=(6, 4)) - - tk.Label(tools, text="FONDO", bg=self.panel, fg=self.muted, - font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6)) - bg_row = tk.Frame(tools, bg=self.panel) - bg_row.pack(fill="x", padx=10) - tk.Button(bg_row, text="Elegir…", command=self.choose_background, - bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3)) - tk.Button(bg_row, text="Quitar", command=self.clear_background, - bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0)) - - tk.Label(tools, text="SPRITES", bg=self.panel, fg=self.muted, - font=("DejaVu Sans", 9, "bold")).pack(anchor="w", padx=14, pady=(16, 6)) - sprite_btn_row = tk.Frame(tools, bg=self.panel) - sprite_btn_row.pack(fill="x", padx=10) - tk.Button(sprite_btn_row, text="+ Añadir", command=self.add_sprite, - bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(0, 3)) - tk.Button(sprite_btn_row, text="− Quitar", command=self.remove_sprite, - bg=self.panel2, fg=self.text, relief="flat").pack(side="left", expand=True, fill="x", padx=(3, 0)) - self.sprite_list = tk.Listbox(tools, bg="#171a20", fg=self.text, height=6, - relief="flat", highlightthickness=0) - self.sprite_list.pack(fill="both", expand=True, padx=10, pady=5) - tk.Button(tools, text="Poner sprite en el lienzo", command=self.place_sprite, - bg=self.accent, fg="white", relief="flat").pack(fill="x", padx=10, pady=(2, 10)) - - # ---------------------------------------------------------- lienzo --- - stage = tk.Frame(body, bg="#0f1115") - stage.pack(side="left", fill="both", expand=True) - - self.canvas = tk.Canvas(stage, bg="#0f1115", highlightthickness=0) - self.canvas.pack(fill="both", expand=True, padx=16, pady=16) - self.canvas.bind("", lambda e: self.render()) - self.canvas.bind("", self.start_draw) - self.canvas.bind("", self.paint) - self.canvas.bind("", 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("", self.timeline_click) - self.timeline.bind("", self._on_timeline_wheel) - self.timeline.bind("", self._on_timeline_wheel) - self.timeline.bind("", self._on_timeline_wheel) - - # -------------------------------------------------------- atajos --- - self.root.bind("", lambda e: self.new_frame()) - self.root.bind("", lambda e: self.select_frame(self.current - 1)) - self.root.bind("", lambda e: self.select_frame(self.current + 1)) - self.root.bind("", lambda e: self.select_frame(0)) - self.root.bind("", lambda e: self.select_frame(len(self.frames) - 1)) - self.root.bind("", lambda e: self.toggle_play()) - self.root.bind("", lambda e: self.undo()) - self.root.bind("", lambda e: self.redo()) - self.root.bind("", lambda e: self.redo()) - self.root.bind("", lambda e: self.paste_image()) - self.root.bind("", lambda e: self.save_asm()) - self.root.bind("", lambda e: self.new_project()) - self.root.bind("", lambda e: self.open_asm()) - self.root.bind("", 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() diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp deleted file mode 100644 index 8f1fe07..0000000 --- a/tests/core_tests.cpp +++ /dev/null @@ -1,101 +0,0 @@ -// Pruebas del núcleo. No usan raylib. -#include -#include -#include -#include - -#include "core/Constants.hpp" -#include "core/Frame.hpp" -#include "core/Project.hpp" - -namespace { - -int g_failures = 0; - -void check(bool condition, const char* expression, int line) { - if (!condition) { - std::fprintf(stderr, "FALLO (línea %d): %s\n", line, expression); - ++g_failures; - } -} - -bool near(double a, double b) { - return std::fabs(a - b) < 1e-9; -} - -} // namespace - -#define CHECK(expr) check((expr), #expr, __LINE__) - -int main() { - using namespace tahgibu; - - // --- Límite de FPS ----------------------------------------------------- - CHECK(Frame::clampFps(120.0f) == kMaxFps); - CHECK(Frame::clampFps(60.0f) == 60.0f); - CHECK(Frame::clampFps(1.0f) == 1.0f); - CHECK(Frame::clampFps(0.0f) == kMinFps); - CHECK(Frame::clampFps(-5.0f) == kMinFps); - CHECK(Frame::clampFps(std::numeric_limits::quiet_NaN()) == kDefaultFps); - CHECK(Frame::clampFps(std::numeric_limits::infinity()) == kDefaultFps); - - // --- Frame --------------------------------------------------------------- - Frame frame; - CHECK(!frame.hasFpsOverride()); - CHECK(near(frame.durationSeconds(24.0f), 1.0 / 24.0)); - - frame.setFpsOverride(1.0f); - CHECK(frame.hasFpsOverride()); - CHECK(near(frame.durationSeconds(60.0f), 1.0)); - - frame.setFpsOverride(1000.0f); // nunca por encima de 60 - CHECK(frame.effectiveFps(24.0f) == kMaxFps); - - frame.clearFpsOverride(); - CHECK(!frame.hasFpsOverride()); - CHECK(frame.effectiveFps(30.0f) == 30.0f); - CHECK(frame.effectiveFps(500.0f) == kMaxFps); // la base también se limita - - // --- Project ------------------------------------------------------------- - Project project; - CHECK(project.frameCount() == 1); - CHECK(project.baseFps() == kDefaultFps); - - project.setBaseFps(999.0f); - CHECK(project.baseFps() == kMaxFps); - project.setBaseFps(std::numeric_limits::quiet_NaN()); - CHECK(project.baseFps() == kDefaultFps); - - // Ejemplo de la especificación: A(60) B(60) C(1) D(60) - Project example; - example.setBaseFps(60.0f); - example.addFrame(); // B - const std::size_t c = example.addFrame(); // C - example.addFrame(); // D - example.frame(c).setFpsOverride(1.0f); - - CHECK(example.frameCount() == 4); - CHECK(near(example.frameDurationSeconds(0), 1.0 / 60.0)); - CHECK(near(example.frameDurationSeconds(c), 1.0)); - CHECK(near(example.totalDurationSeconds(), 3.0 / 60.0 + 1.0)); - - // Cambiar el FPS base afecta a los frames sin FPS propio, pero no al de C. - example.setBaseFps(30.0f); - CHECK(near(example.frameDurationSeconds(0), 1.0 / 30.0)); - CHECK(near(example.frameDurationSeconds(c), 1.0)); - - bool threw = false; - try { - example.frame(99); - } catch (const std::out_of_range&) { - threw = true; - } - CHECK(threw); - - if (g_failures == 0) { - std::puts("OK: todas las pruebas del núcleo pasaron."); - return 0; - } - std::fprintf(stderr, "%d prueba(s) fallaron.\n", g_failures); - return 1; -} diff --git a/tests/fixtures_png.h b/tests/fixtures_png.h new file mode 100644 index 0000000..834568e --- /dev/null +++ b/tests/fixtures_png.h @@ -0,0 +1,35 @@ +/* GENERADO por tools/gen_png_fixtures.py con Pillow: no editar a mano. */ +#ifndef TGB_FIXTURES_PNG_H +#define TGB_FIXTURES_PNG_H +#include +#include +#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 diff --git a/tests/plugins/plug_common.h b/tests/plugins/plug_common.h new file mode 100644 index 0000000..3cad3a3 --- /dev/null +++ b/tests/plugins/plug_common.h @@ -0,0 +1,14 @@ +/* Ayudas comunes de las extensiones de PRUEBA. */ +#ifndef PLUG_COMMON_H +#define PLUG_COMMON_H +#include +#include +#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 diff --git a/tests/plugins/plug_events.c b/tests/plugins/plug_events.c new file mode 100644 index 0000000..c27d783 --- /dev/null +++ b/tests/plugins/plug_events.c @@ -0,0 +1,14 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +static const tgb_host_api *H; +static int seen_pixels = 0, seen_total = 0; +static void on_event(const tgb_event *ev, void *user) { (void)user; seen_total++; if (ev->type == TGB_EVENT_PIXELS_CHANGED) seen_pixels++; } +static tgb_status cmd_seen(tgb_plugin_handle *self, void *user) { (void)self; (void)user; return seen_pixels > 0 ? TGB_OK : TGB_ERR_PLUGIN; } +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + H = host; + tgb_status s = host->subscribe(self, on_event, NULL); + if (s < 0) return s; + host->register_command(self, "eventos-vistos", cmd_seen, NULL); + info->name = "Escucha eventos"; info->version = "1.0"; info->required_caps = TGB_CAP_EVENTS | TGB_CAP_READ_DOCUMENT; + return TGB_OK; +} diff --git a/tests/plugins/plug_good.c b/tests/plugins/plug_good.c new file mode 100644 index 0000000..f2fc43a --- /dev/null +++ b/tests/plugins/plug_good.c @@ -0,0 +1,57 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +static const tgb_host_api *H; +static const tgb_color RED = {255, 0, 0, 255}; + +static tgb_status cmd_cross(tgb_plugin_handle *self, void *user) { + (void)user; + tgb_status s = H->edit_begin(self, 0, 0); + if (s < 0) return s; + int cx = H->doc_width(self) / 2, cy = H->doc_height(self) / 2; + for (int i = -3; i <= 3; i++) { H->put_pixel(self, cx + i, cy, RED); H->put_pixel(self, cx, cy + i, RED); } + return H->edit_end(self); +} +static tgb_status cmd_two_steps(tgb_plugin_handle *self, void *user) { + (void)user; + tgb_status s = H->add_frame(self, H->doc_frame_count(self)); /* paso 1: estructura */ + if (s < 0) return s; + s = H->edit_begin(self, 0, 0); /* paso 2: píxeles */ + if (s < 0) return s; + H->put_pixel(self, 1, 1, RED); + return H->edit_end(self); +} +static tgb_status cmd_fail_midway(tgb_plugin_handle *self, void *user) { + (void)user; + H->add_frame(self, 0); + if (H->edit_begin(self, 0, 0) == TGB_OK) { H->put_pixel(self, 2, 2, RED); H->edit_end(self); } + return TGB_ERR_PLUGIN; /* falla a mitad: el host debe revertirlo todo */ +} +static tgb_status cmd_forget_end(tgb_plugin_handle *self, void *user) { + (void)user; + tgb_status s = H->edit_begin(self, 0, 0); + if (s < 0) return s; + H->put_pixel(self, 3, 3, RED); + return TGB_OK; /* olvida edit_end */ +} +static tgb_status cmd_read(tgb_plugin_handle *self, void *user) { + (void)user; + return H->get_pixel(self, 0, 0, 0, 0).a == 0 ? TGB_OK : TGB_ERR_PLUGIN; +} +static tgb_status cmd_outside(tgb_plugin_handle *self, void *user) { (void)user; (void)self; return TGB_OK; } + +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + H = host; + mark("init"); + if (host->api_version != TGB_PLUGIN_API_VERSION || host->struct_size < sizeof(tgb_host_api)) return TGB_ERR_INCOMPATIBLE; + host->register_command(self, "dibujar-cruz", cmd_cross, NULL); + host->register_command(self, "dos-pasos", cmd_two_steps, NULL); + host->register_command(self, "falla-a-medias", cmd_fail_midway, NULL); + host->register_command(self, "olvida-cerrar", cmd_forget_end, NULL); + host->register_command(self, "lee-pixel", cmd_read, NULL); + host->register_command(self, "no-hace-nada", cmd_outside, NULL); + info->name = "Cruz de prueba"; + info->version = "1.0.0"; + info->required_caps = TGB_CAP_READ_DOCUMENT | TGB_CAP_EDIT_DOCUMENT; + return TGB_OK; +} +TGB_PLUGIN_EXPORT void tgb_plugin_shutdown(tgb_plugin_handle *self) { (void)self; mark("shutdown"); } diff --git a/tests/plugins/plug_init_fails.c b/tests/plugins/plug_init_fails.c new file mode 100644 index 0000000..669a4e0 --- /dev/null +++ b/tests/plugins/plug_init_fails.c @@ -0,0 +1,6 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + (void)host; (void)self; (void)info; + return TGB_ERR_PLUGIN; +} diff --git a/tests/plugins/plug_needs_edit.c b/tests/plugins/plug_needs_edit.c new file mode 100644 index 0000000..afc1696 --- /dev/null +++ b/tests/plugins/plug_needs_edit.c @@ -0,0 +1,9 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + (void)host; (void)self; + mark("init-needs-edit"); + info->name = "Pide editar"; info->version = "1"; info->required_caps = TGB_CAP_EDIT_DOCUMENT; + return TGB_OK; +} +TGB_PLUGIN_EXPORT void tgb_plugin_shutdown(tgb_plugin_handle *self) { (void)self; mark("shutdown-needs-edit"); } diff --git a/tests/plugins/plug_no_entry.c b/tests/plugins/plug_no_entry.c new file mode 100644 index 0000000..7aa6d69 --- /dev/null +++ b/tests/plugins/plug_no_entry.c @@ -0,0 +1,3 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +int plug_unrelated_symbol(void) { return 42; } /* una biblioteca válida pero sin tgb_plugin_init */ diff --git a/tests/plugins/plug_no_name.c b/tests/plugins/plug_no_name.c new file mode 100644 index 0000000..2a8599b --- /dev/null +++ b/tests/plugins/plug_no_name.c @@ -0,0 +1,5 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + (void)host; (void)self; info->name = NULL; info->required_caps = 0; return TGB_OK; +} diff --git a/tests/plugins/plug_no_version.c b/tests/plugins/plug_no_version.c new file mode 100644 index 0000000..f858036 --- /dev/null +++ b/tests/plugins/plug_no_version.c @@ -0,0 +1,4 @@ +#include "plug_common.h" +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + (void)host; (void)self; (void)info; mark("init-no-version"); return TGB_OK; +} diff --git a/tests/plugins/plug_old_api.c b/tests/plugins/plug_old_api.c new file mode 100644 index 0000000..35462a9 --- /dev/null +++ b/tests/plugins/plug_old_api.c @@ -0,0 +1,5 @@ +#include "plug_common.h" +TGB_PLUGIN_EXPORT const uint32_t tgb_plugin_api_version = 0; /* compilada para otra API */ +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + (void)host; (void)self; (void)info; mark("init-old-api"); return TGB_OK; +} diff --git a/tests/plugins/plug_readonly.c b/tests/plugins/plug_readonly.c new file mode 100644 index 0000000..457002c --- /dev/null +++ b/tests/plugins/plug_readonly.c @@ -0,0 +1,12 @@ +#include "plug_common.h" +TGB_PLUGIN_DECLARE_API_VERSION; +static const tgb_host_api *H; +static tgb_status cmd_try_edit(tgb_plugin_handle *self, void *user) { (void)user; return H->edit_begin(self, 0, 0); } +static tgb_status cmd_try_edit_unchecked(tgb_plugin_handle *self, void *user) { (void)user; return H->add_frame(self, 0); } +TGB_PLUGIN_EXPORT tgb_status tgb_plugin_init(const tgb_host_api *host, tgb_plugin_handle *self, tgb_plugin_info *info) { + H = host; + host->register_command(self, "intenta-editar", cmd_try_edit, NULL); + host->register_command(self, "intenta-anadir", cmd_try_edit_unchecked, NULL); + info->name = "Solo lectura"; info->version = "0.1"; info->required_caps = TGB_CAP_READ_DOCUMENT; /* NO pide editar */ + return TGB_OK; +} diff --git a/tests/test_animation.c b/tests/test_animation.c new file mode 100644 index 0000000..1edb69c --- /dev/null +++ b/tests/test_animation.c @@ -0,0 +1,180 @@ +/* Composición de capas, Onion Skin y reproductor. */ +#include +#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(); +} diff --git a/tests/test_canvas_draw.c b/tests/test_canvas_draw.c new file mode 100644 index 0000000..c31523c --- /dev/null +++ b/tests/test_canvas_draw.c @@ -0,0 +1,192 @@ +/* Lienzo, herramientas de dibujo, relleno y transformaciones. */ +#include +#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(); +} diff --git a/tests/test_document_history.c b/tests/test_document_history.c new file mode 100644 index 0000000..25f4473 --- /dev/null +++ b/tests/test_document_history.c @@ -0,0 +1,487 @@ +/* Modelo de documento, sesiones de edición e historial. */ +#include +#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(); +} diff --git a/tests/test_io_files.c b/tests/test_io_files.c new file mode 100644 index 0000000..82847ba --- /dev/null +++ b/tests/test_io_files.c @@ -0,0 +1,323 @@ +/* Guardado atómico, importación y exportación de archivos reales. */ +#include +#include +#include +#include +#include +#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(); +} diff --git a/tests/test_io_formats.c b/tests/test_io_formats.c new file mode 100644 index 0000000..6595253 --- /dev/null +++ b/tests/test_io_formats.c @@ -0,0 +1,450 @@ +/* Base64, JSON, PNG y proyectos .tgb (formatos 2 y 3). */ +#include +#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(); +} diff --git a/tests/test_plugins.c b/tests/test_plugins.c new file mode 100644 index 0000000..0cf18fe --- /dev/null +++ b/tests/test_plugins.c @@ -0,0 +1,378 @@ +/* Extensiones nativas: instalación, permisos, comandos con historial, errores y persistencia. */ +#include +#include +#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(); +} diff --git a/tests/test_recovery.c b/tests/test_recovery.c new file mode 100644 index 0000000..53d593c --- /dev/null +++ b/tests/test_recovery.c @@ -0,0 +1,307 @@ +/* Autoguardado cada 5 minutos y recuperación tras cierres inesperados (con procesos reales). */ +#include +#include +#include +#include +#include +#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(); +} diff --git a/tests/test_recovery_points.c b/tests/test_recovery_points.c new file mode 100644 index 0000000..58d209e --- /dev/null +++ b/tests/test_recovery_points.c @@ -0,0 +1,122 @@ +/* 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(); +} diff --git a/tests/tgb_test.h b/tests/tgb_test.h new file mode 100644 index 0000000..364cee5 --- /dev/null +++ b/tests/tgb_test.h @@ -0,0 +1,91 @@ +/* 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 +#include +#include +#include +#include +#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 diff --git a/tests/ui_smoke/mock_raylib.c b/tests/ui_smoke/mock_raylib.c new file mode 100644 index 0000000..d9436d9 --- /dev/null +++ b/tests/ui_smoke/mock_raylib.c @@ -0,0 +1,113 @@ +/* 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 +#include +#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; } diff --git a/tests/ui_smoke/mock_raylib.h b/tests/ui_smoke/mock_raylib.h new file mode 100644 index 0000000..5770a57 --- /dev/null +++ b/tests/ui_smoke/mock_raylib.h @@ -0,0 +1,14 @@ +/* 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 diff --git a/tests/ui_smoke/ui_smoke.c b/tests/ui_smoke/ui_smoke.c new file mode 100644 index 0000000..f541b24 --- /dev/null +++ b/tests/ui_smoke/ui_smoke.c @@ -0,0 +1,85 @@ +/* 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 +#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(); +} diff --git a/tools/check-includes.py b/tools/check-includes.py new file mode 100644 index 0000000..4ccd5ce --- /dev/null +++ b/tools/check-includes.py @@ -0,0 +1,54 @@ +#!/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) diff --git a/tools/check-ui-syntax.sh b/tools/check-ui-syntax.sh new file mode 100755 index 0000000..8d597d1 --- /dev/null +++ b/tools/check-ui-syntax.sh @@ -0,0 +1,10 @@ +#!/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)" diff --git a/tools/coverage.sh b/tools/coverage.sh new file mode 100755 index 0000000..a5754b9 --- /dev/null +++ b/tools/coverage.sh @@ -0,0 +1,25 @@ +#!/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" diff --git a/tools/dev-build.sh b/tools/dev-build.sh new file mode 100755 index 0000000..de8ce6f --- /dev/null +++ b/tools/dev-build.sh @@ -0,0 +1,45 @@ +#!/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; } diff --git a/tools/dev/raylib_stub/raylib.h b/tools/dev/raylib_stub/raylib.h new file mode 100644 index 0000000..4d6cfc4 --- /dev/null +++ b/tools/dev/raylib_stub/raylib.h @@ -0,0 +1,80 @@ +/* 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 + +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 diff --git a/tools/gen_png_fixtures.py b/tools/gen_png_fixtures.py new file mode 100644 index 0000000..db14c6f --- /dev/null +++ b/tools/gen_png_fixtures.py @@ -0,0 +1,75 @@ +#!/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 \n#include ") +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") diff --git a/tools/verify-external.py b/tools/verify-external.py new file mode 100644 index 0000000..ab4a931 --- /dev/null +++ b/tools/verify-external.py @@ -0,0 +1,48 @@ +#!/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 /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)