feat: zcatgui v0.5.0 - Complete widget library + research docs

Widgets implemented (13 total):
- Label: Static text with alignment
- Button: With importance levels (primary/normal/danger)
- TextInput: Single-line text entry with cursor
- Checkbox: Boolean toggle
- Select: Dropdown selection
- List: Scrollable selectable list
- Focus: Focus manager with tab navigation
- Table: Editable table with dirty tracking, keyboard nav
- Split: HSplit/VSplit draggable panels
- Panel: Container with title bar, collapsible
- Modal: Dialogs (alert, confirm, inputDialog)
- AutoComplete: ComboBox with prefix/contains/fuzzy matching

Core improvements:
- InputState now tracks keyboard state (keys_down, key_events)
- Full keyboard navigation for Table widget

Research documentation:
- WIDGET_COMPARISON.md: zcatgui vs DVUI vs Gio vs zcatui
- SIMIFACTU_ADVANCEDTABLE.md: Analysis of 10K LOC table component
- LEGO_PANELS_SYSTEM.md: Modular panel composition architecture

Examples:
- widgets_demo.zig: All basic widgets showcase
- table_demo.zig: Table, Split, Panel demonstration

All tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-09 11:00:49 +01:00
parent b352cf9672
commit 6ac3856ae2
22 changed files with 6833 additions and 51 deletions

342
CLAUDE.md
View file

@ -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
```

View file

@ -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);
}

View file

@ -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/`

View file

@ -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.

View file

@ -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)

348
examples/table_demo.zig Normal file
View file

@ -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});
}

177
examples/widgets_demo.zig Normal file
View file

@ -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", .{});
}

View file

@ -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);
}

View file

@ -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();
}

179
src/widgets/button.zig Normal file
View file

@ -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();
}

217
src/widgets/checkbox.zig Normal file
View file

@ -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);
}

272
src/widgets/focus.zig Normal file
View file

@ -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());
}

115
src/widgets/label.zig Normal file
View file

@ -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();
}

347
src/widgets/list.zig Normal file
View file

@ -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);
}

435
src/widgets/modal.zig Normal file
View file

@ -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"));
}

324
src/widgets/panel.zig Normal file
View file

@ -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);
}

309
src/widgets/select.zig Normal file
View file

@ -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());
}

324
src/widgets/split.zig Normal file
View file

@ -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();
}

983
src/widgets/table.zig Normal file
View file

@ -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();
}

426
src/widgets/text_input.zig Normal file
View file

@ -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();
}

112
src/widgets/widgets.zig Normal file
View file

@ -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());
}

View file

@ -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