feat(panels): Add DetailPanelBase for edit/detail panels

New module panels/detail/ with:
- DetailPanelState: 8-state machine for edit lifecycle
  (empty, viewing, editing, new, saving, saved, error_save, deleting)
- DetailPanelBase: Common functionality for detail panels
  - Semaphore visual indicator (color + text)
  - Action buttons (New, Save, Delete)
  - Navigation buttons (|< < > >|)
  - Progressive escape handling
  - Saved state timer (2s transition)

Enables code reuse across detail panels (Customer, Document, Product, etc.)

🤖 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-14 12:30:56 +01:00
parent 7aab1ef7c9
commit 7f5550dd1f
4 changed files with 665 additions and 0 deletions

461
src/panels/detail/base.zig Normal file
View file

@ -0,0 +1,461 @@
//! DetailPanelBase - Funcionalidad comun para paneles de detalle/edicion
//!
//! Proporciona:
//! - Maquina de estados (8 estados)
//! - Semaforo visual (indicador de estado)
//! - Botones de accion (Nuevo, Guardar, Eliminar)
//! - Botones de navegacion (|< < > >|)
//! - Escape progresivo
//! - Timer de estado saved
//!
//! Uso: Incluir como campo en paneles de detalle concretos.
//!
//! ```zig
//! const WhoDetailPanel = struct {
//! base: DetailPanelBase,
//! // campos especificos...
//!
//! pub fn draw(self: *Self, ctx: *Context, rect: Rect) void {
//! self.base.updateSavedTimer();
//! self.base.drawSemaphore(ctx, rect);
//! // dibujar campos especificos...
//! const actions = self.base.drawActionButtons(ctx, rect, y, .{...});
//! if (actions.save_clicked) self.save();
//! }
//! };
//! ```
const std = @import("std");
const zcatgui = @import("../../zcatgui.zig");
const Context = zcatgui.Context;
const Rect = zcatgui.Rect;
const Color = zcatgui.Color;
const widgets = zcatgui.widgets;
const state_module = @import("state.zig");
pub const DetailPanelState = state_module.DetailPanelState;
// =============================================================================
// Result Types
// =============================================================================
/// Resultado del escape progresivo
pub const EscapeResult = enum {
/// Se revirtieron los cambios pendientes
consumed_revert,
/// Se limpio el formulario
consumed_clear,
/// El panel no consumio ESC (pasar al nivel superior)
not_consumed,
};
/// Resultado de los botones de accion
pub const ActionButtonsResult = struct {
new_clicked: bool = false,
save_clicked: bool = false,
delete_clicked: bool = false,
};
/// Resultado de los botones de navegacion
pub const NavButtonsResult = struct {
first_clicked: bool = false,
prev_clicked: bool = false,
next_clicked: bool = false,
last_clicked: bool = false,
};
/// Opciones para botones de accion
pub const ActionButtonsOpts = struct {
/// Puede guardar (hay cambios y datos validos)
can_save: bool = false,
/// Puede eliminar (registro existente)
can_delete: bool = false,
/// Ancho de cada boton
button_width: u32 = 80,
/// Alto de cada boton
button_height: u32 = 28,
/// Espacio entre botones
spacing: i32 = 8,
};
/// Opciones para botones de navegacion
pub const NavButtonsOpts = struct {
/// Indice actual (0-based), null si no hay seleccion
current_index: ?usize = null,
/// Total de registros
total_count: usize = 0,
/// Puede ir al primero
can_first: bool = false,
/// Puede ir al anterior
can_prev: bool = false,
/// Puede ir al siguiente
can_next: bool = false,
/// Puede ir al ultimo
can_last: bool = false,
/// Ancho de cada boton
button_width: u32 = 40,
/// Alto de cada boton
button_height: u32 = 28,
};
// =============================================================================
// DetailPanelBase
// =============================================================================
/// Base para paneles de detalle/edicion de entidades
pub const DetailPanelBase = struct {
/// Estado actual del panel
state: DetailPanelState = .empty,
/// Timestamp del ultimo guardado (para timer de estado saved)
saved_timer: i64 = 0,
/// Mensaje de error actual (si hay)
error_message: ?[]const u8 = null,
const Self = @This();
// =========================================================================
// Timer de estado saved
// =========================================================================
/// Actualiza el timer del estado saved.
/// Llamar en cada frame/draw.
/// Transiciona automaticamente de saved -> viewing despues de 2 segundos.
pub fn updateSavedTimer(self: *Self) void {
if (self.state == .saved) {
const elapsed = std.time.milliTimestamp() - self.saved_timer;
if (elapsed >= 2000) {
self.state = .viewing;
}
}
}
// =========================================================================
// Transiciones de estado
// =========================================================================
/// Marca el panel como guardado exitosamente.
/// Inicia el timer para volver a viewing.
pub fn markSaved(self: *Self) void {
self.state = .saved;
self.saved_timer = std.time.milliTimestamp();
self.error_message = null;
}
/// Marca el panel con error de guardado.
pub fn markError(self: *Self, message: []const u8) void {
self.state = .error_save;
self.error_message = message;
}
/// Limpia el error y vuelve al estado anterior.
pub fn clearError(self: *Self, has_entity: bool) void {
self.error_message = null;
self.state = if (has_entity) .editing else .new;
}
/// Transiciona a estado de edicion (si estaba en viewing).
pub fn beginEditing(self: *Self) void {
if (self.state == .viewing) {
self.state = .editing;
}
}
/// Transiciona a estado nuevo.
pub fn beginNew(self: *Self) void {
self.state = .new;
self.error_message = null;
}
/// Transiciona a estado vacio.
pub fn clear(self: *Self) void {
self.state = .empty;
self.error_message = null;
}
/// Transiciona a estado viewing (despues de cargar datos).
pub fn setViewing(self: *Self) void {
self.state = .viewing;
self.error_message = null;
}
// =========================================================================
// Escape progresivo
// =========================================================================
/// Maneja el escape progresivo.
///
/// Logica:
/// 1. Si hay error -> limpiar error, quedarse en edicion
/// 2. Si hay cambios (y no es nuevo) -> revertir (caller debe hacerlo)
/// 3. Si tiene entidad o es nuevo -> limpiar formulario (caller debe hacerlo)
/// 4. Si no consumido -> devolver control (ir a panel anterior)
///
/// Params:
/// - has_changes: true si hay cambios pendientes en los campos
/// - has_entity: true si hay una entidad cargada (uuid != null)
///
/// Returns: EscapeResult indicando que accion tomar
pub fn handleEscape(self: *Self, has_changes: bool, has_entity: bool) EscapeResult {
// 1. Si hay error, limpiar y quedarse
if (self.state == .error_save) {
self.clearError(has_entity);
return .consumed_revert;
}
// 2. Si hay cambios y no es nuevo, revertir
if (has_changes and self.state != .new) {
self.state = .viewing;
return .consumed_revert; // Caller debe revertir campos
}
// 3. Si tiene entidad cargada o es nuevo, limpiar
if (has_entity or self.state == .new) {
return .consumed_clear; // Caller debe limpiar formulario
}
// 4. No consumido, pasar al nivel superior
return .not_consumed;
}
// =========================================================================
// Dibujo: Semaforo
// =========================================================================
/// Dibuja el semaforo de estado en la esquina superior izquierda del rect.
///
/// Muestra:
/// - Cuadrado de color (12x12) indicando el estado
/// - Texto del estado en la esquina superior derecha
pub fn drawSemaphore(self: *Self, ctx: *Context, rect: Rect) void {
const state_color = self.state.getColor();
const state_text = self.state.getText();
// Cuadrado de color (indicador visual)
ctx.pushCommand(.{ .rect = .{
.x = rect.x + 8,
.y = rect.y + 4,
.w = 12,
.h = 12,
.color = state_color,
} });
// Texto del estado (esquina derecha)
const text_x = rect.x + @as(i32, @intCast(rect.w)) - 80;
ctx.pushCommand(.{ .text = .{
.x = text_x,
.y = rect.y + 4,
.text = state_text,
.color = state_color,
} });
}
// =========================================================================
// Dibujo: Botones de accion
// =========================================================================
/// Dibuja los botones de accion (Nuevo, Guardar, Eliminar).
///
/// Params:
/// - ctx: Contexto de dibujo
/// - rect: Rectangulo del panel
/// - y: Posicion Y donde dibujar los botones
/// - opts: Opciones (can_save, can_delete, dimensiones)
///
/// Returns: ActionButtonsResult con los botones clickeados
pub fn drawActionButtons(self: *Self, ctx: *Context, rect: Rect, y: i32, opts: ActionButtonsOpts) ActionButtonsResult {
_ = self;
var result = ActionButtonsResult{};
const x = rect.x + 8;
const bw = opts.button_width;
const bh = opts.button_height;
const sp = opts.spacing;
// Boton Nuevo (siempre habilitado)
if (widgets.button.buttonRect(ctx, .{
.x = x,
.y = y,
.w = bw,
.h = bh,
}, "Nuevo", .{})) {
result.new_clicked = true;
}
// Boton Guardar
if (widgets.button.buttonRect(ctx, .{
.x = x + @as(i32, @intCast(bw)) + sp,
.y = y,
.w = bw,
.h = bh,
}, "Guardar", .{ .disabled = !opts.can_save })) {
result.save_clicked = true;
}
// Boton Eliminar
if (widgets.button.buttonRect(ctx, .{
.x = x + @as(i32, @intCast(bw)) * 2 + sp * 2,
.y = y,
.w = bw,
.h = bh,
}, "Eliminar", .{ .disabled = !opts.can_delete })) {
result.delete_clicked = true;
}
return result;
}
// =========================================================================
// Dibujo: Botones de navegacion
// =========================================================================
/// Dibuja los botones de navegacion (|< < > >|) y el indicador de posicion.
///
/// Params:
/// - ctx: Contexto de dibujo
/// - rect: Rectangulo del panel
/// - y: Posicion Y donde dibujar los botones
/// - opts: Opciones de navegacion
///
/// Returns: NavButtonsResult con los botones clickeados
pub fn drawNavButtons(self: *Self, ctx: *Context, rect: Rect, y: i32, opts: NavButtonsOpts) NavButtonsResult {
_ = self;
var result = NavButtonsResult{};
const x = rect.x + 8;
const bw = opts.button_width;
const bh = opts.button_height;
// |< Primero
if (widgets.button.buttonRect(ctx, .{
.x = x,
.y = y,
.w = bw,
.h = bh,
}, "|<", .{ .disabled = !opts.can_first })) {
result.first_clicked = true;
}
// < Anterior
if (widgets.button.buttonRect(ctx, .{
.x = x + @as(i32, @intCast(bw)) + 4,
.y = y,
.w = bw,
.h = bh,
}, "<", .{ .disabled = !opts.can_prev })) {
result.prev_clicked = true;
}
// > Siguiente
if (widgets.button.buttonRect(ctx, .{
.x = x + @as(i32, @intCast(bw)) * 2 + 8,
.y = y,
.w = bw,
.h = bh,
}, ">", .{ .disabled = !opts.can_next })) {
result.next_clicked = true;
}
// >| Ultimo
if (widgets.button.buttonRect(ctx, .{
.x = x + @as(i32, @intCast(bw)) * 3 + 12,
.y = y,
.w = bw,
.h = bh,
}, ">|", .{ .disabled = !opts.can_last })) {
result.last_clicked = true;
}
// Indicador de posicion (ej: "15/520")
if (opts.current_index != null and opts.total_count > 0) {
const theme = zcatgui.Style.currentTheme();
var pos_buf: [32]u8 = undefined;
const pos_text = std.fmt.bufPrint(&pos_buf, "{d}/{d}", .{
opts.current_index.? + 1,
opts.total_count,
}) catch "?/?";
const pos_x = x + @as(i32, @intCast(bw)) * 4 + 24;
ctx.pushCommand(.{ .text = .{
.x = pos_x,
.y = y + 6,
.text = pos_text,
.color = theme.text_secondary,
} });
}
return result;
}
// =========================================================================
// Dibujo: Mensaje de error
// =========================================================================
/// Dibuja el mensaje de error si hay uno.
pub fn drawErrorMessage(self: *Self, ctx: *Context, rect: Rect, y: i32) void {
if (self.state == .error_save) {
if (self.error_message) |msg| {
ctx.pushCommand(.{ .text = .{
.x = rect.x + 8,
.y = y,
.text = msg,
.color = Color{ .r = 231, .g = 76, .b = 60, .a = 255 },
} });
}
}
}
};
// =============================================================================
// Tests
// =============================================================================
test "DetailPanelBase state transitions" {
var base = DetailPanelBase{};
try std.testing.expectEqual(DetailPanelState.empty, base.state);
base.setViewing();
try std.testing.expectEqual(DetailPanelState.viewing, base.state);
base.beginEditing();
try std.testing.expectEqual(DetailPanelState.editing, base.state);
base.markSaved();
try std.testing.expectEqual(DetailPanelState.saved, base.state);
base.clear();
try std.testing.expectEqual(DetailPanelState.empty, base.state);
}
test "DetailPanelBase error handling" {
var base = DetailPanelBase{};
base.markError("Test error");
try std.testing.expectEqual(DetailPanelState.error_save, base.state);
try std.testing.expect(base.error_message != null);
base.clearError(true);
try std.testing.expectEqual(DetailPanelState.editing, base.state);
try std.testing.expect(base.error_message == null);
}
test "DetailPanelBase escape logic" {
var base = DetailPanelBase{};
// Empty panel -> not consumed
try std.testing.expectEqual(EscapeResult.not_consumed, base.handleEscape(false, false));
// Viewing with entity -> clear
base.state = .viewing;
try std.testing.expectEqual(EscapeResult.consumed_clear, base.handleEscape(false, true));
// Editing with changes -> revert
base.state = .editing;
try std.testing.expectEqual(EscapeResult.consumed_revert, base.handleEscape(true, true));
// Error -> revert (clear error)
base.state = .error_save;
base.error_message = "test";
try std.testing.expectEqual(EscapeResult.consumed_revert, base.handleEscape(false, true));
try std.testing.expect(base.error_message == null);
}

