zcatgui/src/panels/detail/base.zig
reugenio 1a5529dd5b feat: Centrado vertical texto y x_offset para botones
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>
2025-12-14 21:22:23 +01:00

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