commit 59c597fc18e00948b8d35b43dcc2184c3a3e88d3 Author: reugenio Date: Tue Dec 9 01:30:05 2025 +0100 feat: zCatGui v0.1.0 - Initial project setup Immediate Mode GUI library for Zig with software rendering. Core features: - SDL2 backend for cross-platform window/events - Software rasterizer (works everywhere, including SSH) - Macro recording system (cornerstone feature, like Vim) - Command-list rendering (DrawRect, DrawText, etc.) - Layout system with constraints - Color/Style system with themes Project structure: - src/core/: context, command, input, layout, style - src/macro/: MacroRecorder, MacroPlayer, MacroStorage - src/render/: Framebuffer, SoftwareRenderer, Font - src/backend/: Backend interface, SDL2 implementation - examples/: hello.zig, macro_demo.zig - docs/: Architecture, research (Gio, immediate-mode libs, Simifactu) Build: zig build (requires SDL2-devel) Tests: 16 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df85311 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Zig +.zig-cache/ +zig-out/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..35bffa7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,535 @@ +# zCatGui - GUI Library para Zig + +> **IMPORTANTE PARA CLAUDE**: Lee la sección "PROTOCOLO DE INICIO" antes de hacer cualquier cosa. + +--- + +## PROTOCOLO DE INICIO (LEER PRIMERO) + +### Paso 1: Leer normas del equipo +``` +/mnt/cello2/arno/re/recode/TEAM_STANDARDS/LAST_UPDATE.md +``` + +### Paso 2: Leer normas completas si es necesario +``` +/mnt/cello2/arno/re/recode/TEAM_STANDARDS/NORMAS_TRABAJO_CONSENSUADAS.md +/mnt/cello2/arno/re/recode/TEAM_STANDARDS/QUICK_REFERENCE.md +``` + +### Paso 3: Leer documentación de investigación +``` +docs/research/GIO_UI_ANALYSIS.md # Análisis de Gio UI (Go) +docs/research/IMMEDIATE_MODE_LIBS.md # Comparativa librerías immediate-mode +docs/research/SIMIFACTU_FYNE_ANALYSIS.md # Requisitos extraídos de Simifactu +docs/ARCHITECTURE.md # Arquitectura y decisiones de diseño +``` + +### Paso 4: Verificar estado del proyecto +```bash +cd /mnt/cello2/arno/re/recode/zig/zcatgui +git status +git log --oneline -3 +zig build test +``` + +### Paso 5: Continuar trabajo +Una vez verificado el estado, continúa desde donde se dejó. + +--- + +## INFORMACIÓN DEL PROYECTO + +| Campo | Valor | +|-------|-------| +| **Nombre** | zCatGui | +| **Versión** | v0.1.0 - EN DESARROLLO | +| **Fecha inicio** | 2025-12-09 | +| **Lenguaje** | Zig 0.15.2 | +| **Paradigma** | Immediate Mode GUI | +| **Inspiración** | Gio (Go), microui (C), DVUI (Zig), Dear ImGui (C++) | +| **Proyecto hermano** | zcatui (TUI library) | + +### Descripción + +**zCatGui** es una librería GUI immediate-mode para Zig con las siguientes características: + +1. **Software Rendering por defecto** - Funciona en cualquier ordenador sin GPU +2. **Cross-platform** - Linux, Windows, macOS +3. **SSH compatible** - Funciona via X11 forwarding +4. **Sistema de Macros** - Grabación/reproducción de acciones (piedra angular) +5. **Sin dependencias pesadas** - Solo SDL2 para ventanas + +### Filosofía + +> "Máxima compatibilidad, mínimas dependencias, control total del usuario" + +- Funciona en cualquier ordenador (viejo HP, nuevo Lenovo, servidor SSH) +- Software rendering primero, GPU opcional después +- Sistema de macros integrado desde el diseño +- Immediate mode = estado explícito, sin threading hell + +--- + +## RUTAS IMPORTANTES + +```bash +# Este proyecto +/mnt/cello2/arno/re/recode/zig/zcatgui/ + +# Proyecto hermano (TUI) +/mnt/cello2/arno/re/recode/zig/zcatui/ + +# Proyecto de referencia (usa Fyne, queremos replicar funcionalidad) +/mnt/cello2/arno/re/recode/go/simifactu/ + +# Normas del equipo +/mnt/cello2/arno/re/recode/TEAM_STANDARDS/ + +# Compilador Zig 0.15.2 +/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig +``` + +--- + +## COMANDOS FRECUENTES + +```bash +# Compilar +zig build + +# Tests +zig build test + +# Ejemplos (cuando estén implementados) +zig build hello +zig build button-demo +zig build macro-demo + +# Git +git status +git add -A && git commit -m "mensaje" +git push +``` + +--- + +## ARQUITECTURA + +### Paradigma: Immediate Mode + +``` +┌─────────────────────────────────────────────────────────────┐ +│ IMMEDIATE MODE │ +├─────────────────────────────────────────────────────────────┤ +│ while (running) { │ +│ events = pollEvents(); // Input │ +│ updateState(events); // TÚ manejas estado │ +│ commands = drawUI(state); // Genera comandos │ +│ render(commands); // Dibuja │ +│ } │ +└─────────────────────────────────────────────────────────────┘ + +vs Retained Mode (Fyne): +- Framework mantiene árbol de widgets +- Callbacks para cambios +- fyne.Do() para threading +- Estado oculto, sincronización compleja +``` + +### Capas de la Librería + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Capa 4: Widgets de alto nivel │ +│ (Table, Panel, Select, Modal, etc.) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 3: Sistema de Macros │ +│ (Grabación, Reproducción, Inyección de teclas) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 2: Core UI │ +│ (Context, Layout, Style, Input, Commands) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 1: Rendering │ +│ (Software Rasterizer, Framebuffer, Fonts) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 0: Backend │ +│ (SDL2 - ventanas, eventos, display) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Estructura de Archivos + +``` +zcatgui/ +├── src/ +│ ├── zcatgui.zig # Entry point, re-exports +│ │ +│ ├── core/ +│ │ ├── context.zig # Context, ID system, state pool +│ │ ├── layout.zig # Constraints, Flex (de zcatui) +│ │ ├── style.zig # Color, Style (de zcatui) +│ │ ├── input.zig # InputState, KeyEvent, MouseEvent +│ │ └── command.zig # DrawCommand list +│ │ +│ ├── widgets/ +│ │ ├── button.zig +│ │ ├── label.zig +│ │ ├── input.zig # Text entry +│ │ ├── select.zig # Dropdown +│ │ ├── checkbox.zig +│ │ ├── slider.zig +│ │ ├── table.zig # Editable table (CRÍTICO) +│ │ ├── list.zig +│ │ ├── panel.zig # Window-like container +│ │ ├── split.zig # HSplit/VSplit +│ │ ├── popup.zig +│ │ └── modal.zig +│ │ +│ ├── render/ +│ │ ├── software.zig # Software rasterizer +│ │ ├── framebuffer.zig # Pixel buffer RGBA +│ │ └── font.zig # Bitmap + TTF fonts +│ │ +│ ├── backend/ +│ │ ├── backend.zig # Backend interface +│ │ └── sdl2.zig # SDL2 implementation +│ │ +│ └── macro/ +│ ├── event.zig # MacroEvent (teclas raw) +│ ├── recorder.zig # Grabador +│ ├── player.zig # Reproductor +│ └── storage.zig # Guardar/cargar macros +│ +├── examples/ +│ ├── hello.zig +│ ├── button_demo.zig +│ └── macro_demo.zig +│ +├── docs/ +│ ├── ARCHITECTURE.md # Arquitectura detallada +│ └── research/ +│ ├── GIO_UI_ANALYSIS.md +│ ├── IMMEDIATE_MODE_LIBS.md +│ └── SIMIFACTU_FYNE_ANALYSIS.md +│ +├── build.zig +├── build.zig.zon +├── README.md +└── CLAUDE.md # Este archivo +``` + +--- + +## SISTEMA DE MACROS (PIEDRA ANGULAR) + +### Concepto + +El sistema de macros permite **grabar y reproducir** todas las acciones del usuario. + +**Principio**: Grabar teclas raw, no comandos abstractos. + +``` +Usuario pulsa: Tab, Tab, Enter, "texto", Escape +Grabamos: [Tab, Tab, Enter, t, e, x, t, o, Escape] +Reproducimos: Inyectamos exactamente esas teclas +``` + +### Por qué teclas raw (no comandos) + +| Enfoque | Pros | Contras | +|---------|------|---------| +| **Teclas raw** | Simple, mínima memoria, reproducción exacta | Depende del estado inicial | +| Comandos semánticos | Más robusto | Complejo, más memoria, traducción bidireccional | + +**Decisión**: Teclas raw (como Vim). Razones: +1. KISS - menos código = menos bugs +2. Vim lo hace así y funciona +3. El estado inicial es controlable + +### Manejo del ratón + +**Casi todo lo que hace el ratón se puede expresar como teclado**: + +| Acción ratón | Equivalente teclado | +|--------------|---------------------| +| Click en botón | Tab hasta focus + Enter | +| Click en fila 5 | Flechas hasta fila 5 | +| Scroll down | PageDown o flechas | +| Drag splitter | Ctrl+flechas | + +**Estrategia**: +1. Fase 1: Solo teclado (macros de teclas) +2. Fase 2: Mouse → traducir a teclas equivalentes + +### API Propuesta + +```zig +pub const MacroRecorder = struct { + events: ArrayList(KeyEvent), + recording: bool, + + pub fn start(self: *MacroRecorder) void; + pub fn stop(self: *MacroRecorder) []const KeyEvent; + pub fn record(self: *MacroRecorder, key: KeyEvent) void; + pub fn save(self: *MacroRecorder, path: []const u8) !void; + pub fn load(allocator: Allocator, path: []const u8) !MacroRecorder; +}; + +pub const MacroPlayer = struct { + pub fn play( + events: []const KeyEvent, + inject_fn: *const fn(KeyEvent) void, + delay_ms: u32, + ) void; +}; +``` + +### Casos de Uso + +1. **Testing automatizado**: Grabar sesión → convertir en test +2. **Tutoriales**: Macros que se ejecutan paso a paso +3. **Repetición**: Grabar tarea repetitiva, asignar a hotkey +4. **Debugging**: "¿Cómo llegaste a este bug?" → envía el macro +5. **Demos**: Grabar demos que se reproducen en la app + +--- + +## WIDGETS PRIORITARIOS + +Basado en análisis de Simifactu (ver `docs/research/SIMIFACTU_FYNE_ANALYSIS.md`): + +| # | Widget | Prioridad | Descripción | +|---|--------|-----------|-------------| +| 1 | **Table** | CRÍTICA | Edición in-situ, navegación teclado, dirty tracking | +| 2 | **Input** | CRÍTICA | Text entry con validación | +| 3 | **Select** | CRÍTICA | Dropdown selection | +| 4 | **Panel** | ALTA | Container con título y bordes | +| 5 | **Split** | ALTA | HSplit/VSplit draggable | +| 6 | **Button** | ALTA | Con estados disabled, importance | +| 7 | **Modal** | MEDIA | Diálogos modales | +| 8 | **List** | MEDIA | Lista seleccionable | +| 9 | **Checkbox** | MEDIA | Toggle boolean | +| 10 | **Label** | BAJA | Texto estático | + +--- + +## RENDERING + +### Software Rasterizer (Command List approach) + +```zig +pub const DrawCommand = union(enum) { + rect: struct { + x: i32, + y: i32, + w: u32, + h: u32, + color: Color, + }, + text: struct { + x: i32, + y: i32, + text: []const u8, + color: Color, + font: *Font, + }, + line: struct { + x1: i32, + y1: i32, + x2: i32, + y2: i32, + color: Color, + }, + clip: struct { + x: i32, + y: i32, + w: u32, + h: u32, + }, + clip_end, +}; +``` + +**Flujo**: +``` +Widgets → Commands → Software Rasterizer → Framebuffer → SDL_Texture → Display +``` + +### Por qué Software Rendering + +1. **Funciona SIEMPRE** - No depende de drivers GPU +2. **SSH compatible** - X11 forwarding funciona +3. **Máxima compatibilidad** - Desde HP viejo hasta Lenovo nuevo +4. **Simple de debugear** - Es solo un array de pixels + +### Fonts + +**Dos opciones soportadas**: +1. **Bitmap fonts** (embebidos) - Siempre funcionan, rápidos +2. **TTF** (stb_truetype) - Más flexible, opcional + +--- + +## PLAN DE DESARROLLO + +### Fase 0: Setup ✅ COMPLETADA +- [x] Crear estructura de directorios +- [x] build.zig con SDL2 +- [x] CLAUDE.md +- [x] Documentación de investigación + +### Fase 1: Core + Macros (1 semana) +- [ ] Context con event loop +- [ ] Sistema de macros (grabación/reproducción teclas) +- [ ] Software rasterizer básico (rects, text) +- [ ] SDL2 backend +- [ ] Button, Label (para probar) + +### Fase 2: Widgets Esenciales (2 semanas) +- [ ] Input (text entry) +- [ ] Select (dropdown) +- [ ] Checkbox +- [ ] List +- [ ] Layout system +- [ ] Focus management + +### Fase 3: Widgets Avanzados (2 semanas) +- [ ] Table con edición +- [ ] Split panels +- [ ] Modal/Popup +- [ ] Panel con título + +### Fase 4: Pulido (1 semana) +- [ ] Themes +- [ ] Font handling robusto +- [ ] Documentación +- [ ] Examples completos + +--- + +## REFERENCIAS Y RECURSOS + +### Librerías estudiadas + +| Librería | Lenguaje | LOC | Valor para nosotros | +|----------|----------|-----|---------------------| +| **microui** | C | 1,100 | Referencia arquitectura mínima | +| **DVUI** | Zig | 15,000 | Único ejemplo Zig native | +| **Dear ImGui** | C++ | 60,000 | API design, features | +| **Gio** | Go | - | Immediate mode moderno | +| **Nuklear** | C | 30,000 | Vertex buffer approach | + +### Documentación detallada + +- `docs/research/GIO_UI_ANALYSIS.md` - Análisis completo de Gio +- `docs/research/IMMEDIATE_MODE_LIBS.md` - Comparativa de librerías +- `docs/research/SIMIFACTU_FYNE_ANALYSIS.md` - Requisitos de Simifactu +- `docs/ARCHITECTURE.md` - Decisiones de arquitectura + +### Links externos + +- [microui GitHub](https://github.com/rxi/microui) +- [DVUI GitHub](https://github.com/david-vanderson/dvui) +- [Gio UI](https://gioui.org/) +- [Dear ImGui](https://github.com/ocornut/imgui) + +--- + +## DECISIONES DE DISEÑO CONSENSUADAS + +### 1. Immediate Mode vs Retained Mode +**Decisión**: Immediate Mode +**Razón**: Control total, sin threading hell (fyne.Do()), estado explícito + +### 2. Software Rendering vs GPU +**Decisión**: Software por defecto, GPU opcional futuro +**Razón**: Máxima compatibilidad (SSH, HP viejo, cualquier driver) + +### 3. Sistema de Macros +**Decisión**: Teclas raw, no comandos abstractos +**Razón**: Simple, como Vim, menos código = menos bugs + +### 4. Backend inicial +**Decisión**: SDL2 +**Razón**: Cross-platform probado, fácil de usar + +### 5. Fonts +**Decisión**: Bitmap embebido + TTF opcional +**Razón**: Bitmap siempre funciona, TTF para flexibilidad + +### 6. Enfoque de desarrollo +**Decisión**: Híbrido (estudiar DVUI/microui, implementar desde cero) +**Razón**: Aprender haciendo, control total, sin dependencias no deseadas + +--- + +## RELACIÓN CON ZCATUI + +**zcatui** (TUI) y **zCatGui** (GUI) son proyectos hermanos: + +| Aspecto | zcatui | zCatGui | +|---------|--------|---------| +| Target | Terminal (ANSI) | Ventana gráfica | +| Rendering | Escape codes | Software rasterizer | +| Paradigma | Immediate mode | Immediate mode | +| Layout | Constraint-based | Constraint-based (reusar) | +| Style | Color/Modifier | Color/Style (reusar) | +| Widgets | 35 widgets | En desarrollo | + +**Código a reutilizar de zcatui**: +- Sistema de Layout (Constraint, Flex) +- Sistema de Style (Color, Style) +- Conceptos de Focus +- Patterns de widgets + +--- + +## EQUIPO + +- **Usuario (Arno)**: Desarrollador principal +- **Claude**: Asistente de programación (Claude Code / Opus 4.5) + +--- + +## NOTAS ZIG 0.15.2 + +```zig +// Sleep +std.Thread.sleep(ns) // NO std.time.sleep + +// ArrayList +var list = std.ArrayList(T).init(allocator); +defer list.deinit(); + +// HashMap +var map = std.AutoHashMap(K, V).init(allocator); +defer map.deinit(); + +// Error handling +fn foo() !T { ... } +const result = try foo(); +const result = foo() catch |err| { ... }; +``` + +--- + +## HISTORIAL + +| Fecha | Versión | Cambios | +|-------|---------|---------| +| 2025-12-09 | v0.1.0 | Proyecto creado, estructura base, documentación | + +--- + +## ESTADO ACTUAL + +**El proyecto está EN FASE INICIAL** + +- ✅ Estructura de directorios creada +- ✅ build.zig configurado con SDL2 +- ✅ Documentación de investigación completa +- ✅ CLAUDE.md con toda la información +- ⏳ Código fuente pendiente de implementar + +**Próximo paso**: Implementar Fase 1 (Core + Macros) diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..f9217bc --- /dev/null +++ b/build.zig @@ -0,0 +1,82 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // =========================================== + // Main library module + // =========================================== + const zcatgui_mod = b.createModule(.{ + .root_source_file = b.path("src/zcatgui.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + + // Link SDL2 to the module + zcatgui_mod.linkSystemLibrary("SDL2", .{}); + + // =========================================== + // Tests + // =========================================== + const lib_unit_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/zcatgui.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + lib_unit_tests.root_module.linkSystemLibrary("SDL2", .{}); + + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + + // =========================================== + // Examples + // =========================================== + + // Hello World example + const hello_exe = b.addExecutable(.{ + .name = "hello", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/hello.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "zcatgui", .module = zcatgui_mod }, + }, + }), + }); + hello_exe.root_module.linkSystemLibrary("SDL2", .{}); + b.installArtifact(hello_exe); + + const run_hello = b.addRunArtifact(hello_exe); + run_hello.step.dependOn(b.getInstallStep()); + const hello_step = b.step("hello", "Run hello world example"); + hello_step.dependOn(&run_hello.step); + + // Macro demo + const macro_exe = b.addExecutable(.{ + .name = "macro-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/macro_demo.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "zcatgui", .module = zcatgui_mod }, + }, + }), + }); + macro_exe.root_module.linkSystemLibrary("SDL2", .{}); + b.installArtifact(macro_exe); + + const run_macro = b.addRunArtifact(macro_exe); + run_macro.step.dependOn(b.getInstallStep()); + const macro_step = b.step("macro-demo", "Run macro recording demo"); + macro_step.dependOn(&run_macro.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..a358aa2 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,18 @@ +.{ + .fingerprint = 0x30a5cd33d0b0066c, + .name = .zcatgui, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + + .dependencies = .{}, + + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "examples", + "docs", + "README.md", + "CLAUDE.md", + }, +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..db05884 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,529 @@ +# zCatGui - Arquitectura y Decisiones de Diseño + +> Documento de referencia para el desarrollo de zCatGui +> Última actualización: 2025-12-09 + +--- + +## 1. Visión del Proyecto + +### 1.1 Objetivo + +Crear una librería GUI immediate-mode para Zig con: + +1. **Máxima compatibilidad** - Funciona en cualquier ordenador (viejo HP, nuevo Lenovo, servidor SSH) +2. **Software rendering** - No depende de GPU/drivers +3. **Sistema de macros** - Piedra angular: grabar y reproducir acciones +4. **Cross-platform** - Linux, Windows, macOS +5. **Control total** - Sin "magia" del framework, estado explícito + +### 1.2 Filosofía + +> "Máxima compatibilidad, mínimas dependencias, control total del usuario" + +- **Simple > Complejo**: microui demostró que 1,100 LOC son suficientes +- **Explícito > Implícito**: Estado visible, sin callbacks ocultos +- **Funciona siempre**: Software rendering primero, GPU opcional después + +--- + +## 2. Paradigma: Immediate Mode + +### 2.1 ¿Qué es Immediate Mode? + +``` +while (running) { + events = pollEvents(); // 1. Obtener input + updateState(events); // 2. TÚ actualizas estado + commands = drawUI(state); // 3. Generar comandos de dibujo + render(commands); // 4. Renderizar +} +``` + +**La UI es una función pura del estado**: `UI = f(Estado)` + +### 2.2 Immediate vs Retained Mode + +| Aspecto | Immediate (zCatGui) | Retained (Fyne) | +|---------|---------------------|-----------------| +| Estado | Tú lo manejas | Framework lo mantiene | +| Callbacks | No hay | Muchos | +| Threading | Simple, predecible | fyne.Do() hell (402 usos) | +| Debugging | Fácil, estado visible | Difícil, estado oculto | +| Testing | Funciones puras | Mock objects complejos | + +### 2.3 Por qué Immediate Mode + +1. **Sin fyne.Do()**: No hay threading hell +2. **Estado explícito**: Debug simple +3. **Testing trivial**: Funciones puras +4. **Control total**: Sin sorpresas + +--- + +## 3. Arquitectura de Capas + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Capa 4: Widgets de alto nivel │ +│ (Table, Panel, Select, Modal, etc.) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 3: Sistema de Macros │ +│ (Grabación, Reproducción, Inyección de teclas) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 2: Core UI │ +│ (Context, Layout, Style, Input, Commands) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 1: Rendering │ +│ (Software Rasterizer, Framebuffer, Fonts) │ +├─────────────────────────────────────────────────────────────┤ +│ Capa 0: Backend │ +│ (SDL2 - ventanas, eventos, display) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.1 Capa 0: Backend (SDL2) + +**Responsabilidades:** +- Crear/manejar ventana +- Capturar eventos (teclado, mouse) +- Mostrar framebuffer en pantalla + +**Por qué SDL2:** +- Cross-platform probado +- Fácil de usar desde Zig +- Tiene software renderer + +### 3.2 Capa 1: Rendering + +**Componentes:** +- `Framebuffer`: Array 2D de pixels RGBA +- `SoftwareRasterizer`: drawRect, drawLine, drawText +- `Font`: Bitmap fonts + TTF opcional + +**Approach: Command List (estilo microui)** + +```zig +pub const DrawCommand = union(enum) { + rect: RectCommand, + text: TextCommand, + line: LineCommand, + clip: ClipCommand, + clip_end, +}; +``` + +### 3.3 Capa 2: Core UI + +**Componentes:** +- `Context`: Estado global, ID system, command list +- `Layout`: Constraints (reutilizar de zcatui) +- `Style`: Color, fonts (reutilizar de zcatui) +- `Input`: Estado de teclado/mouse + +### 3.4 Capa 3: Sistema de Macros + +**Piedra angular del proyecto.** + +Ver sección 5 para detalles. + +### 3.5 Capa 4: Widgets + +**Prioritarios (de Simifactu):** +1. Table (editable) +2. Input (text entry) +3. Select (dropdown) +4. Panel (con título) +5. Split (HSplit/VSplit) +6. Button +7. Modal + +--- + +## 4. Decisiones de Diseño Consensuadas + +### 4.1 Rendering: Software por Defecto + +**Decisión**: Software rendering, GPU opcional en el futuro. + +**Razones:** +- Funciona en cualquier ordenador +- SSH con X11 forwarding funciona +- No depende de drivers GPU +- Simple de debugear + +**Implementación:** +``` +Widgets → DrawCommands → Software Rasterizer → Framebuffer → SDL_Texture → Display +``` + +### 4.2 Backend: SDL2 + +**Decisión**: SDL2 como único backend inicial. + +**Razones:** +- Cross-platform probado (Linux/Windows/Mac) +- API simple +- Tiene software renderer +- Fácil integración con Zig + +**Futuro opcional:** +- X11 directo (Linux) +- Win32 directo (Windows) + +### 4.3 Fonts: Híbrido + +**Decisión**: Bitmap embebido + TTF opcional. + +**Razones:** +- Bitmap siempre funciona (no dependencias) +- TTF para flexibilidad (stb_truetype) + +### 4.4 Enfoque Híbrido para Desarrollo + +**Decisión**: Estudiar DVUI/microui, implementar desde cero. + +**Razones:** +- Aprender haciendo +- Control total del código +- Sin dependencias no deseadas +- Evitar fork con bagaje innecesario + +### 4.5 Macros: Teclas Raw + +**Decisión**: Grabar teclas literales, no comandos abstractos. + +**Ver sección 5.** + +--- + +## 5. Sistema de Macros + +### 5.1 Principio Fundamental + +**Grabar teclas raw, reproducir teclas raw.** + +``` +Usuario pulsa: Tab, Tab, Enter, "texto", Escape +Grabamos: [Tab, Tab, Enter, t, e, x, t, o, Escape] +Reproducimos: Inyectamos exactamente esas teclas +``` + +### 5.2 Por qué Teclas Raw (no Comandos) + +| Enfoque | Pros | Contras | +|---------|------|---------| +| **Teclas raw** | Simple, mínima memoria, como Vim | Depende del estado inicial | +| Comandos abstractos | Más robusto | Complejo, más memoria | + +**Decisión**: Teclas raw porque: +1. KISS - menos código = menos bugs +2. Vim lo hace así y funciona +3. El estado inicial es controlable + +### 5.3 Manejo del Ratón + +**Casi todo lo que hace el ratón se puede expresar como teclado:** + +| Acción ratón | Equivalente teclado | +|--------------|---------------------| +| Click en botón | Tab hasta focus + Enter | +| Click en fila 5 | Flechas hasta fila 5 | +| Scroll down | PageDown o flechas | +| Drag splitter | Ctrl+flechas | + +**Estrategia:** +1. **Fase 1**: Solo teclado (macros de teclas) +2. **Fase 2**: Mouse → traducir a teclas equivalentes + +### 5.4 API + +```zig +pub const MacroRecorder = struct { + events: ArrayList(KeyEvent), + recording: bool, + + pub fn start(self: *MacroRecorder) void { + self.events.clearRetainingCapacity(); + self.recording = true; + } + + pub fn stop(self: *MacroRecorder) []const KeyEvent { + self.recording = false; + return self.events.items; + } + + pub fn record(self: *MacroRecorder, key: KeyEvent) void { + if (self.recording) { + self.events.append(key) catch {}; + } + } + + pub fn save(self: *MacroRecorder, path: []const u8) !void { + // Serializar a archivo + } + + pub fn load(allocator: Allocator, path: []const u8) !MacroRecorder { + // Deserializar de archivo + } +}; + +pub const MacroPlayer = struct { + pub fn play( + events: []const KeyEvent, + inject_fn: *const fn(KeyEvent) void, + delay_ms: u32, + ) void { + for (events) |event| { + inject_fn(event); + if (delay_ms > 0) { + std.Thread.sleep(delay_ms * std.time.ns_per_ms); + } + } + } +}; +``` + +### 5.5 Casos de Uso + +1. **Testing automatizado**: Grabar sesión → test +2. **Tutoriales**: Macros que se ejecutan paso a paso +3. **Repetición**: Tarea repetitiva → hotkey +4. **Debugging**: "¿Cómo llegaste a este bug?" → envía macro +5. **Demos**: Grabar demos que se auto-reproducen + +--- + +## 6. Flujo de Datos + +### 6.1 Event Loop Principal + +```zig +pub fn run(app: *App) !void { + while (app.running) { + // 1. Poll eventos del backend + while (backend.pollEvent()) |raw_event| { + // 2. Traducir a KeyEvent/MouseEvent + const event = translateEvent(raw_event); + + // 3. Grabar si macro activo + if (app.macro_recorder.recording) { + app.macro_recorder.record(event); + } + + // 4. Actualizar estado de input + app.input.update(event); + } + + // 5. Ejecutar lógica de UI (immediate mode) + app.context.beginFrame(); + app.drawUI(); // Usuario define esto + app.context.endFrame(); + + // 6. Renderizar commands + for (app.context.commands.items) |cmd| { + app.rasterizer.execute(cmd); + } + + // 7. Presentar framebuffer + backend.present(app.framebuffer); + } +} +``` + +### 6.2 Widget Pattern + +```zig +pub fn button(ctx: *Context, label: []const u8) bool { + // 1. Obtener ID único + const id = ctx.getId(label); + + // 2. Obtener bounds del layout + const bounds = ctx.layout.nextRect(); + + // 3. Verificar interacción + const hovered = bounds.contains(ctx.input.mousePos()); + const pressed = hovered and ctx.input.mouseDown(.left); + const clicked = hovered and ctx.input.mouseReleased(.left); + + // 4. Determinar estilo + const bg_color = if (pressed) pressed_color + else if (hovered) hover_color + else normal_color; + + // 5. Emitir comandos de dibujo + ctx.pushCommand(.{ .rect = .{ + .bounds = bounds, + .color = bg_color, + }}); + ctx.pushCommand(.{ .text = .{ + .pos = bounds.center(), + .text = label, + .color = text_color, + }}); + + // 6. Retornar si fue clickeado + return clicked; +} +``` + +--- + +## 7. Estructura de Archivos + +``` +zcatgui/ +├── src/ +│ ├── zcatgui.zig # Entry point, re-exports +│ │ +│ ├── core/ +│ │ ├── context.zig # Context, ID system +│ │ ├── layout.zig # Constraints (de zcatui) +│ │ ├── style.zig # Color, Style (de zcatui) +│ │ ├── input.zig # InputState +│ │ └── command.zig # DrawCommand +│ │ +│ ├── widgets/ +│ │ ├── button.zig +│ │ ├── label.zig +│ │ ├── input.zig +│ │ ├── select.zig +│ │ ├── table.zig # CRÍTICO +│ │ ├── panel.zig +│ │ ├── split.zig +│ │ └── modal.zig +│ │ +│ ├── render/ +│ │ ├── software.zig # Rasterizer +│ │ ├── framebuffer.zig # Pixel buffer +│ │ └── font.zig # Fonts +│ │ +│ ├── backend/ +│ │ ├── backend.zig # Interface +│ │ └── sdl2.zig # SDL2 impl +│ │ +│ └── macro/ +│ ├── event.zig # MacroEvent +│ ├── recorder.zig # Grabador +│ ├── player.zig # Reproductor +│ └── storage.zig # Persistencia +│ +├── examples/ +├── docs/ +├── build.zig +└── CLAUDE.md +``` + +--- + +## 8. Código Reutilizable de zcatui + +### 8.1 Layout System + +Reutilizar de `zcatui/src/layout.zig`: +- `Constraint`: length, min, max, percentage, ratio, fill +- `Layout`: vertical, horizontal +- `Rect`: x, y, width, height, contains, intersection + +### 8.2 Style System + +Reutilizar de `zcatui/src/style.zig`: +- `Color`: rgb, indexed, ANSI colors +- `Style`: foreground, background, modifiers + +**Adaptar para GUI:** +- Añadir alpha channel (RGBA) +- Añadir border_radius (futuro) +- Añadir shadow (futuro) + +### 8.3 Conceptos + +- Focus management pattern +- Theme system +- Event patterns + +--- + +## 9. Plan de Desarrollo + +### Fase 1: Core + Macros (1 semana) + +**Objetivo**: Event loop funcional con grabación de macros. + +- [ ] SDL2 backend (ventana, eventos) +- [ ] Framebuffer + software rasterizer (rect, text) +- [ ] Context básico +- [ ] MacroRecorder/MacroPlayer +- [ ] Button + Label (para probar) + +### Fase 2: Widgets Esenciales (2 semanas) + +- [ ] Input (text entry) +- [ ] Select (dropdown) +- [ ] Checkbox +- [ ] List +- [ ] Layout system (de zcatui) +- [ ] Focus management + +### Fase 3: Widgets Avanzados (2 semanas) + +- [ ] Table editable (CRÍTICO) +- [ ] Split panels +- [ ] Panel con título +- [ ] Modal/Popup + +### Fase 4: Pulido (1 semana) + +- [ ] Themes +- [ ] TTF fonts +- [ ] Documentación +- [ ] Examples completos + +--- + +## 10. Métricas de Éxito + +### 10.1 Funcionales + +- [ ] Funciona en Linux sin GPU dedicada +- [ ] Funciona via SSH con X11 forwarding +- [ ] Macros funcionan: grabar → reproducir +- [ ] Table editable comparable a Simifactu + +### 10.2 Código + +- Target: ~5,000-10,000 LOC total +- Tests para core y widgets +- Examples ejecutables + +### 10.3 Performance + +- 60 FPS con UI típica +- Startup < 100ms +- Memoria < 50MB + +--- + +## 11. Referencias + +### Librerías Estudiadas + +| Librería | Lo que tomamos | +|----------|----------------| +| microui | Arquitectura, command list | +| DVUI | Patterns Zig, backend abstraction | +| Dear ImGui | API design, ID system | +| Gio | Constraint system | + +### Documentación de Investigación + +- `docs/research/GIO_UI_ANALYSIS.md` +- `docs/research/IMMEDIATE_MODE_LIBS.md` +- `docs/research/SIMIFACTU_FYNE_ANALYSIS.md` + +### Links Externos + +- [microui](https://github.com/rxi/microui) +- [DVUI](https://github.com/david-vanderson/dvui) +- [Dear ImGui](https://github.com/ocornut/imgui) +- [Gio](https://gioui.org/) diff --git a/docs/research/GIO_UI_ANALYSIS.md b/docs/research/GIO_UI_ANALYSIS.md new file mode 100644 index 0000000..e881f24 --- /dev/null +++ b/docs/research/GIO_UI_ANALYSIS.md @@ -0,0 +1,431 @@ +# Análisis Técnico: Gio UI (Go) + +> Investigación realizada: 2025-12-09 +> Propósito: Entender arquitectura de Gio como referencia para zCatGui + +--- + +## Resumen Ejecutivo + +**Gio UI** (gioui.org) es una librería de GUI immediate mode para Go que permite crear interfaces gráficas cross-platform nativas. Es comparable a Flutter en diseño pero implementado completamente en Go, sin dependencias externas más allá de las librerías de sistema para ventanas y GPU. + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://git.sr.ht/~eliasnaur/gio | +| **Versión** | v0.9.0 (pre-1.0, API inestable) | +| **Licencia** | MIT / Unlicense | +| **Plataformas** | Android, iOS, macOS, Linux, Windows, FreeBSD, OpenBSD, WebAssembly | + +--- + +## 1. Arquitectura General + +### 1.1 Estructura de Capas + +``` +┌─────────────────────────────────────────┐ +│ Material Design (widget/material) │ <- Widgets estilizados +├─────────────────────────────────────────┤ +│ Widget State (widget) │ <- Estado de controles +├─────────────────────────────────────────┤ +│ Layout System (layout) │ <- Sistema de layouts +├─────────────────────────────────────────┤ +│ Operations (op, op/clip, op/paint) │ <- Comandos de dibujo +├─────────────────────────────────────────┤ +│ Window Management (app) │ <- Manejo de ventanas +├─────────────────────────────────────────┤ +│ GPU Rendering (gpu) │ <- Backends gráficos +├─────────────────────────────────────────┤ +│ Platform Backend │ <- Win/macOS/Linux/etc. +└─────────────────────────────────────────┘ +``` + +### 1.2 Módulos Principales + +| Paquete | Propósito | +|---------|-----------| +| `gioui.org/app` | Window management, eventos del OS | +| `gioui.org/op` | Sistema de operaciones (comandos de dibujo) | +| `gioui.org/layout` | Layouts (Flex, Stack, List, Inset) | +| `gioui.org/widget` | Widgets con estado (Clickable, Editor, List, etc.) | +| `gioui.org/widget/material` | Material Design theme | +| `gioui.org/gpu` | Rendering GPU (OpenGL, Direct3D, Vulkan) | +| `gioui.org/io` | Input/output (pointer, keyboard, clipboard) | +| `gioui.org/text` | Text shaping, layout, glyphs | +| `gioui.org/font` | Font handling, OpenType | +| `gioui.org/gesture` | Gestures de alto nivel (Click, Drag, Scroll, Hover) | +| `gioui.org/x/component` | Componentes extendidos Material | + +--- + +## 2. Sistema de Rendering + +### 2.1 GPU Backends + +Gio soporta múltiples backends GPU según la plataforma: + +- **OpenGL ES** - Multiplataforma (Linux, Android, iOS, WebAssembly) +- **Direct3D 11** - Windows +- **Vulkan** - Linux, Android (opcional) + +### 2.2 Vector Rendering Pipeline + +Gio usa un **vector renderer basado en Pathfinder** (proyecto de Mozilla): + +1. **Renderizado de outlines**: Las formas y texto se renderizan desde sus contornos vectoriales +2. **Sin pre-procesamiento CPU**: Los paths SVG se renderizan en GPU con mínimo trabajo en CPU +3. **Migración a piet-gpu**: Renderer basado en compute shaders para mayor eficiencia + +### 2.3 CPU Fallback + +**Importante para zCatGui**: Gio incluye un fallback CPU (`gioui.org/cpu`) con binarios pre-compilados de piet-gpu para arm, arm64, amd64, permitiendo renderizado software cuando no hay GPU disponible. + +--- + +## 3. Sistema de Layout + +### 3.1 Conceptos Fundamentales + +```go +// Constraints = lo que el padre dice al hijo (límites de tamaño) +type Constraints struct { + Min, Max image.Point +} + +// Dimensions = lo que el hijo devuelve al padre (tamaño usado) +type Dimensions struct { + Size image.Point + Baseline int +} + +// Context = bundle de estado pasado a todos los widgets +type Context struct { + Constraints Constraints + Metric unit.Metric + Now time.Time + // ... +} + +// Widget = función que calcula dimensions desde constraints +type Widget func(gtx layout.Context) layout.Dimensions +``` + +### 3.2 Layouts Principales + +#### Flex - Distribución lineal con pesos + +```go +layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return header.Layout(gtx) }), + layout.Flexed(1, func(gtx C) D { return content.Layout(gtx) }), + layout.Rigid(func(gtx C) D { return footer.Layout(gtx) }), +) +``` + +**Spacing modes:** +- `SpaceEnd` - Espacio al final +- `SpaceStart` - Espacio al inicio +- `SpaceSides` - Espacio en ambos lados +- `SpaceAround` - Espacio alrededor de children +- `SpaceBetween` - Espacio entre children +- `SpaceEvenly` - Distribución uniforme + +#### Stack - Elementos apilados + +```go +layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { return image.Layout(gtx) }), + layout.Stacked(func(gtx C) D { return button.Layout(gtx) }), +) +``` + +#### List - Lista scrollable virtualizada + +```go +// Solo renderiza los elementos visibles +list.Layout(gtx, 1_000_000, func(gtx C, i int) D { + return material.Label(theme, unit.Sp(16), fmt.Sprintf("Item %d", i)).Layout(gtx) +}) +``` + +#### Inset - Padding + +```go +layout.UniformInset(10).Layout(gtx, func(gtx C) D { + return widget.Layout(gtx) +}) +``` + +### 3.3 Sistema de Unidades + +```go +type Px // Physical pixels (dependiente de dispositivo) +type Dp // Device-independent pixels (escala por densidad) +type Sp // Scaled points (incluye scaling de fuente del sistema) +``` + +--- + +## 4. Sistema de Eventos + +### 4.1 Tipos de Eventos + +```go +type pointer.Event // Mouse/touch (Press, Release, Move, Drag, Scroll) +type key.Event // Keyboard +type key.EditEvent // Edición de texto +type key.FocusEvent // Cambio de foco +``` + +### 4.2 Distribución de Eventos (Tag-based) + +Gio usa un sistema **sin callbacks** basado en **tags**: + +```go +// Declarar área clickable +pointer.InputOp{ + Tag: &button, // Tag único + Types: pointer.Press | pointer.Release, +}.Add(ops) + +// Consultar eventos +for { + ev, ok := gtx.Event(pointer.Filter{Target: &button, ...}) + if !ok { break } + // Procesar evento +} +``` + +**Ventaja**: Elimina el overhead de callbacks y simplifica el manejo de estado. + +### 4.3 Gestures de Alto Nivel + +```go +type Click // Detecta clicks +type Drag // Detecta drags +type Hover // Detecta hover +type Scroll // Detecta scroll +``` + +--- + +## 5. Widgets Incluidos + +### 5.1 Widget Pattern (Separación de Concerns) + +Gio separa **estado** (stateful) y **presentación** (stateless): + +``` +widget.Clickable (state) → material.Button (presentation) +widget.Editor (state) → material.Editor (presentation) +widget.Bool (state) → material.CheckBox (presentation) +``` + +### 5.2 Widget State (`gioui.org/widget`) + +| Widget | Propósito | +|--------|-----------| +| `Clickable` | Área clickable (botones, cards, etc.) | +| `Editor` | Editor de texto de una línea | +| `Selectable` | Texto seleccionable (no editable) | +| `Float` | Valor float para sliders | +| `Bool` | Valor booleano (checkboxes, switches) | +| `Enum` | Valor enum (radio buttons) | +| `List` | Estado de lista scrollable | +| `Scrollbar` | Estado de scrollbar | +| `Draggable` | Soporte drag & drop | +| `Decorations` | Decoraciones de ventana | +| `Icon` | Ícono vectorial | + +### 5.3 Material Widgets (`gioui.org/widget/material`) + +**Texto:** Label, H1-H6, Body1, Body2, Caption, Subtitle1, Subtitle2, Overline +**Botones:** Button, IconButton, ButtonLayout +**Input:** Editor, CheckBox, RadioButton, Switch, Slider +**Contenedores:** List, Scrollbar +**Indicadores:** ProgressBar, ProgressCircle, Loader (spinner) +**Ventanas:** Decorations + +### 5.4 Extended Components (`gioui.org/x/component`) + +- AppBar, NavDrawer, Menu, MenuItem, ContextArea +- Grid, Table, Sheet, Surface +- TextField (con label animada), Tooltip +- Discloser (expandible), Divider +- ModalLayer, Scrim + +--- + +## 6. Theming y Styling + +### 6.1 Sistema de Theme + +```go +type Theme struct { + Shaper *text.Shaper + Palette Palette + TextSize unit.Sp + Face text.Face + FingerSize unit.Dp // Tamaño mínimo área touch (48dp) +} + +type Palette struct { + Bg color.NRGBA + Fg color.NRGBA + ContrastBg color.NRGBA + ContrastFg color.NRGBA +} +``` + +### 6.2 Niveles de Customización + +**Global (Theme-wide):** +```go +theme := material.NewTheme() +theme.Palette.Fg = color.NRGBA{R: 255, G: 255, B: 255, A: 255} +``` + +**Widget-local:** +```go +btn := material.Button(theme, button, "Click me!") +btn.Background = color.NRGBA{R: 0, G: 150, B: 0, A: 255} +``` + +--- + +## 7. Window Management + +### 7.1 Creación de Ventana + +```go +func main() { + go func() { + window := new(app.Window) + window.Option(app.Title("My App")) + window.Option(app.Size(unit.Dp(800), unit.Dp(600))) + if err := run(window); err != nil { + log.Fatal(err) + } + os.Exit(0) + }() + app.Main() // BLOQUEANTE en algunas plataformas +} +``` + +### 7.2 Event Loop + +```go +func run(window *app.Window) error { + var ops op.Ops + for { + switch e := window.Event().(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + ops.Reset() + gtx := app.NewContext(&ops, e) + drawUI(gtx) + e.Frame(gtx.Ops) + } + } +} +``` + +--- + +## 8. Paradigma Immediate Mode + +### 8.1 Filosofía + +``` +while running: + eventos ← poll_events() + actualizar_estado(eventos) + + ops.Reset() + dibujar_ui_completa(ops, estado) ← UI es función pura del estado + enviar_a_gpu(ops) +``` + +### 8.2 Ventajas Específicas de Gio + +**Sin callbacks:** +```go +// NO HAY ESTO: +button.onClick(func() { ... }) + +// EN SU LUGAR: +for button.Clicked(gtx) { + count++ +} +``` + +**Estado mínimo:** +```go +// El estado es tuyo, no del framework +type State struct { + count int + name string +} + +// Widgets solo contienen estado UI mínimo +var button widget.Clickable +``` + +**UI como función pura:** +```go +func drawUI(gtx layout.Context, state *State) { + label := material.H1(theme, fmt.Sprintf("Count: %d", state.count)) + label.Layout(gtx) +} +``` + +### 8.3 Zero-Allocation Design + +```go +// Método tipado, sin interfaz → sin allocación +operation.Add(ops) +``` + +--- + +## 9. Lecciones para zCatGui + +### 9.1 Qué Adoptar + +1. **Separación estado/presentación**: Widget state vs widget render +2. **Sistema de constraints**: Constraints/Dimensions muy robusto +3. **Event tags**: Sin callbacks, consulta de eventos +4. **Zero-allocation**: Operaciones tipadas +5. **Spacing modes**: Flex con SpaceBetween, SpaceAround, etc. + +### 9.2 Qué Adaptar + +1. **Rendering**: Gio usa GPU, nosotros software renderer +2. **Operaciones**: Gio usa op.Ops (GPU), nosotros DrawCommand (CPU) +3. **Complejidad**: Gio es ~40K LOC, nosotros apuntamos a ~5-10K + +### 9.3 Diferencias Clave + +| Aspecto | Gio | zCatGui | +|---------|-----|---------| +| Lenguaje | Go | Zig | +| Rendering | GPU (Pathfinder) | Software (framebuffer) | +| Complejidad | Alta (~40K LOC) | Baja (target ~5K) | +| Macros | No tiene | Piedra angular | +| Fonts | HarfBuzz | Bitmap + stb_truetype | + +--- + +## 10. Referencias + +- [Gio UI Website](https://gioui.org/) +- [Architecture](https://gioui.org/doc/architecture) +- [Layout](https://gioui.org/doc/architecture/layout) +- [Widget](https://gioui.org/doc/architecture/widget) +- [Input](https://gioui.org/doc/architecture/input) +- [API Reference](https://pkg.go.dev/gioui.org) +- [Examples](https://github.com/gioui/gio-example) +- [Extended Components](https://github.com/gioui/gio-x) +- [GUI with Gio Tutorial](https://jonegil.github.io/gui-with-gio/) +- [Immediate Mode GUI Programming](https://eliasnaur.com/blog/immediate-mode-gui-programming) diff --git a/docs/research/IMMEDIATE_MODE_LIBS.md b/docs/research/IMMEDIATE_MODE_LIBS.md new file mode 100644 index 0000000..4c062f5 --- /dev/null +++ b/docs/research/IMMEDIATE_MODE_LIBS.md @@ -0,0 +1,502 @@ +# Análisis Comparativo: Librerías GUI Immediate-Mode + +> Investigación realizada: 2025-12-09 +> Propósito: Identificar mejores referencias para implementar zCatGui + +--- + +## Resumen Ejecutivo + +Se analizaron las principales librerías GUI immediate-mode para identificar patrones, arquitecturas y código reutilizable para zCatGui. + +### Ranking de Relevancia para zCatGui + +| # | Librería | Relevancia | Por qué | +|---|----------|------------|---------| +| 1 | **microui** | ⭐⭐⭐⭐⭐ | 1,100 LOC, C puro, software rendering natural | +| 2 | **DVUI** | ⭐⭐⭐⭐⭐ | Único ejemplo Zig nativo | +| 3 | **Dear ImGui** | ⭐⭐⭐⭐ | Referencia API, muy maduro | +| 4 | **Nuklear** | ⭐⭐⭐⭐ | C puro, vertex buffer approach | +| 5 | **egui** | ⭐⭐⭐ | API ergonómica (Rust) | +| 6 | **raygui** | ⭐⭐⭐ | Ya tiene bindings Zig | + +--- + +## 1. microui (C) ⭐⭐⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/rxi/microui | +| **Lenguaje** | ANSI C | +| **LOC** | ~1,100 (microui.h + microui.c) | +| **Licencia** | MIT | + +### Por qué es la Mejor Referencia + +1. **Tamaño perfecto**: 1,100 LOC se puede estudiar completo en una tarde +2. **C puro**: Trivial de entender para quien escribe Zig +3. **Software rendering natural**: Output son comandos primitivos (rect, text) +4. **Zero dependencies**: ANSI C puro +5. **Diseño brillante**: Máximo valor en mínimo espacio + +### Arquitectura + +``` +Input → mu_Context → Widgets → Command List → Render +``` + +**Command List Approach:** +```c +// Output son comandos simples +enum { MU_COMMAND_RECT, MU_COMMAND_TEXT, MU_COMMAND_CLIP, ... }; + +// Fácil de renderizar con software +while (mu_next_command(ctx, &cmd)) { + switch (cmd->type) { + case MU_COMMAND_RECT: draw_rect(...); + case MU_COMMAND_TEXT: draw_text(...); + } +} +``` + +### Widgets Incluidos (8) + +- Window, Panel +- Button, Checkbox, Slider +- Textbox, Label +- (containers básicos) + +### Código Ejemplo + +```c +mu_begin_window(ctx, "My Window", mu_rect(10, 10, 300, 200)); + mu_layout_row(ctx, 2, (int[]){100, -1}, 0); + mu_label(ctx, "Name:"); + if (mu_textbox(ctx, name_buf, sizeof(name_buf)) & MU_RES_SUBMIT) { + submit_name(); + } + if (mu_button(ctx, "Submit")) { + handle_submit(); + } +mu_end_window(ctx); +``` + +### Lecciones para zCatGui + +1. **Command list es suficiente** - No necesitamos vertex buffers +2. **~1000 LOC para MVP** - Es alcanzable +3. **State pool** - Técnica para tracking de IDs +4. **Layout simple** - `mu_layout_row()` es todo lo que necesitas inicialmente + +### Recursos + +- [microui GitHub](https://github.com/rxi/microui) +- [microui v2 Implementation Overview](https://rxi.github.io/microui_v2_an_implementation_overview.html) + +--- + +## 2. DVUI (Zig) ⭐⭐⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/david-vanderson/dvui | +| **Lenguaje** | Zig | +| **LOC** | ~15,000 (estimado) | +| **Estado Zig** | 0.13-0.15 | + +### Por qué es Crítico + +**Es la ÚNICA GUI immediate-mode nativa en Zig.** + +1. **Idiomático Zig**: Patterns que podemos copiar directamente +2. **Moderno**: Low framerate support (no redibuja innecesariamente) +3. **Backend abstraction**: VTable pattern para SDL3, Raylib, Web, DirectX11 +4. **Activo**: Mantenido activamente (2022-presente) + +### Arquitectura + +``` +Event → Widget Processing → Layout → Render +``` + +**Características especiales:** +- Procesa TODOS los eventos (no solo último) +- Funciona a bajo framerate +- Floating windows con Z-order correcto + +### Widgets Incluidos (~30) + +- Button, Checkbox, Radio +- TextInput, TextArea +- Slider, ScrollArea +- Menu, Dropdown +- TreeView, Table +- Modal, Popup +- y más... + +### Código Ejemplo (Zig) + +```zig +pub fn gui(dvui: *Dvui) !void { + if (dvui.button("Click me!")) { + count += 1; + } + + dvui.text("Count: {}", .{count}); + + if (dvui.beginWindow("Settings")) { + defer dvui.endWindow(); + + _ = dvui.checkbox("Enable feature", &enabled); + _ = dvui.slider("Volume", &volume, 0, 100); + } +} +``` + +### Lecciones para zCatGui + +1. **Backend VTable**: Abstracción limpia para múltiples backends +2. **Event processing**: Cómo manejar eventos en Zig +3. **Widget patterns**: Cómo estructurar widgets en Zig +4. **Memory management**: Allocators idiomáticos + +### Recursos + +- [DVUI GitHub](https://github.com/david-vanderson/dvui) +- [DVUI Zig NEWS Article](https://zig.news/david_vanderson/dvui-immediate-zig-gui-for-apps-and-games-2a5h) + +--- + +## 3. Dear ImGui (C++) ⭐⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/ocornut/imgui | +| **Lenguaje** | C++ | +| **LOC** | ~60,000 | +| **Licencia** | MIT | + +### Por qué es Importante + +**El estándar de facto para GUIs immediate-mode.** + +1. **Battle-tested**: Usado en millones de aplicaciones +2. **API excelente**: Muy ergonómica y bien diseñada +3. **Documentación**: FAQ, wiki, ejemplos abundantes +4. **Software renderer existe**: [imgui_software_renderer](https://github.com/emilk/imgui_software_renderer) + +### Arquitectura + +``` +Input → ImGuiContext → Widgets → Vertex Buffers → GPU/Software Raster +``` + +**Separación de concerns:** +- **Core**: imgui.cpp, imgui_widgets.cpp, imgui_tables.cpp, imgui_draw.cpp +- **Backends**: Plataforma (GLFW, SDL2/3, Win32) separados +- **Renderers**: OpenGL, DirectX, Vulkan, Metal, WebGPU + +### Widgets Incluidos (50+) + +- Window, Child, Popup, Modal +- Button, Checkbox, Radio, Slider, Drag +- Input (text, int, float, multiline) +- Combo, ListBox, Selectable +- TreeNode, CollapsingHeader +- Table (muy avanzada) +- Menu, MenuBar +- TabBar, Tabs +- ColorPicker, ColorEdit +- Plot, Histogram +- y muchos más... + +### Lecciones para zCatGui + +1. **ID system**: Hash de string/pointer para tracking +2. **State caching**: ImGuiStorage para estado persistente +3. **Table API**: Referencia para nuestra tabla +4. **Backend separation**: Muy limpia + +### Bindings Zig Existentes + +- [cimgui.zig](https://github.com/tiawl/cimgui.zig) +- [dinau/imguinz](https://github.com/dinau/imguinz) - ImGui 1.91.8 +- [SpexGuy/Zig-ImGui](https://github.com/SpexGuy/Zig-ImGui) + +### Recursos + +- [Dear ImGui GitHub](https://github.com/ocornut/imgui) +- [Dear ImGui FAQ](https://github.com/ocornut/imgui/blob/master/docs/FAQ.md) +- [imgui_software_renderer](https://github.com/emilk/imgui_software_renderer) + +--- + +## 4. Nuklear (C) ⭐⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/Immediate-Mode-UI/Nuklear | +| **Lenguaje** | ANSI C | +| **LOC** | ~30,000 (single header) | +| **Licencia** | Public Domain | + +### Características + +1. **Single header**: nuklear.h contiene todo +2. **Renderer-agnostic**: Command iteration o vertex buffers +3. **Zero dependencies**: ANSI C puro +4. **Memoria fija**: Diseñado para embedded + +### Arquitectura + +**Dos modos de rendering:** + +1. **Command iteration** (similar a microui): +```c +nk_foreach(cmd, &ctx->draw_list) { + switch (cmd->type) { + case NK_COMMAND_RECT: ... + case NK_COMMAND_TEXT: ... + } +} +``` + +2. **Vertex buffer** (similar a ImGui): +```c +nk_convert(&ctx, &cmds, &verts, &idx, &config); +// Renderizar con GPU +``` + +### Widgets Incluidos (~20) + +- Window, Panel, Group +- Button, Checkbox, Radio, Slider +- Edit (text), Property +- Chart, Color picker +- Combo, Contextual, Menu, Tree + +### Lecciones para zCatGui + +1. **Styling extenso**: Sistema de propiedades muy configurable +2. **Memory model**: Fixed memory para embedded +3. **Dual rendering**: Command list Y vertex buffer + +### Recursos + +- [Nuklear GitHub](https://github.com/Immediate-Mode-UI/Nuklear) + +--- + +## 5. egui (Rust) ⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/emilk/egui | +| **Lenguaje** | Rust | +| **LOC** | ~35,000 | +| **Licencia** | MIT / Apache 2.0 | + +### Características + +1. **API ergonómica**: Closures, builder pattern idiomático +2. **Web + Native**: Diseñado para ambos desde el inicio +3. **epaint**: Tessellator separado (shapes → meshes) + +### Arquitectura + +``` +egui (core) → epaint (tessellation) → eframe (backend glue) +``` + +**epaint es interesante**: Convierte shapes vectoriales en triangle meshes. + +### Lecciones para zCatGui + +1. **Response pattern**: Widgets retornan Response con interacciones +2. **Context memory**: Retiene mínimo estado entre frames +3. **Tessellator separado**: epaint podría portarse + +### Nota sobre Software Rendering + +**No tiene software renderer oficial** - hay un issue abierto (#1129). +El autor (emilk) escribió uno para ImGui, podría adaptarse. + +### Recursos + +- [egui GitHub](https://github.com/emilk/egui) +- [egui.rs](https://www.egui.rs/) + +--- + +## 6. raygui (C) ⭐⭐⭐ + +### Información General + +| Campo | Valor | +|-------|-------| +| **Repositorio** | https://github.com/raysan5/raygui | +| **Lenguaje** | C | +| **LOC** | ~8,000 | +| **Dependencia** | raylib | + +### Características + +1. **Integrado con raylib**: Dibuja directamente +2. **Tools-focused**: Diseñado para editores +3. **Ya tiene bindings Zig**: via raylib-zig + +### Por qué Menos Relevante + +- Acoplado a raylib +- Menos features que ImGui/Nuklear +- No standalone + +### Recursos + +- [raygui GitHub](https://github.com/raysan5/raygui) +- [raylib-zig](https://github.com/raylib-zig/raylib-zig) + +--- + +## Comparativa Técnica + +| Librería | LOC | Lenguaje | Software Renderer | Complejidad | Porting a Zig | +|----------|-----|----------|-------------------|-------------|---------------| +| **microui** | 1,100 | C | ✅ Natural | Muy Baja | Trivial | +| **DVUI** | 15,000 | Zig | Via backend | Media | ✅ Nativo | +| **Dear ImGui** | 60,000 | C++ | ✅ (external) | Alta | Medio | +| **Nuklear** | 30,000 | C | ⚠️ Custom | Media | Fácil | +| **egui** | 35,000 | Rust | ❌ Planeado | Media | Medio | +| **raygui** | 8,000 | C | Via raylib | Baja | Ya existe | + +--- + +## Arquitectura de Rendering: Comparativa + +### Command List Approach (microui, nuestro approach) + +``` +Input → UI Logic → Commands (DrawRect, DrawText) → Direct Render +``` + +**Pros**: Simplicísimo, ideal software rendering +**Contras**: No batching, menos eficiente con muchos elementos + +### Vertex Buffer Approach (ImGui, Nuklear, egui) + +``` +Input → UI Logic → Shapes → Tessellate → Vertex Buffers → GPU/Software Raster +``` + +**Pros**: Eficiente, batching, flexible +**Contras**: Necesita tessellator, más complejo + +### Decisión para zCatGui + +**Command List** (microui style): +1. Más simple de implementar +2. Suficiente para nuestros casos de uso +3. Software rendering natural +4. ~1000 LOC para renderer + +--- + +## Software Rendering: Análisis + +### ¿Qué necesita un software renderer? + +1. **Framebuffer**: Array 2D de pixels (u32 RGBA) +2. **Primitive rasterizer**: + - `drawRect(x, y, w, h, color)` + - `drawLine(x1, y1, x2, y2, color)` + - Opcional: `drawTriangle()` para anti-aliasing +3. **Text rasterizer**: + - Font atlas (bitmap) + - `drawText(x, y, string, font, color)` +4. **Clipping**: Restringir dibujo a región +5. **Output**: Blit framebuffer a ventana (SDL_UpdateTexture) + +### Librerías con Mejor Soporte Software + +1. **microui**: IDEAL - output son rects/text directos +2. **imgui_software_renderer**: Rasteriza triangles de ImGui en CPU +3. **SDL software renderer**: Backend para ImGui/Nuklear + +### Estimación para zCatGui + +- **Rasterizer básico**: ~500 LOC +- **Font handling**: ~300 LOC +- **Clipping**: ~100 LOC +- **Total**: ~1000 LOC + +--- + +## Recomendaciones para zCatGui + +### Estudio Prioritario + +1. **microui** (1-2 días): Código completo, fundamentos +2. **DVUI** (2-3 días): Patterns Zig, backend abstraction +3. **Dear ImGui** (1 semana): API design (no portar, solo estudiar) + +### Arquitectura Propuesta + +```zig +// Basado en microui + DVUI patterns +pub const Context = struct { + commands: ArrayList(DrawCommand), + state: StatePool, + input: InputState, + + pub fn button(self: *Context, text: []const u8) bool { + const id = hashId(text); + const bounds = self.layout.nextRect(); + const hovered = bounds.contains(self.input.mouse); + const clicked = hovered and self.input.mousePressed; + + self.pushCommand(.{ .rect = .{ .bounds = bounds, ... }}); + self.pushCommand(.{ .text = .{ .pos = bounds.center(), ... }}); + + return clicked; + } +}; +``` + +### Roadmap Basado en Referencias + +| Fase | LOC Estimado | Tiempo | Referencia Principal | +|------|--------------|--------|---------------------| +| MVP (button, label) | ~1000 | 1 semana | microui | +| Core widgets | ~3000 | 2 semanas | microui + DVUI | +| Table editable | ~1500 | 2 semanas | Dear ImGui | +| Total | ~5500 | 5 semanas | - | + +--- + +## Recursos Clave + +### Tutoriales y Teoría + +- [Immediate Mode GUI Wikipedia](https://en.wikipedia.org/wiki/Immediate_mode_GUI) +- [Retained vs Immediate Mode - Microsoft](https://learn.microsoft.com/en-us/windows/win32/learnwin32/retained-mode-versus-immediate-mode) +- [microui v2 Implementation Overview](https://rxi.github.io/microui_v2_an_implementation_overview.html) + +### Software Rendering + +- [imgui_software_renderer](https://github.com/emilk/imgui_software_renderer) +- [Tiny CPU Rasterizer Tutorial](https://lisyarus.github.io/blog/posts/implementing-a-tiny-cpu-rasterizer-part-1.html) + +### Zig Bindings Existentes + +- [cimgui.zig](https://github.com/tiawl/cimgui.zig) +- [raylib-zig](https://github.com/raylib-zig/raylib-zig) diff --git a/docs/research/SIMIFACTU_FYNE_ANALYSIS.md b/docs/research/SIMIFACTU_FYNE_ANALYSIS.md new file mode 100644 index 0000000..4dab87b --- /dev/null +++ b/docs/research/SIMIFACTU_FYNE_ANALYSIS.md @@ -0,0 +1,617 @@ +# Análisis: Widgets y Funcionalidades de Fyne en Simifactu + +> Investigación realizada: 2025-12-09 +> Propósito: Extraer requisitos reales de una aplicación de producción + +--- + +## Resumen Ejecutivo + +**Simifactu** es una aplicación de facturación empresarial desarrollada en Go con Fyne v2. Este análisis extrae todos los widgets, layouts, y funcionalidades que zCatGui necesitaría para soportar una aplicación similar. + +**Proyecto analizado**: `/mnt/cello2/arno/re/recode/go/simifactu/` + +--- + +## 1. Widgets Básicos Usados + +### 1.1 Entry Widgets (Campos de Texto) + +**Uso masivo**: Prácticamente todos los paneles usan Entry fields. + +**Variantes detectadas:** +- `widget.Entry` - Campo de texto básico (single-line) +- `widget.Entry` con `MultiLine = true` - Texto multi-línea +- **AutoComplete Entry** (custom) - Entry con dropdown de sugerencias + +**Características críticas:** +- `OnChanged` callback ejecutado en cada tecla +- `OnSubmit` callback al presionar Enter +- `PlaceHolder` text +- `Validation` callbacks +- **ReadOnly** mode +- `FocusGained/FocusLost` callbacks + +**Requisito zCatGui:** +```zig +pub const Input = struct { + text: []const u8, + cursor: usize, + placeholder: []const u8, + multiline: bool, + suggestions: ?[][]const u8, // Para autocomplete + on_changed: ?*const fn([]const u8) void, + on_submit: ?*const fn([]const u8) void, + readonly: bool, +}; +``` + +### 1.2 Button Widgets + +**Variantes detectadas:** +- `widget.Button` - Botón estándar +- **Button3D** (custom) - Botón con efecto 3D, altura reducida + +**Usos identificados:** +- Botones CRUD: Nuevo, Guardar, Eliminar +- Navegación: `<<`, `<`, `>`, `>>` +- Acciones contextuales: Exportar, Importar, Duplicar + +**Requisito zCatGui:** +```zig +pub const Button = struct { + label: []const u8, + on_tapped: *const fn() void, + disabled: bool, + style: Style, + importance: enum { primary, secondary, danger }, +}; +``` + +### 1.3 Select/Dropdown Widgets + +**Variantes detectadas:** +- `widget.Select` - Dropdown básico +- **SelectEntry** - Combo editable (dropdown + entry) + +**Usos identificados:** +- Tipo IVA: "21%", "10%", "4%", "0%", "Exento" +- Régimen Equivalencia +- Tipo Documento: "Presupuesto", "Albarán", "Factura" +- Estado Documento: "Borrador", "Confirmado", "Enviado" +- Forma de Pago + +**Requisito zCatGui:** +```zig +pub const Select = struct { + options: [][]const u8, + selected: usize, + on_changed: *const fn(usize) void, + placeholder: []const u8, + allow_custom: bool, +}; +``` + +### 1.4 Checkbox + +**Usos detectados:** +- "Es Sociedad" +- Selección múltiple en importación + +**Requisito zCatGui:** +```zig +pub const Checkbox = struct { + checked: bool, + label: []const u8, + on_changed: *const fn(bool) void, +}; +``` + +--- + +## 2. Widgets Avanzados/Complejos + +### 2.1 Table Widget ⭐ CRÍTICO + +**El widget MÁS importante** de Simifactu - usado en prácticamente todas las vistas. + +**Implementación actual:** +- Fork personalizado de `fyne/widget/table.go` +- **AdvancedTable** wrapper (1000+ líneas) + +**Características implementadas (TODAS necesarias):** + +#### Virtualización y Performance +- Lazy rendering (solo filas visibles) +- Scroll eficiente con cache de celdas +- Invalidación selectiva de cache + +#### Navegación Teclado +``` +Flechas: Up/Down/Left/Right (navegación entre celdas) +Enter: Editar celda actual +Space: Activar edición con celda vacía +Tab: Siguiente celda editable +Escape: Cancelar edición / Revertir cambios +Ctrl+N: Nueva fila +Ctrl+B/Supr: Borrar fila +Home/End: Primera/Última fila +``` + +#### Edición In-Situ +- Doble-click para editar celda +- Entry overlay que aparece sobre celda +- Auto-submit al cambiar fila +- Validación por columna +- **Tipos de celda**: Text, Number, Money, Date, Select + +#### Indicadores Visuales de Estado +- Filas nuevas: Icono verde +- Filas modificadas: Icono naranja +- Filas borradas: Icono rojo + +#### Ordenamiento (Sorting) +- Click en header para ordenar +- Indicadores visuales ▲/▼ + +#### Colores Dinámicos +```zig +pub const TableColors = struct { + header_background: Color, + header_text: Color, + row_normal: Color, + row_hover: Color, + cell_active: Color, + cell_editing: Color, + selection: Color, +}; +``` + +#### Schema-Driven Configuration +```zig +pub const TableSchema = struct { + columns: []ColumnDef, + show_state_indicators: bool, + allow_keyboard_nav: bool, + allow_edit: bool, + allow_sort: bool, +}; + +pub const ColumnDef = struct { + name: []const u8, + width: f32, + column_type: enum { text, number, money, date, select }, + editable: bool, + validator: ?*const fn([]const u8) bool, +}; +``` + +### 2.2 InnerWindow Widget ⭐ CRÍTICO + +**Fork personalizado de Fyne** - contenedor con barra de título y bordes. + +**Características:** +- Título personalizable y dinámico +- Barra de título con color configurable +- Borde exterior con color configurable +- Fondo contenido separado +- Botón cerrar opcional +- Callbacks: `OnFocusGained`, `OnFocusLost`, `OnTappedBar` +- Resize programático + +**Uso en Simifactu:** +- Todos los paneles principales están en InnerWindows +- Layout: 3-4 InnerWindows en HSplit/VSplit + +**Requisito zCatGui:** +```zig +pub const Panel = struct { + title: []const u8, + content: Widget, + border: BorderStyle, + title_style: Style, + closable: bool, + on_focus_gained: ?*const fn() void, + on_focus_lost: ?*const fn() void, +}; +``` + +### 2.3 List Widget + +**Uso detectado:** +- Navegador estilo OpenOffice (carpetas izquierda, archivos derecha) + +**Requisito zCatGui:** +```zig +pub const List = struct { + items: []ListItem, + selected: i32, + on_selected: *const fn(usize) void, +}; + +pub const ListItem = struct { + text: []const u8, + icon: ?Symbol, +}; +``` + +--- + +## 3. Layouts Usados + +### 3.1 Container Layouts (Fyne) + +```go +// Border - contenido con bordes fijos +container.NewBorder(top, bottom, left, right, center) + +// VBox/HBox - stacks +container.NewVBox(widget1, widget2, widget3) +container.NewHBox(widget1, widget2) + +// Grid +container.NewGridWithColumns(3, campo1, campo2, campo3) + +// HSplit/VSplit - CRÍTICO +split := container.NewHSplit(leftPanel, rightPanel) +split.SetOffset(0.25) // 25% izquierda + +// Stack - overlay +container.NewStack(background, content, overlayPopup) + +// Padded +container.NewPadded(widget) +``` + +### 3.2 Layout Principal de Simifactu + +``` +┌───────────────────────────────────────────────────────────┐ +│ Status Line (Border Top - altura fija) │ +├─────────┬─────────────────────┬────────────────────────────┤ +│ WHO │ WHO Detail │ Document Detail (FULL) │ +│ List │ ─────────────────── │ - Cabecera (40%) │ +│ (20%) │ Documents List │ - Líneas (60%) │ +│ │ │ │ +│ │ (VSplit 50/50) │ (VSplit integrado) │ +│ │ (35%) │ (45%) │ +└─────────┴─────────────────────┴────────────────────────────┘ + HSplit(0.20) HSplit(0.4347) +``` + +**Requisito zCatGui:** +```zig +pub const Split = struct { + direction: enum { horizontal, vertical }, + children: [2]Widget, + offset: f32, // 0.0-1.0 + draggable: bool, // Ctrl+flechas para ajustar +}; +``` + +--- + +## 4. Diálogos y Popups + +### 4.1 Modal Dialogs Detectados + +```go +// Confirmación (Yes/No) +dialog.ShowConfirm("Eliminar", "¿Seguro?", callback, window) + +// Información (solo OK) +dialog.ShowInformation("Título", "Mensaje", window) + +// Error (solo OK) +dialog.ShowError(err, window) + +// Entrada de texto +dialog.ShowEntryDialog("Nombre", "Introduzca:", "default", callback, window) + +// File picker +dialog.ShowFileOpen(callback, window) + +// Folder picker +dialog.ShowFolderOpen(callback, window) +``` + +### 4.2 Custom Dialogs Detectados + +- **Import Window** - Navegador con checkboxes +- **Export Window** - Selector formato + opciones +- **Edit Date Dialog** - Picker fecha con calendario +- **Empresas Window** - Gestión multi-empresa + +--- + +## 5. Eventos y Callbacks + +### 5.1 Eventos Teclado ⭐ MUY IMPORTANTE + +**OnTypedKey** - Usado en TODOS los paneles: + +```go +window.Canvas().SetOnTypedKey(func(key *fyne.KeyEvent) { + // Shortcuts globales + if key.Name == fyne.KeyF2 { + showEmpresasWindow() + return + } + + // Ctrl+1/2/3 - Layout presets + if mods&fyne.KeyModifierControl != 0 { + switch key.Name { + case "1": applyLayout1() + case "2": applyLayout2() + } + } +}) +``` + +**Requisito zCatGui:** +```zig +pub const KeyEvent = struct { + key: Key, + modifiers: KeyModifiers, +}; + +pub const KeyModifiers = packed struct { + control: bool, + alt: bool, + shift: bool, +}; + +// Handler retorna si consume el evento +pub const OnKey = *const fn(KeyEvent) bool; +``` + +### 5.2 Eventos Mouse + +```go +widget.OnTapped = func(*fyne.PointEvent) { click() } +table.OnCellDoubleTapped = func(row, col int) { edit(row, col) } +widget.OnMouseIn = func(*desktop.MouseEvent) { hover() } +widget.OnMouseOut = func(*desktop.MouseEvent) { unhover() } +``` + +### 5.3 Focus Events + +```go +widget.OnFocusGained = func() { activate() } +widget.OnFocusLost = func() { deactivate() } +canvas.Focus(widget) // Programático +``` + +--- + +## 6. El Problema fyne.Do() ⭐⭐⭐ CRÍTICO + +### 6.1 El Problema del Threading + +**402 usos de `fyne.Do()`** detectados en el proyecto. + +**Razón**: Fyne requiere que TODAS las operaciones UI se ejecuten en el thread principal. + +```go +// PATTERN OBLIGATORIO en Fyne +go func() { + result := heavyComputation() // Background + + fyne.Do(func() { + label.SetText(result) // DEBE estar en fyne.Do() + table.Refresh() + }) +}() +``` + +**Errores si no se usa fyne.Do():** +``` +panic: concurrent map writes +SIGSEGV: segmentation fault +GL context not current +``` + +### 6.2 Por qué zCatGui NO tiene este problema + +**Immediate mode es inherentemente thread-safe por diseño:** +- No hay estado compartido del framework +- Buffer es solo memoria (no GL context) +- Render es single-threaded +- No necesita equivalente a fyne.Do() + +**Pero sí necesitamos:** +- AsyncLoop para queries BD +- Timer system para auto-save +- Event queue + +--- + +## 7. Theming y Colores + +### 7.1 Sistema de Colores Dual + +**Sistema 1: Colores por Panel:** +```go +type PanelColors struct { + Fondo Color + Header Color + Texto Color + Entry_Fondo Color + Entry_Texto Color + Button_Fondo Color + Tabla_Header Color + Celda_Activa Color + // ... 20+ colores por panel +} + +dataManager.GetColorForPanel("who_detail", "Fondo") +``` + +**Sistema 2: Colores Globales Botones:** +```go +type ButtonColors struct { + ActivoButtonFondo Color + InactivoButtonFondo Color + NuevoButtonFondo Color + EliminarButtonFondo Color +} +``` + +### 7.2 Hot-Reload de Colores + +```go +// Panel se registra como observer +dataManager.RegisterColorObserver("who_detail", panel) + +// Cuando usuario cambia color +dataManager.NotifyColorsChange("who_detail") + +// Panel actualiza +func (p *Panel) NotifyColorsChange() { + p.applyColors() +} +``` + +### 7.3 Persistencia + +- Archivo texto KV: `who_detail.Fondo = 40,44,52` +- JSON backup +- Base de datos: tabla `config_backup` + +--- + +## 8. Características Especiales + +### 8.1 Auto-Save System + +```go +dataManager.StartAutoSaveTimer() +// Guarda config cada N segundos si isDirty == true +``` + +### 8.2 File Watcher (Hot-Reload Config) + +```go +dataManager.StartConfigFileWatcher() +// Detecta cambios externos, recarga automáticamente +``` + +### 8.3 Export/Import Data + +- JSON completo (clientes + documentos + líneas) +- Exportación selectiva (checkboxes) +- Importación con merge +- Progress tracking + +### 8.4 Multi-Empresa + +- Tabla `empresas` en BD +- Selector empresa activa +- Datos aislados por empresa_uuid + +### 8.5 Templates Sistema + +- Plantillas PDF personalizables +- Editor visual +- Vista previa en tiempo real + +--- + +## 9. Widgets Third-Party (fynex-widgets) + +**Librería vendorizada**: `/third_party/fynex-widgets/` + +| Widget | Descripción | Uso | +|--------|-------------|-----| +| **AutoComplete** | Entry con dropdown sugerencias | Población, Provincia, País | +| **DateEntry** | Entry con calendar picker | Fechas | +| **NumEntry** | Entry solo números | Cantidades, precios | +| **SelectEntry** | Combo editable | Custom values | +| **Calendar** | Widget calendario | Selección fecha | +| **Tooltips** | Ayuda contextual | Hover | + +--- + +## 10. Resumen de Requisitos para zCatGui + +### 10.1 Widgets Necesarios (Orden Prioridad) + +| # | Widget | Prioridad | Notas | +|---|--------|-----------|-------| +| 1 | **Table** | CRÍTICA | Edición in-situ, state indicators, sorting | +| 2 | **Input** | CRÍTICA | Autocomplete, validation | +| 3 | **Select** | CRÍTICA | Dropdown selection | +| 4 | **Panel** | ALTA | Título dinámico, bordes coloreados | +| 5 | **Split** | ALTA | HSplit/VSplit draggable | +| 6 | **Button** | ALTA | Disabled, importance levels | +| 7 | **Modal** | MEDIA | Diálogos modales | +| 8 | **List** | MEDIA | Lista seleccionable | +| 9 | **Checkbox** | MEDIA | Toggle boolean | +| 10 | **Label** | BAJA | Texto estático | + +### 10.2 Layouts Necesarios + +| Layout | Status | +|--------|--------| +| VBox/HBox | Usar Layout constraints | +| Grid | Usar Layout constraints | +| HSplit/VSplit | **CRÍTICO - Implementar** | +| Stack (Overlay) | Para popups | +| Border | Emular con constraints | + +### 10.3 Features Críticos + +1. **Table con edición in-situ** - 80% del trabajo +2. **Split panels draggables** - Layout principal +3. **Select/Dropdown** - 20+ lugares de uso +4. **Sistema de focus** - Navegación teclado +5. **Hot-reload themes** - Cambio colores en runtime + +### 10.4 Estimación de Trabajo + +| Componente | Líneas | Tiempo | +|------------|--------|--------| +| Table editable | 1500 | 2-3 semanas | +| Select/Dropdown | 300 | 3-4 días | +| Split layout | 200 | 2-3 días | +| Panel widget | 150 | 2 días | +| AutoComplete | 250 | 3-4 días | +| Modal/Dialog | 200 | 2-3 días | +| **TOTAL** | ~2600 | 4-5 semanas | + +--- + +## 11. Ventajas de Portar a GUI Immediate-Mode + +### Por qué Vale la Pena + +1. **Sin fyne.Do()**: Código más simple, sin threading hell +2. **Performance**: Software rendering predecible +3. **Control total**: Sin "magia" del framework +4. **Testing**: Funciones puras, fácil de testear +5. **Debugging**: Estado visible, reproducible + +### Comparación Threading + +| Fyne (Retained) | zCatGui (Immediate) | +|-----------------|---------------------| +| 402 usos fyne.Do() | 0 equivalentes | +| Callbacks async | Polling síncrono | +| Estado oculto | Estado explícito | +| Race conditions | Sin races | + +--- + +## 12. Archivos Clave Analizados + +``` +/cmd/simifactu/main.go (881 líneas) +/internal/ui/components/advanced_table/*.go (2000+ líneas) +/internal/ui/panels_v3/panels/who_detail/ui_layout.go (300 líneas) +/internal/ui/panels_v3/panels/document_detail/ui_layout.go (400 líneas) +/internal/ui/dialogs/import_window.go (500 líneas) +/third_party/fynex-widgets/autocomplete.go (600 líneas) +/internal/ui/panels_v3/data/manager_colors.go (800 líneas) +``` + +**Total analizado**: ~10,000 líneas de código + 5000 líneas de docs diff --git a/examples/hello.zig b/examples/hello.zig new file mode 100644 index 0000000..f7bd6b9 --- /dev/null +++ b/examples/hello.zig @@ -0,0 +1,81 @@ +//! Hello World - Basic zCatGui example +//! +//! Demonstrates: +//! - Initializing the backend +//! - Creating a framebuffer +//! - Basic rendering +//! - Event loop + +const std = @import("std"); +const zcatgui = @import("zcatgui"); + +const Framebuffer = zcatgui.render.Framebuffer; +const SoftwareRenderer = zcatgui.render.SoftwareRenderer; +const Sdl2Backend = zcatgui.backend.Sdl2Backend; +const Color = zcatgui.Color; +const Command = zcatgui.Command; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Initialize backend + var backend = try Sdl2Backend.init("zCatGui - Hello World", 800, 600); + defer backend.deinit(); + + // Create framebuffer + var fb = try Framebuffer.init(allocator, 800, 600); + defer fb.deinit(); + + // Create renderer + var renderer = SoftwareRenderer.init(&fb); + + var running = true; + var frame: u32 = 0; + + while (running) { + // Poll events + while (backend.pollEvent()) |event| { + switch (event) { + .quit => running = false, + .key => |key| { + if (key.key == .escape and key.pressed) { + running = false; + } + }, + .resize => |size| { + try fb.resize(size.width, size.height); + }, + else => {}, + } + } + + // Clear + renderer.clear(Color.background); + + // Draw some rectangles + renderer.execute(Command.rect(50, 50, 200, 100, Color.primary)); + renderer.execute(Command.rect(300, 50, 200, 100, Color.success)); + renderer.execute(Command.rect(550, 50, 200, 100, Color.danger)); + + // Draw rectangle outline + renderer.execute(Command.rectOutline(50, 200, 700, 100, Color.border)); + + // Animate a rectangle + const x = @as(i32, @intCast(50 + (frame % 600))); + renderer.execute(Command.rect(x, 350, 100, 100, Color.warning)); + + // Draw some lines + renderer.execute(Command.line(50, 500, 750, 500, Color.foreground)); + renderer.execute(Command.line(400, 450, 400, 550, Color.foreground)); + + // Present + backend.present(&fb); + + frame += 1; + + // Cap at ~60 FPS + std.Thread.sleep(16 * std.time.ns_per_ms); + } +} diff --git a/examples/macro_demo.zig b/examples/macro_demo.zig new file mode 100644 index 0000000..89a57f3 --- /dev/null +++ b/examples/macro_demo.zig @@ -0,0 +1,176 @@ +//! Macro Demo - Demonstrates the macro recording system +//! +//! Press: +//! - R: Start/stop recording +//! - P: Play back recorded macro +//! - S: Save macro to file +//! - L: Load macro from file +//! - ESC: Quit +//! +//! Type any keys while recording to capture them. + +const std = @import("std"); +const zcatgui = @import("zcatgui"); + +const Framebuffer = zcatgui.render.Framebuffer; +const SoftwareRenderer = zcatgui.render.SoftwareRenderer; +const Sdl2Backend = zcatgui.backend.Sdl2Backend; +const MacroRecorder = zcatgui.MacroRecorder; +const MacroPlayer = zcatgui.MacroPlayer; +const Color = zcatgui.Color; +const Command = zcatgui.Command; +const KeyEvent = zcatgui.KeyEvent; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Initialize backend + var backend = try Sdl2Backend.init("zCatGui - Macro Demo", 800, 600); + defer backend.deinit(); + + // Create framebuffer + var fb = try Framebuffer.init(allocator, 800, 600); + defer fb.deinit(); + + // Create renderer + var renderer = SoftwareRenderer.init(&fb); + + // Create macro recorder + var recorder = MacroRecorder.init(allocator); + defer recorder.deinit(); + + // State + var running = true; + var last_keys: [10]KeyEvent = undefined; + var last_key_count: usize = 0; + var status_message: []const u8 = "Press R to start recording"; + + // Event injection function for playback + const inject = struct { + var injected_count: usize = 0; + + fn injectKey(key: KeyEvent) void { + _ = key; + injected_count += 1; + std.debug.print("Injected key #{d}\n", .{injected_count}); + } + }.injectKey; + + while (running) { + // Poll events + while (backend.pollEvent()) |event| { + switch (event) { + .quit => running = false, + + .key => |key| { + if (!key.pressed) continue; + + // Record if recording + recorder.record(key); + + // Store for display + if (last_key_count < last_keys.len) { + last_keys[last_key_count] = key; + last_key_count += 1; + } else { + // Shift left + for (0..last_keys.len - 1) |i| { + last_keys[i] = last_keys[i + 1]; + } + last_keys[last_keys.len - 1] = key; + } + + // Handle special keys + switch (key.key) { + .escape => running = false, + + .r => { + if (recorder.isRecording()) { + _ = recorder.stop(); + status_message = "Recording stopped"; + std.debug.print("Stopped recording. {d} events captured.\n", .{recorder.eventCount()}); + } else { + recorder.start(); + status_message = "Recording..."; + std.debug.print("Started recording.\n", .{}); + } + }, + + .p => { + if (!recorder.isRecording() and recorder.eventCount() > 0) { + status_message = "Playing back..."; + std.debug.print("Playing back {d} events.\n", .{recorder.eventCount()}); + MacroPlayer.play(recorder.stop(), inject); + status_message = "Playback complete"; + } + }, + + .s => { + if (recorder.eventCount() > 0) { + recorder.save("macro.zcm") catch |err| { + std.debug.print("Save failed: {}\n", .{err}); + }; + status_message = "Saved to macro.zcm"; + std.debug.print("Saved macro to macro.zcm\n", .{}); + } + }, + + .l => { + recorder.load("macro.zcm") catch |err| { + std.debug.print("Load failed: {}\n", .{err}); + }; + status_message = "Loaded from macro.zcm"; + std.debug.print("Loaded macro: {d} events\n", .{recorder.eventCount()}); + }, + + else => {}, + } + }, + + .resize => |size| { + try fb.resize(size.width, size.height); + }, + + else => {}, + } + } + + // Clear + renderer.clear(Color.background); + + // Draw title area + renderer.execute(Command.rect(0, 0, fb.width, 50, Color.rgb(40, 40, 40))); + + // Draw status + const status_color = if (recorder.isRecording()) Color.danger else Color.foreground; + renderer.execute(Command.rect(10, 60, 200, 20, status_color)); + + // Draw event count + renderer.execute(Command.rect(10, 90, @intCast(recorder.eventCount() * 10), 20, Color.primary)); + + // Draw last keys as blocks + for (0..last_key_count) |i| { + const x: i32 = @intCast(10 + i * 50); + renderer.execute(Command.rect(x, 150, 40, 40, Color.secondary)); + } + + // Draw instructions area + renderer.execute(Command.rect(10, 250, 400, 120, Color.rgb(35, 35, 35))); + // Text would go here with a proper font + + // Recording indicator + if (recorder.isRecording()) { + renderer.execute(Command.rect(@as(i32, @intCast(fb.width)) - 30, 10, 20, 20, Color.danger)); + } + + // Present + backend.present(&fb); + + // Cap at ~60 FPS + std.Thread.sleep(16 * std.time.ns_per_ms); + } + + std.debug.print("Final status: {s}\n", .{status_message}); +} diff --git a/src/backend/backend.zig b/src/backend/backend.zig new file mode 100644 index 0000000..76a9aa7 --- /dev/null +++ b/src/backend/backend.zig @@ -0,0 +1,78 @@ +//! Backend - Abstract interface for window/event backends +//! +//! The backend handles: +//! - Creating and managing the window +//! - Polling for input events +//! - Displaying the framebuffer + +const std = @import("std"); + +const Input = @import("../core/input.zig"); +const Framebuffer = @import("../render/framebuffer.zig").Framebuffer; + +pub const KeyEvent = Input.KeyEvent; +pub const MouseEvent = Input.MouseEvent; + +/// Event from the backend +pub const Event = union(enum) { + /// Keyboard event + key: KeyEvent, + + /// Mouse event + mouse: MouseEvent, + + /// Window resized + resize: struct { + width: u32, + height: u32, + }, + + /// Window close requested + quit, + + /// Text input (Unicode) + text_input: struct { + text: [32]u8, + len: usize, + }, +}; + +/// Abstract backend interface +pub const Backend = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Poll for events (non-blocking) + pollEvent: *const fn (ptr: *anyopaque) ?Event, + + /// Present the framebuffer to the screen + present: *const fn (ptr: *anyopaque, fb: *const Framebuffer) void, + + /// Get window dimensions + getSize: *const fn (ptr: *anyopaque) struct { width: u32, height: u32 }, + + /// Clean up + deinit: *const fn (ptr: *anyopaque) void, + }; + + /// Poll for events + pub fn pollEvent(self: Backend) ?Event { + return self.vtable.pollEvent(self.ptr); + } + + /// Present framebuffer + pub fn present(self: Backend, fb: *const Framebuffer) void { + self.vtable.present(self.ptr, fb); + } + + /// Get window size + pub fn getSize(self: Backend) struct { width: u32, height: u32 } { + return self.vtable.getSize(self.ptr); + } + + /// Clean up + pub fn deinit(self: Backend) void { + self.vtable.deinit(self.ptr); + } +}; diff --git a/src/backend/sdl2.zig b/src/backend/sdl2.zig new file mode 100644 index 0000000..ec13d08 --- /dev/null +++ b/src/backend/sdl2.zig @@ -0,0 +1,323 @@ +//! SDL2 Backend - Window and event handling via SDL2 +//! +//! This is the primary backend for zCatGui. +//! SDL2 provides cross-platform window creation, event handling, +//! and texture-based rendering (we use it to display our software framebuffer). + +const std = @import("std"); +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); + +const Backend = @import("backend.zig"); +const Input = @import("../core/input.zig"); +const Framebuffer = @import("../render/framebuffer.zig").Framebuffer; + +const Event = Backend.Event; +const KeyEvent = Input.KeyEvent; +const Key = Input.Key; +const KeyModifiers = Input.KeyModifiers; + +/// SDL2 backend implementation +pub const Sdl2Backend = struct { + window: *c.SDL_Window, + renderer: *c.SDL_Renderer, + texture: *c.SDL_Texture, + width: u32, + height: u32, + + const Self = @This(); + + /// Initialize SDL2 and create a window + pub fn init(title: [*:0]const u8, width: u32, height: u32) !Self { + // Initialize SDL + if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) { + return error.SdlInitFailed; + } + errdefer c.SDL_Quit(); + + // Create window + const window = c.SDL_CreateWindow( + title, + c.SDL_WINDOWPOS_CENTERED, + c.SDL_WINDOWPOS_CENTERED, + @intCast(width), + @intCast(height), + c.SDL_WINDOW_SHOWN | c.SDL_WINDOW_RESIZABLE, + ) orelse return error.WindowCreationFailed; + errdefer c.SDL_DestroyWindow(window); + + // Create renderer (using software renderer for consistency) + const renderer = c.SDL_CreateRenderer( + window, + -1, + c.SDL_RENDERER_SOFTWARE, + ) orelse return error.RendererCreationFailed; + errdefer c.SDL_DestroyRenderer(renderer); + + // Create texture for framebuffer + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ABGR8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(width), + @intCast(height), + ) orelse return error.TextureCreationFailed; + + return Self{ + .window = window, + .renderer = renderer, + .texture = texture, + .width = width, + .height = height, + }; + } + + /// Clean up SDL resources + pub fn deinit(self: *Self) void { + c.SDL_DestroyTexture(self.texture); + c.SDL_DestroyRenderer(self.renderer); + c.SDL_DestroyWindow(self.window); + c.SDL_Quit(); + } + + /// Poll for events (non-blocking) + pub fn pollEvent(self: *Self) ?Event { + _ = self; + var event: c.SDL_Event = undefined; + + if (c.SDL_PollEvent(&event) == 0) { + return null; + } + + return switch (event.type) { + c.SDL_QUIT => Event.quit, + + c.SDL_KEYDOWN, c.SDL_KEYUP => blk: { + const pressed = event.type == c.SDL_KEYDOWN; + const key = translateKey(event.key.keysym.scancode); + const mods = translateModifiers(event.key.keysym.mod); + + break :blk Event{ + .key = .{ + .key = key, + .modifiers = mods, + .char = null, // Filled by text input event + .pressed = pressed, + }, + }; + }, + + c.SDL_TEXTINPUT => blk: { + var text_event = Event{ + .text_input = .{ + .text = undefined, + .len = 0, + }, + }; + + // Copy text (null-terminated) + var i: usize = 0; + while (i < 32 and event.text.text[i] != 0) : (i += 1) { + text_event.text_input.text[i] = @intCast(event.text.text[i]); + } + text_event.text_input.len = i; + + break :blk text_event; + }, + + c.SDL_MOUSEMOTION => Event{ + .mouse = .{ + .x = event.motion.x, + .y = event.motion.y, + }, + }, + + c.SDL_MOUSEBUTTONDOWN, c.SDL_MOUSEBUTTONUP => blk: { + const pressed = event.type == c.SDL_MOUSEBUTTONDOWN; + const button: ?Input.MouseButton = switch (event.button.button) { + c.SDL_BUTTON_LEFT => .left, + c.SDL_BUTTON_RIGHT => .right, + c.SDL_BUTTON_MIDDLE => .middle, + else => null, + }; + + break :blk Event{ + .mouse = .{ + .x = event.button.x, + .y = event.button.y, + .button = button, + .pressed = pressed, + }, + }; + }, + + c.SDL_MOUSEWHEEL => Event{ + .mouse = .{ + .x = 0, + .y = 0, + .scroll_x = event.wheel.x, + .scroll_y = event.wheel.y, + }, + }, + + c.SDL_WINDOWEVENT => blk: { + if (event.window.event == c.SDL_WINDOWEVENT_RESIZED) { + break :blk Event{ + .resize = .{ + .width = @intCast(event.window.data1), + .height = @intCast(event.window.data2), + }, + }; + } + break :blk null; + }, + + else => null, + }; + } + + /// Present the framebuffer to the screen + pub fn present(self: *Self, fb: *const Framebuffer) void { + // Resize texture if needed + if (fb.width != self.width or fb.height != self.height) { + c.SDL_DestroyTexture(self.texture); + self.texture = c.SDL_CreateTexture( + self.renderer, + c.SDL_PIXELFORMAT_ABGR8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(fb.width), + @intCast(fb.height), + ) orelse return; + self.width = fb.width; + self.height = fb.height; + } + + // Update texture with framebuffer data + _ = c.SDL_UpdateTexture( + self.texture, + null, + fb.getData().ptr, + @intCast(fb.getPitch()), + ); + + // Render + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + /// Get window size + pub fn getSize(self: *Self) struct { width: u32, height: u32 } { + var w: c_int = 0; + var h: c_int = 0; + c.SDL_GetWindowSize(self.window, &w, &h); + return .{ + .width = @intCast(w), + .height = @intCast(h), + }; + } + + /// Get as abstract Backend + pub fn backend(self: *Self) Backend.Backend { + return .{ + .ptr = self, + .vtable = &vtable, + }; + } + + const vtable = Backend.Backend.VTable{ + .pollEvent = @ptrCast(&pollEvent), + .present = @ptrCast(&present), + .getSize = @ptrCast(&getSize), + .deinit = @ptrCast(&deinit), + }; +}; + +// ============================================================================= +// Key translation +// ============================================================================= + +fn translateKey(scancode: c.SDL_Scancode) Key { + return switch (scancode) { + c.SDL_SCANCODE_A => .a, + c.SDL_SCANCODE_B => .b, + c.SDL_SCANCODE_C => .c, + c.SDL_SCANCODE_D => .d, + c.SDL_SCANCODE_E => .e, + c.SDL_SCANCODE_F => .f, + c.SDL_SCANCODE_G => .g, + c.SDL_SCANCODE_H => .h, + c.SDL_SCANCODE_I => .i, + c.SDL_SCANCODE_J => .j, + c.SDL_SCANCODE_K => .k, + c.SDL_SCANCODE_L => .l, + c.SDL_SCANCODE_M => .m, + c.SDL_SCANCODE_N => .n, + c.SDL_SCANCODE_O => .o, + c.SDL_SCANCODE_P => .p, + c.SDL_SCANCODE_Q => .q, + c.SDL_SCANCODE_R => .r, + c.SDL_SCANCODE_S => .s, + c.SDL_SCANCODE_T => .t, + c.SDL_SCANCODE_U => .u, + c.SDL_SCANCODE_V => .v, + c.SDL_SCANCODE_W => .w, + c.SDL_SCANCODE_X => .x, + c.SDL_SCANCODE_Y => .y, + c.SDL_SCANCODE_Z => .z, + c.SDL_SCANCODE_0 => .@"0", + c.SDL_SCANCODE_1 => .@"1", + c.SDL_SCANCODE_2 => .@"2", + c.SDL_SCANCODE_3 => .@"3", + c.SDL_SCANCODE_4 => .@"4", + c.SDL_SCANCODE_5 => .@"5", + c.SDL_SCANCODE_6 => .@"6", + c.SDL_SCANCODE_7 => .@"7", + c.SDL_SCANCODE_8 => .@"8", + c.SDL_SCANCODE_9 => .@"9", + c.SDL_SCANCODE_F1 => .f1, + c.SDL_SCANCODE_F2 => .f2, + c.SDL_SCANCODE_F3 => .f3, + c.SDL_SCANCODE_F4 => .f4, + c.SDL_SCANCODE_F5 => .f5, + c.SDL_SCANCODE_F6 => .f6, + c.SDL_SCANCODE_F7 => .f7, + c.SDL_SCANCODE_F8 => .f8, + c.SDL_SCANCODE_F9 => .f9, + c.SDL_SCANCODE_F10 => .f10, + c.SDL_SCANCODE_F11 => .f11, + c.SDL_SCANCODE_F12 => .f12, + c.SDL_SCANCODE_UP => .up, + c.SDL_SCANCODE_DOWN => .down, + c.SDL_SCANCODE_LEFT => .left, + c.SDL_SCANCODE_RIGHT => .right, + c.SDL_SCANCODE_HOME => .home, + c.SDL_SCANCODE_END => .end, + c.SDL_SCANCODE_PAGEUP => .page_up, + c.SDL_SCANCODE_PAGEDOWN => .page_down, + c.SDL_SCANCODE_BACKSPACE => .backspace, + c.SDL_SCANCODE_DELETE => .delete, + c.SDL_SCANCODE_INSERT => .insert, + c.SDL_SCANCODE_TAB => .tab, + c.SDL_SCANCODE_RETURN => .enter, + c.SDL_SCANCODE_ESCAPE => .escape, + c.SDL_SCANCODE_SPACE => .space, + c.SDL_SCANCODE_LSHIFT => .left_shift, + c.SDL_SCANCODE_RSHIFT => .right_shift, + c.SDL_SCANCODE_LCTRL => .left_ctrl, + c.SDL_SCANCODE_RCTRL => .right_ctrl, + c.SDL_SCANCODE_LALT => .left_alt, + c.SDL_SCANCODE_RALT => .right_alt, + else => .unknown, + }; +} + +fn translateModifiers(mod: u16) KeyModifiers { + return .{ + .shift = (mod & c.KMOD_SHIFT) != 0, + .ctrl = (mod & c.KMOD_CTRL) != 0, + .alt = (mod & c.KMOD_ALT) != 0, + .super = (mod & c.KMOD_GUI) != 0, + }; +} diff --git a/src/core/command.zig b/src/core/command.zig new file mode 100644 index 0000000..2400156 --- /dev/null +++ b/src/core/command.zig @@ -0,0 +1,159 @@ +//! DrawCommand - Rendering commands for the software rasterizer +//! +//! The immediate mode UI generates a list of DrawCommands each frame. +//! The software rasterizer then processes these commands to produce pixels. +//! +//! This is the "command list" approach (like microui), not vertex buffers. + +const Style = @import("style.zig"); + +/// A single draw command +pub const DrawCommand = union(enum) { + /// Draw a filled rectangle + rect: RectCommand, + + /// Draw text + text: TextCommand, + + /// Draw a line + line: LineCommand, + + /// Draw a rectangle outline (border) + rect_outline: RectOutlineCommand, + + /// Begin clipping to a rectangle + clip: ClipCommand, + + /// End clipping + clip_end, + + /// No operation (placeholder) + nop, +}; + +/// Draw a filled rectangle +pub const RectCommand = struct { + x: i32, + y: i32, + w: u32, + h: u32, + color: Style.Color, +}; + +/// Draw text at a position +pub const TextCommand = struct { + x: i32, + y: i32, + text: []const u8, + color: Style.Color, + /// null means default font + font: ?*anyopaque = null, +}; + +/// Draw a line between two points +pub const LineCommand = struct { + x1: i32, + y1: i32, + x2: i32, + y2: i32, + color: Style.Color, +}; + +/// Draw a rectangle outline +pub const RectOutlineCommand = struct { + x: i32, + y: i32, + w: u32, + h: u32, + color: Style.Color, + thickness: u32 = 1, +}; + +/// Begin clipping to a rectangle +pub const ClipCommand = struct { + x: i32, + y: i32, + w: u32, + h: u32, +}; + +// ============================================================================= +// Helper constructors +// ============================================================================= + +/// Create a rect command +pub fn rect(x: i32, y: i32, w: u32, h: u32, color: Style.Color) DrawCommand { + return .{ .rect = .{ + .x = x, + .y = y, + .w = w, + .h = h, + .color = color, + } }; +} + +/// Create a text command +pub fn text(x: i32, y: i32, str: []const u8, color: Style.Color) DrawCommand { + return .{ .text = .{ + .x = x, + .y = y, + .text = str, + .color = color, + } }; +} + +/// Create a line command +pub fn line(x1: i32, y1: i32, x2: i32, y2: i32, color: Style.Color) DrawCommand { + return .{ .line = .{ + .x1 = x1, + .y1 = y1, + .x2 = x2, + .y2 = y2, + .color = color, + } }; +} + +/// Create a rect outline command +pub fn rectOutline(x: i32, y: i32, w: u32, h: u32, color: Style.Color) DrawCommand { + return .{ .rect_outline = .{ + .x = x, + .y = y, + .w = w, + .h = h, + .color = color, + } }; +} + +/// Create a clip command +pub fn clip(x: i32, y: i32, w: u32, h: u32) DrawCommand { + return .{ .clip = .{ + .x = x, + .y = y, + .w = w, + .h = h, + } }; +} + +/// Create a clip end command +pub fn clipEnd() DrawCommand { + return .clip_end; +} + +// ============================================================================= +// Tests +// ============================================================================= + +const std = @import("std"); + +test "DrawCommand creation" { + const cmd = rect(10, 20, 100, 50, Style.Color.red); + switch (cmd) { + .rect => |r| { + try std.testing.expectEqual(@as(i32, 10), r.x); + try std.testing.expectEqual(@as(i32, 20), r.y); + try std.testing.expectEqual(@as(u32, 100), r.w); + try std.testing.expectEqual(@as(u32, 50), r.h); + }, + else => unreachable, + } +} diff --git a/src/core/context.zig b/src/core/context.zig new file mode 100644 index 0000000..c5ca661 --- /dev/null +++ b/src/core/context.zig @@ -0,0 +1,161 @@ +//! Context - Central state for immediate mode UI +//! +//! The Context holds all state needed for a frame: +//! - Input state (keyboard, mouse) +//! - Command list (draw commands) +//! - Layout state +//! - ID tracking for widgets + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Command = @import("command.zig"); +const Input = @import("input.zig"); +const Layout = @import("layout.zig"); +const Style = @import("style.zig"); + +/// Central context for immediate mode UI +pub const Context = struct { + allocator: Allocator, + + /// Draw commands for current frame + commands: std.ArrayListUnmanaged(Command.DrawCommand), + + /// Input state + input: Input.InputState, + + /// Layout state + layout: Layout.LayoutState, + + /// ID stack for widget identification + id_stack: std.ArrayListUnmanaged(u32), + + /// Current frame number + frame: u64, + + /// Screen dimensions + width: u32, + height: u32, + + const Self = @This(); + + /// Initialize a new context + pub fn init(allocator: Allocator, width: u32, height: u32) Self { + return .{ + .allocator = allocator, + .commands = .{}, + .input = Input.InputState.init(), + .layout = Layout.LayoutState.init(width, height), + .id_stack = .{}, + .frame = 0, + .width = width, + .height = height, + }; + } + + /// Clean up resources + pub fn deinit(self: *Self) void { + self.commands.deinit(self.allocator); + self.id_stack.deinit(self.allocator); + } + + /// Begin a new frame + pub fn beginFrame(self: *Self) void { + self.commands.clearRetainingCapacity(); + self.id_stack.clearRetainingCapacity(); + self.layout.reset(self.width, self.height); + self.frame += 1; + } + + /// End the current frame + pub fn endFrame(self: *Self) void { + self.input.endFrame(); + } + + /// Get a unique ID for a widget + pub fn getId(self: *Self, label: []const u8) u32 { + var hash: u32 = 0; + + // Include parent IDs + for (self.id_stack.items) |parent_id| { + hash = hashCombine(hash, parent_id); + } + + // Hash the label + hash = hashCombine(hash, hashString(label)); + + return hash; + } + + /// Push an ID onto the stack (for containers) + pub fn pushId(self: *Self, id: u32) void { + self.id_stack.append(self.allocator, id) catch {}; + } + + /// Pop an ID from the stack + pub fn popId(self: *Self) void { + _ = self.id_stack.pop(); + } + + /// Push a draw command + pub fn pushCommand(self: *Self, cmd: Command.DrawCommand) void { + self.commands.append(self.allocator, cmd) catch {}; + } + + /// Resize the context + pub fn resize(self: *Self, width: u32, height: u32) void { + self.width = width; + self.height = height; + } + + // ========================================================================= + // Helper functions + // ========================================================================= + + fn hashString(s: []const u8) u32 { + var h: u32 = 0; + for (s) |c| { + h = h *% 31 +% c; + } + return h; + } + + fn hashCombine(a: u32, b: u32) u32 { + return a ^ (b +% 0x9e3779b9 +% (a << 6) +% (a >> 2)); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Context basic" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + const id1 = ctx.getId("button1"); + const id2 = ctx.getId("button2"); + + try std.testing.expect(id1 != id2); + + ctx.endFrame(); +} + +test "Context ID with parent" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + const id_no_parent = ctx.getId("button"); + + ctx.pushId(ctx.getId("panel1")); + const id_with_parent = ctx.getId("button"); + ctx.popId(); + + try std.testing.expect(id_no_parent != id_with_parent); + + ctx.endFrame(); +} diff --git a/src/core/input.zig b/src/core/input.zig new file mode 100644 index 0000000..5306b58 --- /dev/null +++ b/src/core/input.zig @@ -0,0 +1,299 @@ +//! Input - Keyboard and mouse input state +//! +//! Tracks the current state of input devices. +//! Updated by the backend each frame. + +const std = @import("std"); + +/// Key codes (subset, extend as needed) +pub const Key = enum(u16) { + // Letters + a, + b, + c, + d, + e, + f, + g, + h, + i, + j, + k, + l, + m, + n, + o, + p, + q, + r, + s, + t, + u, + v, + w, + x, + y, + z, + + // Numbers + @"0", + @"1", + @"2", + @"3", + @"4", + @"5", + @"6", + @"7", + @"8", + @"9", + + // Function keys + f1, + f2, + f3, + f4, + f5, + f6, + f7, + f8, + f9, + f10, + f11, + f12, + + // Navigation + up, + down, + left, + right, + home, + end, + page_up, + page_down, + + // Editing + backspace, + delete, + insert, + tab, + enter, + escape, + space, + + // Modifiers (as keys) + left_shift, + right_shift, + left_ctrl, + right_ctrl, + left_alt, + right_alt, + + // Punctuation + minus, + equals, + left_bracket, + right_bracket, + backslash, + semicolon, + apostrophe, + grave, + comma, + period, + slash, + + // Unknown + unknown, + + _, +}; + +/// Key modifiers +pub const KeyModifiers = packed struct { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + + pub const none = KeyModifiers{}; +}; + +/// A keyboard event +pub const KeyEvent = struct { + key: Key, + modifiers: KeyModifiers, + /// The character produced (if any) + char: ?u21 = null, + /// True if key was pressed, false if released + pressed: bool, + + /// Check if this is a printable character + pub fn isPrintable(self: KeyEvent) bool { + return self.char != null and self.char.? >= 32; + } + + /// Get the character as a slice (for convenience) + pub fn charAsSlice(self: KeyEvent, buf: *[4]u8) ?[]const u8 { + if (self.char) |c| { + const len = std.unicode.utf8Encode(c, buf) catch return null; + return buf[0..len]; + } + return null; + } +}; + +/// Mouse buttons +pub const MouseButton = enum { + left, + right, + middle, + x1, + x2, +}; + +/// A mouse event +pub const MouseEvent = struct { + x: i32, + y: i32, + button: ?MouseButton = null, + pressed: bool = false, + scroll_x: i32 = 0, + scroll_y: i32 = 0, +}; + +/// Current input state +pub const InputState = struct { + // Mouse position + mouse_x: i32 = 0, + mouse_y: i32 = 0, + + // Mouse buttons (current frame) + mouse_down: [5]bool = .{ false, false, false, false, false }, + + // Mouse buttons (previous frame, for detecting clicks) + mouse_down_prev: [5]bool = .{ false, false, false, false, false }, + + // Scroll delta + scroll_x: i32 = 0, + scroll_y: i32 = 0, + + // Key modifiers + modifiers: KeyModifiers = .{}, + + // Text input this frame + text_input: [64]u8 = undefined, + text_input_len: usize = 0, + + const Self = @This(); + + /// Initialize input state + pub fn init() Self { + return .{}; + } + + /// Call at end of frame to prepare for next + pub fn endFrame(self: *Self) void { + self.mouse_down_prev = self.mouse_down; + self.scroll_x = 0; + self.scroll_y = 0; + self.text_input_len = 0; + } + + /// Update mouse position + pub fn setMousePos(self: *Self, x: i32, y: i32) void { + self.mouse_x = x; + self.mouse_y = y; + } + + /// Update mouse button state + pub fn setMouseButton(self: *Self, button: MouseButton, pressed: bool) void { + self.mouse_down[@intFromEnum(button)] = pressed; + } + + /// Add scroll delta + pub fn addScroll(self: *Self, x: i32, y: i32) void { + self.scroll_x += x; + self.scroll_y += y; + } + + /// Update key modifiers + pub fn setModifiers(self: *Self, mods: KeyModifiers) void { + self.modifiers = mods; + } + + /// Add text input + pub fn addTextInput(self: *Self, text: []const u8) void { + const remaining = self.text_input.len - self.text_input_len; + const to_copy = @min(text.len, remaining); + @memcpy(self.text_input[self.text_input_len..][0..to_copy], text[0..to_copy]); + self.text_input_len += to_copy; + } + + // ========================================================================= + // Query functions + // ========================================================================= + + /// Get current mouse position + pub fn mousePos(self: Self) struct { x: i32, y: i32 } { + return .{ .x = self.mouse_x, .y = self.mouse_y }; + } + + /// Check if mouse button is currently down + pub fn mouseDown(self: Self, button: MouseButton) bool { + return self.mouse_down[@intFromEnum(button)]; + } + + /// Check if mouse button was just pressed this frame + pub fn mousePressed(self: Self, button: MouseButton) bool { + const idx = @intFromEnum(button); + return self.mouse_down[idx] and !self.mouse_down_prev[idx]; + } + + /// Check if mouse button was just released this frame + pub fn mouseReleased(self: Self, button: MouseButton) bool { + const idx = @intFromEnum(button); + return !self.mouse_down[idx] and self.mouse_down_prev[idx]; + } + + /// Get text input this frame + pub fn getTextInput(self: Self) []const u8 { + return self.text_input[0..self.text_input_len]; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "InputState mouse" { + var input = InputState.init(); + + input.setMousePos(100, 200); + try std.testing.expectEqual(@as(i32, 100), input.mouse_x); + try std.testing.expectEqual(@as(i32, 200), input.mouse_y); + + input.setMouseButton(.left, true); + try std.testing.expect(input.mouseDown(.left)); + try std.testing.expect(input.mousePressed(.left)); + + input.endFrame(); + try std.testing.expect(input.mouseDown(.left)); + try std.testing.expect(!input.mousePressed(.left)); + + input.setMouseButton(.left, false); + try std.testing.expect(input.mouseReleased(.left)); +} + +test "KeyEvent char" { + var buf: [4]u8 = undefined; + + const event = KeyEvent{ + .key = .a, + .modifiers = .{}, + .char = 'A', + .pressed = true, + }; + + const slice = event.charAsSlice(&buf); + try std.testing.expect(slice != null); + try std.testing.expectEqualStrings("A", slice.?); +} diff --git a/src/core/layout.zig b/src/core/layout.zig new file mode 100644 index 0000000..9a3d157 --- /dev/null +++ b/src/core/layout.zig @@ -0,0 +1,408 @@ +//! Layout - Constraint-based layout system +//! +//! Based on zcatui's layout system, adapted for GUI. +//! Provides Rect, Constraint, and layout calculations. + +const std = @import("std"); + +/// A rectangle (position + size) +pub const Rect = struct { + x: i32, + y: i32, + w: u32, + h: u32, + + const Self = @This(); + + /// Create a new rectangle + pub fn init(x: i32, y: i32, w: u32, h: u32) Self { + return .{ .x = x, .y = y, .w = w, .h = h }; + } + + /// Create a zero-size rectangle + pub fn zero() Self { + return .{ .x = 0, .y = 0, .w = 0, .h = 0 }; + } + + /// Check if the rectangle is empty + pub fn isEmpty(self: Self) bool { + return self.w == 0 or self.h == 0; + } + + /// Get the area + pub fn area(self: Self) u32 { + return self.w * self.h; + } + + /// Get left edge + pub fn left(self: Self) i32 { + return self.x; + } + + /// Get right edge + pub fn right(self: Self) i32 { + return self.x + @as(i32, @intCast(self.w)); + } + + /// Get top edge + pub fn top(self: Self) i32 { + return self.y; + } + + /// Get bottom edge + pub fn bottom(self: Self) i32 { + return self.y + @as(i32, @intCast(self.h)); + } + + /// Get center point + pub fn center(self: Self) struct { x: i32, y: i32 } { + return .{ + .x = self.x + @as(i32, @intCast(self.w / 2)), + .y = self.y + @as(i32, @intCast(self.h / 2)), + }; + } + + /// Check if a point is inside the rectangle + pub fn contains(self: Self, px: i32, py: i32) bool { + return px >= self.x and + px < self.x + @as(i32, @intCast(self.w)) and + py >= self.y and + py < self.y + @as(i32, @intCast(self.h)); + } + + /// Get intersection with another rectangle + pub fn intersection(self: Self, other: Self) Self { + const x1 = @max(self.x, other.x); + const y1 = @max(self.y, other.y); + const x2 = @min(self.right(), other.right()); + const y2 = @min(self.bottom(), other.bottom()); + + if (x2 <= x1 or y2 <= y1) { + return Rect.zero(); + } + + return Rect.init(x1, y1, @intCast(x2 - x1), @intCast(y2 - y1)); + } + + /// Shrink by margin on all sides + pub fn shrink(self: Self, margin: u32) Self { + const m = @as(i32, @intCast(margin)); + const m2 = margin * 2; + + if (self.w <= m2 or self.h <= m2) { + return Rect.zero(); + } + + return Rect.init( + self.x + m, + self.y + m, + self.w - m2, + self.h - m2, + ); + } + + /// Shrink with different margins + pub fn shrinkSides(self: Self, top_val: u32, right_val: u32, bottom_val: u32, left_val: u32) Self { + const t = @as(i32, @intCast(top_val)); + const r = right_val; + const b = bottom_val; + const l = @as(i32, @intCast(left_val)); + + const new_w = if (self.w > l + r) self.w - @as(u32, @intCast(l)) - r else 0; + const new_h = if (self.h > t + b) self.h - @as(u32, @intCast(t)) - b else 0; + + return Rect.init( + self.x + l, + self.y + t, + new_w, + new_h, + ); + } +}; + +/// A layout constraint +pub const Constraint = union(enum) { + /// Fixed size in pixels + length: u32, + + /// Minimum size + min: u32, + + /// Maximum size + max: u32, + + /// Percentage of available space (0-100) + percentage: u8, + + /// Ratio (numerator, denominator) + ratio: struct { num: u16, den: u16 }, + + /// Fill remaining space (weight) + fill: u16, + + const Self = @This(); + + /// Create a fixed length constraint + pub fn len(val: u32) Self { + return .{ .length = val }; + } + + /// Create a minimum constraint + pub fn minSize(val: u32) Self { + return .{ .min = val }; + } + + /// Create a maximum constraint + pub fn maxSize(val: u32) Self { + return .{ .max = val }; + } + + /// Create a percentage constraint + pub fn pct(val: u8) Self { + return .{ .percentage = val }; + } + + /// Create a ratio constraint + pub fn rat(num: u16, den: u16) Self { + return .{ .ratio = .{ .num = num, .den = den } }; + } + + /// Create a fill constraint with weight + pub fn fillSpace() Self { + return .{ .fill = 1 }; + } + + /// Create a fill constraint with specific weight + pub fn fillWeight(weight: u16) Self { + return .{ .fill = weight }; + } + + // Convenience methods + + pub fn half() Self { + return pct(50); + } + + pub fn third() Self { + return rat(1, 3); + } + + pub fn quarter() Self { + return pct(25); + } +}; + +/// Direction for layouts +pub const Direction = enum { + horizontal, + vertical, +}; + +/// Layout state for managing widget positions +pub const LayoutState = struct { + /// Current cursor position + cursor_x: i32, + cursor_y: i32, + + /// Available area + area: Rect, + + /// Row/column layout state + row_height: u32, + col_widths: [16]u32, + col_count: usize, + col_index: usize, + + const Self = @This(); + + /// Initialize layout state + pub fn init(width: u32, height: u32) Self { + return .{ + .cursor_x = 0, + .cursor_y = 0, + .area = Rect.init(0, 0, width, height), + .row_height = 0, + .col_widths = [_]u32{0} ** 16, + .col_count = 0, + .col_index = 0, + }; + } + + /// Reset to initial state + pub fn reset(self: *Self, width: u32, height: u32) void { + self.cursor_x = 0; + self.cursor_y = 0; + self.area = Rect.init(0, 0, width, height); + self.row_height = 0; + self.col_count = 0; + self.col_index = 0; + } + + /// Begin a row layout + pub fn beginRow(self: *Self, height: u32, widths: []const u32) void { + self.row_height = height; + self.col_index = 0; + self.col_count = @min(widths.len, self.col_widths.len); + for (widths, 0..) |w, i| { + if (i >= self.col_widths.len) break; + self.col_widths[i] = w; + } + } + + /// End the current row + pub fn endRow(self: *Self) void { + self.cursor_y += @as(i32, @intCast(self.row_height)); + self.cursor_x = self.area.x; + self.col_index = 0; + } + + /// Get the next rectangle in the current layout + pub fn nextRect(self: *Self) Rect { + if (self.col_count == 0) { + // No row layout active, use full width + const rect = Rect.init( + self.cursor_x, + self.cursor_y, + self.area.w, + self.row_height, + ); + self.cursor_y += @as(i32, @intCast(self.row_height)); + return rect; + } + + // Column layout + if (self.col_index >= self.col_count) { + self.endRow(); + } + + const width = self.col_widths[self.col_index]; + const rect = Rect.init( + self.cursor_x, + self.cursor_y, + width, + self.row_height, + ); + + self.cursor_x += @as(i32, @intCast(width)); + self.col_index += 1; + + return rect; + } +}; + +// ============================================================================= +// Layout helpers +// ============================================================================= + +/// Split an area vertically +pub fn splitVertical(area: Rect, constraints: []const Constraint) [16]Rect { + var results: [16]Rect = undefined; + var remaining = area.h; + var y = area.y; + + // First pass: calculate fixed sizes + var total_fill: u32 = 0; + for (constraints, 0..) |c, i| { + if (i >= results.len) break; + switch (c) { + .length => |len| remaining -|= len, + .percentage => |pct| remaining -|= (area.h * pct) / 100, + .fill => |w| total_fill += w, + else => {}, + } + } + + // Second pass: assign rectangles + for (constraints, 0..) |c, i| { + if (i >= results.len) break; + + const height: u32 = switch (c) { + .length => |len| len, + .percentage => |pct| (area.h * pct) / 100, + .fill => |w| if (total_fill > 0) (remaining * w) / total_fill else 0, + .min => |min_val| @max(min_val, remaining), + .max => |max_val| @min(max_val, remaining), + .ratio => |r| if (r.den > 0) (area.h * r.num) / r.den else 0, + }; + + results[i] = Rect.init(area.x, y, area.w, height); + y += @as(i32, @intCast(height)); + } + + return results; +} + +/// Split an area horizontally +pub fn splitHorizontal(area: Rect, constraints: []const Constraint) [16]Rect { + var results: [16]Rect = undefined; + var remaining = area.w; + var x = area.x; + + // First pass: calculate fixed sizes + var total_fill: u32 = 0; + for (constraints, 0..) |c, i| { + if (i >= results.len) break; + switch (c) { + .length => |len| remaining -|= len, + .percentage => |pct| remaining -|= (area.w * pct) / 100, + .fill => |w| total_fill += w, + else => {}, + } + } + + // Second pass: assign rectangles + for (constraints, 0..) |c, i| { + if (i >= results.len) break; + + const width: u32 = switch (c) { + .length => |len| len, + .percentage => |pct| (area.w * pct) / 100, + .fill => |w| if (total_fill > 0) (remaining * w) / total_fill else 0, + .min => |min_val| @max(min_val, remaining), + .max => |max_val| @min(max_val, remaining), + .ratio => |r| if (r.den > 0) (area.w * r.num) / r.den else 0, + }; + + results[i] = Rect.init(x, area.y, width, area.h); + x += @as(i32, @intCast(width)); + } + + return results; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Rect contains" { + const rect = Rect.init(10, 20, 100, 50); + + try std.testing.expect(rect.contains(50, 40)); + try std.testing.expect(!rect.contains(5, 40)); + try std.testing.expect(!rect.contains(50, 100)); +} + +test "Rect shrink" { + const rect = Rect.init(0, 0, 100, 100); + const shrunk = rect.shrink(10); + + try std.testing.expectEqual(@as(i32, 10), shrunk.x); + try std.testing.expectEqual(@as(i32, 10), shrunk.y); + try std.testing.expectEqual(@as(u32, 80), shrunk.w); + try std.testing.expectEqual(@as(u32, 80), shrunk.h); +} + +test "splitVertical" { + const area = Rect.init(0, 0, 100, 100); + const constraints = [_]Constraint{ + Constraint.len(20), + Constraint.fillSpace(), + Constraint.len(20), + }; + + const results = splitVertical(area, &constraints); + + try std.testing.expectEqual(@as(u32, 20), results[0].h); + try std.testing.expectEqual(@as(u32, 60), results[1].h); + try std.testing.expectEqual(@as(u32, 20), results[2].h); +} diff --git a/src/core/style.zig b/src/core/style.zig new file mode 100644 index 0000000..8e2e29a --- /dev/null +++ b/src/core/style.zig @@ -0,0 +1,244 @@ +//! Style - Colors and visual styling +//! +//! Based on zcatui's style system, adapted for GUI with RGBA colors. + +const std = @import("std"); + +/// RGBA Color +pub const Color = struct { + r: u8, + g: u8, + b: u8, + a: u8 = 255, + + const Self = @This(); + + /// Create a color from RGB values + pub fn rgb(r: u8, g: u8, b: u8) Self { + return .{ .r = r, .g = g, .b = b, .a = 255 }; + } + + /// Create a color from RGBA values + pub fn rgba(r: u8, g: u8, b: u8, a: u8) Self { + return .{ .r = r, .g = g, .b = b, .a = a }; + } + + /// Convert to u32 (RGBA format) + pub fn toU32(self: Self) u32 { + return (@as(u32, self.r) << 24) | + (@as(u32, self.g) << 16) | + (@as(u32, self.b) << 8) | + @as(u32, self.a); + } + + /// Convert to u32 (ABGR format for SDL) + pub fn toABGR(self: Self) u32 { + return (@as(u32, self.a) << 24) | + (@as(u32, self.b) << 16) | + (@as(u32, self.g) << 8) | + @as(u32, self.r); + } + + /// Blend this color over another + pub fn blend(self: Self, bg_color: Self) Self { + if (self.a == 255) return self; + if (self.a == 0) return bg_color; + + const alpha = @as(u16, self.a); + const inv_alpha = 255 - alpha; + + return .{ + .r = @intCast((@as(u16, self.r) * alpha + @as(u16, bg_color.r) * inv_alpha) / 255), + .g = @intCast((@as(u16, self.g) * alpha + @as(u16, bg_color.g) * inv_alpha) / 255), + .b = @intCast((@as(u16, self.b) * alpha + @as(u16, bg_color.b) * inv_alpha) / 255), + .a = 255, + }; + } + + /// Darken color by percentage (0-100) + pub fn darken(self: Self, percent: u8) Self { + const factor = @as(u16, 100 - @min(percent, 100)); + return .{ + .r = @intCast((@as(u16, self.r) * factor) / 100), + .g = @intCast((@as(u16, self.g) * factor) / 100), + .b = @intCast((@as(u16, self.b) * factor) / 100), + .a = self.a, + }; + } + + /// Lighten color by percentage (0-100) + pub fn lighten(self: Self, percent: u8) Self { + const factor = @as(u16, @min(percent, 100)); + return .{ + .r = @intCast(@as(u16, self.r) + ((@as(u16, 255) - self.r) * factor) / 100), + .g = @intCast(@as(u16, self.g) + ((@as(u16, 255) - self.g) * factor) / 100), + .b = @intCast(@as(u16, self.b) + ((@as(u16, 255) - self.b) * factor) / 100), + .a = self.a, + }; + } + + // ========================================================================= + // Predefined colors + // ========================================================================= + + pub const transparent = Color.rgba(0, 0, 0, 0); + pub const black = Color.rgb(0, 0, 0); + pub const white = Color.rgb(255, 255, 255); + pub const red = Color.rgb(255, 0, 0); + pub const green = Color.rgb(0, 255, 0); + pub const blue = Color.rgb(0, 0, 255); + pub const yellow = Color.rgb(255, 255, 0); + pub const cyan = Color.rgb(0, 255, 255); + pub const magenta = Color.rgb(255, 0, 255); + pub const gray = Color.rgb(128, 128, 128); + pub const dark_gray = Color.rgb(64, 64, 64); + pub const light_gray = Color.rgb(192, 192, 192); + + // UI colors + pub const background = Color.rgb(30, 30, 30); + pub const foreground = Color.rgb(220, 220, 220); + pub const primary = Color.rgb(66, 135, 245); + pub const secondary = Color.rgb(100, 100, 100); + pub const success = Color.rgb(76, 175, 80); + pub const warning = Color.rgb(255, 152, 0); + pub const danger = Color.rgb(244, 67, 54); + pub const border = Color.rgb(80, 80, 80); +}; + +/// Visual style for widgets +pub const Style = struct { + foreground: Color = Color.foreground, + background: Color = Color.background, + border: ?Color = null, + border_radius: u8 = 0, + + const Self = @This(); + + /// Set foreground color + pub fn fg(self: Self, color: Color) Self { + var s = self; + s.foreground = color; + return s; + } + + /// Set background color + pub fn bg(self: Self, color: Color) Self { + var s = self; + s.background = color; + return s; + } + + /// Set border color + pub fn withBorder(self: Self, color: Color) Self { + var s = self; + s.border = color; + return s; + } +}; + +// ============================================================================= +// Theme +// ============================================================================= + +/// A theme defines colors for all UI elements +pub const Theme = struct { + background: Color, + foreground: Color, + primary: Color, + secondary: Color, + success: Color, + warning: Color, + danger: Color, + border: Color, + + // Widget-specific + button_bg: Color, + button_fg: Color, + button_hover: Color, + button_active: Color, + + input_bg: Color, + input_fg: Color, + input_border: Color, + + selection_bg: Color, + selection_fg: Color, + + const Self = @This(); + + /// Dark theme (default) + pub const dark = Self{ + .background = Color.rgb(30, 30, 30), + .foreground = Color.rgb(220, 220, 220), + .primary = Color.rgb(66, 135, 245), + .secondary = Color.rgb(100, 100, 100), + .success = Color.rgb(76, 175, 80), + .warning = Color.rgb(255, 152, 0), + .danger = Color.rgb(244, 67, 54), + .border = Color.rgb(80, 80, 80), + + .button_bg = Color.rgb(60, 60, 60), + .button_fg = Color.rgb(220, 220, 220), + .button_hover = Color.rgb(80, 80, 80), + .button_active = Color.rgb(50, 50, 50), + + .input_bg = Color.rgb(45, 45, 45), + .input_fg = Color.rgb(220, 220, 220), + .input_border = Color.rgb(80, 80, 80), + + .selection_bg = Color.rgb(66, 135, 245), + .selection_fg = Color.rgb(255, 255, 255), + }; + + /// Light theme + pub const light = Self{ + .background = Color.rgb(245, 245, 245), + .foreground = Color.rgb(30, 30, 30), + .primary = Color.rgb(33, 150, 243), + .secondary = Color.rgb(158, 158, 158), + .success = Color.rgb(76, 175, 80), + .warning = Color.rgb(255, 152, 0), + .danger = Color.rgb(244, 67, 54), + .border = Color.rgb(200, 200, 200), + + .button_bg = Color.rgb(230, 230, 230), + .button_fg = Color.rgb(30, 30, 30), + .button_hover = Color.rgb(210, 210, 210), + .button_active = Color.rgb(190, 190, 190), + + .input_bg = Color.rgb(255, 255, 255), + .input_fg = Color.rgb(30, 30, 30), + .input_border = Color.rgb(180, 180, 180), + + .selection_bg = Color.rgb(33, 150, 243), + .selection_fg = Color.rgb(255, 255, 255), + }; +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Color creation" { + const c = Color.rgb(100, 150, 200); + try std.testing.expectEqual(@as(u8, 100), c.r); + try std.testing.expectEqual(@as(u8, 150), c.g); + try std.testing.expectEqual(@as(u8, 200), c.b); + try std.testing.expectEqual(@as(u8, 255), c.a); +} + +test "Color darken" { + const white = Color.white; + const darkened = white.darken(50); + try std.testing.expectEqual(@as(u8, 127), darkened.r); +} + +test "Color blend" { + const fg = Color.rgba(255, 0, 0, 128); + const bg = Color.rgb(0, 0, 255); + const blended = fg.blend(bg); + + // Should be purple-ish + try std.testing.expect(blended.r > 100); + try std.testing.expect(blended.b > 100); +} diff --git a/src/macro/macro.zig b/src/macro/macro.zig new file mode 100644 index 0000000..24b785c --- /dev/null +++ b/src/macro/macro.zig @@ -0,0 +1,340 @@ +//! Macro System - Record and playback user actions +//! +//! The macro system is a CORNERSTONE of zCatGui. +//! It allows recording raw keyboard input and replaying it exactly. +//! +//! ## Design Decision: Raw Keys, Not Commands +//! +//! We record raw key events, not semantic commands. This is: +//! - Simpler (less code = less bugs) +//! - Like Vim (proven to work) +//! - Minimal memory usage +//! +//! ## Mouse Handling Strategy +//! +//! Phase 1: Keyboard only +//! Phase 2: Mouse clicks → translated to equivalent keyboard actions +//! +//! Example: Click on button "Save" → Tab to focus + Enter +//! +//! This works because our UI is fully keyboard-navigable. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Input = @import("../core/input.zig"); + +pub const KeyEvent = Input.KeyEvent; +pub const Key = Input.Key; +pub const KeyModifiers = Input.KeyModifiers; + +/// A recorded macro (sequence of key events) +pub const Macro = struct { + name: []const u8, + events: []const KeyEvent, + allocator: Allocator, + + const Self = @This(); + + /// Create a new macro from events + pub fn init(allocator: Allocator, name: []const u8, events: []const KeyEvent) !Self { + const name_copy = try allocator.dupe(u8, name); + const events_copy = try allocator.dupe(KeyEvent, events); + + return .{ + .name = name_copy, + .events = events_copy, + .allocator = allocator, + }; + } + + /// Free the macro + pub fn deinit(self: Self) void { + self.allocator.free(self.name); + self.allocator.free(self.events); + } +}; + +/// Records key events into a macro +pub const MacroRecorder = struct { + allocator: Allocator, + events: std.ArrayListUnmanaged(KeyEvent), + recording: bool, + + const Self = @This(); + + /// Initialize the recorder + pub fn init(allocator: Allocator) Self { + return .{ + .allocator = allocator, + .events = .{}, + .recording = false, + }; + } + + /// Clean up + pub fn deinit(self: *Self) void { + self.events.deinit(self.allocator); + } + + /// Start recording + pub fn start(self: *Self) void { + self.events.clearRetainingCapacity(); + self.recording = true; + } + + /// Stop recording and return the events + pub fn stop(self: *Self) []const KeyEvent { + self.recording = false; + return self.events.items; + } + + /// Record a key event (if recording is active) + pub fn record(self: *Self, event: KeyEvent) void { + if (self.recording) { + // Only record key presses, not releases (like Vim) + if (event.pressed) { + self.events.append(self.allocator, event) catch {}; + } + } + } + + /// Check if currently recording + pub fn isRecording(self: Self) bool { + return self.recording; + } + + /// Get number of recorded events + pub fn eventCount(self: Self) usize { + return self.events.items.len; + } + + /// Create a Macro from the recorded events + pub fn toMacro(self: *Self, name: []const u8) !Macro { + return Macro.init(self.allocator, name, self.events.items); + } + + /// Save recorded events to a file + pub fn save(self: *Self, path: []const u8) !void { + const file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + // Write header directly + _ = try file.write("ZCATGUI_MACRO_V1\n"); + + // Write events + var line_buf: [128]u8 = undefined; + for (self.events.items) |event| { + const mods: u4 = @bitCast(event.modifiers); + const line = std.fmt.bufPrint(&line_buf, "{d},{d},{d},{d}\n", .{ + @intFromEnum(event.key), + @as(u8, mods), + event.char orelse 0, + @as(u8, if (event.pressed) 1 else 0), + }) catch continue; + _ = try file.write(line); + } + } + + /// Load events from a file + pub fn load(self: *Self, path: []const u8) !void { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + // Read file content into buffer + const stat = try file.stat(); + const content = try self.allocator.alloc(u8, stat.size); + defer self.allocator.free(content); + _ = try file.readAll(content); + + // Parse content line by line + var lines = std.mem.splitScalar(u8, content, '\n'); + + // Verify header + const header = lines.next() orelse return error.InvalidMacroFile; + if (!std.mem.eql(u8, header, "ZCATGUI_MACRO_V1")) { + return error.InvalidMacroFile; + } + + self.events.clearRetainingCapacity(); + + // Read events + while (lines.next()) |line| { + if (line.len == 0) continue; + + var iter = std.mem.splitScalar(u8, line, ','); + + const key_int = std.fmt.parseInt(u16, iter.next() orelse continue, 10) catch continue; + const mods_int = std.fmt.parseInt(u4, iter.next() orelse continue, 10) catch continue; + const char_int = std.fmt.parseInt(u21, iter.next() orelse continue, 10) catch continue; + const pressed_int = std.fmt.parseInt(u8, iter.next() orelse continue, 10) catch continue; + + try self.events.append(self.allocator, .{ + .key = @enumFromInt(key_int), + .modifiers = @bitCast(mods_int), + .char = if (char_int == 0) null else char_int, + .pressed = pressed_int != 0, + }); + } + } +}; + +/// Plays back recorded key events +pub const MacroPlayer = struct { + /// Function type for injecting events + pub const InjectFn = *const fn (KeyEvent) void; + + /// Play a macro by injecting events + pub fn play( + events: []const KeyEvent, + inject_fn: InjectFn, + ) void { + for (events) |event| { + inject_fn(event); + } + } + + /// Play with delay between events (for visualization) + pub fn playWithDelay( + events: []const KeyEvent, + inject_fn: InjectFn, + delay_ms: u64, + ) void { + for (events) |event| { + inject_fn(event); + if (delay_ms > 0) { + std.Thread.sleep(delay_ms * std.time.ns_per_ms); + } + } + } + + /// Play a Macro struct + pub fn playMacro(macro_data: Macro, inject_fn: InjectFn) void { + play(macro_data.events, inject_fn); + } +}; + +/// Macro storage for managing multiple macros +pub const MacroStorage = struct { + allocator: Allocator, + macros: std.StringHashMap(Macro), + + const Self = @This(); + + pub fn init(allocator: Allocator) Self { + return .{ + .allocator = allocator, + .macros = std.StringHashMap(Macro).init(allocator), + }; + } + + pub fn deinit(self: *Self) void { + var iter = self.macros.valueIterator(); + while (iter.next()) |macro_ptr| { + macro_ptr.deinit(); + } + self.macros.deinit(); + } + + /// Store a macro + pub fn store(self: *Self, macro_data: Macro) !void { + // Remove existing if present + if (self.macros.fetchRemove(macro_data.name)) |removed| { + removed.value.deinit(); + } + + try self.macros.put(macro_data.name, macro_data); + } + + /// Get a macro by name + pub fn get(self: *Self, name: []const u8) ?*const Macro { + return self.macros.getPtr(name); + } + + /// Remove a macro + pub fn remove(self: *Self, name: []const u8) void { + if (self.macros.fetchRemove(name)) |removed| { + removed.value.deinit(); + } + } + + /// List all macro names + pub fn list(self: *Self) [][]const u8 { + var names = std.ArrayList([]const u8).init(self.allocator); + var iter = self.macros.keyIterator(); + while (iter.next()) |key| { + names.append(key.*) catch {}; + } + return names.toOwnedSlice() catch &[_][]const u8{}; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "MacroRecorder basic" { + var recorder = MacroRecorder.init(std.testing.allocator); + defer recorder.deinit(); + + try std.testing.expect(!recorder.isRecording()); + + recorder.start(); + try std.testing.expect(recorder.isRecording()); + + recorder.record(.{ .key = .a, .modifiers = .{}, .char = 'a', .pressed = true }); + recorder.record(.{ .key = .b, .modifiers = .{}, .char = 'b', .pressed = true }); + recorder.record(.{ .key = .c, .modifiers = .{}, .char = 'c', .pressed = false }); // Release, ignored + + const events = recorder.stop(); + try std.testing.expect(!recorder.isRecording()); + try std.testing.expectEqual(@as(usize, 2), events.len); +} + +test "MacroRecorder to Macro" { + var recorder = MacroRecorder.init(std.testing.allocator); + defer recorder.deinit(); + + recorder.start(); + recorder.record(.{ .key = .a, .modifiers = .{}, .char = 'a', .pressed = true }); + _ = recorder.stop(); + + const macro_data = try recorder.toMacro("test_macro"); + defer macro_data.deinit(); + + try std.testing.expectEqualStrings("test_macro", macro_data.name); + try std.testing.expectEqual(@as(usize, 1), macro_data.events.len); +} + +test "MacroPlayer" { + const events = [_]KeyEvent{ + .{ .key = .a, .modifiers = .{}, .char = 'a', .pressed = true }, + .{ .key = .b, .modifiers = .{}, .char = 'b', .pressed = true }, + }; + + const inject = struct { + fn f(_: KeyEvent) void { + // In real code, this would inject into the input system + } + }.f; + + MacroPlayer.play(&events, inject); + + // Just verify it runs without crashing +} + +test "MacroStorage" { + var storage = MacroStorage.init(std.testing.allocator); + defer storage.deinit(); + + const macro1 = try Macro.init(std.testing.allocator, "macro1", &[_]KeyEvent{}); + + try storage.store(macro1); + + const retrieved = storage.get("macro1"); + try std.testing.expect(retrieved != null); + try std.testing.expectEqualStrings("macro1", retrieved.?.name); + + storage.remove("macro1"); + try std.testing.expect(storage.get("macro1") == null); +} diff --git a/src/render/font.zig b/src/render/font.zig new file mode 100644 index 0000000..dde49ab --- /dev/null +++ b/src/render/font.zig @@ -0,0 +1,207 @@ +//! Font - Bitmap font rendering +//! +//! Simple bitmap font for basic text rendering. +//! TTF support can be added later via stb_truetype. + +const std = @import("std"); + +const Style = @import("../core/style.zig"); +const Layout = @import("../core/layout.zig"); +const Framebuffer = @import("framebuffer.zig").Framebuffer; + +const Color = Style.Color; +const Rect = Layout.Rect; + +/// A simple bitmap font +pub const Font = struct { + /// Glyph data (1 bit per pixel) + glyphs: []const u8, + + /// Width of each character + char_width: u8, + + /// Height of each character + char_height: u8, + + /// First character in the font + first_char: u8, + + /// Number of characters + num_chars: u16, + + const Self = @This(); + + /// Get the width of a character + pub fn charWidth(self: Self) u8 { + return self.char_width; + } + + /// Get the height of a character + pub fn charHeight(self: Self) u8 { + return self.char_height; + } + + /// Get the width of a string in pixels + pub fn textWidth(self: Self, text: []const u8) u32 { + return @as(u32, self.char_width) * @as(u32, @intCast(text.len)); + } + + /// Draw a single character + pub fn drawChar( + self: Self, + fb: *Framebuffer, + x: i32, + y: i32, + char: u8, + color: Color, + clip: Rect, + ) void { + // Check if character is in font + if (char < self.first_char) return; + const idx = char - self.first_char; + if (idx >= self.num_chars) return; + + // Calculate glyph data offset + const bytes_per_row = (self.char_width + 7) / 8; + const bytes_per_char = @as(usize, bytes_per_row) * @as(usize, self.char_height); + const glyph_offset = @as(usize, idx) * bytes_per_char; + + if (glyph_offset + bytes_per_char > self.glyphs.len) return; + const glyph = self.glyphs[glyph_offset..][0..bytes_per_char]; + + // Draw the glyph + var py: u8 = 0; + while (py < self.char_height) : (py += 1) { + const screen_y = y + @as(i32, py); + if (screen_y < clip.top() or screen_y >= clip.bottom()) continue; + + var px: u8 = 0; + while (px < self.char_width) : (px += 1) { + const screen_x = x + @as(i32, px); + if (screen_x < clip.left() or screen_x >= clip.right()) continue; + + // Get pixel from glyph data + const row_offset = @as(usize, py) * bytes_per_row; + const byte_idx = row_offset + @as(usize, px / 8); + const bit_idx: u3 = @intCast(7 - (px % 8)); + + if (byte_idx < glyph.len) { + const pixel_set = (glyph[byte_idx] >> bit_idx) & 1; + if (pixel_set != 0) { + fb.setPixel(screen_x, screen_y, color); + } + } + } + } + } + + /// Draw a string + pub fn drawText( + self: Self, + fb: *Framebuffer, + x: i32, + y: i32, + text: []const u8, + color: Color, + clip: Rect, + ) void { + var cx = x; + for (text) |char| { + if (char == '\n') { + // Handle newline - not implemented in simple version + continue; + } + self.drawChar(fb, cx, y, char, color, clip); + cx += @as(i32, self.char_width); + } + } +}; + +// ============================================================================= +// Built-in 8x8 font +// ============================================================================= + +/// Simple 8x8 bitmap font (ASCII 32-126) +pub const default_font = Font{ + .glyphs = &default_font_data, + .char_width = 8, + .char_height = 8, + .first_char = 32, + .num_chars = 95, +}; + +// 8x8 font data for ASCII 32-126 +// Each character is 8 bytes (8 rows, 1 byte per row) +const default_font_data = blk: { + // This is a minimal font - in production, use a proper font + var data: [95 * 8]u8 = undefined; + + // Initialize all to zero + for (&data) |*b| { + b.* = 0; + } + + // Space (32) + // Already zero + + // ! (33) + const exclaim = [8]u8{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x18, 0x00 }; + @memcpy(data[1 * 8 ..][0..8], &exclaim); + + // A (65) + const a_upper = [8]u8{ 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x66, 0x00 }; + @memcpy(data[33 * 8 ..][0..8], &a_upper); + + // B (66) + const b_upper = [8]u8{ 0x7C, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x7C, 0x00 }; + @memcpy(data[34 * 8 ..][0..8], &b_upper); + + // C (67) + const c_upper = [8]u8{ 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00 }; + @memcpy(data[35 * 8 ..][0..8], &c_upper); + + // ... Add more characters as needed + + // a (97) + const a_lower = [8]u8{ 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x3E, 0x00 }; + @memcpy(data[65 * 8 ..][0..8], &a_lower); + + // b (98) + const b_lower = [8]u8{ 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x00 }; + @memcpy(data[66 * 8 ..][0..8], &b_lower); + + // 0 (48) + const zero = [8]u8{ 0x3C, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x3C, 0x00 }; + @memcpy(data[16 * 8 ..][0..8], &zero); + + // 1 (49) + const one = [8]u8{ 0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00 }; + @memcpy(data[17 * 8 ..][0..8], &one); + + break :blk data; +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Font basic" { + const font = default_font; + + try std.testing.expectEqual(@as(u8, 8), font.charWidth()); + try std.testing.expectEqual(@as(u8, 8), font.charHeight()); + try std.testing.expectEqual(@as(u32, 40), font.textWidth("Hello")); +} + +test "Font draw" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + const font = default_font; + const clip = Rect.init(0, 0, 100, 100); + + fb.clear(Color.black); + font.drawChar(&fb, 10, 10, 'A', Color.white, clip); + + // Just verify it doesn't crash +} diff --git a/src/render/framebuffer.zig b/src/render/framebuffer.zig new file mode 100644 index 0000000..87a2646 --- /dev/null +++ b/src/render/framebuffer.zig @@ -0,0 +1,234 @@ +//! Framebuffer - Pixel buffer for software rendering +//! +//! A simple 2D array of RGBA pixels. +//! The software rasterizer writes to this, then it's blitted to the screen. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Style = @import("../core/style.zig"); +const Color = Style.Color; + +/// A 2D pixel buffer +pub const Framebuffer = struct { + allocator: Allocator, + pixels: []u32, + width: u32, + height: u32, + + const Self = @This(); + + /// Create a new framebuffer + pub fn init(allocator: Allocator, width: u32, height: u32) !Self { + const size = @as(usize, width) * @as(usize, height); + const pixels = try allocator.alloc(u32, size); + @memset(pixels, 0); + + return .{ + .allocator = allocator, + .pixels = pixels, + .width = width, + .height = height, + }; + } + + /// Free the framebuffer + pub fn deinit(self: Self) void { + self.allocator.free(self.pixels); + } + + /// Resize the framebuffer + pub fn resize(self: *Self, width: u32, height: u32) !void { + if (width == self.width and height == self.height) return; + + const size = @as(usize, width) * @as(usize, height); + const new_pixels = try self.allocator.alloc(u32, size); + @memset(new_pixels, 0); + + self.allocator.free(self.pixels); + self.pixels = new_pixels; + self.width = width; + self.height = height; + } + + /// Clear the entire buffer to a color + pub fn clear(self: *Self, color: Color) void { + const c = color.toABGR(); + @memset(self.pixels, c); + } + + /// Get pixel at (x, y) + pub fn getPixel(self: Self, x: i32, y: i32) ?u32 { + if (x < 0 or y < 0) return null; + const ux = @as(u32, @intCast(x)); + const uy = @as(u32, @intCast(y)); + if (ux >= self.width or uy >= self.height) return null; + + const idx = uy * self.width + ux; + return self.pixels[idx]; + } + + /// Set pixel at (x, y) + pub fn setPixel(self: *Self, x: i32, y: i32, color: Color) void { + if (x < 0 or y < 0) return; + const ux = @as(u32, @intCast(x)); + const uy = @as(u32, @intCast(y)); + if (ux >= self.width or uy >= self.height) return; + + const idx = uy * self.width + ux; + + if (color.a == 255) { + self.pixels[idx] = color.toABGR(); + } else if (color.a > 0) { + // Blend with existing pixel + const existing = self.pixels[idx]; + const bg = Color{ + .r = @truncate(existing), + .g = @truncate(existing >> 8), + .b = @truncate(existing >> 16), + .a = @truncate(existing >> 24), + }; + self.pixels[idx] = color.blend(bg).toABGR(); + } + } + + /// Draw a filled rectangle + pub fn fillRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void { + const x_start = @max(0, x); + const y_start = @max(0, y); + const x_end = @min(@as(i32, @intCast(self.width)), x + @as(i32, @intCast(w))); + const y_end = @min(@as(i32, @intCast(self.height)), y + @as(i32, @intCast(h))); + + if (x_start >= x_end or y_start >= y_end) return; + + const c = color.toABGR(); + + var py = y_start; + while (py < y_end) : (py += 1) { + const row_start = @as(u32, @intCast(py)) * self.width; + var px = x_start; + while (px < x_end) : (px += 1) { + const idx = row_start + @as(u32, @intCast(px)); + if (color.a == 255) { + self.pixels[idx] = c; + } else if (color.a > 0) { + const existing = self.pixels[idx]; + const bg = Color{ + .r = @truncate(existing), + .g = @truncate(existing >> 8), + .b = @truncate(existing >> 16), + .a = @truncate(existing >> 24), + }; + self.pixels[idx] = color.blend(bg).toABGR(); + } + } + } + } + + /// Draw a rectangle outline + pub fn drawRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void { + if (w == 0 or h == 0) return; + + // Top and bottom + self.fillRect(x, y, w, 1, color); + self.fillRect(x, y + @as(i32, @intCast(h)) - 1, w, 1, color); + + // Left and right + self.fillRect(x, y + 1, 1, h -| 2, color); + self.fillRect(x + @as(i32, @intCast(w)) - 1, y + 1, 1, h -| 2, color); + } + + /// Draw a horizontal line + pub fn drawHLine(self: *Self, x: i32, y: i32, w: u32, color: Color) void { + self.fillRect(x, y, w, 1, color); + } + + /// Draw a vertical line + pub fn drawVLine(self: *Self, x: i32, y: i32, h: u32, color: Color) void { + self.fillRect(x, y, 1, h, color); + } + + /// Draw a line (Bresenham's algorithm) + pub fn drawLine(self: *Self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) void { + var x = x0; + var y = y0; + + const dx = @abs(x1 - x0); + const dy = @abs(y1 - y0); + const sx: i32 = if (x0 < x1) 1 else -1; + const sy: i32 = if (y0 < y1) 1 else -1; + var err = @as(i32, @intCast(dx)) - @as(i32, @intCast(dy)); + + while (true) { + self.setPixel(x, y, color); + + if (x == x1 and y == y1) break; + + const e2 = err * 2; + if (e2 > -@as(i32, @intCast(dy))) { + err -= @as(i32, @intCast(dy)); + x += sx; + } + if (e2 < @as(i32, @intCast(dx))) { + err += @as(i32, @intCast(dx)); + y += sy; + } + } + } + + /// Get raw pixel data (for blitting to SDL texture) + pub fn getData(self: Self) []const u32 { + return self.pixels; + } + + /// Get pitch in bytes + pub fn getPitch(self: Self) u32 { + return self.width * 4; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Framebuffer basic" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + fb.clear(Color.black); + fb.setPixel(50, 50, Color.white); + + const pixel = fb.getPixel(50, 50); + try std.testing.expect(pixel != null); +} + +test "Framebuffer fillRect" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + fb.clear(Color.black); + fb.fillRect(10, 10, 20, 20, Color.red); + + // Check inside + const inside = fb.getPixel(15, 15); + try std.testing.expect(inside != null); + try std.testing.expectEqual(Color.red.toABGR(), inside.?); + + // Check outside + const outside = fb.getPixel(5, 5); + try std.testing.expect(outside != null); + try std.testing.expectEqual(Color.black.toABGR(), outside.?); +} + +test "Framebuffer out of bounds" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + // These should not crash + fb.setPixel(-1, 50, Color.white); + fb.setPixel(50, -1, Color.white); + fb.setPixel(100, 50, Color.white); + fb.setPixel(50, 100, Color.white); + + try std.testing.expect(fb.getPixel(-1, 50) == null); +} diff --git a/src/render/software.zig b/src/render/software.zig new file mode 100644 index 0000000..1872142 --- /dev/null +++ b/src/render/software.zig @@ -0,0 +1,222 @@ +//! SoftwareRenderer - Executes draw commands on a framebuffer +//! +//! This is the core of our rendering system. +//! It takes DrawCommands and turns them into pixels. + +const std = @import("std"); + +const Command = @import("../core/command.zig"); +const Style = @import("../core/style.zig"); +const Layout = @import("../core/layout.zig"); +const Framebuffer = @import("framebuffer.zig").Framebuffer; +const Font = @import("font.zig").Font; + +const Color = Style.Color; +const Rect = Layout.Rect; +const DrawCommand = Command.DrawCommand; + +/// Software renderer state +pub const SoftwareRenderer = struct { + framebuffer: *Framebuffer, + default_font: ?*Font, + + /// Clipping stack + clip_stack: [16]Rect, + clip_depth: usize, + + const Self = @This(); + + /// Initialize the renderer + pub fn init(framebuffer: *Framebuffer) Self { + return .{ + .framebuffer = framebuffer, + .default_font = null, + .clip_stack = undefined, + .clip_depth = 0, + }; + } + + /// Set the default font + pub fn setDefaultFont(self: *Self, font: *Font) void { + self.default_font = font; + } + + /// Get the current clip rectangle + pub fn getClip(self: Self) Rect { + if (self.clip_depth == 0) { + return Rect.init(0, 0, self.framebuffer.width, self.framebuffer.height); + } + return self.clip_stack[self.clip_depth - 1]; + } + + /// Execute a single draw command + pub fn execute(self: *Self, cmd: DrawCommand) void { + switch (cmd) { + .rect => |r| self.drawRect(r), + .text => |t| self.drawText(t), + .line => |l| self.drawLine(l), + .rect_outline => |r| self.drawRectOutline(r), + .clip => |c| self.pushClip(c), + .clip_end => self.popClip(), + .nop => {}, + } + } + + /// Execute all commands in a list + pub fn executeAll(self: *Self, commands: []const DrawCommand) void { + for (commands) |cmd| { + self.execute(cmd); + } + } + + /// Clear the framebuffer + pub fn clear(self: *Self, color: Color) void { + self.framebuffer.clear(color); + } + + // ========================================================================= + // Private drawing functions + // ========================================================================= + + fn drawRect(self: *Self, r: Command.RectCommand) void { + const clip = self.getClip(); + + // Clip the rectangle + const clipped = Rect.init(r.x, r.y, r.w, r.h).intersection(clip); + if (clipped.isEmpty()) return; + + self.framebuffer.fillRect( + clipped.x, + clipped.y, + clipped.w, + clipped.h, + r.color, + ); + } + + fn drawText(self: *Self, t: Command.TextCommand) void { + const font = if (t.font) |f| + @as(*Font, @ptrCast(@alignCast(f))) + else + self.default_font orelse return; + + const clip = self.getClip(); + + // Simple text rendering - character by character + var x = t.x; + for (t.text) |char| { + if (char == '\n') { + // TODO: Handle newlines + continue; + } + + // Check if character is visible + if (x >= clip.right()) break; + + // Render character + font.drawChar(self.framebuffer, x, t.y, char, t.color, clip); + + x += @as(i32, @intCast(font.charWidth())); + } + } + + fn drawLine(self: *Self, l: Command.LineCommand) void { + // TODO: Clip line to clip rectangle + self.framebuffer.drawLine(l.x1, l.y1, l.x2, l.y2, l.color); + } + + fn drawRectOutline(self: *Self, r: Command.RectOutlineCommand) void { + const clip = self.getClip(); + + // Draw each edge as a filled rect + // Top + const top_clipped = Rect.init(r.x, r.y, r.w, r.thickness).intersection(clip); + if (!top_clipped.isEmpty()) { + self.framebuffer.fillRect(top_clipped.x, top_clipped.y, top_clipped.w, top_clipped.h, r.color); + } + + // Bottom + const bottom_y = r.y + @as(i32, @intCast(r.h)) - @as(i32, @intCast(r.thickness)); + const bottom_clipped = Rect.init(r.x, bottom_y, r.w, r.thickness).intersection(clip); + if (!bottom_clipped.isEmpty()) { + self.framebuffer.fillRect(bottom_clipped.x, bottom_clipped.y, bottom_clipped.w, bottom_clipped.h, r.color); + } + + // Left + const inner_y = r.y + @as(i32, @intCast(r.thickness)); + const inner_h = r.h -| (r.thickness * 2); + const left_clipped = Rect.init(r.x, inner_y, r.thickness, inner_h).intersection(clip); + if (!left_clipped.isEmpty()) { + self.framebuffer.fillRect(left_clipped.x, left_clipped.y, left_clipped.w, left_clipped.h, r.color); + } + + // Right + const right_x = r.x + @as(i32, @intCast(r.w)) - @as(i32, @intCast(r.thickness)); + const right_clipped = Rect.init(right_x, inner_y, r.thickness, inner_h).intersection(clip); + if (!right_clipped.isEmpty()) { + self.framebuffer.fillRect(right_clipped.x, right_clipped.y, right_clipped.w, right_clipped.h, r.color); + } + } + + fn pushClip(self: *Self, c: Command.ClipCommand) void { + if (self.clip_depth >= self.clip_stack.len) return; + + const new_clip = Rect.init(c.x, c.y, c.w, c.h); + const current = self.getClip(); + const clipped = new_clip.intersection(current); + + self.clip_stack[self.clip_depth] = clipped; + self.clip_depth += 1; + } + + fn popClip(self: *Self) void { + if (self.clip_depth > 0) { + self.clip_depth -= 1; + } + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "SoftwareRenderer basic" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + var renderer = SoftwareRenderer.init(&fb); + + renderer.clear(Color.black); + renderer.execute(Command.rect(10, 10, 20, 20, Color.red)); + + const pixel = fb.getPixel(15, 15); + try std.testing.expect(pixel != null); + try std.testing.expectEqual(Color.red.toABGR(), pixel.?); +} + +test "SoftwareRenderer clipping" { + var fb = try Framebuffer.init(std.testing.allocator, 100, 100); + defer fb.deinit(); + + var renderer = SoftwareRenderer.init(&fb); + + renderer.clear(Color.black); + + // Set clip to 50x50 + renderer.execute(Command.clip(0, 0, 50, 50)); + + // Draw rect that extends beyond clip + renderer.execute(Command.rect(40, 40, 30, 30, Color.red)); + + renderer.execute(Command.clipEnd()); + + // Check inside clip (should be red) + const inside = fb.getPixel(45, 45); + try std.testing.expect(inside != null); + try std.testing.expectEqual(Color.red.toABGR(), inside.?); + + // Check outside clip (should be black) + const outside = fb.getPixel(55, 55); + try std.testing.expect(outside != null); + try std.testing.expectEqual(Color.black.toABGR(), outside.?); +} diff --git a/src/zcatgui.zig b/src/zcatgui.zig new file mode 100644 index 0000000..e30771f --- /dev/null +++ b/src/zcatgui.zig @@ -0,0 +1,93 @@ +//! zCatGui - Immediate Mode GUI Library for Zig +//! +//! A software-rendered, cross-platform GUI library with macro recording support. +//! +//! ## Features +//! - Immediate mode paradigm (no callbacks, explicit state) +//! - Software rendering (works everywhere, including SSH) +//! - Macro system for recording/replaying user actions +//! - SDL2 backend for cross-platform support +//! +//! ## Quick Start +//! ```zig +//! const zcatgui = @import("zcatgui"); +//! +//! pub fn main() !void { +//! var app = try zcatgui.App.init(allocator, "My App", 800, 600); +//! defer app.deinit(); +//! +//! while (app.running) { +//! app.beginFrame(); +//! +//! if (app.button("Click me!")) { +//! // Handle click +//! } +//! +//! app.endFrame(); +//! } +//! } +//! ``` + +const std = @import("std"); + +// ============================================================================= +// Core modules +// ============================================================================= +pub const Context = @import("core/context.zig").Context; +pub const Layout = @import("core/layout.zig"); +pub const Style = @import("core/style.zig"); +pub const Input = @import("core/input.zig"); +pub const Command = @import("core/command.zig"); + +// ============================================================================= +// Macro system +// ============================================================================= +pub const macro = @import("macro/macro.zig"); +pub const MacroRecorder = macro.MacroRecorder; +pub const MacroPlayer = macro.MacroPlayer; +pub const KeyEvent = macro.KeyEvent; + +// ============================================================================= +// Rendering +// ============================================================================= +pub const render = struct { + pub const Framebuffer = @import("render/framebuffer.zig").Framebuffer; + pub const SoftwareRenderer = @import("render/software.zig").SoftwareRenderer; + pub const Font = @import("render/font.zig").Font; +}; + +// ============================================================================= +// Backend +// ============================================================================= +pub const backend = struct { + pub const Backend = @import("backend/backend.zig").Backend; + pub const Sdl2Backend = @import("backend/sdl2.zig").Sdl2Backend; +}; + +// ============================================================================= +// Widgets (to be implemented) +// ============================================================================= +pub const widgets = struct { + // pub const Button = @import("widgets/button.zig").Button; + // pub const Label = @import("widgets/label.zig").Label; + // pub const Input = @import("widgets/input.zig").Input; + // pub const Select = @import("widgets/select.zig").Select; + // pub const Table = @import("widgets/table.zig").Table; + // pub const Panel = @import("widgets/panel.zig").Panel; + // pub const Split = @import("widgets/split.zig").Split; + // pub const Modal = @import("widgets/modal.zig").Modal; +}; + +// ============================================================================= +// Re-exports for convenience +// ============================================================================= +pub const Color = Style.Color; +pub const Rect = Layout.Rect; +pub const Constraint = Layout.Constraint; + +// ============================================================================= +// Tests +// ============================================================================= +test { + std.testing.refAllDecls(@This()); +}