View file

@ -0,0 +1,53 @@
//! Detail Panel Module - Componentes para paneles de detalle/edicion
//!
//! Este modulo proporciona la infraestructura comun para paneles que
//! muestran y editan fichas/registros individuales (ej: Cliente, Documento, Producto).
//!
//! Componentes:
//! - DetailPanelState: Maquina de 8 estados para ciclo de vida de edicion
//! - DetailPanelBase: Funcionalidad comun (semaforo, botones, escape progresivo)
//!
//! Ejemplo de uso:
//! ```zig
//! const detail = @import("zcatgui").panels.detail;
//!
//! pub const CustomerDetailPanel = struct {
//! base: detail.DetailPanelBase,
//! name_input: widgets.TextInputState,
//! // ...
//!
//! pub fn draw(self: *Self, ctx: *Context, rect: Rect) void {
//! self.base.updateSavedTimer();
//! self.base.drawSemaphore(ctx, rect);
//! // dibujar campos...
//! const actions = self.base.drawActionButtons(ctx, rect, y, .{
//! .can_save = self.base.state.canSave(),
//! .can_delete = self.base.state.canDelete(),
//! });
//! if (actions.save_clicked) self.save();
//! }
//! };
//! ```
const std = @import("std");
// Re-exports
pub const state = @import("state.zig");
pub const base = @import("base.zig");
// Tipos principales
pub const DetailPanelState = state.DetailPanelState;
pub const DetailPanelBase = base.DetailPanelBase;
pub const EscapeResult = base.EscapeResult;
pub const ActionButtonsResult = base.ActionButtonsResult;
pub const NavButtonsResult = base.NavButtonsResult;
pub const ActionButtonsOpts = base.ActionButtonsOpts;
pub const NavButtonsOpts = base.NavButtonsOpts;
// =============================================================================
// Tests
// =============================================================================
test {
std.testing.refAllDecls(@This());
}

