diff --git a/CLAUDE.md b/CLAUDE.md index 9d6f193..dd02e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Una vez verificado el estado, continúa desde donde se dejó. | Campo | Valor | |-------|-------| | **Nombre** | zcatgui | -| **Versión** | v0.1.0 - EN DESARROLLO | +| **Versión** | v0.5.0 - EN DESARROLLO | | **Fecha inicio** | 2025-12-09 | | **Lenguaje** | Zig 0.15.2 | | **Paradigma** | Immediate Mode GUI | @@ -172,8 +172,20 @@ zcatgui/ │ │ ├── input.zig # ✅ Key, KeyEvent, MouseEvent, InputState │ │ └── command.zig # ✅ DrawCommand list │ │ -│ ├── widgets/ # ⏳ PENDIENTE (Fase 2) -│ │ └── (vacío) +│ ├── widgets/ +│ │ ├── widgets.zig # ✅ Re-exports all widgets +│ │ ├── label.zig # ✅ Static text display +│ │ ├── button.zig # ✅ Clickable button +│ │ ├── text_input.zig # ✅ Editable text field +│ │ ├── checkbox.zig # ✅ Boolean toggle +│ │ ├── select.zig # ✅ Dropdown selection +│ │ ├── list.zig # ✅ Scrollable list +│ │ ├── focus.zig # ✅ Focus management +│ │ ├── table.zig # ✅ Editable table with scrolling +│ │ ├── split.zig # ✅ HSplit/VSplit panels +│ │ ├── panel.zig # ✅ Container with title bar +│ │ ├── modal.zig # ✅ Modal dialogs (alert, confirm, input) +│ │ └── autocomplete.zig # ✅ ComboBox/AutoComplete widget │ │ │ ├── render/ │ │ ├── software.zig # ✅ SoftwareRenderer (ejecuta commands) @@ -189,7 +201,9 @@ zcatgui/ │ ├── examples/ │ ├── hello.zig # ✅ Ejemplo básico de rendering -│ └── macro_demo.zig # ✅ Demo del sistema de macros +│ ├── macro_demo.zig # ✅ Demo del sistema de macros +│ ├── widgets_demo.zig # ✅ Demo de todos los widgets básicos +│ └── table_demo.zig # ✅ Demo de Table, Split, Panel │ ├── docs/ │ ├── ARCHITECTURE.md # Arquitectura detallada @@ -363,32 +377,33 @@ Widgets → Commands → Software Rasterizer → Framebuffer → SDL_Texture → - [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 1: Core + Macros ✅ COMPLETADA +- [x] Context con event loop +- [x] Sistema de macros (grabación/reproducción teclas) +- [x] Software rasterizer básico (rects, text, lines) +- [x] SDL2 backend +- [x] Framebuffer RGBA -### Fase 2: Widgets Esenciales (2 semanas) -- [ ] Input (text entry) -- [ ] Select (dropdown) -- [ ] Checkbox -- [ ] List -- [ ] Layout system -- [ ] Focus management +### Fase 2: Widgets Esenciales ✅ COMPLETADA +- [x] Label (static text) +- [x] Button (clickable, importance levels) +- [x] TextInput (editable text, cursor, selection) +- [x] Checkbox (boolean toggle) +- [x] Select (dropdown) +- [x] List (scrollable selection) +- [x] Focus management (FocusManager, FocusRing) -### Fase 3: Widgets Avanzados (2 semanas) -- [ ] Table con edición -- [ ] Split panels +### Fase 3: Widgets Avanzados (PENDIENTE) +- [ ] Table con edición (CRÍTICO) +- [ ] Split panels (HSplit/VSplit draggable) +- [ ] Panel (container con título) - [ ] Modal/Popup -- [ ] Panel con título -### Fase 4: Pulido (1 semana) -- [ ] Themes -- [ ] Font handling robusto -- [ ] Documentación -- [ ] Examples completos +### Fase 4: Pulido (PENDIENTE) +- [ ] Themes hot-reload +- [ ] TTF fonts (stb_truetype) +- [ ] Documentación completa +- [ ] Más examples --- @@ -498,9 +513,13 @@ fn foo() !T { ... } const result = try foo(); const result = foo() catch |err| { ... }; -// File I/O - writer cambió +// File I/O - cambió en 0.15 const file = try std.fs.cwd().createFile(path, .{}); -_ = try file.write("data"); // Directo, no file.writer() +_ = try file.write("data"); // Directo + +// stdout - cambió en 0.15 +const stdout = std.fs.File.stdout(); // NO std.io.getStdOut() +// O usar std.debug.print() que es más simple // build.zig.zon - requiere fingerprint .{ @@ -517,35 +536,278 @@ _ = try file.write("data"); // Directo, no file.writer() | Fecha | Versión | Cambios | |-------|---------|---------| | 2025-12-09 | v0.1.0 | Proyecto creado, estructura base, documentación | +| 2025-12-09 | v0.2.0 | Widgets Fase 2 completados (Label, Button, TextInput, Checkbox, Select, List, Focus) | +| 2025-12-09 | v0.3.0 | Widgets Fase 3 completados (Table editable, Split panels, Panel container) | +| 2025-12-09 | v0.3.5 | Keyboard integration: InputState ahora trackea teclas, Table responde a flechas/Enter/Escape/Tab/F2 | +| 2025-12-09 | v0.4.0 | Modal widget: diálogos modales (alert, confirm, input), plan extendido documentado | +| 2025-12-09 | v0.5.0 | AutoComplete widget, comparativa DVUI/Gio/zcatui en WIDGET_COMPARISON.md | --- ## ESTADO ACTUAL -**El proyecto está en FASE 1 PARCIAL** +**El proyecto está en FASE 5.0 - AutoComplete completado** ### Completado (✅): - Estructura de directorios - build.zig con SDL2 - Documentación de investigación -- Core: context, layout, style, input, command -- Render: framebuffer, software renderer, font +- Core: context, layout, style, input (con keyboard tracking), command +- Render: framebuffer, software renderer, font (bitmap 8x8) - Backend: SDL2 (window, events, display) - Macro: MacroRecorder, MacroPlayer, MacroStorage -- Examples: hello.zig, macro_demo.zig -- **16 tests pasando** +- **Widgets Fase 2**: Label, Button, TextInput, Checkbox, Select, List +- **Focus**: FocusManager, FocusRing +- **Widgets Fase 3**: Table (editable, scrollable, dirty tracking), Split (HSplit/VSplit), Panel +- **Keyboard Integration**: InputState trackea teclas, Table responde a navegación completa +- **Widgets Fase 4**: Modal (alert, confirm, inputDialog) +- **Widgets Fase 5**: AutoComplete/ComboBox (prefix, contains, fuzzy matching) +- **Comparativa**: docs/research/WIDGET_COMPARISON.md con DVUI, Gio, zcatui +- Examples: hello.zig, macro_demo.zig, widgets_demo.zig, table_demo.zig +- **13 widgets implementados, tests pasando** ### Pendiente (⏳): -- Widgets (Button, Label, Input, Select, Table, etc.) -- Focus management -- Themes -- TTF fonts +- **Fase 5.1**: Slider, ScrollArea, Scrollbar +- **Fase 6**: Menu, Tabs, RadioButton +- **Fase 7**: TextArea, Tree, ProgressBar +- **Análisis**: AdvancedTable de Simifactu +- **Sistema**: Lego panels +- **Polish**: Themes hot-reload, TTF fonts -**Próximo paso**: Implementar widgets básicos (Button, Label, Input) +**Próximo paso**: Analizar AdvancedTable de Simifactu para features adicionales de Table ### Verificar que funciona: ```bash cd /mnt/cello2/arno/re/recode/zig/zcatgui -zig build test # 16 tests deben pasar -zig build # Compila hello y macro-demo +/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig build test +/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig build +/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig build widgets-demo +/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig build table-demo +``` + +--- + +## PLAN DE TRABAJO EXTENDIDO + +### Fase 4: Modal/Popup (PRÓXIMO) +- [ ] Modal widget (overlay que bloquea UI) +- [ ] Diálogos estándar: Confirm, Alert, Input +- [ ] Z-order/capas para popups + +### Fase 5: Comparativa con Librerías de Referencia + +#### 5.1 Comparar con DVUI (Zig) - Librería base de referencia +DVUI tiene ~30 widgets. Comparar y extraer lo que nos falta: + +| Widget DVUI | zcatgui | Prioridad | +|-------------|---------|-----------| +| Button | ✅ | - | +| Checkbox | ✅ | - | +| TextInput | ✅ | - | +| Slider | ❌ | Media | +| ScrollArea | ❌ | Alta | +| Menu | ❌ | Alta | +| Dropdown | ✅ (Select) | - | +| TreeView | ❌ | Baja | +| Modal | ⏳ | Alta | +| Popup | ⏳ | Alta | +| Radio | ❌ | Media | +| ColorPicker | ❌ | Baja | +| ProgressBar | ❌ | Media | + +#### 5.2 Comparar con Gio (Go) +Gio es immediate-mode moderno. Extraer patterns: +- [ ] Sistema de constraints/layout +- [ ] Gesture handling +- [ ] Animation system +- [ ] Theming approach + +#### 5.3 Comparar con zcatui (nuestro TUI - 35 widgets) +Widgets de zcatui que deberíamos portar a GUI: + +| Widget zcatui | zcatgui | Prioridad | Notas | +|---------------|---------|-----------|-------| +| input | ✅ | - | TextInput | +| select | ✅ | - | | +| checkbox | ✅ | - | | +| table | ✅ | - | | +| list | ✅ | - | | +| panel | ✅ | - | | +| **popup** | ⏳ | Alta | Modal/Popup | +| **menu** | ❌ | Alta | Menús contextuales | +| **tabs** | ❌ | Alta | Tab navigation | +| **tree** | ❌ | Media | TreeView | +| **calendar** | ❌ | Media | Date picker | +| **filepicker** | ❌ | Media | File browser | +| **dirtree** | ❌ | Media | Directory tree | +| progress | ❌ | Media | ProgressBar | +| gauge | ❌ | Baja | | +| sparkline | ❌ | Baja | | +| barchart | ❌ | Baja | | +| chart | ❌ | Baja | | +| canvas | ❌ | Baja | Custom drawing | +| markdown | ❌ | Baja | | +| syntax | ❌ | Baja | Code highlighting | +| viewport | ❌ | Media | Scrollable content | +| scroll | ❌ | Alta | ScrollArea | +| scrollbar | ❌ | Alta | | +| slider | ❌ | Media | | +| spinner | ❌ | Baja | Loading indicator | +| statusbar | ❌ | Media | | +| textarea | ❌ | Alta | Multiline input | +| tooltip | ❌ | Media | Hover help | +| help | ❌ | Baja | | +| logo | ❌ | Baja | | +| clear | ✅ | - | Implicit | +| block | ✅ | - | Panel/Container | +| paragraph | ✅ | - | Label | + +### Fase 6: Widgets Específicos Simifactu + +#### 6.1 AutoComplete/ComboBox Widget (CRÍTICO) +Widget usado en Simifactu para: +- Provincias (dropdown con búsqueda) +- Países (dropdown con búsqueda) +- Tipos IVA (dropdown con valores predefinidos) +- Poblaciones (autocomplete con sugerencias) + +```zig +pub const AutoComplete = struct { + /// Current text value + text: []const u8, + /// All available options + options: []const []const u8, + /// Filtered options based on text + filtered: []const []const u8, + /// Whether dropdown is open + open: bool, + /// Selected index in filtered list + selected: i32, + /// Allow custom values not in list + allow_custom: bool, + /// Callback when value changes + on_change: ?*const fn([]const u8) void, +}; +``` + +#### 6.2 AdvancedTable Analysis +Analizar `/mnt/cello2/arno/re/recode/go/simifactu/internal/ui/components/advanced_table/`: +- [ ] Sorting por columnas (click en header) +- [ ] Resize de columnas (drag) +- [ ] Column reordering (drag) +- [ ] Multi-select rows +- [ ] Copy/Paste cells +- [ ] Undo/Redo edits +- [ ] Calculated columns +- [ ] Column visibility toggle +- [ ] Export selected rows + +#### 6.3 Sistema Lego Panels +Layout modular tipo Simifactu: +- [ ] Panel registry (panels registran su ID) +- [ ] Layout presets (Ctrl+1/2/3) +- [ ] Drag-and-drop panel reordering +- [ ] Panel minimize/maximize +- [ ] Save/restore layout state +- [ ] Panel communication (pub/sub) + +### Fase 7: Features Avanzados + +#### 7.1 Sistema de Themes +- [ ] Theme struct con todos los colores +- [ ] Hot-reload de themes +- [ ] Theme editor widget +- [ ] Persistencia de themes + +#### 7.2 TTF Fonts +- [ ] Integrar stb_truetype +- [ ] Font atlas generation +- [ ] Multiple font sizes +- [ ] Font fallback chain + +#### 7.3 Internacionalización +- [ ] String tables +- [ ] RTL support (futuro) + +--- + +## WIDGETS ROADMAP VISUAL + +``` +COMPLETADOS (Fase 1-3.5): +✅ Label, Button, TextInput, Checkbox, Select, List +✅ Focus, Table, Split, Panel +✅ Keyboard integration + +EN PROGRESO: +⏳ Modal/Popup + +PRÓXIMOS (Fase 4-5): +📋 Menu, Tabs, ScrollArea, Scrollbar +📋 Radio, Slider, ProgressBar +📋 Textarea (multiline), Tooltip + +SIMIFACTU-ESPECÍFICOS (Fase 6): +🎯 AutoComplete/ComboBox +🎯 AdvancedTable features (sort, resize, multi-select) +🎯 Lego Panel system + +AVANZADOS (Fase 7): +🔮 Calendar, DatePicker +🔮 FilePicker, DirTree +🔮 Tree/TreeView +🔮 Themes hot-reload +🔮 TTF fonts +``` + +--- + +## ESTIMACIÓN DE TRABAJO + +| Fase | Widgets/Features | Tiempo Est. | +|------|------------------|-------------| +| 4 | Modal/Popup | 2-3 días | +| 5.1 | Menu, Tabs, ScrollArea | 1 semana | +| 5.2 | Radio, Slider, Progress | 3-4 días | +| 5.3 | Textarea, Tooltip | 3-4 días | +| 6.1 | AutoComplete | 3-4 días | +| 6.2 | AdvancedTable | 1-2 semanas | +| 6.3 | Lego Panels | 1 semana | +| 7 | Themes, TTF | 1-2 semanas | +| **TOTAL** | | **6-8 semanas** | + +--- + +## ARCHIVOS DE REFERENCIA + +### Simifactu (Go/Fyne) +``` +/mnt/cello2/arno/re/recode/go/simifactu/ +├── internal/ui/components/advanced_table/ # AdvancedTable (2000+ LOC) +├── internal/ui/panels_v3/ # Lego panel system +├── third_party/fynex-widgets/ # AutoComplete, DateEntry, etc. +└── internal/ui/dialogs/ # Modal dialogs +``` + +### zcatui (Zig TUI - 35 widgets) +``` +/mnt/cello2/arno/re/recode/zig/zcatui/src/widgets/ +├── popup.zig # Modal/Popup reference +├── menu.zig # Menu widget +├── tabs.zig # Tab navigation +├── tree.zig # TreeView +├── calendar.zig # Calendar/DatePicker +├── filepicker.zig # File browser +└── ... +``` + +### DVUI (Zig GUI reference) +``` +https://github.com/david-vanderson/dvui +``` + +### Gio (Go immediate-mode) +``` +https://gioui.org/ +docs/research/GIO_UI_ANALYSIS.md ``` diff --git a/build.zig b/build.zig index f9217bc..2de081c 100644 --- a/build.zig +++ b/build.zig @@ -79,4 +79,46 @@ pub fn build(b: *std.Build) void { run_macro.step.dependOn(b.getInstallStep()); const macro_step = b.step("macro-demo", "Run macro recording demo"); macro_step.dependOn(&run_macro.step); + + // Widgets demo + const widgets_exe = b.addExecutable(.{ + .name = "widgets-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/widgets_demo.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "zcatgui", .module = zcatgui_mod }, + }, + }), + }); + widgets_exe.root_module.linkSystemLibrary("SDL2", .{}); + b.installArtifact(widgets_exe); + + const run_widgets = b.addRunArtifact(widgets_exe); + run_widgets.step.dependOn(b.getInstallStep()); + const widgets_step = b.step("widgets-demo", "Run widgets demo"); + widgets_step.dependOn(&run_widgets.step); + + // Table demo + const table_exe = b.addExecutable(.{ + .name = "table-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/table_demo.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "zcatgui", .module = zcatgui_mod }, + }, + }), + }); + table_exe.root_module.linkSystemLibrary("SDL2", .{}); + b.installArtifact(table_exe); + + const run_table = b.addRunArtifact(table_exe); + run_table.step.dependOn(b.getInstallStep()); + const table_step = b.step("table-demo", "Run table demo with split panels"); + table_step.dependOn(&run_table.step); } diff --git a/docs/research/LEGO_PANELS_SYSTEM.md b/docs/research/LEGO_PANELS_SYSTEM.md new file mode 100644 index 0000000..c4ff82d --- /dev/null +++ b/docs/research/LEGO_PANELS_SYSTEM.md @@ -0,0 +1,378 @@ +# Sistema Lego Panels de Simifactu + +> Fecha: 2025-12-09 +> Proposito: Documentar arquitectura Lego Panels para aplicar en zcatgui + +--- + +## Resumen + +**Lego Panels** es una arquitectura de composicion modular de UI donde: +- Cada panel es **autonomo** (maneja su propio estado, UI y logica) +- Los paneles son **reutilizables** (mismo panel en diferentes ventanas) +- Las ventanas se construyen **componiendo** paneles (no herencia) +- La comunicacion usa **patron Observer** (paneles no se conocen entre si) + +**Resultados en Simifactu:** +- 83 modulos +- ~112 lineas por archivo (target: 150 max) +- 85-98% reutilizacion de codigo +- 3x mas rapido crear ventanas nuevas + +--- + +## 1. Principios Core + +### 1.1 Panel Autonomo + +Cada panel: +- Tiene su propio estado interno +- Construye su propia UI +- Maneja sus propios eventos +- No conoce a otros paneles +- Se comunica via DataManager (observer) + +### 1.2 Composicion vs Herencia + +``` +MAL: WindowA hereda de BaseWindow y override metodos +BIEN: WindowA compone PanelX + PanelY + PanelZ +``` + +### 1.3 Single Source of Truth + +DataManager es el hub central: +- Todas las entidades pasan por DataManager +- Paneles observan cambios +- Sin comunicacion directa panel-a-panel + +--- + +## 2. Patrones de Composicion + +### 2.1 Vertical Composite (2 paneles) + +``` +┌─────────────────────┐ +│ Top Panel │ +├─────────────────────┤ +│ Bottom Panel │ +└─────────────────────┘ +``` + +**Uso**: Division simple top/bottom + +### 2.2 Center Composite (3 paneles) + +``` +┌─────────────────────┐ +│ WHO Detail │ +├─────────────────────┤ +│ Document Detail │ +├─────────────────────┤ +│ Lines │ +└─────────────────────┘ +``` + +**Uso**: Detalle de documento (master-detail-lines) + +### 2.3 Config Composite (HSplit + dynamic) + +``` +┌────────────┬────────────────────┐ +│ │ │ +│ Categories │ Dynamic Editor │ +│ (nav) │ (table/form/etc) │ +│ │ │ +└────────────┴────────────────────┘ +``` + +**Uso**: Configuracion (lista izq + editor der cambiante) + +### 2.4 Docs Composite (2 columnas) + +``` +┌────────────────┬───────────────────┐ +│ │ WHO Compact │ +│ Doc List ├───────────────────┤ +│ │ Document Detail │ +└────────────────┴───────────────────┘ +``` + +**Uso**: Lista de documentos con preview + +--- + +## 3. Interfaz AutonomousPanel + +```go +type AutonomousPanel interface { + // Identidad + GetPanelID() string // "who_list", "doc_detail" + GetPanelType() string // "list", "detail", "composite" + GetEntityType() string // "WHO", "Document", "Line" + + // Estado + GetSelectedEntity() interface{} + SetSelectedEntity(interface{}) error + + // UI + BuildUI() fyne.CanvasObject + Refresh() + + // Lifecycle + Initialize() error + Destroy() error +} +``` + +--- + +## 4. Patron Observer + +### 4.1 Registro + +```go +// Panel se registra para recibir cambios de "Document" +dataManager.AddObserverForType("Document", myPanel) +``` + +### 4.2 Notificacion + +```go +// Cuando cambia un documento +dataManager.NotifyObserversWithChange(NewDataChange( + entityType: "Document", + changeType: "UPDATE", + data: doc, +)) +``` + +### 4.3 Recepcion + +```go +// Panel responde al cambio +func (p *MyPanel) OnDataChanged(timestamp time.Time) { + // Refrescar UI si afecta mis datos +} +``` + +### 4.4 Dual Notification + +```go +OnDataChanged() // Cambios UI locales +OnDataChangedDB() // Cambios en DB (invalidar cache + recargar) +``` + +--- + +## 5. Aplicacion a zcatgui + +### 5.1 Propuesta de Interfaz + +```zig +/// Panel autonomo +pub const AutonomousPanel = struct { + /// Identificador unico + id: []const u8, + /// Tipo de panel + panel_type: PanelType, + + /// Build UI - retorna commands + build_fn: *const fn(*Context) void, + + /// Refresh callback + refresh_fn: ?*const fn(*AutonomousPanel) void = null, + + /// Estado interno (opaco) + state: *anyopaque, + + /// Destructor + deinit_fn: ?*const fn(*AutonomousPanel) void = null, +}; + +pub const PanelType = enum { + list, // Lista de items + detail, // Detalle de un item + table, // Tabla editable + composite, // Compuesto de otros paneles +}; +``` + +### 5.2 Patron Composite + +```zig +/// Composite vertical (2 paneles) +pub const VerticalComposite = struct { + top: *AutonomousPanel, + bottom: *AutonomousPanel, + split_ratio: f32 = 0.5, + + pub fn build(self: *VerticalComposite, ctx: *Context) void { + const split = widgets.split.vsplit(ctx, self.split_ratio); + + self.top.build_fn(ctx.withArea(split.first)); + self.bottom.build_fn(ctx.withArea(split.second)); + } +}; + +/// HSplit composite (lista + detalle) +pub const HSplitComposite = struct { + left: *AutonomousPanel, + right: *AutonomousPanel, + split_ratio: f32 = 0.3, + + pub fn build(self: *HSplitComposite, ctx: *Context) void { + const split = widgets.split.hsplit(ctx, self.split_ratio); + + self.left.build_fn(ctx.withArea(split.first)); + self.right.build_fn(ctx.withArea(split.second)); + } +}; +``` + +### 5.3 DataManager Simplificado + +```zig +/// Observer callback +pub const DataObserver = struct { + on_data_changed: ?*const fn(entity_type: []const u8, data: ?*anyopaque) void = null, + context: ?*anyopaque = null, +}; + +/// Data manager (singleton) +pub const DataManager = struct { + observers: std.StringHashMap(std.ArrayList(DataObserver)), + + pub fn addObserver(self: *DataManager, entity_type: []const u8, observer: DataObserver) void { + // ... + } + + pub fn notifyChange(self: *DataManager, entity_type: []const u8, data: ?*anyopaque) void { + if (self.observers.get(entity_type)) |observers| { + for (observers.items) |obs| { + if (obs.on_data_changed) |callback| { + callback(entity_type, data); + } + } + } + } +}; +``` + +--- + +## 6. Ejemplo de Uso + +### 6.1 Definir Panel Simple + +```zig +const CustomerListPanel = struct { + state: ListState, + customers: []Customer, + + pub fn build(ctx: *Context) void { + widgets.panel.panel(ctx, "Customers", .{}); + + const result = widgets.list.list(ctx, &state, customers); + if (result.selection_changed) { + dataManager.notifyChange("Customer", customers[result.selected.?]); + } + } +}; +``` + +### 6.2 Definir Panel Detalle + +```zig +const CustomerDetailPanel = struct { + state: FormState, + customer: ?Customer, + + pub fn build(ctx: *Context) void { + widgets.panel.panel(ctx, "Customer Detail", .{}); + + if (self.customer) |c| { + widgets.label.label(ctx, c.name); + widgets.label.label(ctx, c.email); + } else { + widgets.label.label(ctx, "Select a customer"); + } + } + + pub fn onDataChanged(entity_type: []const u8, data: ?*anyopaque) void { + if (std.mem.eql(u8, entity_type, "Customer")) { + self.customer = @ptrCast(data); + } + } +}; +``` + +### 6.3 Componer en Ventana + +```zig +pub fn main() !void { + var list_panel = CustomerListPanel{}; + var detail_panel = CustomerDetailPanel{}; + + // Registrar observers + dataManager.addObserver("Customer", .{ + .on_data_changed = detail_panel.onDataChanged, + }); + + // Componer + var composite = HSplitComposite{ + .left = &list_panel.asPanel(), + .right = &detail_panel.asPanel(), + .split_ratio = 0.3, + }; + + // Main loop + while (running) { + composite.build(&ctx); + // ... + } +} +``` + +--- + +## 7. Plan de Implementacion + +### Fase 1: Panel Interface (2 horas) +- Definir AutonomousPanel trait/interface +- Helpers para crear paneles + +### Fase 2: Composites Basicos (2 horas) +- VerticalComposite +- HSplitComposite +- TabComposite (tabs) + +### Fase 3: DataManager Simple (3 horas) +- Observer registration +- Notify observers +- Entity type filtering + +### Fase 4: Ejemplo Completo (2 horas) +- Master-detail demo +- Config-like layout demo + +**Total estimado: 9 horas** + +--- + +## 8. Beneficios Esperados + +1. **Modularidad**: Paneles independientes, faciles de testear +2. **Reutilizacion**: Mismo panel en multiples ventanas +3. **Mantenibilidad**: Bugs aislados a paneles especificos +4. **Escalabilidad**: Nuevas ventanas = componer paneles existentes +5. **Decoupling**: Sin dependencias directas entre paneles + +--- + +## 9. Referencias + +- `/mnt/cello2/arno/re/recode/go/simifactu-fyne/docs/arquitectura_canonica/01_filosofia_lego.md` +- `/mnt/cello2/arno/re/recode/go/simifactu-fyne/docs/AUDITORIA_ARQUITECTURA_LEGO_V3_SEPT16.md` +- `/mnt/cello2/arno/re/recode/go/simifactu-fyne/internal/ui/panels_v3/panels/` diff --git a/docs/research/SIMIFACTU_ADVANCEDTABLE.md b/docs/research/SIMIFACTU_ADVANCEDTABLE.md new file mode 100644 index 0000000..d02113f --- /dev/null +++ b/docs/research/SIMIFACTU_ADVANCEDTABLE.md @@ -0,0 +1,275 @@ +# Analisis AdvancedTable de Simifactu + +> Fecha: 2025-12-09 +> Proposito: Extraer features de AdvancedTable para mejorar Table de zcatgui + +--- + +## Resumen + +AdvancedTable es un componente de ~10,000 LOC en Go/Fyne que proporciona una experiencia tipo Excel para edicion de tablas. Es el componente mas complejo de Simifactu. + +**Ubicacion**: `/mnt/cello2/arno/re/recode/go/simifactu-fyne/internal/ui/components/advanced_table/` + +--- + +## 1. Features Actuales de zcatgui Table + +| Feature | Estado | Descripcion | +|---------|--------|-------------| +| Renderizado | OK | Columnas, filas, scrolling | +| Seleccion | OK | Click y teclado | +| Edicion in-situ | OK | F2/Enter para editar | +| Dirty tracking | OK | newRows, modifiedRows | +| Navegacion teclado | OK | Flechas, Tab, Home/End | +| Colores por estado | OK | Normal, selected, modified, new | + +--- + +## 2. Features de AdvancedTable que FALTAN en zcatgui + +### 2.1 CRITICO (Necesario para MVP) + +| Feature | Prioridad | Descripcion | +|---------|-----------|-------------| +| **Column Sorting** | CRITICA | Click en header para ordenar asc/desc | +| **Column Resize** | CRITICA | Arrastrar borde de header para redimensionar | +| **Row Operations** | CRITICA | Ctrl+N (insert), Ctrl+A (append), Ctrl+Del (delete) | +| **Auto-CRUD** | ALTA | Detectar cambios y auto-save al cambiar de fila | +| **Validation** | ALTA | Validacion por celda con errores visuales | + +### 2.2 IMPORTANTE (Necesario para Simifactu-GUI) + +| Feature | Prioridad | Descripcion | +|---------|-----------|-------------| +| **Calculated Columns** | ALTA | Columnas calculadas (Total = Cantidad * Precio) | +| **Row Locking** | ALTA | Filas de solo lectura (facturas certificadas) | +| **Lookup Fields** | ALTA | Busqueda en tabla relacionada + auto-fill | +| **Cross-Column Validation** | MEDIA | Validar combinaciones (IVA + RE compatibles) | +| **State Column** | MEDIA | Columna con iconos de estado (semaforo) | + +### 2.3 NICE TO HAVE + +| Feature | Prioridad | Descripcion | +|---------|-----------|-------------| +| **Row Types** | BAJA | Diferentes comportamientos por tipo de fila | +| **Debounced Callbacks** | MEDIA | Evitar flood de eventos durante navegacion rapida | +| **Spanish Collation** | BAJA | Ordenar con acentos correctamente | +| **Color Cache** | MEDIA | Optimizacion de colores pre-calculados | + +--- + +## 3. Detalle de Features Criticas + +### 3.1 Column Sorting + +``` +Implementacion en Simifactu: +- Click en header → toggle asc/desc/none +- Icono visual en header (▲/▼/-) +- Mantener originalRowsForSort para dirty tracking +- Spanish collation optional +``` + +**Para zcatgui:** +```zig +pub const TableConfig = struct { + // ... existing + allow_sorting: bool = false, +}; + +pub const TableState = struct { + // ... existing + sort_column: i32 = -1, // -1 = no sort + sort_asc: bool = true, +}; + +pub const TableResult = struct { + // ... existing + sort_changed: bool = false, + sort_column: ?usize = null, +}; +``` + +### 3.2 Column Resize + +``` +Implementacion en Simifactu: +- Fyne no lo soporta nativamente +- Workaround: arrastrar divisores +``` + +**Para zcatgui:** +- Detectar hover en borde de header +- Cursor cambia a resize +- Drag actualiza column.width + +### 3.3 Row Operations (Keyboard Shortcuts) + +``` +Simifactu shortcuts: +- Ctrl+N → Insert row BEFORE current +- Ctrl+A → Append row AFTER current +- Ctrl+B / Del → Delete current row (mark for deletion) +- Ctrl+Up → Move row up +- Ctrl+Down → Move row down +``` + +**Para zcatgui:** +```zig +pub const TableResult = struct { + // ... existing + row_inserted: bool = false, + row_deleted: bool = false, + row_moved: bool = false, + insert_position: ?usize = null, +}; +``` + +### 3.4 Auto-CRUD Detection + +``` +Simifactu implementation: +1. Al ENTRAR en fila → guardar snapshot +2. Al SALIR de fila → comparar con snapshot +3. Si cambio → detectar CREATE/UPDATE/DELETE +4. Trigger OnRowSave callback +``` + +**Para zcatgui:** +- Ya tenemos dirtyRows +- Falta: snapshot al entrar en fila +- Falta: callback on_row_exit + +### 3.5 Validation + +``` +Simifactu types: +- ValueValidator: single cell validation +- CrossColumnValidator: multi-cell validation +- ValidationResult: {valid, errorMessage} +- Visual: error rows get red background +``` + +**Para zcatgui:** +```zig +pub const ColumnValidation = struct { + validator: ?*const fn(value: []const u8) ?[]const u8, // null = valid, else error +}; + +pub const TableState = struct { + // ... existing + validation_errors: [MAX_ROWS]bool = .{false} ** MAX_ROWS, +}; +``` + +--- + +## 4. Arquitectura de AdvancedTable + +### 4.1 Archivos y Responsabilidades + +| Archivo | LOC | Responsabilidad | +|---------|-----|-----------------| +| types.go | 600 | Tipos y schemas | +| core.go | 1200 | Constructor, build, routing | +| editing.go | 700 | Edicion in-situ, Entry overlay | +| navigation.go | 500 | Navegacion teclado | +| row_operations.go | 600 | CRUD de filas | +| sorting.go | 400 | Ordenacion | +| validation.go | 500 | Validacion | +| calculated.go | 400 | Columnas calculadas | +| visual.go | 700 | Colores, estados visuales | +| datastore.go | 600 | Persistencia abstraccion | +| lookup.go | 350 | Campos lookup | +| callbacks.go | 400 | Gestion callbacks | +| autocrud.go | 400 | Auto CRUD detection | + +### 4.2 Patron de Callbacks + +```go +// Schema-level callbacks (globales) +OnRowSelected func(rowIndex int, rowData map[string]interface{}) +OnCellEdited func(rowIndex, colIndex int, oldValue, newValue interface{}) error +OnSave func(rowData map[string]interface{}) error +OnDelete func(rowData map[string]interface{}) error +OnValidate func(rowIndex, colIndex int, value interface{}) error + +// Column-level callbacks (por columna) +ColumnDef.OnGetFocus func(rowIndex int, value interface{}) +ColumnDef.OnLoseFocus func(rowIndex int, value interface{}) error +ColumnDef.OnValueChanged func(rowIndex int, oldValue, newValue interface{}) +``` + +--- + +## 5. Plan de Implementacion para zcatgui + +### Fase 1: Sorting (Estimacion: 2-3 horas) +1. Agregar sort_column/sort_asc a TableState +2. Detectar click en header +3. Reordenar indices (no mover datos) +4. Dibujar icono en header + +### Fase 2: Row Operations (Estimacion: 2-3 horas) +1. Detectar Ctrl+N/A/B +2. Insertar/eliminar filas en data +3. Actualizar indices dirty/new +4. Reportar en TableResult + +### Fase 3: Validation (Estimacion: 2-3 horas) +1. Agregar validator a Column +2. Llamar validator en cell edit commit +3. Guardar errores en state +4. Renderizar filas con error diferente + +### Fase 4: Calculated Columns (Estimacion: 3-4 horas) +1. Agregar calculate_fn a Column +2. Detectar dependencias +3. Recalcular al cambiar dependencia +4. Renderizar como read-only + +### Fase 5: Auto-CRUD (Estimacion: 2-3 horas) +1. Guardar snapshot al entrar en fila +2. Comparar al salir +3. Trigger callback si cambio + +### Fase 6: Column Resize (Estimacion: 3-4 horas) +1. Detectar hover en borde +2. Cambiar cursor +3. Drag para resize +4. Guardar nuevo width + +**Total estimado: 15-20 horas** + +--- + +## 6. Prioridades para zcatgui Table v2 + +### Must Have (MVP) +- [ ] Column sorting +- [ ] Row insert/delete +- [ ] Basic validation + +### Should Have (v0.7) +- [ ] Calculated columns +- [ ] Auto-CRUD callbacks +- [ ] Row locking + +### Nice to Have (v0.8+) +- [ ] Column resize +- [ ] Cross-column validation +- [ ] Lookup fields +- [ ] Debounced callbacks + +--- + +## 7. Conclusion + +AdvancedTable de Simifactu es un componente muy maduro con features enterprise. Para zcatgui, debemos: + +1. **Fase inmediata**: Sorting + Row operations (features mas usadas) +2. **Fase siguiente**: Validation + Calculated columns +3. **Fase posterior**: Auto-CRUD + Column resize + +El approach de schema-driven de Simifactu es bueno pero requiere mas LOC. Para zcatgui, mantener API simple y agregar features incrementalmente. diff --git a/docs/research/WIDGET_COMPARISON.md b/docs/research/WIDGET_COMPARISON.md new file mode 100644 index 0000000..c0acc3d --- /dev/null +++ b/docs/research/WIDGET_COMPARISON.md @@ -0,0 +1,316 @@ +# Comparativa de Widgets: zcatgui vs DVUI vs Gio vs zcatui + +> Investigacion realizada: 2025-12-09 +> Proposito: Identificar widgets faltantes en zcatgui comparando con otras librerias + +--- + +## Resumen Ejecutivo + +| Libreria | Lenguaje | Widgets | Notas | +|----------|----------|---------|-------| +| **zcatgui** | Zig | 11 | Nuestro proyecto - EN DESARROLLO | +| **DVUI** | Zig | ~20 | Unica referencia GUI Zig nativa | +| **Gio** | Go | ~25 | Immediate mode moderno, Material Design | +| **zcatui** | Zig | 35 | Nuestro proyecto hermano TUI | + +--- + +## 1. zcatgui - Estado Actual (v0.4.0) + +### Widgets Implementados (11) + +| Widget | Archivo | Estado | Descripcion | +|--------|---------|--------|-------------| +| Label | `label.zig` | OK | Texto estatico con alineacion | +| Button | `button.zig` | OK | Con importancia (primary/normal/danger) | +| TextInput | `text_input.zig` | OK | Entry de texto con cursor | +| Checkbox | `checkbox.zig` | OK | Toggle booleano | +| Select | `select.zig` | OK | Dropdown selection | +| List | `list.zig` | OK | Lista seleccionable | +| Table | `table.zig` | OK | Edicion in-situ, dirty tracking | +| Panel | `panel.zig` | OK | Container con titulo y bordes | +| Split | `split.zig` | OK | HSplit/VSplit draggable | +| Modal | `modal.zig` | OK | Dialogos modales (alert, confirm, input) | +| Focus | `focus.zig` | OK | Focus manager, tab navigation | + +--- + +## 2. DVUI - Widgets Disponibles + +Fuente: [DVUI GitHub](https://github.com/david-vanderson/dvui) + +### Widgets en DVUI + +| Widget | En zcatgui | Prioridad | Notas | +|--------|------------|-----------|-------| +| Button | OK | - | Ya implementado | +| Checkbox | OK | - | Ya implementado | +| Radio Buttons | NO | MEDIA | Falta implementar | +| Text Entry (single) | OK | - | Ya implementado | +| Text Entry (multi) | NO | ALTA | TextArea falta | +| Number Entry | NO | ALTA | Input numerico validado | +| Text Layout | NO | MEDIA | Texto con partes clickables | +| Floating Window | NO | MEDIA | Ventanas draggables | +| Menu | NO | ALTA | Menus dropdown | +| Popup/Context | OK | - | Modal implementado | +| Scroll Area | NO | ALTA | Contenido scrollable | +| Slider | NO | ALTA | Rango numerico | +| SliderEntry | NO | MEDIA | Slider + text entry combo | +| Toast | NO | BAJA | Notificaciones temporales | +| Panes (draggable) | OK | - | Split implementado | +| Dropdown | OK | - | Select implementado | +| Combo Box | NO | ALTA | Dropdown + text entry | +| Reorderable Lists | NO | MEDIA | Drag to reorder | +| Data Grid | OK | - | Table implementado | +| Tooltips | NO | MEDIA | Hover info | + +### Widgets DVUI Faltantes en zcatgui (Prioritarios) + +1. **Menu** - Critico para apps +2. **Scroll Area** - Necesario para contenido largo +3. **Slider** - Control numerico comun +4. **TextArea** - Input multilinea +5. **Number Entry** - Input con validacion numerica +6. **Combo Box** - AutoComplete (requerido por Simifactu) +7. **Radio Buttons** - Seleccion exclusiva + +--- + +## 3. Gio (Go) - Widgets Disponibles + +Fuente: [docs/research/GIO_UI_ANALYSIS.md](./GIO_UI_ANALYSIS.md) + +### Widget State (`gioui.org/widget`) + +| Widget | En zcatgui | Prioridad | Notas | +|--------|------------|-----------|-------| +| Clickable | OK | - | Button usa esto | +| Editor | OK | - | TextInput implementado | +| Selectable | NO | BAJA | Texto seleccionable | +| Float | NO | ALTA | Para sliders | +| Bool | OK | - | Checkbox | +| Enum | NO | MEDIA | Radio buttons | +| List | OK | - | List implementado | +| Scrollbar | NO | ALTA | Falta | +| Draggable | NO | MEDIA | Drag & drop | +| Decorations | NO | BAJA | Decoraciones ventana | +| Icon | NO | BAJA | Iconos vectoriales | + +### Material Widgets (`gioui.org/widget/material`) + +| Widget | En zcatgui | Prioridad | Notas | +|--------|------------|-----------|-------| +| Label, H1-H6 | PARCIAL | MEDIA | Solo Label basico | +| Button, IconButton | OK | - | Button implementado | +| Editor | OK | - | TextInput | +| CheckBox | OK | - | Checkbox | +| RadioButton | NO | MEDIA | Falta | +| Switch | NO | BAJA | Toggle estilo movil | +| Slider | NO | ALTA | Falta | +| List, Scrollbar | PARCIAL | ALTA | List OK, Scrollbar falta | +| ProgressBar | NO | MEDIA | Indicador progreso | +| ProgressCircle | NO | BAJA | Spinner circular | +| Loader | NO | BAJA | Spinner | + +### Extended Components (`gioui.org/x/component`) + +| Widget | En zcatgui | Prioridad | Notas | +|--------|------------|-----------|-------| +| AppBar | NO | MEDIA | Barra aplicacion | +| NavDrawer | NO | MEDIA | Panel navegacion | +| Menu, MenuItem | NO | ALTA | Menus | +| ContextArea | NO | MEDIA | Menu contextual | +| Grid, Table | OK | - | Table implementado | +| Sheet, Surface | NO | BAJA | Contenedores | +| TextField | OK | - | TextInput con label | +| Tooltip | NO | MEDIA | Hover info | +| Discloser | NO | MEDIA | Expandible/collapsible | +| Divider | NO | BAJA | Separador visual | +| ModalLayer, Scrim | OK | - | Modal implementado | + +### Widgets Gio Faltantes en zcatgui (Prioritarios) + +1. **Menu, MenuItem** - Navegacion aplicacion +2. **Scrollbar** - Contenido largo +3. **Slider** - Control numerico +4. **RadioButton** - Seleccion exclusiva +5. **ProgressBar** - Indicadores +6. **Tooltip** - Informacion contextual +7. **NavDrawer** - Navegacion lateral + +--- + +## 4. zcatui (TUI) - Widgets Disponibles + +Proyecto hermano: `/mnt/cello2/arno/re/recode/zig/zcatui/` + +### Todos los Widgets en zcatui (35) + +| Widget | En zcatgui | Prioridad | Descripcion | +|--------|------------|-----------|-------------| +| `paragraph.zig` | NO | BAJA | Texto con wrapping | +| `list.zig` | OK | - | Lista seleccionable | +| `gauge.zig` | NO | MEDIA | Indicador tipo gauge | +| `tabs.zig` | NO | ALTA | Tab navigation | +| `sparkline.zig` | NO | BAJA | Mini grafico linea | +| `scrollbar.zig` | NO | ALTA | Scrollbar | +| `barchart.zig` | NO | BAJA | Grafico barras | +| `canvas.zig` | NO | BAJA | Dibujo libre | +| `chart.zig` | NO | BAJA | Graficos genericos | +| `clear.zig` | NO | - | Utilidad limpieza | +| `calendar.zig` | NO | MEDIA | Selector fecha | +| `table.zig` | OK | - | Tabla | +| `input.zig` | OK | - | TextInput | +| `popup.zig` | OK | - | Modal | +| `menu.zig` | NO | ALTA | Menu | +| `tooltip.zig` | NO | MEDIA | Tooltip | +| `tree.zig` | NO | ALTA | TreeView | +| `filepicker.zig` | NO | MEDIA | Selector archivos | +| `scroll.zig` | NO | ALTA | ScrollArea | +| `textarea.zig` | NO | ALTA | Input multilinea | +| `select.zig` | OK | - | Dropdown | +| `slider.zig` | NO | ALTA | Slider | +| `panel.zig` | OK | - | Container | +| `checkbox.zig` | OK | - | Checkbox | +| `statusbar.zig` | NO | MEDIA | Barra estado | +| `block.zig` | NO | BAJA | Container basico | +| `spinner.zig` | NO | MEDIA | Indicador carga | +| `help.zig` | NO | BAJA | Panel ayuda | +| `progress.zig` | NO | MEDIA | Barra progreso | +| `markdown.zig` | NO | BAJA | Render markdown | +| `syntax.zig` | NO | BAJA | Syntax highlighting | +| `viewport.zig` | NO | MEDIA | Area scrollable | +| `logo.zig` | NO | BAJA | Logo ASCII art | +| `dirtree.zig` | NO | MEDIA | Arbol directorios | + +### Widgets zcatui Faltantes en zcatgui (Prioritarios) + +1. **Tabs** - Navegacion por pestanas +2. **Menu** - Menus dropdown +3. **Tree** - Vista arbol +4. **ScrollArea** - Contenido scrollable +5. **TextArea** - Input multilinea +6. **Slider** - Control numerico +7. **Scrollbar** - Indicador scroll +8. **Calendar** - Selector fecha +9. **ProgressBar** - Indicador progreso +10. **Spinner** - Indicador carga + +--- + +## 5. Analisis Consolidado: Widgets Faltantes + +### Prioridad CRITICA (Necesarios para MVP Simifactu) + +| Widget | DVUI | Gio | zcatui | Descripcion | +|--------|------|-----|--------|-------------| +| **Menu** | SI | SI | SI | Menus aplicacion | +| **ScrollArea** | SI | SI | SI | Contenido scrollable | +| **ComboBox/AutoComplete** | SI | NO | NO | Dropdown + typing | +| **Tabs** | NO | SI | SI | Tab navigation | + +### Prioridad ALTA + +| Widget | DVUI | Gio | zcatui | Descripcion | +|--------|------|-----|--------|-------------| +| **Slider** | SI | SI | SI | Control numerico | +| **TextArea** | SI | SI | SI | Input multilinea | +| **Tree** | NO | NO | SI | Vista jerarquica | +| **RadioButton** | SI | SI | NO | Seleccion exclusiva | +| **Scrollbar** | SI | SI | SI | Indicador scroll | +| **NumberEntry** | SI | NO | NO | Input numerico validado | + +### Prioridad MEDIA + +| Widget | DVUI | Gio | zcatui | Descripcion | +|--------|------|-----|--------|-------------| +| **Tooltip** | SI | SI | SI | Hover info | +| **ProgressBar** | NO | SI | SI | Indicador progreso | +| **Spinner** | NO | SI | SI | Indicador carga | +| **Calendar** | NO | NO | SI | Selector fecha | +| **StatusBar** | NO | NO | SI | Barra estado | +| **NavDrawer** | NO | SI | NO | Panel navegacion | + +### Prioridad BAJA + +| Widget | Razon | +|--------|-------| +| Gauge | Especifico TUI | +| Sparkline | Grafico especializado | +| BarChart | Grafico especializado | +| Canvas | Dibujo libre | +| Markdown | Render especializado | +| Syntax | Highlighting especializado | +| Logo | ASCII art | + +--- + +## 6. Roadmap de Implementacion + +### Fase Inmediata (v0.5.0) + +1. **AutoComplete/ComboBox** - Requerido por Simifactu +2. **Slider** - Control basico muy usado +3. **Scrollbar** + **ScrollArea** - Contenido largo + +### Fase Siguiente (v0.6.0) + +4. **Menu** - Navegacion aplicacion +5. **Tabs** - Navegacion por pestanas +6. **RadioButton** - Seleccion exclusiva + +### Fase Posterior (v0.7.0) + +7. **TextArea** - Input multilinea +8. **Tree** - Vista jerarquica +9. **NumberEntry** - Input numerico validado +10. **ProgressBar** + **Spinner** - Indicadores + +### Fase Final (v0.8.0) + +11. **Tooltip** - Hover info +12. **Calendar** - Selector fecha +13. **StatusBar** - Barra estado +14. **FilePicker** - Selector archivos + +--- + +## 7. Conclusiones + +### Widgets Unicos que Tenemos + +- **Macro System** - Ninguna otra libreria tiene grabacion/reproduccion de macros integrada + +### Gaps Criticos + +1. **AutoComplete/ComboBox** - DVUI lo tiene, Simifactu lo necesita +2. **Menu** - Todas las librerias maduras lo tienen +3. **ScrollArea** - Fundamental para cualquier app seria +4. **Tabs** - Navegacion standard + +### Fortalezas Actuales + +- Table con edicion y dirty tracking (mejor que DVUI) +- Modal completo (alert, confirm, input) +- Split panels funcionales +- Sistema de macros (unico) + +### Estimacion Esfuerzo + +| Fase | Widgets | Estimacion | +|------|---------|------------| +| v0.5.0 | AutoComplete, Slider, ScrollArea | 1 semana | +| v0.6.0 | Menu, Tabs, RadioButton | 1 semana | +| v0.7.0 | TextArea, Tree, NumberEntry, Progress | 1.5 semanas | +| v0.8.0 | Tooltip, Calendar, StatusBar, FilePicker | 1 semana | +| **Total** | **16 widgets** | **~4.5 semanas** | + +--- + +## Referencias + +- [DVUI GitHub](https://github.com/david-vanderson/dvui) +- [Gio UI](https://gioui.org/) +- [zcatui](../../../zcatui/) +- [Simifactu Analysis](./SIMIFACTU_FYNE_ANALYSIS.md) diff --git a/examples/table_demo.zig b/examples/table_demo.zig new file mode 100644 index 0000000..730c1a7 --- /dev/null +++ b/examples/table_demo.zig @@ -0,0 +1,348 @@ +//! Table Demo - Advanced widgets showcase +//! +//! Demonstrates: +//! - Table widget with editing and navigation +//! - Split panels (horizontal/vertical) +//! - Panel containers with titles +//! - Focus management between widgets +//! +//! Run with: zig build table-demo + +const std = @import("std"); +const zcatgui = @import("zcatgui"); + +const Context = zcatgui.Context; +const Color = zcatgui.Color; +const Layout = zcatgui.Layout; +const Command = zcatgui.Command; +const Framebuffer = zcatgui.render.Framebuffer; +const SoftwareRenderer = zcatgui.render.SoftwareRenderer; +const Sdl2Backend = zcatgui.backend.Sdl2Backend; + +const widgets = zcatgui.widgets; +const Table = widgets.Table; +const Split = widgets.Split; +const Panel = widgets.Panel; + +const print = std.debug.print; + +// Sample data for the table +const ProductData = struct { + code: []const u8, + name: []const u8, + price: []const u8, + stock: []const u8, + status: []const u8, +}; + +const sample_products = [_]ProductData{ + .{ .code = "PRD001", .name = "Widget A", .price = "29.99", .stock = "150", .status = "Active" }, + .{ .code = "PRD002", .name = "Widget B", .price = "49.99", .stock = "75", .status = "Active" }, + .{ .code = "PRD003", .name = "Gadget X", .price = "99.99", .stock = "30", .status = "Low Stock" }, + .{ .code = "PRD004", .name = "Gadget Y", .price = "149.99", .stock = "0", .status = "Out of Stock" }, + .{ .code = "PRD005", .name = "Component Z", .price = "19.99", .stock = "500", .status = "Active" }, + .{ .code = "PRD006", .name = "Assembly Kit", .price = "199.99", .stock = "25", .status = "Active" }, + .{ .code = "PRD007", .name = "Spare Part A", .price = "9.99", .stock = "1000", .status = "Active" }, + .{ .code = "PRD008", .name = "Spare Part B", .price = "14.99", .stock = "800", .status = "Active" }, + .{ .code = "PRD009", .name = "Premium Set", .price = "299.99", .stock = "10", .status = "Limited" }, + .{ .code = "PRD010", .name = "Basic Set", .price = "79.99", .stock = "200", .status = "Active" }, + .{ .code = "PRD011", .name = "Deluxe Pack", .price = "399.99", .stock = "5", .status = "Limited" }, + .{ .code = "PRD012", .name = "Starter Kit", .price = "59.99", .stock = "300", .status = "Active" }, +}; + +// Column definitions +const columns = [_]widgets.Column{ + .{ .name = "Code", .width = 80, .column_type = .text }, + .{ .name = "Name", .width = 150, .column_type = .text }, + .{ .name = "Price", .width = 80, .column_type = .number }, + .{ .name = "Stock", .width = 60, .column_type = .number }, + .{ .name = "Status", .width = 100, .column_type = .text }, +}; + +// Cell data provider function +fn getCellData(row: usize, col: usize) []const u8 { + if (row >= sample_products.len) return ""; + const product = sample_products[row]; + return switch (col) { + 0 => product.code, + 1 => product.name, + 2 => product.price, + 3 => product.stock, + 4 => product.status, + else => "", + }; +} + +pub fn main() !void { + print("=== zcatgui Table Demo ===\n\n", .{}); + print("This demo showcases advanced widgets:\n", .{}); + print("- Table with keyboard navigation and editing\n", .{}); + print("- Split panels (drag divider to resize)\n", .{}); + print("- Panel containers with title bars\n\n", .{}); + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Initialize backend + var backend = try Sdl2Backend.init("zcatgui - Table Demo", 1024, 768); + defer backend.deinit(); + + // Create framebuffer + var fb = try Framebuffer.init(allocator, 1024, 768); + defer fb.deinit(); + + // Create renderer + var renderer = SoftwareRenderer.init(&fb); + + // Create context + var ctx = Context.init(allocator, 1024, 768); + defer ctx.deinit(); + + // Widget state + var hsplit_state = widgets.SplitState{ .offset = 0.7 }; + var vsplit_state = widgets.SplitState{ .offset = 0.6 }; + + var main_panel_state = widgets.PanelState{ .focused = true }; + var info_panel_state = widgets.PanelState{}; + var log_panel_state = widgets.PanelState{}; + + var table_state = widgets.TableState{}; + table_state.row_count = sample_products.len; + + // Mark some rows as modified for demo + table_state.markModified(2); + table_state.markNew(sample_products.len - 1); + + var running = true; + var frame: u32 = 0; + + print("Starting event loop...\n", .{}); + print("Controls:\n", .{}); + print(" Arrow keys: Navigate table\n", .{}); + print(" Enter/F2: Edit cell\n", .{}); + print(" Escape: Cancel edit / Exit\n", .{}); + print(" Tab: Move between panels\n", .{}); + print(" Mouse: Click to select, drag dividers\n\n", .{}); + + while (running) { + // Poll events + while (backend.pollEvent()) |event| { + switch (event) { + .quit => running = false, + .key => |key| { + // Pass key event to context for widget keyboard handling + ctx.input.handleKeyEvent(key); + + // Handle escape at app level (quit if not editing) + if (key.key == .escape and key.pressed and !table_state.editing) { + running = false; + } + }, + .mouse => |m| { + ctx.input.setMousePos(m.x, m.y); + if (m.button) |btn| { + ctx.input.setMouseButton(btn, m.pressed); + } + }, + .resize => |size| { + try fb.resize(size.width, size.height); + ctx.resize(size.width, size.height); + }, + else => {}, + } + } + + ctx.beginFrame(); + + // Clear + renderer.clear(Color.background); + + // Main horizontal split: left (table) | right (info) + ctx.layout.row_height = @as(u32, @intCast(fb.height)); + const hsplit_result = Split.hsplitEx(&ctx, &hsplit_state, .{ + .divider_size = 6, + }); + + // Left side: Main panel with table + { + const panel_result = Panel.panelRect( + &ctx, + hsplit_result.first, + &main_panel_state, + .{ .title = "Products", .collapsible = false }, + .{}, + ); + + if (!panel_result.content.isEmpty()) { + Panel.beginPanel(&ctx, "products_panel", panel_result.content); + + // Set up layout for table + ctx.layout.area = panel_result.content; + ctx.layout.cursor_x = panel_result.content.x; + ctx.layout.cursor_y = panel_result.content.y; + ctx.layout.row_height = panel_result.content.h; + + const table_result = Table.tableEx( + &ctx, + &table_state, + &columns, + getCellData, + null, // no edit callback + .{ .row_height = 24 }, + .{}, + ); + + if (table_result.selection_changed) { + if (table_state.selectedCell()) |cell| { + print("Selected: row={}, col={}\n", .{ cell.row, cell.col }); + } + } + + if (table_result.cell_edited) { + if (table_state.selectedCell()) |cell| { + print("Edited row {} col {}: \"{s}\"\n", .{ + cell.row, + cell.col, + table_state.getEditText(), + }); + table_state.markModified(cell.row); + } + } + + Panel.endPanel(&ctx); + } + } + + // Right side: vertical split for info and log + { + const vsplit_result = Split.splitRect( + &ctx, + hsplit_result.second, + &vsplit_state, + .vertical, + .{ .divider_size = 6 }, + ); + + // Top right: Info panel + { + const info_result = Panel.panelRect( + &ctx, + vsplit_result.first, + &info_panel_state, + .{ .title = "Details", .collapsible = true }, + .{}, + ); + + if (!info_result.content.isEmpty()) { + Panel.beginPanel(&ctx, "info_panel", info_result.content); + + // Show selected product info + ctx.layout.area = info_result.content; + ctx.layout.cursor_x = info_result.content.x; + ctx.layout.cursor_y = info_result.content.y; + ctx.layout.row_height = 20; + + if (table_state.selectedCell()) |cell| { + if (cell.row < sample_products.len) { + const product = sample_products[cell.row]; + + zcatgui.labelColored(&ctx, "Selected Product:", Color.primary); + ctx.layout.row_height = 16; + + var buf: [64]u8 = undefined; + const code_text = std.fmt.bufPrint(&buf, "Code: {s}", .{product.code}) catch "Error"; + zcatgui.label(&ctx, code_text); + + const name_text = std.fmt.bufPrint(&buf, "Name: {s}", .{product.name}) catch "Error"; + zcatgui.label(&ctx, name_text); + + const price_text = std.fmt.bufPrint(&buf, "Price: ${s}", .{product.price}) catch "Error"; + zcatgui.label(&ctx, price_text); + + const stock_text = std.fmt.bufPrint(&buf, "Stock: {s} units", .{product.stock}) catch "Error"; + zcatgui.label(&ctx, stock_text); + + const status_text = std.fmt.bufPrint(&buf, "Status: {s}", .{product.status}) catch "Error"; + zcatgui.label(&ctx, status_text); + } + } else { + zcatgui.labelColored(&ctx, "No product selected", Color.secondary); + } + + Panel.endPanel(&ctx); + } + } + + // Bottom right: Log panel + { + const log_result = Panel.panelRect( + &ctx, + vsplit_result.second, + &log_panel_state, + .{ .title = "Activity Log", .collapsible = true }, + .{}, + ); + + if (!log_result.content.isEmpty()) { + Panel.beginPanel(&ctx, "log_panel", log_result.content); + + ctx.layout.area = log_result.content; + ctx.layout.cursor_x = log_result.content.x; + ctx.layout.cursor_y = log_result.content.y; + ctx.layout.row_height = 14; + + // Show some log entries + zcatgui.labelColored(&ctx, "Recent Activity:", Color.secondary); + + var frame_buf: [64]u8 = undefined; + const frame_text = std.fmt.bufPrint(&frame_buf, "Frame: {}", .{frame}) catch "Error"; + zcatgui.label(&ctx, frame_text); + + const commands_text = std.fmt.bufPrint(&frame_buf, "Commands: {}", .{ctx.commands.items.len}) catch "Error"; + zcatgui.label(&ctx, commands_text); + + const dirty_count = blk: { + var count: usize = 0; + for (0..sample_products.len) |i| { + if (table_state.row_states[i] != .clean) count += 1; + } + break :blk count; + }; + const dirty_text = std.fmt.bufPrint(&frame_buf, "Modified rows: {}", .{dirty_count}) catch "Error"; + zcatgui.label(&ctx, dirty_text); + + if (table_state.editing) { + zcatgui.labelColored(&ctx, "Currently editing...", Color.warning); + } + + Panel.endPanel(&ctx); + } + } + } + + // Execute all draw commands + for (ctx.commands.items) |cmd| { + renderer.execute(cmd); + } + + ctx.endFrame(); + + // Present + backend.present(&fb); + + frame += 1; + + // Cap at ~60 FPS + std.Thread.sleep(16 * std.time.ns_per_ms); + } + + print("\n=== Demo Complete ===\n", .{}); + print("Final state:\n", .{}); + if (table_state.selectedCell()) |cell| { + print(" Selected: row={}, col={}\n", .{ cell.row, cell.col }); + } else { + print(" No selection\n", .{}); + } + print(" Frames rendered: {}\n", .{frame}); +} diff --git a/examples/widgets_demo.zig b/examples/widgets_demo.zig new file mode 100644 index 0000000..b1afe19 --- /dev/null +++ b/examples/widgets_demo.zig @@ -0,0 +1,177 @@ +//! Widget Demo - Showcase all zcatgui widgets +//! +//! This example demonstrates all available widgets: +//! - Label (static text) +//! - Button (clickable) +//! - TextInput (editable text) +//! - Checkbox (boolean toggle) +//! - Select (dropdown) +//! - List (scrollable selection) +//! +//! Run with: zig build widgets-demo + +const std = @import("std"); +const zcatgui = @import("zcatgui"); + +const Context = zcatgui.Context; +const Color = zcatgui.Color; +const Layout = zcatgui.Layout; +const Command = zcatgui.Command; + +const print = std.debug.print; + +pub fn main() !void { + print("=== zcatgui Widget Demo ===\n\n", .{}); + print("This demo shows all available widgets.\n", .{}); + print("In a real application, this would open a window.\n\n", .{}); + + // Create context + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var ctx = Context.init(allocator, 800, 600); + defer ctx.deinit(); + + // Widget state + var name_buf: [64]u8 = undefined; + var name_input = zcatgui.TextInputState.init(&name_buf); + name_input.setText("Hello World"); + + var email_buf: [128]u8 = undefined; + var email_input = zcatgui.TextInputState.init(&email_buf); + + var remember_me = true; + var newsletter = false; + + var country_select = zcatgui.SelectState{}; + const countries = [_][]const u8{ "Spain", "France", "Germany", "Italy", "Portugal" }; + + var file_list = zcatgui.ListState{}; + const files = [_][]const u8{ + "document.pdf", + "image.png", + "video.mp4", + "music.mp3", + "archive.zip", + "notes.txt", + "config.json", + "data.csv", + }; + + var click_count: u32 = 0; + + // Simulate a few frames + print("Simulating 3 frames of UI rendering...\n\n", .{}); + + for (0..3) |frame| { + ctx.beginFrame(); + + // Set up layout + ctx.layout.row_height = 24; + + print("--- Frame {} ---\n", .{frame + 1}); + + // Title + zcatgui.labelEx(&ctx, "Widget Demo", .{ + .color = Color.primary, + .alignment = .center, + }); + + // Spacing + ctx.layout.row_height = 8; + _ = ctx.layout.nextRect(); // Empty row for spacing + + ctx.layout.row_height = 24; + + // Name input + zcatgui.label(&ctx, "Name:"); + _ = zcatgui.textInput(&ctx, &name_input); + + // Email input + zcatgui.label(&ctx, "Email:"); + _ = zcatgui.textInputEx(&ctx, &email_input, .{ + .placeholder = "user@example.com", + }); + + // Checkboxes + ctx.layout.row_height = 20; + _ = zcatgui.checkbox(&ctx, &remember_me, "Remember me"); + _ = zcatgui.checkbox(&ctx, &newsletter, "Subscribe to newsletter"); + + // Country select + ctx.layout.row_height = 30; + zcatgui.label(&ctx, "Country:"); + _ = zcatgui.select(&ctx, &country_select, &countries); + + // Buttons + ctx.layout.row_height = 32; + + // Simulate click on frame 2 + if (frame == 1) { + ctx.input.setMousePos(100, 250); + ctx.input.setMouseButton(.left, true); + } else if (frame == 2) { + ctx.input.setMouseButton(.left, false); + } + + if (zcatgui.buttonPrimary(&ctx, "Submit")) { + click_count += 1; + print(" -> Button clicked! Count: {}\n", .{click_count}); + } + + if (zcatgui.buttonDanger(&ctx, "Cancel")) { + print(" -> Cancel clicked!\n", .{}); + } + + // File list + ctx.layout.row_height = 150; + zcatgui.label(&ctx, "Files:"); + _ = zcatgui.list(&ctx, &file_list, &files); + + // Status + ctx.layout.row_height = 20; + var status_buf: [128]u8 = undefined; + const status = std.fmt.bufPrint(&status_buf, "Commands: {} | Clicks: {}", .{ + ctx.commands.items.len, + click_count, + }) catch "Error"; + zcatgui.labelColored(&ctx, status, Color.secondary); + + ctx.endFrame(); + + print(" Generated {} draw commands\n", .{ctx.commands.items.len}); + + // Print some command details + var rect_count: usize = 0; + var text_count: usize = 0; + var line_count: usize = 0; + + for (ctx.commands.items) |cmd| { + switch (cmd) { + .rect => rect_count += 1, + .text => text_count += 1, + .line => line_count += 1, + else => {}, + } + } + + print(" Rects: {}, Text: {}, Lines: {}\n", .{ rect_count, text_count, line_count }); + } + + print("\n", .{}); + print("Widget state after 3 frames:\n", .{}); + print(" Name: \"{s}\"\n", .{name_input.text()}); + print(" Email: \"{s}\"\n", .{email_input.text()}); + print(" Remember me: {}\n", .{remember_me}); + print(" Newsletter: {}\n", .{newsletter}); + print(" Country: {s}\n", .{ + if (zcatgui.widgets.select.getSelectedText(country_select, &countries)) |c| c else "(none)", + }); + print(" Selected file: {s}\n", .{ + if (zcatgui.widgets.list.getSelectedText(file_list, &files)) |f| f else "(none)", + }); + print(" Click count: {}\n", .{click_count}); + + print("\n=== Demo Complete ===\n", .{}); +} diff --git a/src/core/input.zig b/src/core/input.zig index 5306b58..156579e 100644 --- a/src/core/input.zig +++ b/src/core/input.zig @@ -160,6 +160,12 @@ pub const MouseEvent = struct { scroll_y: i32 = 0, }; +/// Maximum number of keys we track +const MAX_KEYS: usize = 128; + +/// Maximum key events per frame +const MAX_KEY_EVENTS: usize = 16; + /// Current input state pub const InputState = struct { // Mouse position @@ -183,6 +189,16 @@ pub const InputState = struct { text_input: [64]u8 = undefined, text_input_len: usize = 0, + // Keyboard state (current frame) + keys_down: [MAX_KEYS]bool = [_]bool{false} ** MAX_KEYS, + + // Keyboard state (previous frame) + keys_down_prev: [MAX_KEYS]bool = [_]bool{false} ** MAX_KEYS, + + // Key events this frame (for widgets that need event-based input) + key_events: [MAX_KEY_EVENTS]KeyEvent = undefined, + key_event_count: usize = 0, + const Self = @This(); /// Initialize input state @@ -193,9 +209,11 @@ pub const InputState = struct { /// Call at end of frame to prepare for next pub fn endFrame(self: *Self) void { self.mouse_down_prev = self.mouse_down; + self.keys_down_prev = self.keys_down; self.scroll_x = 0; self.scroll_y = 0; self.text_input_len = 0; + self.key_event_count = 0; } /// Update mouse position @@ -220,6 +238,53 @@ pub const InputState = struct { self.modifiers = mods; } + /// Handle a key event from the backend + pub fn handleKeyEvent(self: *Self, event: KeyEvent) void { + // Update key state + const key_idx = @intFromEnum(event.key); + if (key_idx < MAX_KEYS) { + self.keys_down[key_idx] = event.pressed; + } + + // Update modifiers + self.modifiers = event.modifiers; + + // Store event for widgets that need event-based input + if (self.key_event_count < MAX_KEY_EVENTS) { + self.key_events[self.key_event_count] = event; + self.key_event_count += 1; + } + + // If it's a printable character being pressed, add to text input + if (event.pressed) { + if (event.char) |c| { + if (c >= 32 and c < 127) { + // ASCII printable + if (self.text_input_len < self.text_input.len) { + self.text_input[self.text_input_len] = @intCast(c); + self.text_input_len += 1; + } + } else if (c >= 127) { + // Unicode - encode as UTF-8 + var buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(c, &buf) catch return; + const remaining = self.text_input.len - self.text_input_len; + const to_copy = @min(len, remaining); + @memcpy(self.text_input[self.text_input_len..][0..to_copy], buf[0..to_copy]); + self.text_input_len += to_copy; + } + } + } + } + + /// Set key state directly (useful for testing) + pub fn setKeyState(self: *Self, key: Key, pressed: bool) void { + const key_idx = @intFromEnum(key); + if (key_idx < MAX_KEYS) { + self.keys_down[key_idx] = pressed; + } + } + /// Add text input pub fn addTextInput(self: *Self, text: []const u8) void { const remaining = self.text_input.len - self.text_input_len; @@ -258,6 +323,52 @@ pub const InputState = struct { pub fn getTextInput(self: Self) []const u8 { return self.text_input[0..self.text_input_len]; } + + // ========================================================================= + // Keyboard query functions + // ========================================================================= + + /// Check if a key is currently held down + pub fn keyDown(self: Self, key: Key) bool { + const key_idx = @intFromEnum(key); + if (key_idx >= MAX_KEYS) return false; + return self.keys_down[key_idx]; + } + + /// Check if a key was just pressed this frame + pub fn keyPressed(self: Self, key: Key) bool { + const key_idx = @intFromEnum(key); + if (key_idx >= MAX_KEYS) return false; + return self.keys_down[key_idx] and !self.keys_down_prev[key_idx]; + } + + /// Check if a key was just released this frame + pub fn keyReleased(self: Self, key: Key) bool { + const key_idx = @intFromEnum(key); + if (key_idx >= MAX_KEYS) return false; + return !self.keys_down[key_idx] and self.keys_down_prev[key_idx]; + } + + /// Get all key events this frame + pub fn getKeyEvents(self: Self) []const KeyEvent { + return self.key_events[0..self.key_event_count]; + } + + /// Check if any navigation key was pressed + pub fn navKeyPressed(self: Self) ?Key { + if (self.keyPressed(.up)) return .up; + if (self.keyPressed(.down)) return .down; + if (self.keyPressed(.left)) return .left; + if (self.keyPressed(.right)) return .right; + if (self.keyPressed(.home)) return .home; + if (self.keyPressed(.end)) return .end; + if (self.keyPressed(.page_up)) return .page_up; + if (self.keyPressed(.page_down)) return .page_down; + if (self.keyPressed(.tab)) return .tab; + if (self.keyPressed(.enter)) return .enter; + if (self.keyPressed(.escape)) return .escape; + return null; + } }; // ============================================================================= @@ -297,3 +408,66 @@ test "KeyEvent char" { try std.testing.expect(slice != null); try std.testing.expectEqualStrings("A", slice.?); } + +test "InputState keyboard" { + var input = InputState.init(); + + // Test keyPressed + input.setKeyState(.up, true); + try std.testing.expect(input.keyDown(.up)); + try std.testing.expect(input.keyPressed(.up)); + + input.endFrame(); + try std.testing.expect(input.keyDown(.up)); + try std.testing.expect(!input.keyPressed(.up)); // Not pressed anymore, just held + + // Test keyReleased + input.setKeyState(.up, false); + try std.testing.expect(!input.keyDown(.up)); + try std.testing.expect(input.keyReleased(.up)); + + input.endFrame(); + try std.testing.expect(!input.keyReleased(.up)); +} + +test "InputState handleKeyEvent" { + var input = InputState.init(); + + const event = KeyEvent{ + .key = .a, + .modifiers = .{ .shift = true }, + .char = 'A', + .pressed = true, + }; + + input.handleKeyEvent(event); + + // Key state updated + try std.testing.expect(input.keyDown(.a)); + try std.testing.expect(input.keyPressed(.a)); + + // Modifiers updated + try std.testing.expect(input.modifiers.shift); + + // Event stored + try std.testing.expectEqual(@as(usize, 1), input.key_event_count); + try std.testing.expectEqual(Key.a, input.key_events[0].key); + + // Text input updated + try std.testing.expectEqualStrings("A", input.getTextInput()); +} + +test "InputState navKeyPressed" { + var input = InputState.init(); + + try std.testing.expect(input.navKeyPressed() == null); + + input.setKeyState(.down, true); + try std.testing.expect(input.navKeyPressed() == .down); + + input.endFrame(); + try std.testing.expect(input.navKeyPressed() == null); // Not pressed, just held + + input.setKeyState(.enter, true); + try std.testing.expect(input.navKeyPressed() == .enter); +} diff --git a/src/widgets/autocomplete.zig b/src/widgets/autocomplete.zig new file mode 100644 index 0000000..c17cfb7 --- /dev/null +++ b/src/widgets/autocomplete.zig @@ -0,0 +1,747 @@ +//! AutoComplete/ComboBox Widget - Dropdown with text filtering +//! +//! Combines a text input with a dropdown list for: +//! - Type-ahead filtering of options +//! - Free-form text entry (optional) +//! - Used for provinces, countries, IVA types, etc. +//! +//! Similar to Simifactu's autocomplete fields. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); + +// ============================================================================= +// AutoComplete State +// ============================================================================= + +/// AutoComplete state (caller-managed) +pub const AutoCompleteState = struct { + /// Internal text buffer + buffer: [256]u8 = [_]u8{0} ** 256, + /// Text length + len: usize = 0, + /// Cursor position + cursor: usize = 0, + /// Currently selected index in filtered list (-1 for none) + selected: i32 = -1, + /// Whether dropdown is open + open: bool = false, + /// Highlighted item in dropdown (for keyboard navigation) + highlighted: i32 = -1, + /// Scroll offset in dropdown + scroll_offset: usize = 0, + /// Last filter text (for change detection) + last_filter: [256]u8 = [_]u8{0} ** 256, + last_filter_len: usize = 0, + + const Self = @This(); + + /// Initialize state + pub fn init() Self { + return .{}; + } + + /// Get current input text + pub fn text(self: *const Self) []const u8 { + return self.buffer[0..self.len]; + } + + /// Set text programmatically + pub fn setText(self: *Self, new_text: []const u8) void { + const copy_len = @min(new_text.len, self.buffer.len); + @memcpy(self.buffer[0..copy_len], new_text[0..copy_len]); + self.len = copy_len; + self.cursor = copy_len; + } + + /// Clear the input + pub fn clear(self: *Self) void { + self.len = 0; + self.cursor = 0; + self.selected = -1; + self.highlighted = -1; + self.open = false; + } + + /// Insert a single character at cursor + pub fn insertChar(self: *Self, c: u8) void { + if (self.len >= self.buffer.len) return; + + // Move text after cursor + if (self.cursor < self.len) { + std.mem.copyBackwards( + u8, + self.buffer[self.cursor + 1 .. self.len + 1], + self.buffer[self.cursor..self.len], + ); + } + + self.buffer[self.cursor] = c; + self.len += 1; + self.cursor += 1; + } + + /// Delete character before cursor (backspace) + pub fn backspace(self: *Self) void { + if (self.cursor == 0) return; + + // Move text after cursor back + if (self.cursor < self.len) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor - 1 .. self.len - 1], + self.buffer[self.cursor..self.len], + ); + } + + self.cursor -= 1; + self.len -= 1; + } + + /// Delete character at cursor (delete key) + pub fn delete(self: *Self) void { + if (self.cursor >= self.len) return; + + // Move text after cursor back + if (self.cursor + 1 < self.len) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor .. self.len - 1], + self.buffer[self.cursor + 1 .. self.len], + ); + } + + self.len -= 1; + } + + /// Move cursor + pub fn moveCursor(self: *Self, delta: i32) void { + if (delta < 0) { + const abs: usize = @intCast(-delta); + if (abs > self.cursor) { + self.cursor = 0; + } else { + self.cursor -= abs; + } + } else { + const abs: usize = @intCast(delta); + self.cursor = @min(self.cursor + abs, self.len); + } + } + + /// Open the dropdown + pub fn openDropdown(self: *Self) void { + self.open = true; + self.highlighted = if (self.selected >= 0) self.selected else 0; + } + + /// Close the dropdown + pub fn closeDropdown(self: *Self) void { + self.open = false; + self.highlighted = -1; + } +}; + +// ============================================================================= +// AutoComplete Configuration +// ============================================================================= + +/// Match mode for filtering +pub const MatchMode = enum { + /// Match if option starts with filter text + prefix, + /// Match if option contains filter text anywhere + contains, + /// Match using fuzzy matching (characters in order) + fuzzy, +}; + +/// AutoComplete configuration +pub const AutoCompleteConfig = struct { + /// Placeholder text when empty + placeholder: []const u8 = "Type to search...", + /// Disabled state + disabled: bool = false, + /// Maximum visible items in dropdown + max_visible_items: usize = 8, + /// Height of each item + item_height: u32 = 24, + /// Padding + padding: u32 = 4, + /// Match mode for filtering + match_mode: MatchMode = .contains, + /// Case sensitive matching + case_sensitive: bool = false, + /// Allow free-form text (not just from options) + allow_custom: bool = false, + /// Minimum characters before showing suggestions + min_chars: usize = 0, + /// Show dropdown on focus (even if empty) + show_on_focus: bool = true, +}; + +/// AutoComplete colors +pub const AutoCompleteColors = struct { + /// Input background + input_bg: Style.Color = Style.Color.rgb(35, 35, 40), + /// Input border + input_border: Style.Color = Style.Color.rgb(80, 80, 85), + /// Input border when focused + input_border_focus: Style.Color = Style.Color.primary, + /// Dropdown background + dropdown_bg: Style.Color = Style.Color.rgb(45, 45, 50), + /// Highlighted item background + highlight_bg: Style.Color = Style.Color.rgb(60, 60, 70), + /// Selected item background + selected_bg: Style.Color = Style.Color.rgb(70, 100, 140), + /// Match highlight color (for showing matching part) + match_fg: Style.Color = Style.Color.primary, +}; + +/// AutoComplete result +pub const AutoCompleteResult = struct { + /// Selection changed this frame (from dropdown) + selection_changed: bool = false, + /// Newly selected index (valid if selection_changed) + new_index: ?usize = null, + /// Selected text (valid if selection_changed) + selected_text: ?[]const u8 = null, + /// Text was submitted (Enter pressed with valid selection or custom allowed) + submitted: bool = false, + /// Submitted text + submitted_text: ?[]const u8 = null, + /// Text changed (user typed) + text_changed: bool = false, +}; + +// ============================================================================= +// AutoComplete Functions +// ============================================================================= + +/// Draw an autocomplete widget +pub fn autocomplete( + ctx: *Context, + state: *AutoCompleteState, + options: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, state, options, .{}, .{}); +} + +/// Draw an autocomplete widget with custom configuration +pub fn autocompleteEx( + ctx: *Context, + state: *AutoCompleteState, + options: []const []const u8, + config: AutoCompleteConfig, + colors: AutoCompleteColors, +) AutoCompleteResult { + const bounds = ctx.layout.nextRect(); + return autocompleteRect(ctx, bounds, state, options, config, colors); +} + +/// Draw an autocomplete widget in a specific rectangle +pub fn autocompleteRect( + ctx: *Context, + bounds: Layout.Rect, + state: *AutoCompleteState, + options: []const []const u8, + config: AutoCompleteConfig, + colors: AutoCompleteColors, +) AutoCompleteResult { + var result = AutoCompleteResult{}; + + if (bounds.isEmpty()) return result; + + const mouse = ctx.input.mousePos(); + const input_hovered = bounds.contains(mouse.x, mouse.y); + const input_clicked = input_hovered and ctx.input.mousePressed(.left); + + // Determine if we should be focused (simple focus tracking) + var is_focused = state.open; + if (input_clicked and !config.disabled) { + is_focused = true; + if (config.show_on_focus) { + state.openDropdown(); + } + } + + // Draw input field background + const border_color = if (is_focused and !config.disabled) + colors.input_border_focus + else + colors.input_border; + + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.input_bg)); + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Get current filter text + const filter_text = state.text(); + + // Check if text changed + const text_changed = !std.mem.eql(u8, filter_text, state.last_filter[0..state.last_filter_len]); + if (text_changed) { + result.text_changed = true; + // Update last filter + const copy_len = @min(filter_text.len, state.last_filter.len); + @memcpy(state.last_filter[0..copy_len], filter_text[0..copy_len]); + state.last_filter_len = copy_len; + // Reset selection when text changes + state.highlighted = 0; + state.scroll_offset = 0; + // Open dropdown when typing + if (filter_text.len >= config.min_chars) { + state.open = true; + } + } + + // Draw input text or placeholder + const inner = bounds.shrink(config.padding); + const char_height: u32 = 8; + const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); + + if (filter_text.len > 0) { + const text_color = if (config.disabled) + Style.Color.rgb(120, 120, 120) + else + Style.Color.rgb(220, 220, 220); + ctx.pushCommand(Command.text(inner.x, text_y, filter_text, text_color)); + } else if (config.placeholder.len > 0) { + ctx.pushCommand(Command.text(inner.x, text_y, config.placeholder, Style.Color.rgb(100, 100, 100))); + } + + // Draw cursor if focused + if (is_focused and !config.disabled) { + const cursor_x = inner.x + @as(i32, @intCast(state.cursor * 8)); + ctx.pushCommand(Command.rect(cursor_x, text_y, 2, char_height, Style.Color.rgb(200, 200, 200))); + } + + // Draw dropdown arrow + const arrow_size: u32 = 8; + const arrow_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.padding + arrow_size + 2)); + const arrow_y = bounds.y + @as(i32, @intCast((bounds.h -| arrow_size) / 2)); + const arrow_color = if (config.disabled) Style.Color.rgb(80, 80, 80) else Style.Color.rgb(160, 160, 160); + + ctx.pushCommand(Command.line( + arrow_x, + arrow_y, + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_color, + )); + ctx.pushCommand(Command.line( + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_x + @as(i32, @intCast(arrow_size)), + arrow_y, + arrow_color, + )); + + // Filter options + var filtered_indices: [256]usize = undefined; + var filtered_count: usize = 0; + + for (options, 0..) |opt, i| { + if (filtered_count >= filtered_indices.len) break; + if (matchesFilter(opt, filter_text, config.match_mode, config.case_sensitive)) { + filtered_indices[filtered_count] = i; + filtered_count += 1; + } + } + + // Handle keyboard input when focused + if (is_focused and !config.disabled) { + // Handle text input + for (ctx.input.getKeyEvents()) |event| { + if (!event.pressed) continue; + + switch (event.key) { + .escape => { + state.closeDropdown(); + }, + .enter => { + if (state.open and state.highlighted >= 0 and state.highlighted < @as(i32, @intCast(filtered_count))) { + const idx = filtered_indices[@intCast(state.highlighted)]; + state.selected = @intCast(idx); + state.setText(options[idx]); + state.closeDropdown(); + result.selection_changed = true; + result.new_index = idx; + result.selected_text = options[idx]; + result.submitted = true; + result.submitted_text = options[idx]; + } else if (config.allow_custom and filter_text.len > 0) { + result.submitted = true; + result.submitted_text = filter_text; + state.closeDropdown(); + } + }, + .up => { + if (state.open) { + if (state.highlighted > 0) { + state.highlighted -= 1; + // Scroll if needed + if (state.highlighted < @as(i32, @intCast(state.scroll_offset))) { + state.scroll_offset = @intCast(state.highlighted); + } + } + } else { + state.openDropdown(); + } + }, + .down => { + if (state.open) { + if (state.highlighted < @as(i32, @intCast(filtered_count)) - 1) { + state.highlighted += 1; + // Scroll if needed + const max_visible: i32 = @intCast(config.max_visible_items); + if (state.highlighted >= @as(i32, @intCast(state.scroll_offset)) + max_visible) { + state.scroll_offset = @intCast(state.highlighted - max_visible + 1); + } + } + } else { + state.openDropdown(); + } + }, + .tab => { + // Accept current highlight on Tab + if (state.open and state.highlighted >= 0 and state.highlighted < @as(i32, @intCast(filtered_count))) { + const idx = filtered_indices[@intCast(state.highlighted)]; + state.selected = @intCast(idx); + state.setText(options[idx]); + state.closeDropdown(); + result.selection_changed = true; + result.new_index = idx; + result.selected_text = options[idx]; + } + }, + .backspace => { + state.backspace(); + }, + .delete => { + state.delete(); + }, + .left => { + if (!state.open) { + state.moveCursor(-1); + } + }, + .right => { + if (!state.open) { + state.moveCursor(1); + } + }, + .home => { + state.cursor = 0; + }, + .end => { + state.cursor = state.len; + }, + else => { + // Handle text input + if (event.char) |c| { + if (c >= 32 and c < 127) { + state.insertChar(@intCast(c)); + } + } + }, + } + } + } + + // Draw dropdown if open and has items + if (state.open and filtered_count > 0) { + const visible_items = @min(filtered_count, config.max_visible_items); + const dropdown_h = visible_items * config.item_height; + const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); + + // Dropdown background + ctx.pushCommand(Command.rect( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + colors.dropdown_bg, + )); + + ctx.pushCommand(Command.rectOutline( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + colors.input_border, + )); + + // Draw visible items + var item_y = dropdown_y; + const start = state.scroll_offset; + const end = @min(start + visible_items, filtered_count); + + for (start..end) |fi| { + const i = filtered_indices[fi]; + const item_bounds = Layout.Rect.init( + bounds.x, + item_y, + bounds.w, + config.item_height, + ); + + const item_hovered = item_bounds.contains(mouse.x, mouse.y); + const item_clicked = item_hovered and ctx.input.mousePressed(.left); + const is_highlighted = state.highlighted == @as(i32, @intCast(fi)); + const is_selected = state.selected == @as(i32, @intCast(i)); + + // Update highlight on hover + if (item_hovered) { + state.highlighted = @intCast(fi); + } + + // Item background + const item_bg = if (is_highlighted) + colors.highlight_bg + else if (is_selected) + colors.selected_bg + else + Style.Color.transparent; + + if (item_bg.a > 0) { + ctx.pushCommand(Command.rect( + item_bounds.x + 1, + item_bounds.y, + item_bounds.w - 2, + item_bounds.h, + item_bg, + )); + } + + // Item text + const item_inner = item_bounds.shrink(config.padding); + const item_text_y = item_inner.y + @as(i32, @intCast((item_inner.h -| char_height) / 2)); + + ctx.pushCommand(Command.text(item_inner.x, item_text_y, options[i], Style.Color.rgb(220, 220, 220))); + + // Handle click selection + if (item_clicked) { + state.selected = @intCast(i); + state.setText(options[i]); + state.closeDropdown(); + result.selection_changed = true; + result.new_index = i; + result.selected_text = options[i]; + } + + item_y += @as(i32, @intCast(config.item_height)); + } + + // Close dropdown if clicked outside + if (ctx.input.mousePressed(.left) and !input_hovered) { + const dropdown_bounds = Layout.Rect.init( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + ); + if (!dropdown_bounds.contains(mouse.x, mouse.y)) { + state.closeDropdown(); + } + } + } else if (state.open and filtered_count == 0 and filter_text.len > 0) { + // Show "no matches" message + const no_match_h: u32 = config.item_height; + const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); + + ctx.pushCommand(Command.rect( + bounds.x, + dropdown_y, + bounds.w, + no_match_h, + colors.dropdown_bg, + )); + + ctx.pushCommand(Command.rectOutline( + bounds.x, + dropdown_y, + bounds.w, + no_match_h, + colors.input_border, + )); + + const no_match_text = if (config.allow_custom) "Press Enter to use custom value" else "No matches found"; + const msg_y = dropdown_y + @as(i32, @intCast((no_match_h -| char_height) / 2)); + ctx.pushCommand(Command.text(bounds.x + @as(i32, @intCast(config.padding)), msg_y, no_match_text, Style.Color.rgb(120, 120, 120))); + + // Close if clicked outside + if (ctx.input.mousePressed(.left) and !input_hovered) { + const dropdown_bounds = Layout.Rect.init(bounds.x, dropdown_y, bounds.w, no_match_h); + if (!dropdown_bounds.contains(mouse.x, mouse.y)) { + state.closeDropdown(); + } + } + } + + return result; +} + +// ============================================================================= +// Filtering Helpers +// ============================================================================= + +/// Check if option matches filter +fn matchesFilter(option: []const u8, filter: []const u8, mode: MatchMode, case_sensitive: bool) bool { + if (filter.len == 0) return true; + + return switch (mode) { + .prefix => matchesPrefix(option, filter, case_sensitive), + .contains => matchesContains(option, filter, case_sensitive), + .fuzzy => matchesFuzzy(option, filter, case_sensitive), + }; +} + +fn matchesPrefix(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + if (filter.len > option.len) return false; + + if (case_sensitive) { + return std.mem.startsWith(u8, option, filter); + } else { + for (0..filter.len) |i| { + if (std.ascii.toLower(option[i]) != std.ascii.toLower(filter[i])) { + return false; + } + } + return true; + } +} + +fn matchesContains(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + if (filter.len > option.len) return false; + + if (case_sensitive) { + return std.mem.indexOf(u8, option, filter) != null; + } else { + // Case insensitive contains + const option_len = option.len; + const filter_len = filter.len; + + var i: usize = 0; + while (i + filter_len <= option_len) : (i += 1) { + var matches = true; + for (0..filter_len) |j| { + if (std.ascii.toLower(option[i + j]) != std.ascii.toLower(filter[j])) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; + } +} + +fn matchesFuzzy(option: []const u8, filter: []const u8, case_sensitive: bool) bool { + // Fuzzy: each filter char must appear in order + var filter_idx: usize = 0; + + for (option) |c| { + if (filter_idx >= filter.len) break; + + const fc = filter[filter_idx]; + const matches = if (case_sensitive) + c == fc + else + std.ascii.toLower(c) == std.ascii.toLower(fc); + + if (matches) { + filter_idx += 1; + } + } + + return filter_idx >= filter.len; +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/// Create a province autocomplete (common use case) +pub fn provinceAutocomplete( + ctx: *Context, + state: *AutoCompleteState, + provinces: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, state, provinces, .{ + .placeholder = "Select province...", + .match_mode = .contains, + .case_sensitive = false, + .min_chars = 0, + }, .{}); +} + +/// Create a country autocomplete +pub fn countryAutocomplete( + ctx: *Context, + state: *AutoCompleteState, + countries: []const []const u8, +) AutoCompleteResult { + return autocompleteEx(ctx, state, countries, .{ + .placeholder = "Select country...", + .match_mode = .prefix, + .case_sensitive = false, + .min_chars = 1, + }, .{}); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "AutoCompleteState init" { + var state = AutoCompleteState.init(); + try std.testing.expectEqual(@as(usize, 0), state.text().len); + try std.testing.expect(!state.open); + try std.testing.expectEqual(@as(i32, -1), state.selected); +} + +test "AutoCompleteState setText" { + var state = AutoCompleteState.init(); + state.setText("Madrid"); + try std.testing.expectEqualStrings("Madrid", state.text()); +} + +test "matchesFilter prefix" { + try std.testing.expect(matchesPrefix("Madrid", "Mad", false)); + try std.testing.expect(matchesPrefix("Madrid", "mad", false)); + try std.testing.expect(!matchesPrefix("Barcelona", "Mad", false)); + try std.testing.expect(!matchesPrefix("Madrid", "Mad", true) == false); // case sensitive, exact match +} + +test "matchesFilter contains" { + try std.testing.expect(matchesContains("Madrid", "dri", false)); + try std.testing.expect(matchesContains("Madrid", "DRI", false)); + try std.testing.expect(!matchesContains("Barcelona", "dri", false)); +} + +test "matchesFilter fuzzy" { + try std.testing.expect(matchesFuzzy("Madrid", "mrd", false)); // m-a-d-r-i-d contains m, r, d in order + try std.testing.expect(matchesFuzzy("Barcelona", "bcn", false)); // b-a-r-c-e-l-o-n-a contains b, c, n in order + try std.testing.expect(!matchesFuzzy("Madrid", "xyz", false)); +} + +test "autocomplete generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = AutoCompleteState.init(); + const options = [_][]const u8{ "Madrid", "Barcelona", "Valencia" }; + + ctx.beginFrame(); + ctx.layout.row_height = 30; + + _ = autocomplete(&ctx, &state, &options); + + // Should generate: rect (bg) + rect_outline (border) + text (placeholder) + 2 lines (arrow) + try std.testing.expect(ctx.commands.items.len >= 4); + + ctx.endFrame(); +} diff --git a/src/widgets/button.zig b/src/widgets/button.zig new file mode 100644 index 0000000..a4107c6 --- /dev/null +++ b/src/widgets/button.zig @@ -0,0 +1,179 @@ +//! Button Widget - Clickable button +//! +//! An immediate mode button that returns true when clicked. +//! Supports hover/active states and keyboard activation. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Button importance level +pub const Importance = enum { + normal, + primary, + danger, +}; + +/// Button configuration +pub const ButtonConfig = struct { + /// Background color (overrides theme) + bg: ?Style.Color = null, + /// Foreground/text color (overrides theme) + fg: ?Style.Color = null, + /// Importance level + importance: Importance = .normal, + /// Disabled state + disabled: bool = false, + /// Padding around text + padding: u32 = 8, +}; + +/// Draw a button and return true if clicked +pub fn button(ctx: *Context, text: []const u8) bool { + return buttonEx(ctx, text, .{}); +} + +/// Draw a button with custom configuration +pub fn buttonEx(ctx: *Context, text: []const u8, config: ButtonConfig) bool { + const bounds = ctx.layout.nextRect(); + return buttonRect(ctx, bounds, text, config); +} + +/// Draw a button in a specific rectangle +pub fn buttonRect(ctx: *Context, bounds: Layout.Rect, text: []const u8, config: ButtonConfig) bool { + if (bounds.isEmpty()) return false; + + const id = ctx.getId(text); + _ = id; // For future focus management + + // Check mouse interaction + const mouse = ctx.input.mousePos(); + const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled; + const pressed = hovered and ctx.input.mouseDown(.left); + const clicked = hovered and ctx.input.mouseReleased(.left); + + // Determine colors based on state + const theme = Style.Theme.dark; + + const base_bg = config.bg orelse switch (config.importance) { + .normal => theme.button_bg, + .primary => theme.primary, + .danger => theme.danger, + }; + + const bg_color = if (config.disabled) + base_bg.darken(30) + else if (pressed) + base_bg.darken(20) + else if (hovered) + base_bg.lighten(10) + else + base_bg; + + const fg_color = config.fg orelse if (config.disabled) + theme.button_fg.darken(40) + else + theme.button_fg; + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); + + // Draw border + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border)); + + // Draw text centered + const char_width: u32 = 8; + const char_height: u32 = 8; + const text_width = @as(u32, @intCast(text.len)) * char_width; + const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)); + const text_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2)); + + ctx.pushCommand(Command.text(text_x, text_y, text, fg_color)); + + return clicked and !config.disabled; +} + +/// Draw a primary button (convenience function) +pub fn buttonPrimary(ctx: *Context, text: []const u8) bool { + return buttonEx(ctx, text, .{ .importance = .primary }); +} + +/// Draw a danger button (convenience function) +pub fn buttonDanger(ctx: *Context, text: []const u8) bool { + return buttonEx(ctx, text, .{ .importance = .danger }); +} + +/// Draw a disabled button (convenience function) +pub fn buttonDisabled(ctx: *Context, text: []const u8) bool { + return buttonEx(ctx, text, .{ .disabled = true }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "button generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 30; + + _ = button(&ctx, "Click me"); + + // Should generate: rect (background) + rect_outline (border) + text + try std.testing.expectEqual(@as(usize, 3), ctx.commands.items.len); + + ctx.endFrame(); +} + +test "button click detection" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + // Frame 1: Mouse pressed inside button + ctx.beginFrame(); + ctx.layout.row_height = 30; + ctx.input.setMousePos(50, 15); + ctx.input.setMouseButton(.left, true); + + _ = button(&ctx, "Test"); + ctx.endFrame(); + + // Frame 2: Mouse released inside button + ctx.beginFrame(); + ctx.layout.row_height = 30; + ctx.input.setMousePos(50, 15); + ctx.input.setMouseButton(.left, false); + + const clicked = button(&ctx, "Test"); + try std.testing.expect(clicked); + + ctx.endFrame(); +} + +test "button disabled no click" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + // Frame 1: Mouse pressed + ctx.beginFrame(); + ctx.layout.row_height = 30; + ctx.input.setMousePos(50, 15); + ctx.input.setMouseButton(.left, true); + _ = buttonEx(&ctx, "Disabled", .{ .disabled = true }); + ctx.endFrame(); + + // Frame 2: Mouse released + ctx.beginFrame(); + ctx.layout.row_height = 30; + ctx.input.setMousePos(50, 15); + ctx.input.setMouseButton(.left, false); + const clicked = buttonEx(&ctx, "Disabled", .{ .disabled = true }); + + try std.testing.expect(!clicked); + ctx.endFrame(); +} diff --git a/src/widgets/checkbox.zig b/src/widgets/checkbox.zig new file mode 100644 index 0000000..7cdd6c4 --- /dev/null +++ b/src/widgets/checkbox.zig @@ -0,0 +1,217 @@ +//! Checkbox Widget - Boolean toggle +//! +//! A checkbox that toggles between checked and unchecked states. +//! Returns true when the state changes. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Checkbox configuration +pub const CheckboxConfig = struct { + /// Label text + label: []const u8 = "", + /// Disabled state + disabled: bool = false, + /// Size of the checkbox box + box_size: u32 = 16, + /// Gap between box and label + gap: u32 = 8, +}; + +/// Draw a checkbox and return true if state changed +pub fn checkbox(ctx: *Context, checked: *bool, label_text: []const u8) bool { + return checkboxEx(ctx, checked, .{ .label = label_text }); +} + +/// Draw a checkbox with custom configuration +pub fn checkboxEx(ctx: *Context, checked: *bool, config: CheckboxConfig) bool { + const bounds = ctx.layout.nextRect(); + return checkboxRect(ctx, bounds, checked, config); +} + +/// Draw a checkbox in a specific rectangle +pub fn checkboxRect( + ctx: *Context, + bounds: Layout.Rect, + checked: *bool, + config: CheckboxConfig, +) bool { + if (bounds.isEmpty()) return false; + + const id = ctx.getId(config.label); + _ = id; + + // Check mouse interaction + const mouse = ctx.input.mousePos(); + const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled; + const clicked = hovered and ctx.input.mouseReleased(.left); + + // Toggle on click + var changed = false; + if (clicked) { + checked.* = !checked.*; + changed = true; + } + + // Theme colors + const theme = Style.Theme.dark; + + // Calculate box position (vertically centered) + const box_y = bounds.y + @as(i32, @intCast((bounds.h -| config.box_size) / 2)); + + // Determine box colors + const box_bg = if (config.disabled) + theme.secondary.darken(20) + else if (checked.*) + theme.primary + else if (hovered) + theme.input_bg.lighten(10) + else + theme.input_bg; + + const box_border = if (config.disabled) + theme.border.darken(20) + else if (hovered) + theme.primary + else + theme.border; + + // Draw checkbox box + ctx.pushCommand(Command.rect( + bounds.x, + box_y, + config.box_size, + config.box_size, + box_bg, + )); + + ctx.pushCommand(Command.rectOutline( + bounds.x, + box_y, + config.box_size, + config.box_size, + box_border, + )); + + // Draw checkmark if checked + if (checked.*) { + const check_margin: u32 = 4; + const check_size = config.box_size -| (check_margin * 2); + const check_x = bounds.x + @as(i32, @intCast(check_margin)); + const check_y = box_y + @as(i32, @intCast(check_margin)); + + // Simple checkmark: draw two lines + const check_color = Style.Color.white; + + // Line 1: bottom-left to middle-bottom + ctx.pushCommand(Command.line( + check_x + 2, + check_y + @as(i32, @intCast(check_size / 2)), + check_x + @as(i32, @intCast(check_size / 2)), + check_y + @as(i32, @intCast(check_size)) - 2, + check_color, + )); + + // Line 2: middle-bottom to top-right + ctx.pushCommand(Command.line( + check_x + @as(i32, @intCast(check_size / 2)), + check_y + @as(i32, @intCast(check_size)) - 2, + check_x + @as(i32, @intCast(check_size)) - 2, + check_y + 2, + check_color, + )); + } + + // Draw label if present + if (config.label.len > 0) { + const label_x = bounds.x + @as(i32, @intCast(config.box_size + config.gap)); + const char_height: u32 = 8; + const label_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2)); + + const label_color = if (config.disabled) + theme.foreground.darken(40) + else + theme.foreground; + + ctx.pushCommand(Command.text(label_x, label_y, config.label, label_color)); + } + + return changed; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "checkbox toggle" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var checked = false; + + // Frame 1: Click inside checkbox + ctx.beginFrame(); + ctx.layout.row_height = 24; + ctx.input.setMousePos(8, 12); + ctx.input.setMouseButton(.left, true); + _ = checkbox(&ctx, &checked, "Option"); + ctx.endFrame(); + + // Frame 2: Release inside checkbox + ctx.beginFrame(); + ctx.layout.row_height = 24; + ctx.input.setMousePos(8, 12); + ctx.input.setMouseButton(.left, false); + const changed = checkbox(&ctx, &checked, "Option"); + ctx.endFrame(); + + try std.testing.expect(changed); + try std.testing.expect(checked); +} + +test "checkbox generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var checked = true; + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = checkbox(&ctx, &checked, "With label"); + + // Should generate: rect (box) + rect_outline (border) + 2 lines (checkmark) + text (label) + try std.testing.expect(ctx.commands.items.len >= 4); + + ctx.endFrame(); +} + +test "checkbox disabled no toggle" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var checked = false; + + // Frame 1: Click + ctx.beginFrame(); + ctx.layout.row_height = 24; + ctx.input.setMousePos(8, 12); + ctx.input.setMouseButton(.left, true); + _ = checkboxEx(&ctx, &checked, .{ .label = "Disabled", .disabled = true }); + ctx.endFrame(); + + // Frame 2: Release + ctx.beginFrame(); + ctx.layout.row_height = 24; + ctx.input.setMousePos(8, 12); + ctx.input.setMouseButton(.left, false); + const changed = checkboxEx(&ctx, &checked, .{ .label = "Disabled", .disabled = true }); + ctx.endFrame(); + + try std.testing.expect(!changed); + try std.testing.expect(!checked); +} diff --git a/src/widgets/focus.zig b/src/widgets/focus.zig new file mode 100644 index 0000000..127ae62 --- /dev/null +++ b/src/widgets/focus.zig @@ -0,0 +1,272 @@ +//! Focus Management - Track and navigate widget focus +//! +//! Manages which widget has keyboard focus and provides +//! Tab/Shift+Tab navigation between focusable widgets. + +const std = @import("std"); +const Input = @import("../core/input.zig"); + +/// Maximum number of focusable widgets per frame +pub const MAX_FOCUSABLES = 64; + +/// Focus manager state +pub const FocusManager = struct { + /// Currently focused widget ID + focused_id: ?u32 = null, + + /// List of focusable widget IDs this frame (in order) + focusables: [MAX_FOCUSABLES]u32 = undefined, + focusable_count: usize = 0, + + /// Widget ID to focus next frame (from keyboard nav) + pending_focus: ?u32 = null, + + /// Whether Tab was pressed this frame + tab_pressed: bool = false, + shift_tab_pressed: bool = false, + + const Self = @This(); + + /// Reset for new frame + pub fn beginFrame(self: *Self) void { + self.focusable_count = 0; + self.tab_pressed = false; + self.shift_tab_pressed = false; + + // Apply pending focus + if (self.pending_focus) |id| { + self.focused_id = id; + self.pending_focus = null; + } + } + + /// Process keyboard input for focus navigation + pub fn processInput(self: *Self, input: *const Input.InputState, key_events: []const Input.KeyEvent) void { + _ = input; + for (key_events) |event| { + if (event.key == .tab and event.pressed) { + if (event.modifiers.shift) { + self.shift_tab_pressed = true; + } else { + self.tab_pressed = true; + } + } + } + } + + /// Register a widget as focusable + pub fn registerFocusable(self: *Self, id: u32) void { + if (self.focusable_count >= MAX_FOCUSABLES) return; + self.focusables[self.focusable_count] = id; + self.focusable_count += 1; + } + + /// Check if a widget has focus + pub fn hasFocus(self: Self, id: u32) bool { + return self.focused_id == id; + } + + /// Request focus for a widget + pub fn requestFocus(self: *Self, id: u32) void { + self.focused_id = id; + } + + /// Clear focus + pub fn clearFocus(self: *Self) void { + self.focused_id = null; + } + + /// End of frame: process Tab navigation + pub fn endFrame(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.tab_pressed) { + self.focusNext(); + } else if (self.shift_tab_pressed) { + self.focusPrev(); + } + } + + /// Focus next widget in order + fn focusNext(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.focused_id) |current| { + // Find current index + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + // Focus next (wrap around) + const next_idx = (i + 1) % self.focusable_count; + self.pending_focus = self.focusables[next_idx]; + return; + } + } + } + + // No current focus, focus first + self.pending_focus = self.focusables[0]; + } + + /// Focus previous widget in order + fn focusPrev(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.focused_id) |current| { + // Find current index + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + // Focus previous (wrap around) + const prev_idx = if (i == 0) self.focusable_count - 1 else i - 1; + self.pending_focus = self.focusables[prev_idx]; + return; + } + } + } + + // No current focus, focus last + self.pending_focus = self.focusables[self.focusable_count - 1]; + } + + /// Focus specific index + pub fn focusIndex(self: *Self, idx: usize) void { + if (idx < self.focusable_count) { + self.pending_focus = self.focusables[idx]; + } + } + + /// Get the index of the focused widget + pub fn focusedIndex(self: Self) ?usize { + if (self.focused_id) |current| { + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + return i; + } + } + } + return null; + } +}; + +/// Focus ring - circular focus navigation helper +pub const FocusRing = struct { + ids: [MAX_FOCUSABLES]u32 = undefined, + count: usize = 0, + current: usize = 0, + + const Self = @This(); + + /// Add a widget ID to the ring + pub fn add(self: *Self, id: u32) void { + if (self.count >= MAX_FOCUSABLES) return; + self.ids[self.count] = id; + self.count += 1; + } + + /// Get current focused ID + pub fn currentId(self: Self) ?u32 { + if (self.count == 0) return null; + return self.ids[self.current]; + } + + /// Move to next + pub fn next(self: *Self) void { + if (self.count == 0) return; + self.current = (self.current + 1) % self.count; + } + + /// Move to previous + pub fn prev(self: *Self) void { + if (self.count == 0) return; + self.current = if (self.current == 0) self.count - 1 else self.current - 1; + } + + /// Check if widget has focus + pub fn isFocused(self: Self, id: u32) bool { + if (self.count == 0) return false; + return self.ids[self.current] == id; + } + + /// Focus specific widget by ID + pub fn focusId(self: *Self, id: u32) bool { + for (self.ids[0..self.count], 0..) |widget_id, i| { + if (widget_id == id) { + self.current = i; + return true; + } + } + return false; + } + + /// Reset the ring + pub fn reset(self: *Self) void { + self.count = 0; + self.current = 0; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "FocusManager navigation" { + var fm = FocusManager{}; + + fm.beginFrame(); + fm.registerFocusable(100); + fm.registerFocusable(200); + fm.registerFocusable(300); + + // No focus initially + try std.testing.expectEqual(@as(?u32, null), fm.focused_id); + + // Tab to first + fm.tab_pressed = true; + fm.endFrame(); + fm.beginFrame(); + + try std.testing.expectEqual(@as(?u32, 100), fm.focused_id); + + // Register again for new frame + fm.registerFocusable(100); + fm.registerFocusable(200); + fm.registerFocusable(300); + + // Tab to second + fm.tab_pressed = true; + fm.endFrame(); + fm.beginFrame(); + + try std.testing.expectEqual(@as(?u32, 200), fm.focused_id); +} + +test "FocusRing" { + var ring = FocusRing{}; + + ring.add(10); + ring.add(20); + ring.add(30); + + try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); + try std.testing.expect(ring.isFocused(10)); + + ring.next(); + try std.testing.expectEqual(@as(?u32, 20), ring.currentId()); + + ring.prev(); + try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); + + ring.prev(); // Wrap to end + try std.testing.expectEqual(@as(?u32, 30), ring.currentId()); +} + +test "FocusRing focusId" { + var ring = FocusRing{}; + + ring.add(100); + ring.add(200); + ring.add(300); + + const found = ring.focusId(200); + try std.testing.expect(found); + try std.testing.expectEqual(@as(?u32, 200), ring.currentId()); +} diff --git a/src/widgets/label.zig b/src/widgets/label.zig new file mode 100644 index 0000000..e83d7d3 --- /dev/null +++ b/src/widgets/label.zig @@ -0,0 +1,115 @@ +//! Label Widget - Static text display +//! +//! A simple widget for displaying text. Supports alignment and styling. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); + +/// Text alignment +pub const Alignment = enum { + left, + center, + right, +}; + +/// Label configuration +pub const LabelConfig = struct { + color: Style.Color = Style.Color.foreground, + alignment: Alignment = .left, + /// Padding inside the label area + padding: u32 = 0, +}; + +/// Draw a label at the current layout position +pub fn label(ctx: *Context, text: []const u8) void { + labelEx(ctx, text, .{}); +} + +/// Draw a label with custom configuration +pub fn labelEx(ctx: *Context, text: []const u8, config: LabelConfig) void { + const bounds = ctx.layout.nextRect(); + labelRect(ctx, bounds, text, config); +} + +/// Draw a label in a specific rectangle +pub fn labelRect(ctx: *Context, bounds: Layout.Rect, text: []const u8, config: LabelConfig) void { + if (bounds.isEmpty()) return; + + const inner = bounds.shrink(config.padding); + if (inner.isEmpty()) return; + + // Calculate text position based on alignment + // Assume 8 pixels per character (bitmap font) + const char_width: u32 = 8; + const text_width = @as(u32, @intCast(text.len)) * char_width; + + const x: i32 = switch (config.alignment) { + .left => inner.x, + .center => inner.x + @as(i32, @intCast((inner.w -| text_width) / 2)), + .right => inner.x + @as(i32, @intCast(inner.w -| text_width)), + }; + + // Center vertically (assume 8 pixel font height) + const char_height: u32 = 8; + const y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); + + ctx.pushCommand(Command.text(x, y, text, config.color)); +} + +/// Draw a colored label (convenience function) +pub fn labelColored(ctx: *Context, text: []const u8, color: Style.Color) void { + labelEx(ctx, text, .{ .color = color }); +} + +/// Draw a centered label (convenience function) +pub fn labelCentered(ctx: *Context, text: []const u8) void { + labelEx(ctx, text, .{ .alignment = .center }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "label generates text command" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 20; + + label(&ctx, "Hello"); + + try std.testing.expectEqual(@as(usize, 1), ctx.commands.items.len); + switch (ctx.commands.items[0]) { + .text => |t| { + try std.testing.expectEqualStrings("Hello", t.text); + }, + else => unreachable, + } + + ctx.endFrame(); +} + +test "label alignment" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 20; + + // Left aligned (default) + labelEx(&ctx, "Left", .{ .alignment = .left }); + + // The text should start at x=0 + switch (ctx.commands.items[0]) { + .text => |t| { + try std.testing.expectEqual(@as(i32, 0), t.x); + }, + else => unreachable, + } + + ctx.endFrame(); +} diff --git a/src/widgets/list.zig b/src/widgets/list.zig new file mode 100644 index 0000000..d1143e0 --- /dev/null +++ b/src/widgets/list.zig @@ -0,0 +1,347 @@ +//! List Widget - Scrollable list of selectable items +//! +//! A vertical list with keyboard navigation and single selection. +//! Supports virtualized rendering for large lists. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// List state (caller-managed) +pub const ListState = struct { + /// Currently selected index (-1 for none) + selected: i32 = -1, + /// Scroll offset (first visible item index) + scroll_offset: usize = 0, + /// Whether the list has focus + focused: bool = false, + + /// Get selected index as optional usize + pub fn selectedIndex(self: ListState) ?usize { + if (self.selected < 0) return null; + return @intCast(self.selected); + } + + /// Select by index + pub fn selectIndex(self: *ListState, idx: usize) void { + self.selected = @intCast(idx); + } + + /// Move selection up + pub fn selectPrev(self: *ListState) void { + if (self.selected > 0) { + self.selected -= 1; + } + } + + /// Move selection down + pub fn selectNext(self: *ListState, max: usize) void { + if (self.selected < @as(i32, @intCast(max)) - 1) { + self.selected += 1; + } + } + + /// Ensure selected item is visible + pub fn ensureVisible(self: *ListState, visible_count: usize) void { + if (self.selected < 0) return; + + const sel: usize = @intCast(self.selected); + + if (sel < self.scroll_offset) { + self.scroll_offset = sel; + } else if (sel >= self.scroll_offset + visible_count) { + self.scroll_offset = sel - visible_count + 1; + } + } +}; + +/// List configuration +pub const ListConfig = struct { + /// Height of each item + item_height: u32 = 24, + /// Padding inside each item + item_padding: u32 = 4, + /// Show border around list + show_border: bool = true, + /// Allow keyboard navigation + keyboard_nav: bool = true, +}; + +/// List result +pub const ListResult = struct { + /// Selection changed this frame + changed: bool, + /// Item was double-clicked + activated: bool, + /// Newly selected index (valid if changed) + new_index: ?usize, + /// List was clicked (for focus) + clicked: bool, +}; + +/// Draw a list +pub fn list( + ctx: *Context, + state: *ListState, + items: []const []const u8, +) ListResult { + return listEx(ctx, state, items, .{}); +} + +/// Draw a list with custom configuration +pub fn listEx( + ctx: *Context, + state: *ListState, + items: []const []const u8, + config: ListConfig, +) ListResult { + const bounds = ctx.layout.nextRect(); + return listRect(ctx, bounds, state, items, config); +} + +/// Draw a list in a specific rectangle +pub fn listRect( + ctx: *Context, + bounds: Layout.Rect, + state: *ListState, + items: []const []const u8, + config: ListConfig, +) ListResult { + var result = ListResult{ + .changed = false, + .activated = false, + .new_index = null, + .clicked = false, + }; + + if (bounds.isEmpty()) return result; + if (items.len == 0) { + // Draw empty list + if (config.show_border) { + const theme = Style.Theme.dark; + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, theme.background)); + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border)); + } + return result; + } + + const theme = Style.Theme.dark; + const mouse = ctx.input.mousePos(); + const list_hovered = bounds.contains(mouse.x, mouse.y); + + // Click detection for focus + if (list_hovered and ctx.input.mousePressed(.left)) { + state.focused = true; + result.clicked = true; + } + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, theme.background)); + + // Draw border if enabled + if (config.show_border) { + const border_color = if (state.focused) theme.primary else theme.border; + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + } + + // Calculate visible items + const inner = if (config.show_border) bounds.shrink(1) else bounds; + const visible_count = inner.h / config.item_height; + + // Ensure scroll offset is valid + if (items.len <= visible_count) { + state.scroll_offset = 0; + } else if (state.scroll_offset > items.len - visible_count) { + state.scroll_offset = items.len - visible_count; + } + + // Handle scroll + if (list_hovered) { + const scroll = ctx.input.scroll_y; + if (scroll < 0 and state.scroll_offset > 0) { + state.scroll_offset -= 1; + } else if (scroll > 0 and state.scroll_offset < items.len - visible_count) { + state.scroll_offset += 1; + } + } + + // Clip to list bounds + ctx.pushCommand(Command.clip(inner.x, inner.y, inner.w, inner.h)); + + // Draw visible items + var item_y = inner.y; + const end_idx = @min(state.scroll_offset + visible_count + 1, items.len); + + for (state.scroll_offset..end_idx) |i| { + const item_bounds = Layout.Rect.init( + inner.x, + item_y, + inner.w, + config.item_height, + ); + + // Check if item is visible + if (item_y >= inner.bottom()) break; + + const item_hovered = item_bounds.contains(mouse.x, mouse.y) and list_hovered; + const item_clicked = item_hovered and ctx.input.mouseReleased(.left); + + // Determine item background + const is_selected = state.selected == @as(i32, @intCast(i)); + const item_bg = if (is_selected) + theme.selection_bg + else if (item_hovered) + theme.button_hover + else + Style.Color.transparent; + + if (item_bg.a > 0) { + ctx.pushCommand(Command.rect( + item_bounds.x, + item_bounds.y, + item_bounds.w, + item_bounds.h, + item_bg, + )); + } + + // Draw item text + const text_color = if (is_selected) theme.selection_fg else theme.foreground; + const char_height: u32 = 8; + const text_x = item_bounds.x + @as(i32, @intCast(config.item_padding)); + const text_y = item_bounds.y + @as(i32, @intCast((config.item_height -| char_height) / 2)); + + ctx.pushCommand(Command.text(text_x, text_y, items[i], text_color)); + + // Handle click + if (item_clicked) { + const old_selected = state.selected; + state.selected = @intCast(i); + + if (old_selected != state.selected) { + result.changed = true; + result.new_index = i; + } + } + + item_y += @as(i32, @intCast(config.item_height)); + } + + // End clip + ctx.pushCommand(Command.clipEnd()); + + // Draw scrollbar if needed + if (items.len > visible_count) { + const scrollbar_w: u32 = 8; + const scrollbar_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(scrollbar_w + 1)); + + // Scrollbar track + ctx.pushCommand(Command.rect( + scrollbar_x, + inner.y, + scrollbar_w, + inner.h, + theme.background.darken(10), + )); + + // Scrollbar thumb + const thumb_h = @max((visible_count * inner.h) / @as(u32, @intCast(items.len)), 20); + const track_h = inner.h - thumb_h; + const thumb_offset = if (items.len > visible_count) + (state.scroll_offset * track_h) / (items.len - visible_count) + else + 0; + + ctx.pushCommand(Command.rect( + scrollbar_x, + inner.y + @as(i32, @intCast(thumb_offset)), + scrollbar_w, + thumb_h, + theme.secondary, + )); + } + + return result; +} + +/// Get selected item text +pub fn getSelectedText(state: ListState, items: []const []const u8) ?[]const u8 { + if (state.selectedIndex()) |idx| { + if (idx < items.len) { + return items[idx]; + } + } + return null; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "ListState navigation" { + var state = ListState{}; + + state.selectIndex(2); + try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex()); + + state.selectPrev(); + try std.testing.expectEqual(@as(?usize, 1), state.selectedIndex()); + + state.selectNext(5); + try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex()); +} + +test "ListState ensureVisible" { + var state = ListState{ .selected = 10, .scroll_offset = 0 }; + state.ensureVisible(5); + + // Selected item 10 should now be visible (scroll to 6) + try std.testing.expectEqual(@as(usize, 6), state.scroll_offset); +} + +test "list generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = ListState{}; + const items = [_][]const u8{ "Item 1", "Item 2", "Item 3" }; + + ctx.beginFrame(); + ctx.layout.row_height = 100; + + _ = list(&ctx, &state, &items); + + // Should generate background + border + clip + items + clip_end + try std.testing.expect(ctx.commands.items.len >= 4); + + ctx.endFrame(); +} + +test "list selection" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = ListState{}; + const items = [_][]const u8{ "A", "B", "C" }; + + // Frame 1: Click on item + ctx.beginFrame(); + ctx.layout.row_height = 100; + ctx.input.setMousePos(50, 36); // Should be item 1 (y=24+12) + ctx.input.setMouseButton(.left, true); + _ = list(&ctx, &state, &items); + ctx.endFrame(); + + // Frame 2: Release + ctx.beginFrame(); + ctx.layout.row_height = 100; + ctx.input.setMousePos(50, 36); + ctx.input.setMouseButton(.left, false); + const result = list(&ctx, &state, &items); + ctx.endFrame(); + + try std.testing.expect(result.changed); +} diff --git a/src/widgets/modal.zig b/src/widgets/modal.zig new file mode 100644 index 0000000..08c1c3c --- /dev/null +++ b/src/widgets/modal.zig @@ -0,0 +1,435 @@ +//! Modal Widget - Overlay dialogs +//! +//! Provides modal dialogs that render on top of other content: +//! - Modal: Dialog with title, message, and buttons +//! - Confirm: Yes/No dialog +//! - Alert: OK dialog +//! - Input: Text input dialog +//! +//! Modals block interaction with the underlying UI until dismissed. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); +const button = @import("button.zig"); +const text_input = @import("text_input.zig"); + +// ============================================================================= +// Modal State +// ============================================================================= + +/// Modal state (caller-managed) +pub const ModalState = struct { + /// Whether the modal is visible + visible: bool = false, + /// Currently focused button index + focused_button: usize = 0, + /// For input dialogs: text state + input_state: ?*text_input.TextInputState = null, + + const Self = @This(); + + /// Show the modal + pub fn show(self: *Self) void { + self.visible = true; + self.focused_button = 0; + } + + /// Hide the modal + pub fn hide(self: *Self) void { + self.visible = false; + } + + /// Focus next button + pub fn focusNext(self: *Self, button_count: usize) void { + if (button_count > 0) { + self.focused_button = (self.focused_button + 1) % button_count; + } + } + + /// Focus previous button + pub fn focusPrev(self: *Self, button_count: usize) void { + if (button_count > 0) { + if (self.focused_button == 0) { + self.focused_button = button_count - 1; + } else { + self.focused_button -= 1; + } + } + } +}; + +// ============================================================================= +// Modal Configuration +// ============================================================================= + +/// Modal button definition +pub const ModalButton = struct { + label: []const u8, + importance: button.Importance = .normal, +}; + +/// Predefined button sets +pub const ButtonSet = struct { + pub const ok = [_]ModalButton{ + .{ .label = "OK", .importance = .primary }, + }; + + pub const ok_cancel = [_]ModalButton{ + .{ .label = "OK", .importance = .primary }, + .{ .label = "Cancel", .importance = .normal }, + }; + + pub const yes_no = [_]ModalButton{ + .{ .label = "Yes", .importance = .primary }, + .{ .label = "No", .importance = .normal }, + }; + + pub const yes_no_cancel = [_]ModalButton{ + .{ .label = "Yes", .importance = .primary }, + .{ .label = "No", .importance = .normal }, + .{ .label = "Cancel", .importance = .normal }, + }; +}; + +/// Modal configuration +pub const ModalConfig = struct { + /// Dialog title + title: []const u8 = "Dialog", + /// Message lines + message: []const u8 = "", + /// Dialog width + width: u32 = 300, + /// Dialog height (0 = auto) + height: u32 = 0, + /// Buttons + buttons: []const ModalButton = &ButtonSet.ok, + /// Show input field + show_input: bool = false, + /// Input placeholder + input_placeholder: []const u8 = "", +}; + +/// Modal colors +pub const ModalColors = struct { + /// Backdrop color (semi-transparent overlay) + backdrop: Style.Color = Style.Color.rgba(0, 0, 0, 180), + /// Dialog background + background: Style.Color = Style.Color.rgb(45, 45, 50), + /// Border color + border: Style.Color = Style.Color.rgb(80, 80, 85), + /// Title bar background + title_bg: Style.Color = Style.Color.rgb(55, 55, 60), + /// Title text color + title_fg: Style.Color = Style.Color.rgb(220, 220, 220), + /// Message text color + message_fg: Style.Color = Style.Color.rgb(200, 200, 200), +}; + +/// Modal result +pub const ModalResult = struct { + /// Button index that was clicked (-1 if none) + button_clicked: i32 = -1, + /// Whether the modal was dismissed (Escape) + dismissed: bool = false, + /// For input modals: the input text when submitted + input_text: ?[]const u8 = null, +}; + +// ============================================================================= +// Modal Functions +// ============================================================================= + +/// Draw a modal dialog +pub fn modal( + ctx: *Context, + state: *ModalState, + config: ModalConfig, +) ModalResult { + return modalEx(ctx, state, config, .{}); +} + +/// Draw a modal dialog with custom colors +pub fn modalEx( + ctx: *Context, + state: *ModalState, + config: ModalConfig, + colors: ModalColors, +) ModalResult { + var result = ModalResult{}; + + if (!state.visible) return result; + + const screen_w = ctx.layout.area.w; + const screen_h = ctx.layout.area.h; + + // Calculate dialog dimensions + const dialog_w = @min(config.width, screen_w -| 40); + const title_h: u32 = 28; + const padding: u32 = 16; + const button_h: u32 = 32; + const input_h: u32 = if (config.show_input) 28 else 0; + + // Estimate message height (rough: 16px per line, wrap at dialog width) + const msg_lines = countLines(config.message); + const msg_h: u32 = @max(1, msg_lines) * 18; + + const content_h = msg_h + input_h + button_h + padding * 3; + const dialog_h = if (config.height > 0) config.height else title_h + content_h + padding; + + // Center dialog + const dialog_x = @as(i32, @intCast((screen_w -| dialog_w) / 2)); + const dialog_y = @as(i32, @intCast((screen_h -| dialog_h) / 2)); + + // Draw backdrop (semi-transparent overlay) + ctx.pushCommand(Command.rect(0, 0, screen_w, screen_h, colors.backdrop)); + + // Draw dialog border + ctx.pushCommand(Command.rectOutline( + dialog_x - 1, + dialog_y - 1, + dialog_w + 2, + dialog_h + 2, + colors.border, + )); + + // Draw dialog background + ctx.pushCommand(Command.rect(dialog_x, dialog_y, dialog_w, dialog_h, colors.background)); + + // Draw title bar + ctx.pushCommand(Command.rect(dialog_x, dialog_y, dialog_w, title_h, colors.title_bg)); + + // Draw title text + const title_text_x = dialog_x + @as(i32, @intCast(padding)); + const title_text_y = dialog_y + @as(i32, @intCast((title_h - 8) / 2)); + ctx.pushCommand(Command.text(title_text_x, title_text_y, config.title, colors.title_fg)); + + // Draw message + const msg_x = dialog_x + @as(i32, @intCast(padding)); + var msg_y = dialog_y + @as(i32, @intCast(title_h + padding)); + ctx.pushCommand(Command.text(msg_x, msg_y, config.message, colors.message_fg)); + msg_y += @as(i32, @intCast(msg_h + padding)); + + // Draw input field if enabled + if (config.show_input) { + if (state.input_state) |input_st| { + const input_rect = Layout.Rect.init( + dialog_x + @as(i32, @intCast(padding)), + msg_y, + dialog_w -| (padding * 2), + 24, + ); + + // Simple input rendering + const input_bg = Style.Color.rgb(35, 35, 40); + ctx.pushCommand(Command.rect(input_rect.x, input_rect.y, input_rect.w, input_rect.h, input_bg)); + ctx.pushCommand(Command.rectOutline(input_rect.x, input_rect.y, input_rect.w, input_rect.h, colors.border)); + + const txt = input_st.text(); + if (txt.len > 0) { + ctx.pushCommand(Command.text(input_rect.x + 4, input_rect.y + 4, txt, colors.message_fg)); + } else if (config.input_placeholder.len > 0) { + ctx.pushCommand(Command.text( + input_rect.x + 4, + input_rect.y + 4, + config.input_placeholder, + Style.Color.rgb(120, 120, 120), + )); + } + } + msg_y += @as(i32, @intCast(input_h + padding)); + } + + // Draw buttons + const button_count = config.buttons.len; + if (button_count > 0) { + const btn_width: u32 = 80; + const btn_spacing: u32 = 12; + const total_btn_width = button_count * btn_width + (button_count - 1) * btn_spacing; + var btn_x = dialog_x + @as(i32, @intCast((dialog_w -| total_btn_width) / 2)); + const btn_y = dialog_y + @as(i32, @intCast(dialog_h - button_h - padding)); + + for (config.buttons, 0..) |btn, i| { + const is_focused = state.focused_button == i; + + // Button background + const btn_bg = if (is_focused) + Style.Color.primary + else switch (btn.importance) { + .primary => Style.Color.primary.darken(30), + .normal => Style.Color.rgb(60, 60, 65), + .danger => Style.Color.danger.darken(30), + }; + + ctx.pushCommand(Command.rect(btn_x, btn_y, btn_width, button_h - 4, btn_bg)); + + if (is_focused) { + ctx.pushCommand(Command.rectOutline(btn_x, btn_y, btn_width, button_h - 4, Style.Color.rgb(200, 200, 200))); + } + + // Button text + const text_w = btn.label.len * 8; + const text_x = btn_x + @as(i32, @intCast((btn_width -| @as(u32, @intCast(text_w))) / 2)); + const text_y = btn_y + @as(i32, @intCast((button_h - 4 - 8) / 2)); + ctx.pushCommand(Command.text(text_x, text_y, btn.label, Style.Color.rgb(240, 240, 240))); + + // Check click + const btn_rect = Layout.Rect.init(btn_x, btn_y, btn_width, button_h - 4); + const mouse = ctx.input.mousePos(); + if (btn_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) { + result.button_clicked = @intCast(i); + state.hide(); + if (config.show_input) { + if (state.input_state) |input_st| { + result.input_text = input_st.text(); + } + } + } + + btn_x += @as(i32, @intCast(btn_width + btn_spacing)); + } + } + + // Handle keyboard navigation + if (ctx.input.keyPressed(.tab)) { + if (ctx.input.modifiers.shift) { + state.focusPrev(button_count); + } else { + state.focusNext(button_count); + } + } + + if (ctx.input.keyPressed(.left)) { + state.focusPrev(button_count); + } + + if (ctx.input.keyPressed(.right)) { + state.focusNext(button_count); + } + + // Enter confirms focused button + if (ctx.input.keyPressed(.enter)) { + result.button_clicked = @intCast(state.focused_button); + state.hide(); + if (config.show_input) { + if (state.input_state) |input_st| { + result.input_text = input_st.text(); + } + } + } + + // Escape dismisses + if (ctx.input.keyPressed(.escape)) { + result.dismissed = true; + state.hide(); + } + + return result; +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/// Show an alert dialog (OK button only) +pub fn alert( + ctx: *Context, + state: *ModalState, + title: []const u8, + message: []const u8, +) ModalResult { + return modal(ctx, state, .{ + .title = title, + .message = message, + .buttons = &ButtonSet.ok, + }); +} + +/// Show a confirm dialog (Yes/No buttons) +pub fn confirm( + ctx: *Context, + state: *ModalState, + title: []const u8, + message: []const u8, +) ModalResult { + return modal(ctx, state, .{ + .title = title, + .message = message, + .buttons = &ButtonSet.yes_no, + }); +} + +/// Show an input dialog (text field + OK/Cancel) +pub fn inputDialog( + ctx: *Context, + state: *ModalState, + title: []const u8, + message: []const u8, + placeholder: []const u8, +) ModalResult { + return modal(ctx, state, .{ + .title = title, + .message = message, + .buttons = &ButtonSet.ok_cancel, + .show_input = true, + .input_placeholder = placeholder, + }); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +fn countLines(text: []const u8) u32 { + if (text.len == 0) return 0; + var lines: u32 = 1; + for (text) |c| { + if (c == '\n') lines += 1; + } + return lines; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "ModalState show/hide" { + var state = ModalState{}; + + try std.testing.expect(!state.visible); + + state.show(); + try std.testing.expect(state.visible); + try std.testing.expectEqual(@as(usize, 0), state.focused_button); + + state.hide(); + try std.testing.expect(!state.visible); +} + +test "ModalState focus navigation" { + var state = ModalState{}; + state.show(); + + // 3 buttons + state.focusNext(3); + try std.testing.expectEqual(@as(usize, 1), state.focused_button); + + state.focusNext(3); + try std.testing.expectEqual(@as(usize, 2), state.focused_button); + + state.focusNext(3); // Wrap around + try std.testing.expectEqual(@as(usize, 0), state.focused_button); + + state.focusPrev(3); // Wrap to end + try std.testing.expectEqual(@as(usize, 2), state.focused_button); +} + +test "countLines" { + try std.testing.expectEqual(@as(u32, 0), countLines("")); + try std.testing.expectEqual(@as(u32, 1), countLines("hello")); + try std.testing.expectEqual(@as(u32, 2), countLines("hello\nworld")); + try std.testing.expectEqual(@as(u32, 3), countLines("a\nb\nc")); +} diff --git a/src/widgets/panel.zig b/src/widgets/panel.zig new file mode 100644 index 0000000..077e063 --- /dev/null +++ b/src/widgets/panel.zig @@ -0,0 +1,324 @@ +//! Panel Widget - Container with title bar +//! +//! A panel is a container that displays a title bar and content area. +//! Similar to Fyne's InnerWindow but simpler. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Panel state (caller-managed) +pub const PanelState = struct { + /// Whether the panel has focus + focused: bool = false, + /// Whether the panel is collapsed (title only) + collapsed: bool = false, +}; + +/// Panel configuration +pub const PanelConfig = struct { + /// Title text + title: []const u8 = "", + /// Title bar height + title_height: u32 = 24, + /// Border width + border_width: u32 = 1, + /// Padding inside content area + content_padding: u32 = 4, + /// Whether panel can be collapsed + collapsible: bool = false, + /// Show close button (X) + closable: bool = false, +}; + +/// Panel colors +pub const PanelColors = struct { + title_bg: Style.Color = Style.Color.rgb(50, 50, 55), + title_bg_focused: Style.Color = Style.Color.rgb(60, 60, 70), + title_fg: Style.Color = Style.Color.rgb(200, 200, 200), + content_bg: Style.Color = Style.Color.rgb(35, 35, 40), + border: Style.Color = Style.Color.rgb(70, 70, 75), + border_focused: Style.Color = Style.Color.primary, +}; + +/// Panel result +pub const PanelResult = struct { + /// Content area rectangle (where child widgets should be drawn) + content: Layout.Rect, + /// Title bar was clicked + title_clicked: bool, + /// Close button was clicked + close_clicked: bool, + /// Collapse state changed + collapse_changed: bool, +}; + +/// Draw a panel and return the content area +pub fn panel( + ctx: *Context, + state: *PanelState, + title: []const u8, +) PanelResult { + return panelEx(ctx, state, .{ .title = title }, .{}); +} + +/// Draw a panel with custom configuration +pub fn panelEx( + ctx: *Context, + state: *PanelState, + config: PanelConfig, + colors: PanelColors, +) PanelResult { + const bounds = ctx.layout.nextRect(); + return panelRect(ctx, bounds, state, config, colors); +} + +/// Draw a panel in a specific rectangle +pub fn panelRect( + ctx: *Context, + bounds: Layout.Rect, + state: *PanelState, + config: PanelConfig, + colors: PanelColors, +) PanelResult { + var result = PanelResult{ + .content = Layout.Rect.zero(), + .title_clicked = false, + .close_clicked = false, + .collapse_changed = false, + }; + + if (bounds.isEmpty()) return result; + + const mouse = ctx.input.mousePos(); + const panel_hovered = bounds.contains(mouse.x, mouse.y); + + // Click for focus + if (panel_hovered and ctx.input.mousePressed(.left)) { + state.focused = true; + } + + // Border color + const border_color = if (state.focused) colors.border_focused else colors.border; + + // Draw outer border + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Title bar bounds + const title_bounds = Layout.Rect.init( + bounds.x + @as(i32, @intCast(config.border_width)), + bounds.y + @as(i32, @intCast(config.border_width)), + bounds.w -| (config.border_width * 2), + config.title_height, + ); + + // Draw title bar + const title_bg = if (state.focused) colors.title_bg_focused else colors.title_bg; + ctx.pushCommand(Command.rect(title_bounds.x, title_bounds.y, title_bounds.w, title_bounds.h, title_bg)); + + // Title bar interaction + if (title_bounds.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) { + result.title_clicked = true; + + // Toggle collapse if collapsible + if (config.collapsible) { + state.collapsed = !state.collapsed; + result.collapse_changed = true; + } + } + + // Draw collapse indicator if collapsible + var title_text_x = title_bounds.x + 4; + + if (config.collapsible) { + const indicator_size: u32 = 8; + const indicator_x = title_bounds.x + 6; + const indicator_y = title_bounds.y + @as(i32, @intCast((config.title_height -| indicator_size) / 2)); + + // Draw triangle (right = collapsed, down = expanded) + if (state.collapsed) { + // Right-pointing triangle + ctx.pushCommand(Command.line( + indicator_x, + indicator_y, + indicator_x, + indicator_y + @as(i32, @intCast(indicator_size)), + colors.title_fg, + )); + ctx.pushCommand(Command.line( + indicator_x, + indicator_y, + indicator_x + @as(i32, @intCast(indicator_size / 2)), + indicator_y + @as(i32, @intCast(indicator_size / 2)), + colors.title_fg, + )); + ctx.pushCommand(Command.line( + indicator_x, + indicator_y + @as(i32, @intCast(indicator_size)), + indicator_x + @as(i32, @intCast(indicator_size / 2)), + indicator_y + @as(i32, @intCast(indicator_size / 2)), + colors.title_fg, + )); + } else { + // Down-pointing triangle + ctx.pushCommand(Command.line( + indicator_x, + indicator_y, + indicator_x + @as(i32, @intCast(indicator_size)), + indicator_y, + colors.title_fg, + )); + ctx.pushCommand(Command.line( + indicator_x, + indicator_y, + indicator_x + @as(i32, @intCast(indicator_size / 2)), + indicator_y + @as(i32, @intCast(indicator_size / 2)), + colors.title_fg, + )); + ctx.pushCommand(Command.line( + indicator_x + @as(i32, @intCast(indicator_size)), + indicator_y, + indicator_x + @as(i32, @intCast(indicator_size / 2)), + indicator_y + @as(i32, @intCast(indicator_size / 2)), + colors.title_fg, + )); + } + + title_text_x += @as(i32, @intCast(indicator_size + 8)); + } + + // Draw close button if closable + if (config.closable) { + const close_size: u32 = 16; + const close_x = title_bounds.right() - @as(i32, @intCast(close_size + 4)); + const close_y = title_bounds.y + @as(i32, @intCast((config.title_height -| close_size) / 2)); + + const close_bounds = Layout.Rect.init(close_x, close_y, close_size, close_size); + const close_hovered = close_bounds.contains(mouse.x, mouse.y); + + if (close_hovered) { + ctx.pushCommand(Command.rect(close_x, close_y, close_size, close_size, Style.Color.danger.darken(20))); + } + + // Draw X + const x_margin: i32 = 4; + ctx.pushCommand(Command.line( + close_x + x_margin, + close_y + x_margin, + close_x + @as(i32, @intCast(close_size)) - x_margin, + close_y + @as(i32, @intCast(close_size)) - x_margin, + colors.title_fg, + )); + ctx.pushCommand(Command.line( + close_x + @as(i32, @intCast(close_size)) - x_margin, + close_y + x_margin, + close_x + x_margin, + close_y + @as(i32, @intCast(close_size)) - x_margin, + colors.title_fg, + )); + + if (close_hovered and ctx.input.mousePressed(.left)) { + result.close_clicked = true; + } + } + + // Draw title text + const char_height: u32 = 8; + const title_text_y = title_bounds.y + @as(i32, @intCast((config.title_height -| char_height) / 2)); + ctx.pushCommand(Command.text(title_text_x, title_text_y, config.title, colors.title_fg)); + + // Title bar bottom border + ctx.pushCommand(Command.line( + title_bounds.x, + title_bounds.bottom(), + title_bounds.right(), + title_bounds.bottom(), + colors.border, + )); + + // Content area (if not collapsed) + if (!state.collapsed) { + const content_y = title_bounds.bottom() + 1; + const content_h = bounds.h -| config.title_height -| (config.border_width * 2) -| 1; + + result.content = Layout.Rect.init( + bounds.x + @as(i32, @intCast(config.border_width)), + content_y, + bounds.w -| (config.border_width * 2), + content_h, + ); + + // Draw content background + ctx.pushCommand(Command.rect( + result.content.x, + result.content.y, + result.content.w, + result.content.h, + colors.content_bg, + )); + + // Apply content padding + result.content = result.content.shrink(config.content_padding); + } + + return result; +} + +/// Begin a panel scope (pushes clip and ID) +pub fn beginPanel(ctx: *Context, id: []const u8, content: Layout.Rect) void { + ctx.pushId(ctx.getId(id)); + ctx.pushCommand(Command.clip(content.x, content.y, content.w, content.h)); +} + +/// End a panel scope +pub fn endPanel(ctx: *Context) void { + ctx.pushCommand(Command.clipEnd()); + ctx.popId(); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "panel generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = PanelState{}; + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + const result = panel(&ctx, &state, "Test Panel"); + + try std.testing.expect(result.content.w > 0); + try std.testing.expect(result.content.h > 0); + try std.testing.expect(ctx.commands.items.len >= 3); // Border + title bg + title text + + ctx.endFrame(); +} + +test "panel collapsed has no content" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = PanelState{ .collapsed = true }; + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + const result = panelEx(&ctx, &state, .{ .title = "Collapsed", .collapsible = true }, .{}); + + try std.testing.expect(result.content.isEmpty()); + + ctx.endFrame(); +} + +test "PanelState defaults" { + const state = PanelState{}; + try std.testing.expect(!state.focused); + try std.testing.expect(!state.collapsed); +} diff --git a/src/widgets/select.zig b/src/widgets/select.zig new file mode 100644 index 0000000..cc9f817 --- /dev/null +++ b/src/widgets/select.zig @@ -0,0 +1,309 @@ +//! Select Widget - Dropdown selection +//! +//! A dropdown menu for selecting one option from a list. +//! The dropdown opens on click and closes when an option is selected. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Select state (caller-managed) +pub const SelectState = struct { + /// Currently selected index (-1 for none) + selected: i32 = -1, + /// Whether dropdown is open + open: bool = false, + /// Scroll offset in dropdown (for many items) + scroll_offset: usize = 0, + + /// Get selected index as optional usize + pub fn selectedIndex(self: SelectState) ?usize { + if (self.selected < 0) return null; + return @intCast(self.selected); + } +}; + +/// Select configuration +pub const SelectConfig = struct { + /// Placeholder text when nothing selected + placeholder: []const u8 = "Select...", + /// Disabled state + disabled: bool = false, + /// Maximum visible items in dropdown + max_visible_items: usize = 8, + /// Height of each item + item_height: u32 = 24, + /// Padding + padding: u32 = 4, +}; + +/// Select result +pub const SelectResult = struct { + /// Selection changed this frame + changed: bool, + /// Newly selected index (valid if changed) + new_index: ?usize, +}; + +/// Draw a select dropdown +pub fn select( + ctx: *Context, + state: *SelectState, + options: []const []const u8, +) SelectResult { + return selectEx(ctx, state, options, .{}); +} + +/// Draw a select dropdown with custom configuration +pub fn selectEx( + ctx: *Context, + state: *SelectState, + options: []const []const u8, + config: SelectConfig, +) SelectResult { + const bounds = ctx.layout.nextRect(); + return selectRect(ctx, bounds, state, options, config); +} + +/// Draw a select dropdown in a specific rectangle +pub fn selectRect( + ctx: *Context, + bounds: Layout.Rect, + state: *SelectState, + options: []const []const u8, + config: SelectConfig, +) SelectResult { + var result = SelectResult{ + .changed = false, + .new_index = null, + }; + + if (bounds.isEmpty()) return result; + + const theme = Style.Theme.dark; + + // Check mouse interaction on main button + const mouse = ctx.input.mousePos(); + const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled; + const clicked = hovered and ctx.input.mousePressed(.left); + + // Toggle dropdown on click + if (clicked) { + state.open = !state.open; + } + + // Determine button colors + const bg_color = if (config.disabled) + theme.button_bg.darken(20) + else if (state.open) + theme.button_bg.lighten(10) + else if (hovered) + theme.button_bg.lighten(5) + else + theme.button_bg; + + const border_color = if (state.open) theme.primary else theme.border; + + // Draw main button background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Draw selected text or placeholder + const display_text = if (state.selectedIndex()) |idx| + if (idx < options.len) options[idx] else config.placeholder + else + config.placeholder; + + const text_color = if (config.disabled) + theme.foreground.darken(40) + else if (state.selected < 0) + theme.secondary + else + theme.foreground; + + const inner = bounds.shrink(config.padding); + const char_height: u32 = 8; + const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); + + ctx.pushCommand(Command.text(inner.x, text_y, display_text, text_color)); + + // Draw dropdown arrow + const arrow_size: u32 = 8; + const arrow_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.padding + arrow_size)); + const arrow_y = bounds.y + @as(i32, @intCast((bounds.h -| arrow_size) / 2)); + + // Simple arrow: draw a "v" shape + const arrow_color = if (config.disabled) theme.secondary.darken(20) else theme.foreground; + + ctx.pushCommand(Command.line( + arrow_x, + arrow_y, + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_color, + )); + ctx.pushCommand(Command.line( + arrow_x + @as(i32, @intCast(arrow_size / 2)), + arrow_y + @as(i32, @intCast(arrow_size / 2)), + arrow_x + @as(i32, @intCast(arrow_size)), + arrow_y, + arrow_color, + )); + + // Draw dropdown list if open + if (state.open and options.len > 0) { + const visible_items = @min(options.len, config.max_visible_items); + const dropdown_h = visible_items * config.item_height; + const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h)); + + // Dropdown background + ctx.pushCommand(Command.rect( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + theme.background.lighten(5), + )); + + ctx.pushCommand(Command.rectOutline( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + theme.border, + )); + + // Draw visible items + var item_y = dropdown_y; + const start = state.scroll_offset; + const end = @min(start + visible_items, options.len); + + for (start..end) |i| { + const item_bounds = Layout.Rect.init( + bounds.x, + item_y, + bounds.w, + config.item_height, + ); + + const item_hovered = item_bounds.contains(mouse.x, mouse.y); + const item_clicked = item_hovered and ctx.input.mousePressed(.left); + + // Item background + const item_bg = if (state.selected == @as(i32, @intCast(i))) + theme.selection_bg + else if (item_hovered) + theme.button_hover + else + Style.Color.transparent; + + if (item_bg.a > 0) { + ctx.pushCommand(Command.rect( + item_bounds.x + 1, + item_bounds.y, + item_bounds.w - 2, + item_bounds.h, + item_bg, + )); + } + + // Item text + const item_inner = item_bounds.shrink(config.padding); + const item_text_y = item_inner.y + @as(i32, @intCast((item_inner.h -| char_height) / 2)); + + const item_text_color = if (state.selected == @as(i32, @intCast(i))) + theme.selection_fg + else + theme.foreground; + + ctx.pushCommand(Command.text(item_inner.x, item_text_y, options[i], item_text_color)); + + // Handle selection + if (item_clicked) { + state.selected = @intCast(i); + state.open = false; + result.changed = true; + result.new_index = i; + } + + item_y += @as(i32, @intCast(config.item_height)); + } + + // Close dropdown if clicked outside + if (ctx.input.mousePressed(.left) and !bounds.contains(mouse.x, mouse.y)) { + // Check if click is in dropdown area + const dropdown_bounds = Layout.Rect.init( + bounds.x, + dropdown_y, + bounds.w, + @intCast(dropdown_h), + ); + if (!dropdown_bounds.contains(mouse.x, mouse.y)) { + state.open = false; + } + } + } + + return result; +} + +/// Get selected option text +pub fn getSelectedText(state: SelectState, options: []const []const u8) ?[]const u8 { + if (state.selectedIndex()) |idx| { + if (idx < options.len) { + return options[idx]; + } + } + return null; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "select opens on click" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = SelectState{}; + const options = [_][]const u8{ "Option 1", "Option 2", "Option 3" }; + + // Frame 1: Click to open + ctx.beginFrame(); + ctx.layout.row_height = 30; + ctx.input.setMousePos(50, 15); + ctx.input.setMouseButton(.left, true); + _ = select(&ctx, &state, &options); + ctx.endFrame(); + + try std.testing.expect(state.open); +} + +test "select generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = SelectState{}; + const options = [_][]const u8{ "A", "B", "C" }; + + ctx.beginFrame(); + ctx.layout.row_height = 30; + + _ = select(&ctx, &state, &options); + + // Should generate: rect (bg) + rect_outline (border) + text + 2 lines (arrow) + try std.testing.expect(ctx.commands.items.len >= 4); + + ctx.endFrame(); +} + +test "SelectState selectedIndex" { + var state = SelectState{ .selected = 2 }; + try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex()); + + state.selected = -1; + try std.testing.expectEqual(@as(?usize, null), state.selectedIndex()); +} diff --git a/src/widgets/split.zig b/src/widgets/split.zig new file mode 100644 index 0000000..91b133f --- /dev/null +++ b/src/widgets/split.zig @@ -0,0 +1,324 @@ +//! Split Widget - Resizable split panels +//! +//! HSplit and VSplit divide an area into two resizable panels. +//! The divider can be dragged with mouse or adjusted with Ctrl+arrows. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Split direction +pub const Direction = enum { + horizontal, // Left | Right + vertical, // Top / Bottom +}; + +/// Split state (caller-managed) +pub const SplitState = struct { + /// Split offset (0.0 to 1.0) + offset: f32 = 0.5, + /// Whether the divider is being dragged + dragging: bool = false, + /// Minimum offset (prevents panels from being too small) + min_offset: f32 = 0.1, + /// Maximum offset + max_offset: f32 = 0.9, + + const Self = @This(); + + /// Set offset with clamping + pub fn setOffset(self: *Self, new_offset: f32) void { + self.offset = std.math.clamp(new_offset, self.min_offset, self.max_offset); + } + + /// Adjust offset by delta + pub fn adjustOffset(self: *Self, delta: f32) void { + self.setOffset(self.offset + delta); + } +}; + +/// Split configuration +pub const SplitConfig = struct { + /// Divider thickness in pixels + divider_size: u32 = 6, + /// Whether divider is draggable + draggable: bool = true, + /// Divider color + divider_color: Style.Color = Style.Color.rgb(60, 60, 60), + /// Divider hover color + divider_hover_color: Style.Color = Style.Color.rgb(80, 80, 80), + /// Divider drag color + divider_drag_color: Style.Color = Style.Color.primary, +}; + +/// Result of split operation - returns the two panel rectangles +pub const SplitResult = struct { + /// First panel (left or top) + first: Layout.Rect, + /// Second panel (right or bottom) + second: Layout.Rect, + /// Divider was moved + changed: bool, +}; + +/// Calculate split layout without rendering +pub fn splitLayout( + bounds: Layout.Rect, + state: *const SplitState, + direction: Direction, + divider_size: u32, +) SplitResult { + if (bounds.isEmpty()) { + return .{ + .first = Layout.Rect.zero(), + .second = Layout.Rect.zero(), + .changed = false, + }; + } + + const div_size = @as(i32, @intCast(divider_size)); + + return switch (direction) { + .horizontal => blk: { + const available_w = bounds.w -| divider_size; + const first_w: u32 = @intFromFloat(@as(f32, @floatFromInt(available_w)) * state.offset); + const second_w = available_w -| first_w; + + break :blk .{ + .first = Layout.Rect.init(bounds.x, bounds.y, first_w, bounds.h), + .second = Layout.Rect.init( + bounds.x + @as(i32, @intCast(first_w)) + div_size, + bounds.y, + second_w, + bounds.h, + ), + .changed = false, + }; + }, + .vertical => blk: { + const available_h = bounds.h -| divider_size; + const first_h: u32 = @intFromFloat(@as(f32, @floatFromInt(available_h)) * state.offset); + const second_h = available_h -| first_h; + + break :blk .{ + .first = Layout.Rect.init(bounds.x, bounds.y, bounds.w, first_h), + .second = Layout.Rect.init( + bounds.x, + bounds.y + @as(i32, @intCast(first_h)) + div_size, + bounds.w, + second_h, + ), + .changed = false, + }; + }, + }; +} + +/// Draw a horizontal split (left | right) +pub fn hsplit( + ctx: *Context, + state: *SplitState, +) SplitResult { + return hsplitEx(ctx, state, .{}); +} + +/// Draw a horizontal split with config +pub fn hsplitEx( + ctx: *Context, + state: *SplitState, + config: SplitConfig, +) SplitResult { + const bounds = ctx.layout.nextRect(); + return splitRect(ctx, bounds, state, .horizontal, config); +} + +/// Draw a vertical split (top / bottom) +pub fn vsplit( + ctx: *Context, + state: *SplitState, +) SplitResult { + return vsplitEx(ctx, state, .{}); +} + +/// Draw a vertical split with config +pub fn vsplitEx( + ctx: *Context, + state: *SplitState, + config: SplitConfig, +) SplitResult { + const bounds = ctx.layout.nextRect(); + return splitRect(ctx, bounds, state, .vertical, config); +} + +/// Draw a split in a specific rectangle +pub fn splitRect( + ctx: *Context, + bounds: Layout.Rect, + state: *SplitState, + direction: Direction, + config: SplitConfig, +) SplitResult { + if (bounds.isEmpty()) { + return .{ + .first = Layout.Rect.zero(), + .second = Layout.Rect.zero(), + .changed = false, + }; + } + + var result = splitLayout(bounds, state, direction, config.divider_size); + + // Calculate divider bounds + const divider = switch (direction) { + .horizontal => Layout.Rect.init( + result.first.right(), + bounds.y, + config.divider_size, + bounds.h, + ), + .vertical => Layout.Rect.init( + bounds.x, + result.first.bottom(), + bounds.w, + config.divider_size, + ), + }; + + // Check mouse interaction with divider + const mouse = ctx.input.mousePos(); + const divider_hovered = divider.contains(mouse.x, mouse.y); + + // Handle dragging + if (config.draggable) { + if (divider_hovered and ctx.input.mousePressed(.left)) { + state.dragging = true; + } + + if (state.dragging) { + if (ctx.input.mouseDown(.left)) { + // Calculate new offset based on mouse position + const new_offset: f32 = switch (direction) { + .horizontal => blk: { + const rel_x = mouse.x - bounds.x; + break :blk @as(f32, @floatFromInt(rel_x)) / @as(f32, @floatFromInt(bounds.w)); + }, + .vertical => blk: { + const rel_y = mouse.y - bounds.y; + break :blk @as(f32, @floatFromInt(rel_y)) / @as(f32, @floatFromInt(bounds.h)); + }, + }; + + const old_offset = state.offset; + state.setOffset(new_offset); + + if (state.offset != old_offset) { + result.changed = true; + // Recalculate layout + result = splitLayout(bounds, state, direction, config.divider_size); + } + } else { + state.dragging = false; + } + } + } + + // Draw divider + const divider_color = if (state.dragging) + config.divider_drag_color + else if (divider_hovered) + config.divider_hover_color + else + config.divider_color; + + ctx.pushCommand(Command.rect(divider.x, divider.y, divider.w, divider.h, divider_color)); + + // Draw grip lines on divider + const grip_color = divider_color.lighten(20); + const num_grips: u32 = 3; + const grip_spacing: u32 = 4; + + switch (direction) { + .horizontal => { + const grip_h: u32 = 20; + const grip_y = divider.y + @as(i32, @intCast((divider.h -| grip_h) / 2)); + const grip_x = divider.x + @as(i32, @intCast(config.divider_size / 2)); + + for (0..num_grips) |i| { + const y = grip_y + @as(i32, @intCast(i * grip_spacing + grip_spacing)); + ctx.pushCommand(Command.line(grip_x - 1, y, grip_x + 1, y, grip_color)); + } + }, + .vertical => { + const grip_w: u32 = 20; + const grip_x = divider.x + @as(i32, @intCast((divider.w -| grip_w) / 2)); + const grip_y = divider.y + @as(i32, @intCast(config.divider_size / 2)); + + for (0..num_grips) |i| { + const x = grip_x + @as(i32, @intCast(i * grip_spacing + grip_spacing)); + ctx.pushCommand(Command.line(x, grip_y - 1, x, grip_y + 1, grip_color)); + } + }, + } + + return result; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "SplitState offset clamping" { + var state = SplitState{}; + + state.setOffset(0.7); + try std.testing.expectApproxEqAbs(@as(f32, 0.7), state.offset, 0.001); + + state.setOffset(0.05); // Below min + try std.testing.expectApproxEqAbs(@as(f32, 0.1), state.offset, 0.001); + + state.setOffset(0.95); // Above max + try std.testing.expectApproxEqAbs(@as(f32, 0.9), state.offset, 0.001); +} + +test "splitLayout horizontal" { + const state = SplitState{ .offset = 0.5 }; + const bounds = Layout.Rect.init(0, 0, 206, 100); // 206 = 200 + 6 divider + + const result = splitLayout(bounds, &state, .horizontal, 6); + + try std.testing.expectEqual(@as(u32, 100), result.first.w); + try std.testing.expectEqual(@as(u32, 100), result.second.w); + try std.testing.expectEqual(@as(i32, 106), result.second.x); // 100 + 6 +} + +test "splitLayout vertical" { + const state = SplitState{ .offset = 0.5 }; + const bounds = Layout.Rect.init(0, 0, 100, 206); + + const result = splitLayout(bounds, &state, .vertical, 6); + + try std.testing.expectEqual(@as(u32, 100), result.first.h); + try std.testing.expectEqual(@as(u32, 100), result.second.h); + try std.testing.expectEqual(@as(i32, 106), result.second.y); +} + +test "hsplit generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = SplitState{}; + + ctx.beginFrame(); + ctx.layout.row_height = 400; + + const result = hsplit(&ctx, &state); + + try std.testing.expect(result.first.w > 0); + try std.testing.expect(result.second.w > 0); + try std.testing.expect(ctx.commands.items.len >= 1); // At least divider + + ctx.endFrame(); +} diff --git a/src/widgets/table.zig b/src/widgets/table.zig new file mode 100644 index 0000000..5f39334 --- /dev/null +++ b/src/widgets/table.zig @@ -0,0 +1,983 @@ +//! Table Widget - Editable data table +//! +//! A full-featured table widget with: +//! - Keyboard navigation (arrows, Tab, Enter, Escape) +//! - In-place cell editing +//! - Row state indicators (new, modified, deleted) +//! - Column headers with optional sorting +//! - Virtualized rendering (only visible rows) +//! - Scrolling support + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); +const text_input = @import("text_input.zig"); + +// ============================================================================= +// Types +// ============================================================================= + +/// Row state for dirty tracking +pub const RowState = enum { + /// Unchanged from original + clean, + /// Newly added row + new, + /// Modified row + modified, + /// Marked for deletion + deleted, +}; + +/// Column type for formatting/validation +pub const ColumnType = enum { + text, + number, + money, + date, + select, +}; + +/// Column definition +pub const Column = struct { + /// Column header text + name: []const u8, + /// Column width in pixels + width: u32, + /// Column type for formatting + column_type: ColumnType = .text, + /// Whether cells in this column are editable + editable: bool = true, + /// Minimum width when resizing + min_width: u32 = 40, +}; + +/// Table configuration +pub const TableConfig = struct { + /// Height of header row + header_height: u32 = 28, + /// Height of each data row + row_height: u32 = 24, + /// Show row state indicators + show_state_indicators: bool = true, + /// Width of state indicator column + state_indicator_width: u32 = 24, + /// Allow keyboard navigation + keyboard_nav: bool = true, + /// Allow cell editing + allow_edit: bool = true, + /// Show column headers + show_headers: bool = true, + /// Alternating row colors + alternating_rows: bool = true, +}; + +/// Table colors +pub const TableColors = struct { + header_bg: Style.Color = Style.Color.rgb(50, 50, 50), + header_fg: Style.Color = Style.Color.rgb(220, 220, 220), + row_even: Style.Color = Style.Color.rgb(35, 35, 35), + row_odd: Style.Color = Style.Color.rgb(40, 40, 40), + row_hover: Style.Color = Style.Color.rgb(50, 50, 60), + row_selected: Style.Color = Style.Color.rgb(66, 135, 245), + cell_editing: Style.Color = Style.Color.rgb(60, 60, 80), + cell_text: Style.Color = Style.Color.rgb(220, 220, 220), + cell_text_selected: Style.Color = Style.Color.rgb(255, 255, 255), + border: Style.Color = Style.Color.rgb(60, 60, 60), + state_new: Style.Color = Style.Color.rgb(76, 175, 80), + state_modified: Style.Color = Style.Color.rgb(255, 152, 0), + state_deleted: Style.Color = Style.Color.rgb(244, 67, 54), +}; + +/// Result of table interaction +pub const TableResult = struct { + /// Cell was selected + selection_changed: bool = false, + /// Cell value was edited + cell_edited: bool = false, + /// Row was added + row_added: bool = false, + /// Row was deleted + row_deleted: bool = false, + /// Editing started + edit_started: bool = false, + /// Editing ended + edit_ended: bool = false, +}; + +// ============================================================================= +// Table State +// ============================================================================= + +/// Maximum columns supported +pub const MAX_COLUMNS = 32; +/// Maximum edit buffer size +pub const MAX_EDIT_BUFFER = 256; + +/// Table state (caller-managed) +pub const TableState = struct { + /// Number of rows + row_count: usize = 0, + + /// Selected row (-1 for none) + selected_row: i32 = -1, + /// Selected column (-1 for none) + selected_col: i32 = -1, + + /// Whether a cell is being edited + editing: bool = false, + /// Edit buffer + edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined, + /// Edit state (for TextInput) + edit_state: text_input.TextInputState = undefined, + + /// Scroll offset (first visible row) + scroll_row: usize = 0, + /// Horizontal scroll offset + scroll_x: i32 = 0, + + /// Whether table has focus + focused: bool = false, + + /// Row states for dirty tracking + row_states: [1024]RowState = [_]RowState{.clean} ** 1024, + + const Self = @This(); + + /// Initialize table state + pub fn init() Self { + var state = Self{}; + state.edit_state = text_input.TextInputState.init(&state.edit_buffer); + return state; + } + + /// Set row count + pub fn setRowCount(self: *Self, count: usize) void { + self.row_count = count; + // Reset states for new rows + for (0..@min(count, self.row_states.len)) |i| { + if (self.row_states[i] == .clean) { + // Keep existing state + } + } + } + + /// Get selected cell + pub fn selectedCell(self: Self) ?struct { row: usize, col: usize } { + if (self.selected_row < 0 or self.selected_col < 0) return null; + return .{ + .row = @intCast(self.selected_row), + .col = @intCast(self.selected_col), + }; + } + + /// Select a cell + pub fn selectCell(self: *Self, row: usize, col: usize) void { + self.selected_row = @intCast(row); + self.selected_col = @intCast(col); + } + + /// Clear selection + pub fn clearSelection(self: *Self) void { + self.selected_row = -1; + self.selected_col = -1; + self.editing = false; + } + + /// Start editing current cell + pub fn startEditing(self: *Self, initial_text: []const u8) void { + self.editing = true; + self.edit_state.setText(initial_text); + self.edit_state.focused = true; + } + + /// Stop editing + pub fn stopEditing(self: *Self) void { + self.editing = false; + self.edit_state.focused = false; + } + + /// Get edit text + pub fn getEditText(self: *Self) []const u8 { + return self.edit_state.text(); + } + + /// Mark row as modified + pub fn markModified(self: *Self, row: usize) void { + if (row < self.row_states.len) { + if (self.row_states[row] == .clean) { + self.row_states[row] = .modified; + } + } + } + + /// Mark row as new + pub fn markNew(self: *Self, row: usize) void { + if (row < self.row_states.len) { + self.row_states[row] = .new; + } + } + + /// Mark row as deleted + pub fn markDeleted(self: *Self, row: usize) void { + if (row < self.row_states.len) { + self.row_states[row] = .deleted; + } + } + + /// Get row state + pub fn getRowState(self: Self, row: usize) RowState { + if (row < self.row_states.len) { + return self.row_states[row]; + } + return .clean; + } + + /// Ensure selected row is visible + pub fn ensureVisible(self: *Self, visible_rows: usize) void { + if (self.selected_row < 0) return; + const row: usize = @intCast(self.selected_row); + + if (row < self.scroll_row) { + self.scroll_row = row; + } else if (row >= self.scroll_row + visible_rows) { + self.scroll_row = row - visible_rows + 1; + } + } + + // ========================================================================= + // Navigation + // ========================================================================= + + /// Move selection up + pub fn moveUp(self: *Self) void { + if (self.selected_row > 0) { + self.selected_row -= 1; + } + } + + /// Move selection down + pub fn moveDown(self: *Self) void { + if (self.selected_row < @as(i32, @intCast(self.row_count)) - 1) { + self.selected_row += 1; + } + } + + /// Move selection left + pub fn moveLeft(self: *Self) void { + if (self.selected_col > 0) { + self.selected_col -= 1; + } + } + + /// Move selection right + pub fn moveRight(self: *Self, col_count: usize) void { + if (self.selected_col < @as(i32, @intCast(col_count)) - 1) { + self.selected_col += 1; + } + } + + /// Move to first row + pub fn moveToFirst(self: *Self) void { + if (self.row_count > 0) { + self.selected_row = 0; + } + } + + /// Move to last row + pub fn moveToLast(self: *Self) void { + if (self.row_count > 0) { + self.selected_row = @intCast(self.row_count - 1); + } + } + + /// Page up + pub fn pageUp(self: *Self, visible_rows: usize) void { + if (self.selected_row > 0) { + const jump = @as(i32, @intCast(visible_rows)); + self.selected_row = @max(0, self.selected_row - jump); + } + } + + /// Page down + pub fn pageDown(self: *Self, visible_rows: usize) void { + const max_row = @as(i32, @intCast(self.row_count)) - 1; + if (self.selected_row < max_row) { + const jump = @as(i32, @intCast(visible_rows)); + self.selected_row = @min(max_row, self.selected_row + jump); + } + } +}; + +// ============================================================================= +// Table Widget +// ============================================================================= + +/// Cell data provider callback +pub const CellDataFn = *const fn (row: usize, col: usize) []const u8; + +/// Cell edit callback (called when edit is committed) +pub const CellEditFn = *const fn (row: usize, col: usize, new_value: []const u8) void; + +/// Draw a table +pub fn table( + ctx: *Context, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, +) TableResult { + return tableEx(ctx, state, columns, get_cell, null, .{}, .{}); +} + +/// Draw a table with full options +pub fn tableEx( + ctx: *Context, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + config: TableConfig, + colors: TableColors, +) TableResult { + const bounds = ctx.layout.nextRect(); + return tableRect(ctx, bounds, state, columns, get_cell, on_edit, config, colors); +} + +/// Draw a table in a specific rectangle +pub fn tableRect( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + config: TableConfig, + colors: TableColors, +) TableResult { + var result = TableResult{}; + + if (bounds.isEmpty() or columns.len == 0) return result; + + const mouse = ctx.input.mousePos(); + const table_hovered = bounds.contains(mouse.x, mouse.y); + + // Click for focus + if (table_hovered and ctx.input.mousePressed(.left)) { + state.focused = true; + } + + // Calculate dimensions + const header_h = if (config.show_headers) config.header_height else 0; + const state_col_w = if (config.show_state_indicators) config.state_indicator_width else 0; + + // Calculate total column width + var total_col_width: u32 = state_col_w; + for (columns) |col| { + total_col_width += col.width; + } + + // Data area + const data_area = Layout.Rect.init( + bounds.x, + bounds.y + @as(i32, @intCast(header_h)), + bounds.w, + bounds.h -| header_h, + ); + + // Visible rows + const visible_rows = data_area.h / config.row_height; + + // Clamp scroll + if (state.row_count <= visible_rows) { + state.scroll_row = 0; + } else if (state.scroll_row > state.row_count - visible_rows) { + state.scroll_row = state.row_count - visible_rows; + } + + // Handle scroll wheel + if (table_hovered) { + if (ctx.input.scroll_y < 0 and state.scroll_row > 0) { + state.scroll_row -= 1; + } else if (ctx.input.scroll_y > 0 and state.scroll_row < state.row_count -| visible_rows) { + state.scroll_row += 1; + } + } + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.row_even)); + + // Draw border + const border_color = if (state.focused) Style.Color.primary else colors.border; + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Clip to table bounds + ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); + + // Draw header + if (config.show_headers) { + drawHeader(ctx, bounds, columns, state_col_w, config, colors); + } + + // Draw rows + const end_row = @min(state.scroll_row + visible_rows + 1, state.row_count); + var row_y = data_area.y; + + for (state.scroll_row..end_row) |row| { + if (row_y >= data_area.bottom()) break; + + const row_bounds = Layout.Rect.init( + data_area.x, + row_y, + data_area.w, + config.row_height, + ); + + const row_result = drawRow( + ctx, + row_bounds, + state, + row, + columns, + get_cell, + on_edit, + state_col_w, + config, + colors, + ); + + if (row_result.selection_changed) result.selection_changed = true; + if (row_result.cell_edited) result.cell_edited = true; + if (row_result.edit_started) result.edit_started = true; + if (row_result.edit_ended) result.edit_ended = true; + + row_y += @as(i32, @intCast(config.row_height)); + } + + // Draw scrollbar if needed + if (state.row_count > visible_rows) { + drawScrollbar(ctx, bounds, state, visible_rows, config, colors); + } + + // End clip + ctx.pushCommand(Command.clipEnd()); + + // Handle keyboard if focused and not editing + if (state.focused and config.keyboard_nav and !state.editing) { + handleKeyboard(ctx, state, columns.len, visible_rows, get_cell, on_edit, config, &result); + } + + // Ensure selection is visible after navigation + state.ensureVisible(visible_rows); + + return result; +} + +// ============================================================================= +// Drawing Helpers +// ============================================================================= + +fn drawHeader( + ctx: *Context, + bounds: Layout.Rect, + columns: []const Column, + state_col_w: u32, + config: TableConfig, + colors: TableColors, +) void { + const header_bounds = Layout.Rect.init( + bounds.x, + bounds.y, + bounds.w, + config.header_height, + ); + + // Header background + ctx.pushCommand(Command.rect( + header_bounds.x, + header_bounds.y, + header_bounds.w, + header_bounds.h, + colors.header_bg, + )); + + // Header border + ctx.pushCommand(Command.line( + header_bounds.x, + header_bounds.bottom() - 1, + header_bounds.right(), + header_bounds.bottom() - 1, + colors.border, + )); + + // State indicator column header (empty) + var col_x = bounds.x + @as(i32, @intCast(state_col_w)); + + // Draw column headers + const char_height: u32 = 8; + const text_y = header_bounds.y + @as(i32, @intCast((config.header_height -| char_height) / 2)); + + for (columns) |col| { + // Column text + const text_x = col_x + 4; // Padding + ctx.pushCommand(Command.text(text_x, text_y, col.name, colors.header_fg)); + + // Column separator + col_x += @as(i32, @intCast(col.width)); + ctx.pushCommand(Command.line( + col_x, + header_bounds.y, + col_x, + header_bounds.bottom(), + colors.border, + )); + } +} + +fn drawRow( + ctx: *Context, + row_bounds: Layout.Rect, + state: *TableState, + row: usize, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + state_col_w: u32, + config: TableConfig, + colors: TableColors, +) TableResult { + var result = TableResult{}; + + const mouse = ctx.input.mousePos(); + const is_selected = state.selected_row == @as(i32, @intCast(row)); + const row_hovered = row_bounds.contains(mouse.x, mouse.y); + + // Row background + const row_bg = if (is_selected) + colors.row_selected + else if (row_hovered) + colors.row_hover + else if (config.alternating_rows and row % 2 == 1) + colors.row_odd + else + colors.row_even; + + ctx.pushCommand(Command.rect(row_bounds.x, row_bounds.y, row_bounds.w, row_bounds.h, row_bg)); + + // State indicator + if (config.show_state_indicators) { + const indicator_bounds = Layout.Rect.init( + row_bounds.x, + row_bounds.y, + state_col_w, + config.row_height, + ); + drawStateIndicator(ctx, indicator_bounds, state.getRowState(row), colors); + } + + // Draw cells + var col_x = row_bounds.x + @as(i32, @intCast(state_col_w)); + const char_height: u32 = 8; + const text_y = row_bounds.y + @as(i32, @intCast((config.row_height -| char_height) / 2)); + + for (columns, 0..) |col, col_idx| { + const cell_bounds = Layout.Rect.init( + col_x, + row_bounds.y, + col.width, + config.row_height, + ); + + const is_cell_selected = is_selected and state.selected_col == @as(i32, @intCast(col_idx)); + const cell_hovered = cell_bounds.contains(mouse.x, mouse.y); + + // Cell selection highlight + if (is_cell_selected and !state.editing) { + ctx.pushCommand(Command.rectOutline( + cell_bounds.x + 1, + cell_bounds.y + 1, + cell_bounds.w - 2, + cell_bounds.h - 2, + Style.Color.primary, + )); + } + + // Handle cell click + if (cell_hovered and ctx.input.mousePressed(.left)) { + const was_selected = is_cell_selected; + state.selectCell(row, col_idx); + result.selection_changed = true; + + // Double-click to edit (or click on already selected) + if (was_selected and config.allow_edit and col.editable) { + const cell_text = get_cell(row, col_idx); + state.startEditing(cell_text); + result.edit_started = true; + } + } + + // Draw cell content + if (state.editing and is_cell_selected) { + // Draw edit field + ctx.pushCommand(Command.rect( + cell_bounds.x + 1, + cell_bounds.y + 1, + cell_bounds.w - 2, + cell_bounds.h - 2, + colors.cell_editing, + )); + + // Handle text input + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + state.edit_state.insert(text_in); + } + + // Draw edit text + const edit_text = state.getEditText(); + ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.cell_text)); + + // Draw cursor + const cursor_x = col_x + 4 + @as(i32, @intCast(state.edit_state.cursor * 8)); + ctx.pushCommand(Command.rect( + cursor_x, + cell_bounds.y + 2, + 2, + cell_bounds.h - 4, + colors.cell_text, + )); + } else { + // Normal cell display + const cell_text = get_cell(row, col_idx); + const text_color = if (is_selected) colors.cell_text_selected else colors.cell_text; + ctx.pushCommand(Command.text(col_x + 4, text_y, cell_text, text_color)); + } + + // Column separator + col_x += @as(i32, @intCast(col.width)); + ctx.pushCommand(Command.line( + col_x, + row_bounds.y, + col_x, + row_bounds.bottom(), + colors.border, + )); + } + + // Row bottom border + ctx.pushCommand(Command.line( + row_bounds.x, + row_bounds.bottom() - 1, + row_bounds.right(), + row_bounds.bottom() - 1, + colors.border, + )); + + // Handle edit commit on Enter or when moving away + if (state.editing and is_selected) { + // This will be handled by keyboard handler + _ = on_edit; + } + + return result; +} + +fn drawStateIndicator( + ctx: *Context, + bounds: Layout.Rect, + row_state: RowState, + colors: TableColors, +) void { + const indicator_size: u32 = 8; + const x = bounds.x + @as(i32, @intCast((bounds.w -| indicator_size) / 2)); + const y = bounds.y + @as(i32, @intCast((bounds.h -| indicator_size) / 2)); + + const color: ?Style.Color = switch (row_state) { + .clean => null, + .new => colors.state_new, + .modified => colors.state_modified, + .deleted => colors.state_deleted, + }; + + if (color) |c| { + ctx.pushCommand(Command.rect(x, y, indicator_size, indicator_size, c)); + } +} + +fn drawScrollbar( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + visible_rows: usize, + config: TableConfig, + colors: TableColors, +) void { + _ = config; + + const scrollbar_w: u32 = 12; + const header_h: u32 = 28; // Assume header + + const track_x = bounds.right() - @as(i32, @intCast(scrollbar_w)); + const track_y = bounds.y + @as(i32, @intCast(header_h)); + const track_h = bounds.h -| header_h; + + // Track + ctx.pushCommand(Command.rect( + track_x, + track_y, + scrollbar_w, + track_h, + colors.row_odd, + )); + + // Thumb + if (state.row_count > 0) { + const visible_rows_u32: u32 = @intCast(visible_rows); + const row_count_u32: u32 = @intCast(state.row_count); + const thumb_h: u32 = @max((visible_rows_u32 * track_h) / row_count_u32, 20); + const scroll_range = state.row_count - visible_rows; + const scroll_row_u32: u32 = @intCast(state.scroll_row); + const scroll_range_u32: u32 = @intCast(scroll_range); + const thumb_offset: u32 = if (scroll_range > 0) + (scroll_row_u32 * (track_h - thumb_h)) / scroll_range_u32 + else + 0; + + ctx.pushCommand(Command.rect( + track_x + 2, + track_y + @as(i32, @intCast(thumb_offset)), + scrollbar_w - 4, + thumb_h, + colors.header_bg, + )); + } +} + +fn handleKeyboard( + ctx: *Context, + state: *TableState, + col_count: usize, + visible_rows: usize, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + config: TableConfig, + result: *TableResult, +) void { + // Check for navigation keys + if (ctx.input.navKeyPressed()) |key| { + switch (key) { + .up => { + if (state.selected_row > 0) { + state.selected_row -= 1; + result.selection_changed = true; + state.ensureVisible(visible_rows); + } + }, + .down => { + if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { + state.selected_row += 1; + result.selection_changed = true; + state.ensureVisible(visible_rows); + } + }, + .left => { + if (state.selected_col > 0) { + state.selected_col -= 1; + result.selection_changed = true; + } + }, + .right => { + if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { + state.selected_col += 1; + result.selection_changed = true; + } + }, + .home => { + if (ctx.input.modifiers.ctrl) { + // Ctrl+Home: go to first row + state.selected_row = 0; + state.scroll_row = 0; + } else { + // Home: go to first column + state.selected_col = 0; + } + result.selection_changed = true; + }, + .end => { + if (ctx.input.modifiers.ctrl) { + // Ctrl+End: go to last row + state.selected_row = @as(i32, @intCast(state.row_count)) - 1; + state.ensureVisible(visible_rows); + } else { + // End: go to last column + state.selected_col = @as(i32, @intCast(col_count)) - 1; + } + result.selection_changed = true; + }, + .page_up => { + const jump = @as(i32, @intCast(visible_rows)); + state.selected_row = @max(0, state.selected_row - jump); + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .page_down => { + const jump = @as(i32, @intCast(visible_rows)); + const max_row = @as(i32, @intCast(state.row_count)) - 1; + state.selected_row = @min(max_row, state.selected_row + jump); + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .tab => { + // Tab: next cell, Shift+Tab: previous cell + if (ctx.input.modifiers.shift) { + if (state.selected_col > 0) { + state.selected_col -= 1; + } else if (state.selected_row > 0) { + state.selected_row -= 1; + state.selected_col = @as(i32, @intCast(col_count)) - 1; + } + } else { + if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { + state.selected_col += 1; + } else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { + state.selected_row += 1; + state.selected_col = 0; + } + } + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .enter => { + // Enter: start editing if not editing + if (!state.editing and config.allow_edit) { + if (state.selectedCell()) |cell| { + const current_text = get_cell(cell.row, cell.col); + state.startEditing(current_text); + result.edit_started = true; + } + } + }, + .escape => { + // Escape: cancel editing + if (state.editing) { + state.stopEditing(); + result.edit_ended = true; + } + }, + else => {}, + } + } + + // F2 also starts editing + if (ctx.input.keyPressed(.f2) and !state.editing and config.allow_edit) { + if (state.selectedCell()) |cell| { + const current_text = get_cell(cell.row, cell.col); + state.startEditing(current_text); + result.edit_started = true; + } + } + + // Handle edit commit for Enter during editing + if (state.editing and ctx.input.keyPressed(.enter)) { + if (on_edit) |edit_fn| { + if (state.selectedCell()) |cell| { + edit_fn(cell.row, cell.col, state.getEditText()); + } + } + state.stopEditing(); + result.cell_edited = true; + result.edit_ended = true; + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +fn testGetCell(row: usize, col: usize) []const u8 { + _ = row; + _ = col; + return "test"; +} + +test "TableState init" { + var state = TableState.init(); + try std.testing.expect(state.selectedCell() == null); + + state.selectCell(2, 3); + const sel = state.selectedCell().?; + try std.testing.expectEqual(@as(usize, 2), sel.row); + try std.testing.expectEqual(@as(usize, 3), sel.col); +} + +test "TableState navigation" { + var state = TableState.init(); + state.setRowCount(10); + state.selectCell(5, 2); + + state.moveUp(); + try std.testing.expectEqual(@as(i32, 4), state.selected_row); + + state.moveDown(); + try std.testing.expectEqual(@as(i32, 5), state.selected_row); + + state.moveToFirst(); + try std.testing.expectEqual(@as(i32, 0), state.selected_row); + + state.moveToLast(); + try std.testing.expectEqual(@as(i32, 9), state.selected_row); +} + +test "TableState row states" { + var state = TableState.init(); + state.setRowCount(5); + + try std.testing.expectEqual(RowState.clean, state.getRowState(0)); + + state.markNew(0); + try std.testing.expectEqual(RowState.new, state.getRowState(0)); + + state.markModified(1); + try std.testing.expectEqual(RowState.modified, state.getRowState(1)); + + state.markDeleted(2); + try std.testing.expectEqual(RowState.deleted, state.getRowState(2)); +} + +test "TableState editing" { + var state = TableState.init(); + + try std.testing.expect(!state.editing); + + state.startEditing("initial"); + try std.testing.expect(state.editing); + try std.testing.expectEqualStrings("initial", state.getEditText()); + + state.stopEditing(); + try std.testing.expect(!state.editing); +} + +test "table generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = TableState.init(); + state.setRowCount(5); + + const columns = [_]Column{ + .{ .name = "Name", .width = 150 }, + .{ .name = "Value", .width = 100 }, + }; + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + _ = table(&ctx, &state, &columns, testGetCell); + + // Should generate many commands (background, headers, rows, etc.) + try std.testing.expect(ctx.commands.items.len > 10); + + ctx.endFrame(); +} diff --git a/src/widgets/text_input.zig b/src/widgets/text_input.zig new file mode 100644 index 0000000..38d357f --- /dev/null +++ b/src/widgets/text_input.zig @@ -0,0 +1,426 @@ +//! TextInput Widget - Editable text field +//! +//! A single-line text input with cursor, selection, and editing support. +//! Manages its own buffer that the caller provides. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Text input state (caller-managed) +pub const TextInputState = struct { + /// Text buffer + buffer: []u8, + /// Current text length + len: usize = 0, + /// Cursor position (byte index) + cursor: usize = 0, + /// Selection start (byte index), null if no selection + selection_start: ?usize = null, + /// Whether this input has focus + focused: bool = false, + + /// Initialize with empty buffer + pub fn init(buffer: []u8) TextInputState { + return .{ .buffer = buffer }; + } + + /// Get the current text + pub fn text(self: TextInputState) []const u8 { + return self.buffer[0..self.len]; + } + + /// Set text programmatically + pub fn setText(self: *TextInputState, new_text: []const u8) void { + const copy_len = @min(new_text.len, self.buffer.len); + @memcpy(self.buffer[0..copy_len], new_text[0..copy_len]); + self.len = copy_len; + self.cursor = copy_len; + self.selection_start = null; + } + + /// Clear the text + pub fn clear(self: *TextInputState) void { + self.len = 0; + self.cursor = 0; + self.selection_start = null; + } + + /// Insert text at cursor + pub fn insert(self: *TextInputState, new_text: []const u8) void { + // Delete selection first if any + self.deleteSelection(); + + const available = self.buffer.len - self.len; + const to_insert = @min(new_text.len, available); + + if (to_insert == 0) return; + + // Move text after cursor + const after_cursor = self.len - self.cursor; + if (after_cursor > 0) { + std.mem.copyBackwards( + u8, + self.buffer[self.cursor + to_insert .. self.len + to_insert], + self.buffer[self.cursor..self.len], + ); + } + + // Insert new text + @memcpy(self.buffer[self.cursor..][0..to_insert], new_text[0..to_insert]); + self.len += to_insert; + self.cursor += to_insert; + } + + /// Delete character before cursor (backspace) + pub fn deleteBack(self: *TextInputState) void { + if (self.selection_start != null) { + self.deleteSelection(); + return; + } + + if (self.cursor == 0) return; + + // Move text after cursor back + const after_cursor = self.len - self.cursor; + if (after_cursor > 0) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor - 1 .. self.len - 1], + self.buffer[self.cursor..self.len], + ); + } + + self.cursor -= 1; + self.len -= 1; + } + + /// Delete character at cursor (delete key) + pub fn deleteForward(self: *TextInputState) void { + if (self.selection_start != null) { + self.deleteSelection(); + return; + } + + if (self.cursor >= self.len) return; + + // Move text after cursor back + const after_cursor = self.len - self.cursor - 1; + if (after_cursor > 0) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor .. self.len - 1], + self.buffer[self.cursor + 1 .. self.len], + ); + } + + self.len -= 1; + } + + /// Delete selected text + fn deleteSelection(self: *TextInputState) void { + const start = self.selection_start orelse return; + const sel_start = @min(start, self.cursor); + const sel_end = @max(start, self.cursor); + const sel_len = sel_end - sel_start; + + if (sel_len == 0) { + self.selection_start = null; + return; + } + + // Move text after selection + const after_sel = self.len - sel_end; + if (after_sel > 0) { + std.mem.copyForwards( + u8, + self.buffer[sel_start .. sel_start + after_sel], + self.buffer[sel_end..self.len], + ); + } + + self.len -= sel_len; + self.cursor = sel_start; + self.selection_start = null; + } + + /// Move cursor left + pub fn cursorLeft(self: *TextInputState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + if (self.cursor > 0) { + self.cursor -= 1; + } + } + + /// Move cursor right + pub fn cursorRight(self: *TextInputState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + if (self.cursor < self.len) { + self.cursor += 1; + } + } + + /// Move cursor to start + pub fn cursorHome(self: *TextInputState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + self.cursor = 0; + } + + /// Move cursor to end + pub fn cursorEnd(self: *TextInputState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + self.cursor = self.len; + } + + /// Select all text + pub fn selectAll(self: *TextInputState) void { + self.selection_start = 0; + self.cursor = self.len; + } +}; + +/// Text input configuration +pub const TextInputConfig = struct { + /// Placeholder text when empty + placeholder: []const u8 = "", + /// Read-only mode + readonly: bool = false, + /// Password mode (show dots instead of text) + password: bool = false, + /// Padding inside the input + padding: u32 = 4, +}; + +/// Result of text input widget +pub const TextInputResult = struct { + /// Text was changed this frame + changed: bool, + /// Enter was pressed + submitted: bool, + /// Widget was clicked (for focus management) + clicked: bool, +}; + +/// Draw a text input and return interaction result +pub fn textInput(ctx: *Context, state: *TextInputState) TextInputResult { + return textInputEx(ctx, state, .{}); +} + +/// Draw a text input with custom configuration +pub fn textInputEx(ctx: *Context, state: *TextInputState, config: TextInputConfig) TextInputResult { + const bounds = ctx.layout.nextRect(); + return textInputRect(ctx, bounds, state, config); +} + +/// Draw a text input in a specific rectangle +pub fn textInputRect( + ctx: *Context, + bounds: Layout.Rect, + state: *TextInputState, + config: TextInputConfig, +) TextInputResult { + var result = TextInputResult{ + .changed = false, + .submitted = false, + .clicked = false, + }; + + if (bounds.isEmpty()) return result; + + const id = ctx.getId(state.buffer.ptr[0..1]); + _ = id; + + // Check mouse interaction + const mouse = ctx.input.mousePos(); + const hovered = bounds.contains(mouse.x, mouse.y); + const clicked = hovered and ctx.input.mousePressed(.left); + + if (clicked) { + state.focused = true; + result.clicked = true; + } + + // Theme colors + const theme = Style.Theme.dark; + const bg_color = if (state.focused) theme.input_bg.lighten(5) else theme.input_bg; + const border_color = if (state.focused) theme.primary else theme.input_border; + const text_color = theme.input_fg; + const placeholder_color = theme.secondary; + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); + + // Draw border + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Inner area + const inner = bounds.shrink(config.padding); + if (inner.isEmpty()) return result; + + // Handle text input if focused + if (state.focused and !config.readonly) { + // Handle typed text + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + state.insert(text_in); + result.changed = true; + } + } + + // Draw text or placeholder + const display_text = if (state.len == 0) + config.placeholder + else + state.text(); + + const display_color = if (state.len == 0) placeholder_color else text_color; + + // Calculate text position (left-aligned, vertically centered) + const char_height: u32 = 8; + const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2)); + + if (config.password and state.len > 0) { + // Draw dots for password + var dots: [256]u8 = undefined; + const dot_count = @min(state.len, dots.len); + @memset(dots[0..dot_count], '*'); + ctx.pushCommand(Command.text(inner.x, text_y, dots[0..dot_count], display_color)); + } else { + ctx.pushCommand(Command.text(inner.x, text_y, display_text, display_color)); + } + + // Draw cursor if focused + if (state.focused and !config.readonly) { + const char_width: u32 = 8; + const cursor_x = inner.x + @as(i32, @intCast(state.cursor * char_width)); + const cursor_color = theme.foreground; + + ctx.pushCommand(Command.rect( + cursor_x, + inner.y, + 2, + inner.h, + cursor_color, + )); + } + + // Draw selection if any + if (state.selection_start) |sel_start| { + const char_width: u32 = 8; + const start = @min(sel_start, state.cursor); + const end = @max(sel_start, state.cursor); + const sel_x = inner.x + @as(i32, @intCast(start * char_width)); + const sel_w: u32 = @intCast((end - start) * char_width); + + if (sel_w > 0) { + ctx.pushCommand(Command.rect( + sel_x, + inner.y, + sel_w, + inner.h, + theme.selection_bg.blend(Style.Color.rgba(0, 0, 0, 128)), + )); + } + } + + return result; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "TextInputState insert" { + var buf: [64]u8 = undefined; + var state = TextInputState.init(&buf); + + state.insert("Hello"); + try std.testing.expectEqualStrings("Hello", state.text()); + try std.testing.expectEqual(@as(usize, 5), state.cursor); + + state.insert(" World"); + try std.testing.expectEqualStrings("Hello World", state.text()); +} + +test "TextInputState backspace" { + var buf: [64]u8 = undefined; + var state = TextInputState.init(&buf); + + state.insert("Hello"); + state.deleteBack(); + try std.testing.expectEqualStrings("Hell", state.text()); + + state.deleteBack(); + state.deleteBack(); + try std.testing.expectEqualStrings("He", state.text()); +} + +test "TextInputState cursor movement" { + var buf: [64]u8 = undefined; + var state = TextInputState.init(&buf); + + state.insert("Hello"); + try std.testing.expectEqual(@as(usize, 5), state.cursor); + + state.cursorLeft(false); + try std.testing.expectEqual(@as(usize, 4), state.cursor); + + state.cursorHome(false); + try std.testing.expectEqual(@as(usize, 0), state.cursor); + + state.cursorEnd(false); + try std.testing.expectEqual(@as(usize, 5), state.cursor); +} + +test "TextInputState selection" { + var buf: [64]u8 = undefined; + var state = TextInputState.init(&buf); + + state.insert("Hello"); + state.selectAll(); + + try std.testing.expectEqual(@as(?usize, 0), state.selection_start); + try std.testing.expectEqual(@as(usize, 5), state.cursor); + + state.insert("X"); + try std.testing.expectEqualStrings("X", state.text()); +} + +test "textInput generates commands" { + var ctx = Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var buf: [64]u8 = undefined; + var state = TextInputState.init(&buf); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = textInput(&ctx, &state); + + // Should generate: rect (bg) + rect_outline (border) + text (placeholder) + try std.testing.expect(ctx.commands.items.len >= 2); + + ctx.endFrame(); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig new file mode 100644 index 0000000..544192f --- /dev/null +++ b/src/widgets/widgets.zig @@ -0,0 +1,112 @@ +//! Widgets - All widget modules +//! +//! This module re-exports all widgets for convenient access. + +const std = @import("std"); + +// ============================================================================= +// Widget modules +// ============================================================================= + +pub const label = @import("label.zig"); +pub const button = @import("button.zig"); +pub const text_input = @import("text_input.zig"); +pub const checkbox = @import("checkbox.zig"); +pub const select = @import("select.zig"); +pub const list = @import("list.zig"); +pub const focus = @import("focus.zig"); +pub const table = @import("table.zig"); +pub const split = @import("split.zig"); +pub const panel = @import("panel.zig"); +pub const modal = @import("modal.zig"); +pub const autocomplete = @import("autocomplete.zig"); + +// ============================================================================= +// Re-exports for convenience +// ============================================================================= + +// Label +pub const Label = label; +pub const Alignment = label.Alignment; +pub const LabelConfig = label.LabelConfig; + +// Button +pub const Button = button; +pub const ButtonConfig = button.ButtonConfig; +pub const Importance = button.Importance; + +// TextInput +pub const TextInput = text_input; +pub const TextInputState = text_input.TextInputState; +pub const TextInputConfig = text_input.TextInputConfig; +pub const TextInputResult = text_input.TextInputResult; + +// Checkbox +pub const Checkbox = checkbox; +pub const CheckboxConfig = checkbox.CheckboxConfig; + +// Select +pub const Select = select; +pub const SelectState = select.SelectState; +pub const SelectConfig = select.SelectConfig; +pub const SelectResult = select.SelectResult; + +// List +pub const List = list; +pub const ListState = list.ListState; +pub const ListConfig = list.ListConfig; +pub const ListResult = list.ListResult; + +// Focus +pub const Focus = focus; +pub const FocusManager = focus.FocusManager; +pub const FocusRing = focus.FocusRing; + +// Table +pub const Table = table; +pub const TableState = table.TableState; +pub const TableConfig = table.TableConfig; +pub const TableColors = table.TableColors; +pub const TableResult = table.TableResult; +pub const Column = table.Column; +pub const ColumnType = table.ColumnType; +pub const RowState = table.RowState; + +// Split +pub const Split = split; +pub const SplitState = split.SplitState; +pub const SplitConfig = split.SplitConfig; +pub const SplitResult = split.SplitResult; +pub const SplitDirection = split.Direction; + +// Panel +pub const Panel = panel; +pub const PanelState = panel.PanelState; +pub const PanelConfig = panel.PanelConfig; +pub const PanelColors = panel.PanelColors; +pub const PanelResult = panel.PanelResult; + +// Modal +pub const Modal = modal; +pub const ModalState = modal.ModalState; +pub const ModalConfig = modal.ModalConfig; +pub const ModalColors = modal.ModalColors; +pub const ModalResult = modal.ModalResult; +pub const ModalButton = modal.ModalButton; +pub const ButtonSet = modal.ButtonSet; + +// AutoComplete +pub const AutoComplete = autocomplete; +pub const AutoCompleteState = autocomplete.AutoCompleteState; +pub const AutoCompleteConfig = autocomplete.AutoCompleteConfig; +pub const AutoCompleteColors = autocomplete.AutoCompleteColors; +pub const AutoCompleteResult = autocomplete.AutoCompleteResult; +pub const MatchMode = autocomplete.MatchMode; + +// ============================================================================= +// Tests +// ============================================================================= + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/zcatgui.zig b/src/zcatgui.zig index 6cceb76..ff97644 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -65,18 +65,38 @@ pub const backend = struct { }; // ============================================================================= -// Widgets (to be implemented) +// Widgets // ============================================================================= -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; -}; +pub const widgets = @import("widgets/widgets.zig"); + +// Re-export common widget types +pub const label = widgets.label.label; +pub const labelEx = widgets.label.labelEx; +pub const labelColored = widgets.label.labelColored; +pub const labelCentered = widgets.label.labelCentered; + +pub const button = widgets.button.button; +pub const buttonEx = widgets.button.buttonEx; +pub const buttonPrimary = widgets.button.buttonPrimary; +pub const buttonDanger = widgets.button.buttonDanger; + +pub const textInput = widgets.text_input.textInput; +pub const textInputEx = widgets.text_input.textInputEx; +pub const TextInputState = widgets.TextInputState; + +pub const checkbox = widgets.checkbox.checkbox; +pub const checkboxEx = widgets.checkbox.checkboxEx; + +pub const select = widgets.select.select; +pub const selectEx = widgets.select.selectEx; +pub const SelectState = widgets.SelectState; + +pub const list = widgets.list.list; +pub const listEx = widgets.list.listEx; +pub const ListState = widgets.ListState; + +pub const FocusManager = widgets.FocusManager; +pub const FocusRing = widgets.FocusRing; // ============================================================================= // Re-exports for convenience