diff --git a/src/panels/detail/base.zig b/src/panels/detail/base.zig new file mode 100644 index 0000000..ccf5061 --- /dev/null +++ b/src/panels/detail/base.zig @@ -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); +} diff --git a/src/panels/detail/detail.zig b/src/panels/detail/detail.zig new file mode 100644 index 0000000..5f51a2a --- /dev/null +++ b/src/panels/detail/detail.zig @@ -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()); +} diff --git a/src/panels/detail/state.zig b/src/panels/detail/state.zig new file mode 100644 index 0000000..ac9429e --- /dev/null +++ b/src/panels/detail/state.zig @@ -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()); +} diff --git a/src/panels/panels.zig b/src/panels/panels.zig index e441ade..a88f566 100644 --- a/src/panels/panels.zig +++ b/src/panels/panels.zig @@ -4,6 +4,7 @@ //! - AutonomousPanel: Self-contained UI component //! - Composite patterns: Vertical, Horizontal, Split, Tab, Grid //! - DataManager: Observer pattern for panel communication +//! - Detail: Components for detail/edit panels (state machine, semaphore, buttons) //! //! Architecture based on Simifactu's Lego Panels system: //! - Panels are autonomous (own state, UI, logic) @@ -20,6 +21,7 @@ const std = @import("std"); pub const panel = @import("panel.zig"); pub const composite = @import("composite.zig"); pub const data_manager = @import("data_manager.zig"); +pub const detail = @import("detail/detail.zig"); // ============================================================================= // Panel types @@ -58,6 +60,18 @@ pub const ObserverCallback = data_manager.ObserverCallback; pub const getDataManager = data_manager.getDataManager; 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 // =============================================================================