137
src/panels/detail/state.zig Normal file
View file

@ -0,0 +1,137 @@
//! DetailPanelState - Maquina de estados para paneles de detalle/edicion
//!
//! Define los 8 estados posibles de un panel de edicion de fichas
//! y sus propiedades visuales (color, simbolo, texto).
//!
//! Uso tipico: paneles de detalle de entidades (Cliente, Documento, Producto)
const zcatgui = @import("../../zcatgui.zig");
const Color = zcatgui.Color;
/// Estados posibles de un panel de detalle/edicion
pub const DetailPanelState = enum {
/// Panel vacio, sin datos cargados
empty,
/// Mostrando registro existente (sin cambios pendientes)
viewing,
/// Registro existente con cambios pendientes
editing,
/// Creando registro nuevo (antes de guardar)
new,
/// Guardando en BD (estado transitorio)
saving,
/// Guardado exitoso (temporal ~2 segundos)
saved,
/// Error al guardar
error_save,
/// Confirmando eliminacion
deleting,
/// Retorna el color del semaforo para este estado
pub fn getColor(self: DetailPanelState) Color {
return switch (self) {
.empty => Color{ .r = 128, .g = 128, .b = 128, .a = 255 }, // Gris
.viewing => Color{ .r = 39, .g = 174, .b = 96, .a = 255 }, // Verde
.editing => Color{ .r = 241, .g = 196, .b = 15, .a = 255 }, // Amarillo
.new => Color{ .r = 52, .g = 152, .b = 219, .a = 255 }, // Azul
.saving => Color{ .r = 241, .g = 196, .b = 15, .a = 255 }, // Amarillo
.saved => Color{ .r = 46, .g = 204, .b = 113, .a = 255 }, // Verde brillante
.error_save => Color{ .r = 231, .g = 76, .b = 60, .a = 255 }, // Rojo
.deleting => Color{ .r = 231, .g = 76, .b = 60, .a = 255 }, // Rojo
};
}
/// Retorna el simbolo del semaforo para este estado (ASCII safe)
pub fn getSymbol(self: DetailPanelState) []const u8 {
return switch (self) {
.empty => "o", // Circulo vacio
.viewing => "*", // Circulo lleno
.editing => "~", // Modificado
.new => "+", // Nuevo
.saving => "...", // Procesando
.saved => "OK", // Exito
.error_save => "X", // Error
.deleting => "!", // Advertencia
};
}
/// Retorna el texto descriptivo del estado
pub fn getText(self: DetailPanelState) []const u8 {
return switch (self) {
.empty => "Vacio",
.viewing => "Viendo",
.editing => "Editando",
.new => "Nuevo",
.saving => "Guardando...",
.saved => "Guardado",
.error_save => "Error",
.deleting => "Eliminar?",
};
}
/// Verifica si el estado permite guardar
pub fn canSave(self: DetailPanelState) bool {
return self == .editing or self == .new;
}
/// Verifica si el estado permite eliminar
pub fn canDelete(self: DetailPanelState) bool {
return self == .viewing or self == .editing;
}
/// Verifica si el estado tiene cambios pendientes
pub fn isDirty(self: DetailPanelState) bool {
return self == .editing or self == .new;
}
/// Verifica si el estado es de error
pub fn isError(self: DetailPanelState) bool {
return self == .error_save;
}
/// Verifica si el panel tiene datos cargados
pub fn hasData(self: DetailPanelState) bool {
return self != .empty;
}
};
// =============================================================================
// Tests
// =============================================================================
const std = @import("std");
test "DetailPanelState colors" {
// Verificar que cada estado tiene un color definido
const states = [_]DetailPanelState{
.empty, .viewing, .editing, .new, .saving, .saved, .error_save, .deleting,
};
for (states) |state| {
const color = state.getColor();
// Alpha siempre debe ser 255
try std.testing.expectEqual(@as(u8, 255), color.a);
}
}
test "DetailPanelState canSave" {
try std.testing.expect(!DetailPanelState.empty.canSave());
try std.testing.expect(!DetailPanelState.viewing.canSave());
try std.testing.expect(DetailPanelState.editing.canSave());
try std.testing.expect(DetailPanelState.new.canSave());
try std.testing.expect(!DetailPanelState.saved.canSave());
}
test "DetailPanelState canDelete" {
try std.testing.expect(!DetailPanelState.empty.canDelete());
try std.testing.expect(DetailPanelState.viewing.canDelete());
try std.testing.expect(DetailPanelState.editing.canDelete());
try std.testing.expect(!DetailPanelState.new.canDelete());
}
test "DetailPanelState isDirty" {
try std.testing.expect(!DetailPanelState.empty.isDirty());
try std.testing.expect(!DetailPanelState.viewing.isDirty());
try std.testing.expect(DetailPanelState.editing.isDirty());
try std.testing.expect(DetailPanelState.new.isDirty());
}

