diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ccc6f..0cae49d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ | 2025-12-19 | v0.22.2 | Cursor blink rate: 500ms→300ms (más responsive durante edición) | | 2025-12-30 | v0.23.0 | ⭐ FilledTriangle primitive: scanline rasterizer for 3D graphics | | 2025-12-30 | v0.24.0 | ⭐ FilledCircle primitive: Midpoint Circle Algorithm (Bresenham) | +| 2025-12-30 | v0.25.0 | ⭐ IdleCompanion widget: mascota animada que aparece tras inactividad | --- @@ -86,3 +87,30 @@ Nuevas primitivas para gráficos 2D y mascotas animadas: → Archivos: `core/command.zig`, `render/software.zig` → Doc: `zsimifactu/docs/PLAN_CIRCULOS_Y_ZCAT_2025-12-30.md` + +### v0.25.0 - IdleCompanion Widget (2025-12-30) +Sistema de mascota animada reutilizable para cualquier aplicación: + +- **Características:** + - Aparece tras periodo configurable de inactividad (default: 15s) + - Se asoma por bordes de paneles/rectángulos aleatorios + - Animación suave con clipping correcto (respeta bordes) + - Ojos que miran a los lados (izq/centro/der) + - "Salto de pánico" al detectar actividad del usuario + - Diseño de gato con orejas puntiagudas, pupilas verticales, mejillas y bigotes + +- **Estados de animación:** + - `hidden`: Esperando idle + - `peeking`: Asomándose lentamente (4s) + - `watching`: Completamente visible, mirando alrededor (3s) + - `hiding`: Bajando normal (1s) o salto de pánico (0.2s) + +- **Uso:** + ```zig + const IdleCompanion = zcatgui.widgets.idle_companion; + var state: IdleCompanion.State = .{}; + const panels = [_]IdleCompanion.Rect{ ... }; + IdleCompanion.draw(ctx, &panels, &state, last_activity, color); + ``` + +→ Archivo: `widgets/idle_companion.zig` diff --git a/CLAUDE.md b/CLAUDE.md index be154af..fc7f616 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,9 +247,9 @@ zcatgui/ --- -## ESTADO ACTUAL (v0.21.2) +## ESTADO ACTUAL (v0.25.0) -### Widgets (37 total) +### Widgets (38 total) | Categoría | Widgets | |-----------|---------| @@ -258,7 +258,7 @@ zcatgui/ | **Datos** | List, Table, Tree, ReorderableList, VirtualScroll | | **Feedback** | Progress, Tooltip, Toast, Spinner | | **Input avanzado** | AutoComplete, Select, TextArea, ColorPicker, DatePicker | -| **Especial** | Image, Icon, Canvas, Chart, RichText | +| **Especial** | Image, Icon, Canvas, Chart, RichText, **IdleCompanion** | | **Navegación** | Breadcrumb, Focus, Badge/TagGroup | ### Backends (5 plataformas) @@ -285,6 +285,20 @@ zcatgui/ ## HITOS RECIENTES +### IdleCompanion Widget ✅ (2025-12-30) +Mascota animada que aparece tras inactividad del usuario: +- Se asoma por bordes de paneles aleatorios +- Ojos que miran a los lados, orejas con movimiento +- Salto de pánico al detectar actividad +- Clipping correcto (respeta límites del panel) +→ Archivo: `widgets/idle_companion.zig` + +### Primitivas Gráficas 2D ✅ (2025-12-30) +Nuevas primitivas para formas orgánicas: +- **FilledTriangle**: Rasterización por scanlines (v0.23.0) +- **FilledCircle**: Algoritmo Midpoint/Bresenham (v0.24.0) +→ Archivos: `core/command.zig`, `render/software.zig` + ### Refactorización Modular ✅ (2025-12-29) Archivos grandes modularizados en carpetas: - `autocomplete/` (910→571 LOC hub, -37%): state, types, filtering diff --git a/src/widgets/idle_companion.zig b/src/widgets/idle_companion.zig new file mode 100644 index 0000000..7a5585b --- /dev/null +++ b/src/widgets/idle_companion.zig @@ -0,0 +1,474 @@ +//! Idle Companion System - Zcat "The Drifter" +//! +//! Un sistema de mascota animada que aparece cuando el usuario está inactivo. +//! El gatito se asoma por los bordes de rectángulos definidos, mira alrededor, +//! y desaparece con un "salto de pánico" si detecta actividad. +//! +//! ## Características +//! - Aparece tras periodo configurable de inactividad (default: 15s) +//! - Se asoma por bordes de paneles/rectángulos aleatorios +//! - Animación suave con clipping correcto +//! - Ojos que miran a los lados +//! - "Salto de pánico" al detectar actividad del usuario +//! - Diseño de gato con orejas puntiagudas, pupilas verticales, mejillas y bigotes +//! +//! ## Uso básico +//! ```zig +//! const zcatgui = @import("zcatgui"); +//! const IdleCompanion = zcatgui.widgets.IdleCompanion; +//! +//! // Estado persistente +//! var companion_state: IdleCompanion.State = .{}; +//! +//! // Lista de rectángulos donde puede aparecer +//! const panels = [_]zcatgui.Rect{ +//! .{ .x = 0, .y = 50, .w = 300, .h = 400 }, +//! .{ .x = 304, .y = 50, .w = 300, .h = 200 }, +//! }; +//! +//! // En el loop de renderizado: +//! IdleCompanion.draw(ctx, &panels, &companion_state, last_activity_timestamp, theme.primary); +//! ``` +//! +//! ## Personalización +//! Los tiempos son configurables mediante constantes en State: +//! - IDLE_THRESHOLD_MS: Tiempo de inactividad para aparecer (15s) +//! - PEEK_DURATION_MS: Duración del asomarse (4s) +//! - WATCH_DURATION_MS: Duración mirando alrededor (3s) +//! - HIDE_DURATION_MS: Duración al esconderse (1s) +//! - PANIC_HIDE_DURATION_MS: Duración del salto de pánico (0.2s) +//! - RELOCATE_DELAY_MS: Pausa antes de reaparecer (5s) + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Style = @import("../core/style.zig"); +const Color = Style.Color; + +/// Rectángulo (compatible con zcatgui.Rect) +pub const Rect = struct { + x: i32, + y: i32, + w: u32, + h: u32, +}; + +/// Estado de animación del compañero +pub const AnimState = enum { + /// No visible, esperando idle + hidden, + /// Asomándose lentamente, mirando izq/der + peeking, + /// Completamente visible, mirando alrededor + watching, + /// Bajando (normal o salto de pánico) + hiding, +}; + +/// Estado completo del sistema Idle Companion +pub const State = struct { + /// Estado actual de la animación + anim_state: AnimState = .hidden, + + /// Índice del panel/rectángulo donde está el compañero + panel_index: usize = 0, + + /// Posición X relativa al panel (0.0 - 1.0) + x_ratio: f32 = 0.5, + + /// Progreso de aparición (0 = oculto, 1 = completamente visible) + appear_progress: f32 = 0.0, + + /// Dirección de mirada (-1 = izq, 0 = centro, 1 = der) + look_dir: i8 = 0, + + /// Tiempo en que entró al estado actual + state_start_time: i64 = 0, + + /// Fase de animación general (para orejas) + anim_phase: f32 = 0, + + /// Si el usuario causó actividad durante la animación (salto de pánico) + panic_jump: bool = false, + + /// Último timestamp de actividad registrado + last_known_activity: i64 = 0, + + /// Semilla para números pseudo-aleatorios (LCG simple) + random_seed: u32 = 12345, + + // ========================================================================= + // Constantes de tiempo (personalizables) + // ========================================================================= + + /// Tiempo de inactividad para aparecer (ms) + pub const IDLE_THRESHOLD_MS: i64 = 15_000; + /// Duración del estado peeking (ms) + pub const PEEK_DURATION_MS: i64 = 4_000; + /// Duración del estado watching (ms) + pub const WATCH_DURATION_MS: i64 = 3_000; + /// Duración al esconderse normal (ms) + pub const HIDE_DURATION_MS: i64 = 1_000; + /// Duración del salto de pánico (ms) + pub const PANIC_HIDE_DURATION_MS: i64 = 200; + /// Pausa antes de reaparecer en otro lugar (ms) + pub const RELOCATE_DELAY_MS: i64 = 5_000; + + /// Generador de números pseudo-aleatorios simple (LCG) + fn nextRandom(self: *State) u32 { + self.random_seed = self.random_seed *% 1103515245 +% 12345; + return self.random_seed >> 16; + } + + /// Random float 0.0 - 1.0 + fn randomFloat(self: *State) f32 { + return @as(f32, @floatFromInt(self.nextRandom() % 10000)) / 10000.0; + } + + /// Elige un panel y posición aleatorios + fn pickRandomLocation(self: *State, num_panels: usize) void { + if (num_panels == 0) return; + + // Panel aleatorio + self.panel_index = self.nextRandom() % @as(u32, @intCast(num_panels)); + + // Posición X aleatoria (25% - 75% del ancho) + self.x_ratio = 0.25 + self.randomFloat() * 0.5; + + // Resetear + self.appear_progress = 0.0; + self.look_dir = 0; + } + + /// Actualiza el estado según el tiempo de inactividad + pub fn update(self: *State, last_activity: i64, num_panels: usize) void { + const now = std.time.milliTimestamp(); + const idle_time = now - last_activity; + const state_elapsed = now - self.state_start_time; + + // Detectar si hubo nueva actividad (para salto de pánico) + if (last_activity > self.last_known_activity and self.anim_state != .hidden) { + self.panic_jump = true; + self.last_known_activity = last_activity; + if (self.anim_state != .hiding) { + self.anim_state = .hiding; + self.state_start_time = now; + } + } + self.last_known_activity = last_activity; + + // Actualizar fase de animación general + if (self.anim_state != .hidden) { + self.anim_phase = @as(f32, @floatFromInt(@mod(now, 2000))) / 2000.0 * std.math.pi * 2.0; + } + + // Máquina de estados + switch (self.anim_state) { + .hidden => { + if (idle_time >= IDLE_THRESHOLD_MS and num_panels > 0) { + self.pickRandomLocation(num_panels); + self.anim_state = .peeking; + self.state_start_time = now; + self.panic_jump = false; + } + }, + + .peeking => { + // Subir lentamente (0 -> 1) con ease-out + const progress = @as(f32, @floatFromInt(@min(state_elapsed, PEEK_DURATION_MS))) / + @as(f32, @floatFromInt(PEEK_DURATION_MS)); + self.appear_progress = 1.0 - (1.0 - progress) * (1.0 - progress); + + // Mirar a los lados + const look_cycle = @mod(state_elapsed, 2000); + if (look_cycle < 600) { + self.look_dir = -1; + } else if (look_cycle < 1200) { + self.look_dir = 1; + } else { + self.look_dir = 0; + } + + if (state_elapsed >= PEEK_DURATION_MS) { + self.anim_state = .watching; + self.state_start_time = now; + self.appear_progress = 1.0; + } + }, + + .watching => { + // Mirar alrededor más rápido + const look_cycle = @mod(state_elapsed, 1200); + if (look_cycle < 400) { + self.look_dir = -1; + } else if (look_cycle < 800) { + self.look_dir = 1; + } else { + self.look_dir = 0; + } + + if (state_elapsed >= WATCH_DURATION_MS) { + self.anim_state = .hiding; + self.state_start_time = now; + self.panic_jump = false; + } + }, + + .hiding => { + const hide_duration = if (self.panic_jump) PANIC_HIDE_DURATION_MS else HIDE_DURATION_MS; + const progress = @as(f32, @floatFromInt(@min(state_elapsed, hide_duration))) / + @as(f32, @floatFromInt(hide_duration)); + + if (self.panic_jump) { + // Salto de pánico: bajada muy rápida (ease-in) + self.appear_progress = 1.0 - progress * progress; + } else { + // Bajada suave + self.appear_progress = 1.0 - progress; + } + + if (state_elapsed >= hide_duration) { + self.anim_state = .hidden; + self.state_start_time = now + RELOCATE_DELAY_MS; + self.appear_progress = 0; + self.panic_jump = false; + } + }, + } + } + + /// Retorna true si el compañero es visible + pub fn isVisible(self: *const State) bool { + return self.anim_state != .hidden; + } +}; + +/// Dimensiones del gato +const CAT_WIDTH: i32 = 40; +const CAT_HEIGHT: i32 = 35; + +/// Dibuja el compañero idle en los paneles especificados +/// +/// Parámetros: +/// - ctx: Contexto de renderizado +/// - panels: Slice de rectángulos donde puede aparecer el compañero +/// - state: Estado persistente del compañero +/// - last_activity: Timestamp de la última actividad del usuario (ms) +/// - color: Color principal del pelaje +pub fn draw( + ctx: *Context, + panels: []const Rect, + state: *State, + last_activity: i64, + color: Color, +) void { + state.update(last_activity, panels.len); + + if (!state.isVisible()) return; + if (state.panel_index >= panels.len) return; + + const panel_rect = panels[state.panel_index]; + drawCat(ctx, panel_rect, state, color); +} + +/// Dibuja el gato asomándose por el borde inferior del panel +fn drawCat(ctx: *Context, panel_rect: Rect, state: *const State, color: Color) void { + const panel_w = @as(i32, @intCast(panel_rect.w)); + const panel_h = @as(i32, @intCast(panel_rect.h)); + + // Posición X centrada en el ratio del panel + const cat_x = panel_rect.x + @as(i32, @intFromFloat(state.x_ratio * @as(f32, @floatFromInt(panel_w)))) - CAT_WIDTH / 2; + + // Cuánto del gato es visible + const visible_amount = @as(i32, @intFromFloat(state.appear_progress * @as(f32, CAT_HEIGHT))); + + // Posición Y: sube desde el borde inferior + const cat_y = panel_rect.y + panel_h - visible_amount; + + // === ESTABLECER CLIP al interior del panel === + ctx.pushCommand(.{ .clip = .{ + .x = panel_rect.x, + .y = panel_rect.y, + .w = panel_rect.w, + .h = panel_rect.h, + } }); + + // Colores del gato + const fur_color = color; + const dark_fur = Color{ + .r = color.r -| 40, + .g = color.g -| 40, + .b = color.b -| 40, + .a = 255, + }; + const light_fur = Color{ + .r = color.r +| 30, + .g = color.g +| 20, + .b = color.b, + .a = 255, + }; + const pink = Color{ .r = 255, .g = 170, .b = 170, .a = 255 }; + const eye_white = Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; + const eye_pupil = Color{ .r = 30, .g = 30, .b = 30, .a = 255 }; + + // Offset de mirada + const look_offset: i32 = state.look_dir * 2; + + // Animación de orejas + const ear_wiggle = @as(i32, @intFromFloat(std.math.sin(state.anim_phase * 3.0) * 1.5)); + + // === OREJAS === + // Oreja izquierda + ctx.pushCommand(.{ .filled_triangle = .{ + .x1 = cat_x + 5, + .y1 = cat_y + 18, + .x2 = cat_x + 15, + .y2 = cat_y + 16, + .x3 = cat_x + 4 + ear_wiggle, + .y3 = cat_y - 2, + .color = fur_color, + } }); + ctx.pushCommand(.{ .filled_triangle = .{ + .x1 = cat_x + 7, + .y1 = cat_y + 16, + .x2 = cat_x + 13, + .y2 = cat_y + 15, + .x3 = cat_x + 6 + ear_wiggle, + .y3 = cat_y + 2, + .color = pink, + } }); + + // Oreja derecha + ctx.pushCommand(.{ .filled_triangle = .{ + .x1 = cat_x + 25, + .y1 = cat_y + 16, + .x2 = cat_x + 35, + .y2 = cat_y + 18, + .x3 = cat_x + 36 - ear_wiggle, + .y3 = cat_y - 2, + .color = fur_color, + } }); + ctx.pushCommand(.{ .filled_triangle = .{ + .x1 = cat_x + 27, + .y1 = cat_y + 15, + .x2 = cat_x + 33, + .y2 = cat_y + 16, + .x3 = cat_x + 34 - ear_wiggle, + .y3 = cat_y + 2, + .color = pink, + } }); + + // === CABEZA === + ctx.pushCommand(.{ .filled_circle = .{ + .cx = cat_x + 20, + .cy = cat_y + 20, + .radius = 14, + .color = fur_color, + } }); + + // Mejillas + ctx.pushCommand(.{ .filled_circle = .{ + .cx = cat_x + 8, + .cy = cat_y + 24, + .radius = 6, + .color = light_fur, + } }); + ctx.pushCommand(.{ .filled_circle = .{ + .cx = cat_x + 32, + .cy = cat_y + 24, + .radius = 6, + .color = light_fur, + } }); + + // === OJOS === + // Ojo izquierdo + ctx.pushCommand(.{ .filled_circle = .{ + .cx = cat_x + 13 + look_offset, + .cy = cat_y + 17, + .radius = 5, + .color = eye_white, + } }); + ctx.pushCommand(.{ .rect = .{ + .x = cat_x + 12 + look_offset, + .y = cat_y + 14, + .w = 3, + .h = 7, + .color = eye_pupil, + } }); + ctx.pushCommand(.{ .rect = .{ + .x = cat_x + 11 + look_offset, + .y = cat_y + 14, + .w = 2, + .h = 2, + .color = eye_white, + } }); + + // Ojo derecho + ctx.pushCommand(.{ .filled_circle = .{ + .cx = cat_x + 27 + look_offset, + .cy = cat_y + 17, + .radius = 5, + .color = eye_white, + } }); + ctx.pushCommand(.{ .rect = .{ + .x = cat_x + 26 + look_offset, + .y = cat_y + 14, + .w = 3, + .h = 7, + .color = eye_pupil, + } }); + ctx.pushCommand(.{ .rect = .{ + .x = cat_x + 25 + look_offset, + .y = cat_y + 14, + .w = 2, + .h = 2, + .color = eye_white, + } }); + + // === NARIZ === + ctx.pushCommand(.{ .filled_triangle = .{ + .x1 = cat_x + 17, + .y1 = cat_y + 22, + .x2 = cat_x + 23, + .y2 = cat_y + 22, + .x3 = cat_x + 20, + .y3 = cat_y + 26, + .color = pink, + } }); + + // === BOCA === + ctx.pushCommand(.{ .line = .{ + .x1 = cat_x + 20, + .y1 = cat_y + 26, + .x2 = cat_x + 15, + .y2 = cat_y + 29, + .color = dark_fur, + } }); + ctx.pushCommand(.{ .line = .{ + .x1 = cat_x + 20, + .y1 = cat_y + 26, + .x2 = cat_x + 25, + .y2 = cat_y + 29, + .color = dark_fur, + } }); + + // === BIGOTES === + // Izquierdos + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 10, .y1 = cat_y + 23, .x2 = cat_x - 8, .y2 = cat_y + 20, .color = dark_fur } }); + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 10, .y1 = cat_y + 25, .x2 = cat_x - 8, .y2 = cat_y + 25, .color = dark_fur } }); + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 10, .y1 = cat_y + 27, .x2 = cat_x - 8, .y2 = cat_y + 30, .color = dark_fur } }); + // Derechos + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 30, .y1 = cat_y + 23, .x2 = cat_x + 48, .y2 = cat_y + 20, .color = dark_fur } }); + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 30, .y1 = cat_y + 25, .x2 = cat_x + 48, .y2 = cat_y + 25, .color = dark_fur } }); + ctx.pushCommand(.{ .line = .{ .x1 = cat_x + 30, .y1 = cat_y + 27, .x2 = cat_x + 48, .y2 = cat_y + 30, .color = dark_fur } }); + + // === PATITAS === + if (state.appear_progress > 0.6) { + ctx.pushCommand(.{ .filled_circle = .{ .cx = cat_x + 8, .cy = cat_y + 32, .radius = 4, .color = fur_color } }); + ctx.pushCommand(.{ .filled_circle = .{ .cx = cat_x + 8, .cy = cat_y + 34, .radius = 2, .color = pink } }); + ctx.pushCommand(.{ .filled_circle = .{ .cx = cat_x + 32, .cy = cat_y + 32, .radius = 4, .color = fur_color } }); + ctx.pushCommand(.{ .filled_circle = .{ .cx = cat_x + 32, .cy = cat_y + 34, .radius = 2, .color = pink } }); + } + + // === RESTAURAR CLIP === + ctx.pushCommand(.clip_end); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 9d56b0d..fad5aba 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -69,6 +69,9 @@ pub const table_core = @import("table_core/table_core.zig"); // Advanced widgets pub const advanced_table = @import("advanced_table/advanced_table.zig"); +// Idle companion (mascot that appears when user is idle) +pub const idle_companion = @import("idle_companion.zig"); + // ============================================================================= // Re-exports for convenience // ============================================================================= @@ -436,6 +439,12 @@ pub const SelectableColors = selectable.Colors; pub const SelectableResult = selectable.Result; pub const SelectionGroup = selectable.SelectionGroup; +// IdleCompanion (Zcat mascot) +pub const IdleCompanion = idle_companion; +pub const IdleCompanionState = idle_companion.State; +pub const IdleCompanionAnimState = idle_companion.AnimState; +pub const IdleCompanionRect = idle_companion.Rect; + // ============================================================================= // Tests // =============================================================================