zcatgui/src/widgets/modal.zig
reugenio 364a7d963f feat: Paridad visual DVUI - RenderMode dual (simple/fancy)
Sistema de rendering dual para zcatgui:

Core:
- RenderMode enum (simple/fancy) en style.zig
- global_render_mode con helpers: isFancy(), setRenderMode()
- fillRoundedRect con edge-fade AA en framebuffer.zig (~350 LOC)
- Nuevos comandos: rounded_rect, rounded_rect_outline

Widgets actualizados:
- Button: corner_radius=4, usa roundedRect en fancy mode
- Panel: corner_radius=6, show_shadow=true, sombra offset 4px
- TextInput: corner_radius=3
- Select: corner_radius=3
- Modal: corner_radius=8, show_shadow=true, sombra offset 6px
  - Botones y input field del modal también redondeados

Técnica edge-fade (de DVUI):
- Anti-aliasing por gradiente alfa en bordes
- Sin supersampling, mínimo impacto en rendimiento
- Bordes suaves sin multisampling

+589 líneas, 9 archivos modificados

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 01:02:46 +01:00

475 lines
15 KiB
Zig

//! Modal Widget - Overlay dialogs
//!
//! Provides modal dialogs that render on top of other content:
//! - Modal: Dialog with title, message, and buttons
//! - Confirm: Yes/No dialog
//! - Alert: OK dialog
//! - Input: Text input dialog
//!
//! Modals block interaction with the underlying UI until dismissed.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
const Input = @import("../core/input.zig");
const button = @import("button.zig");
const text_input = @import("text_input.zig");
// =============================================================================
// Modal State
// =============================================================================
/// Modal state (caller-managed)
pub const ModalState = struct {
/// Whether the modal is visible
visible: bool = false,
/// Currently focused button index
focused_button: usize = 0,
/// For input dialogs: text state
input_state: ?*text_input.TextInputState = null,
const Self = @This();
/// Show the modal
pub fn show(self: *Self) void {
self.visible = true;
self.focused_button = 0;
}
/// Hide the modal
pub fn hide(self: *Self) void {
self.visible = false;
}
/// Focus next button
pub fn focusNext(self: *Self, button_count: usize) void {
if (button_count > 0) {
self.focused_button = (self.focused_button + 1) % button_count;
}
}
/// Focus previous button
pub fn focusPrev(self: *Self, button_count: usize) void {
if (button_count > 0) {
if (self.focused_button == 0) {
self.focused_button = button_count - 1;
} else {
self.focused_button -= 1;
}
}
}
};
// =============================================================================
// Modal Configuration
// =============================================================================
/// Modal button definition
pub const ModalButton = struct {
label: []const u8,
importance: button.Importance = .normal,
};
/// Predefined button sets
pub const ButtonSet = struct {
pub const ok = [_]ModalButton{
.{ .label = "OK", .importance = .primary },
};
pub const ok_cancel = [_]ModalButton{
.{ .label = "OK", .importance = .primary },
.{ .label = "Cancel", .importance = .normal },
};
pub const yes_no = [_]ModalButton{
.{ .label = "Yes", .importance = .primary },
.{ .label = "No", .importance = .normal },
};
pub const yes_no_cancel = [_]ModalButton{
.{ .label = "Yes", .importance = .primary },
.{ .label = "No", .importance = .normal },
.{ .label = "Cancel", .importance = .normal },
};
};
/// Modal configuration
pub const ModalConfig = struct {
/// Dialog title
title: []const u8 = "Dialog",
/// Message lines
message: []const u8 = "",
/// Dialog width
width: u32 = 300,
/// Dialog height (0 = auto)
height: u32 = 0,
/// Buttons
buttons: []const ModalButton = &ButtonSet.ok,
/// Show input field
show_input: bool = false,
/// Input placeholder
input_placeholder: []const u8 = "",
/// Corner radius (default 8 for fancy mode)
corner_radius: u8 = 8,
/// Show shadow (fancy mode only)
show_shadow: bool = true,
};
/// Modal colors
pub const ModalColors = struct {
/// Backdrop color (semi-transparent overlay)
backdrop: Style.Color = Style.Color.rgba(0, 0, 0, 180),
/// Dialog background
background: Style.Color = Style.Color.rgb(45, 45, 50),
/// Border color
border: Style.Color = Style.Color.rgb(80, 80, 85),
/// Title bar background
title_bg: Style.Color = Style.Color.rgb(55, 55, 60),
/// Title text color
title_fg: Style.Color = Style.Color.rgb(220, 220, 220),
/// Message text color
message_fg: Style.Color = Style.Color.rgb(200, 200, 200),
/// Shadow color (fancy mode only)
shadow: Style.Color = Style.Color.rgba(0, 0, 0, 80),
};
/// Modal result
pub const ModalResult = struct {
/// Button index that was clicked (-1 if none)
button_clicked: i32 = -1,
/// Whether the modal was dismissed (Escape)
dismissed: bool = false,
/// For input modals: the input text when submitted
input_text: ?[]const u8 = null,
};
// =============================================================================
// Modal Functions
// =============================================================================
/// Draw a modal dialog
pub fn modal(
ctx: *Context,
state: *ModalState,
config: ModalConfig,
) ModalResult {
return modalEx(ctx, state, config, .{});
}
/// Draw a modal dialog with custom colors
pub fn modalEx(
ctx: *Context,
state: *ModalState,
config: ModalConfig,
colors: ModalColors,
) ModalResult {
var result = ModalResult{};
if (!state.visible) return result;
const screen_w = ctx.layout.area.w;
const screen_h = ctx.layout.area.h;
// Calculate dialog dimensions
const dialog_w = @min(config.width, screen_w -| 40);
const title_h: u32 = 28;
const padding: u32 = 16;
const button_h: u32 = 32;
const input_h: u32 = if (config.show_input) 28 else 0;
// Estimate message height (rough: 16px per line, wrap at dialog width)
const msg_lines = countLines(config.message);
const msg_h: u32 = @max(1, msg_lines) * 18;
const content_h = msg_h + input_h + button_h + padding * 3;
const dialog_h = if (config.height > 0) config.height else title_h + content_h + padding;
// Center dialog
const dialog_x = @as(i32, @intCast((screen_w -| dialog_w) / 2));
const dialog_y = @as(i32, @intCast((screen_h -| dialog_h) / 2));
// Draw backdrop (semi-transparent overlay)
ctx.pushCommand(Command.rect(0, 0, screen_w, screen_h, colors.backdrop));
// Check render mode for fancy features
const fancy = Style.isFancy() and config.corner_radius > 0;
// Draw shadow first (behind dialog) in fancy mode
if (fancy and config.show_shadow) {
const shadow_offset: i32 = 6;
ctx.pushCommand(Command.roundedRect(
dialog_x + shadow_offset,
dialog_y + shadow_offset,
dialog_w,
dialog_h,
colors.shadow,
config.corner_radius,
));
}
// Draw dialog border and background based on render mode
if (fancy) {
// Fancy mode: rounded corners
ctx.pushCommand(Command.roundedRect(dialog_x, dialog_y, dialog_w, dialog_h, colors.background, config.corner_radius));
ctx.pushCommand(Command.roundedRectOutline(dialog_x, dialog_y, dialog_w, dialog_h, colors.border, config.corner_radius));
} else {
// Simple mode: square corners
ctx.pushCommand(Command.rectOutline(
dialog_x - 1,
dialog_y - 1,
dialog_w + 2,
dialog_h + 2,
colors.border,
));
ctx.pushCommand(Command.rect(dialog_x, dialog_y, dialog_w, dialog_h, colors.background));
}
// Draw title bar (inside dialog, so no rounded corners needed)
ctx.pushCommand(Command.rect(dialog_x, dialog_y, dialog_w, title_h, colors.title_bg));
// Draw title text
const title_text_x = dialog_x + @as(i32, @intCast(padding));
const title_text_y = dialog_y + @as(i32, @intCast((title_h - 8) / 2));
ctx.pushCommand(Command.text(title_text_x, title_text_y, config.title, colors.title_fg));
// Draw message
const msg_x = dialog_x + @as(i32, @intCast(padding));
var msg_y = dialog_y + @as(i32, @intCast(title_h + padding));
ctx.pushCommand(Command.text(msg_x, msg_y, config.message, colors.message_fg));
msg_y += @as(i32, @intCast(msg_h + padding));
// Draw input field if enabled
if (config.show_input) {
if (state.input_state) |input_st| {
const input_rect = Layout.Rect.init(
dialog_x + @as(i32, @intCast(padding)),
msg_y,
dialog_w -| (padding * 2),
24,
);
// Input rendering
const input_bg = Style.Color.rgb(35, 35, 40);
const input_radius: u8 = 3;
if (fancy) {
ctx.pushCommand(Command.roundedRect(input_rect.x, input_rect.y, input_rect.w, input_rect.h, input_bg, input_radius));
ctx.pushCommand(Command.roundedRectOutline(input_rect.x, input_rect.y, input_rect.w, input_rect.h, colors.border, input_radius));
} else {
ctx.pushCommand(Command.rect(input_rect.x, input_rect.y, input_rect.w, input_rect.h, input_bg));
ctx.pushCommand(Command.rectOutline(input_rect.x, input_rect.y, input_rect.w, input_rect.h, colors.border));
}
const txt = input_st.text();
if (txt.len > 0) {
ctx.pushCommand(Command.text(input_rect.x + 4, input_rect.y + 4, txt, colors.message_fg));
} else if (config.input_placeholder.len > 0) {
ctx.pushCommand(Command.text(
input_rect.x + 4,
input_rect.y + 4,
config.input_placeholder,
Style.Color.rgb(120, 120, 120),
));
}
}
msg_y += @as(i32, @intCast(input_h + padding));
}
// Draw buttons
const button_count = config.buttons.len;
if (button_count > 0) {
const btn_width: u32 = 80;
const btn_spacing: u32 = 12;
const total_btn_width = button_count * btn_width + (button_count - 1) * btn_spacing;
var btn_x = dialog_x + @as(i32, @intCast((dialog_w -| total_btn_width) / 2));
const btn_y = dialog_y + @as(i32, @intCast(dialog_h - button_h - padding));
for (config.buttons, 0..) |btn, i| {
const is_focused = state.focused_button == i;
// Button background
const btn_bg = if (is_focused)
Style.Color.primary
else switch (btn.importance) {
.primary => Style.Color.primary.darken(30),
.normal => Style.Color.rgb(60, 60, 65),
.danger => Style.Color.danger.darken(30),
};
const btn_radius: u8 = 4;
if (fancy) {
ctx.pushCommand(Command.roundedRect(btn_x, btn_y, btn_width, button_h - 4, btn_bg, btn_radius));
if (is_focused) {
ctx.pushCommand(Command.roundedRectOutline(btn_x, btn_y, btn_width, button_h - 4, Style.Color.rgb(200, 200, 200), btn_radius));
}
} else {
ctx.pushCommand(Command.rect(btn_x, btn_y, btn_width, button_h - 4, btn_bg));
if (is_focused) {
ctx.pushCommand(Command.rectOutline(btn_x, btn_y, btn_width, button_h - 4, Style.Color.rgb(200, 200, 200)));
}
}
// Button text
const text_w = btn.label.len * 8;
const text_x = btn_x + @as(i32, @intCast((btn_width -| @as(u32, @intCast(text_w))) / 2));
const text_y = btn_y + @as(i32, @intCast((button_h - 4 - 8) / 2));
ctx.pushCommand(Command.text(text_x, text_y, btn.label, Style.Color.rgb(240, 240, 240)));
// Check click
const btn_rect = Layout.Rect.init(btn_x, btn_y, btn_width, button_h - 4);
const mouse = ctx.input.mousePos();
if (btn_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) {
result.button_clicked = @intCast(i);
state.hide();
if (config.show_input) {
if (state.input_state) |input_st| {
result.input_text = input_st.text();
}
}
}
btn_x += @as(i32, @intCast(btn_width + btn_spacing));
}
}
// Handle keyboard navigation
if (ctx.input.keyPressed(.tab)) {
if (ctx.input.modifiers.shift) {
state.focusPrev(button_count);
} else {
state.focusNext(button_count);
}
}
if (ctx.input.keyPressed(.left)) {
state.focusPrev(button_count);
}
if (ctx.input.keyPressed(.right)) {
state.focusNext(button_count);
}
// Enter confirms focused button
if (ctx.input.keyPressed(.enter)) {
result.button_clicked = @intCast(state.focused_button);
state.hide();
if (config.show_input) {
if (state.input_state) |input_st| {
result.input_text = input_st.text();
}
}
}
// Escape dismisses
if (ctx.input.keyPressed(.escape)) {
result.dismissed = true;
state.hide();
}
return result;
}
// =============================================================================
// Convenience Functions
// =============================================================================
/// Show an alert dialog (OK button only)
pub fn alert(
ctx: *Context,
state: *ModalState,
title: []const u8,
message: []const u8,
) ModalResult {
return modal(ctx, state, .{
.title = title,
.message = message,
.buttons = &ButtonSet.ok,
});
}
/// Show a confirm dialog (Yes/No buttons)
pub fn confirm(
ctx: *Context,
state: *ModalState,
title: []const u8,
message: []const u8,
) ModalResult {
return modal(ctx, state, .{
.title = title,
.message = message,
.buttons = &ButtonSet.yes_no,
});
}
/// Show an input dialog (text field + OK/Cancel)
pub fn inputDialog(
ctx: *Context,
state: *ModalState,
title: []const u8,
message: []const u8,
placeholder: []const u8,
) ModalResult {
return modal(ctx, state, .{
.title = title,
.message = message,
.buttons = &ButtonSet.ok_cancel,
.show_input = true,
.input_placeholder = placeholder,
});
}
// =============================================================================
// Helpers
// =============================================================================
fn countLines(text: []const u8) u32 {
if (text.len == 0) return 0;
var lines: u32 = 1;
for (text) |c| {
if (c == '\n') lines += 1;
}
return lines;
}
// =============================================================================
// Tests
// =============================================================================
test "ModalState show/hide" {
var state = ModalState{};
try std.testing.expect(!state.visible);
state.show();
try std.testing.expect(state.visible);
try std.testing.expectEqual(@as(usize, 0), state.focused_button);
state.hide();
try std.testing.expect(!state.visible);
}
test "ModalState focus navigation" {
var state = ModalState{};
state.show();
// 3 buttons
state.focusNext(3);
try std.testing.expectEqual(@as(usize, 1), state.focused_button);
state.focusNext(3);
try std.testing.expectEqual(@as(usize, 2), state.focused_button);
state.focusNext(3); // Wrap around
try std.testing.expectEqual(@as(usize, 0), state.focused_button);
state.focusPrev(3); // Wrap to end
try std.testing.expectEqual(@as(usize, 2), state.focused_button);
}
test "countLines" {
try std.testing.expectEqual(@as(u32, 0), countLines(""));
try std.testing.expectEqual(@as(u32, 1), countLines("hello"));
try std.testing.expectEqual(@as(u32, 2), countLines("hello\nworld"));
try std.testing.expectEqual(@as(u32, 3), countLines("a\nb\nc"));
}