View file

@ -4,6 +4,7 @@
//! - AutonomousPanel: Self-contained UI component //! - AutonomousPanel: Self-contained UI component
//! - Composite patterns: Vertical, Horizontal, Split, Tab, Grid //! - Composite patterns: Vertical, Horizontal, Split, Tab, Grid
//! - DataManager: Observer pattern for panel communication //! - DataManager: Observer pattern for panel communication
//! - Detail: Components for detail/edit panels (state machine, semaphore, buttons)
//! //!
//! Architecture based on Simifactu's Lego Panels system: //! Architecture based on Simifactu's Lego Panels system:
//! - Panels are autonomous (own state, UI, logic) //! - Panels are autonomous (own state, UI, logic)
@ -20,6 +21,7 @@ const std = @import("std");
pub const panel = @import("panel.zig"); pub const panel = @import("panel.zig");
pub const composite = @import("composite.zig"); pub const composite = @import("composite.zig");
pub const data_manager = @import("data_manager.zig"); pub const data_manager = @import("data_manager.zig");
pub const detail = @import("detail/detail.zig");
// ============================================================================= // =============================================================================
// Panel types // Panel types
@ -58,6 +60,18 @@ pub const ObserverCallback = data_manager.ObserverCallback;
pub const getDataManager = data_manager.getDataManager; pub const getDataManager = data_manager.getDataManager;
pub const setDataManager = data_manager.setDataManager; pub const setDataManager = data_manager.setDataManager;
// =============================================================================
// Detail Panel types (for edit/detail panels)
// =============================================================================
pub const DetailPanelState = detail.DetailPanelState;
pub const DetailPanelBase = detail.DetailPanelBase;
pub const EscapeResult = detail.EscapeResult;
pub const ActionButtonsResult = detail.ActionButtonsResult;
pub const NavButtonsResult = detail.NavButtonsResult;
pub const ActionButtonsOpts = detail.ActionButtonsOpts;
pub const NavButtonsOpts = detail.NavButtonsOpts;
// ============================================================================= // =============================================================================
// Tests // Tests
// ============================================================================= // =============================================================================