Cambios: - base.zig: Añadido x_offset a ActionButtonsOpts y NavButtonsOpts - text_input.zig: char_height 8→16 para fuente 8x16 - table/render.zig: char_height 8→16 (header y celdas) El centrado vertical del texto ahora funciona correctamente con fuentes 8x16 (VGA standard). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
16 KiB
Zig
465 lines
16 KiB
Zig
//! 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);
|
|
}
|