//! 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, /// Offset X desde el borde izquierdo del panel x_offset: 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, /// Offset X desde el borde izquierdo del panel x_offset: i32 = 8, }; // ============================================================================= // 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 + opts.x_offset; 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 + opts.x_offset; 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); }