Widgets implemented (13 total): - Label: Static text with alignment - Button: With importance levels (primary/normal/danger) - TextInput: Single-line text entry with cursor - Checkbox: Boolean toggle - Select: Dropdown selection - List: Scrollable selectable list - Focus: Focus manager with tab navigation - Table: Editable table with dirty tracking, keyboard nav - Split: HSplit/VSplit draggable panels - Panel: Container with title bar, collapsible - Modal: Dialogs (alert, confirm, inputDialog) - AutoComplete: ComboBox with prefix/contains/fuzzy matching Core improvements: - InputState now tracks keyboard state (keys_down, key_events) - Full keyboard navigation for Table widget Research documentation: - WIDGET_COMPARISON.md: zcatgui vs DVUI vs Gio vs zcatui - SIMIFACTU_ADVANCEDTABLE.md: Analysis of 10K LOC table component - LEGO_PANELS_SYSTEM.md: Modular panel composition architecture Examples: - widgets_demo.zig: All basic widgets showcase - table_demo.zig: Table, Split, Panel demonstration All tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
14 KiB
Zig
435 lines
14 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 = "",
|
|
};
|
|
|
|
/// 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),
|
|
};
|
|
|
|
/// 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));
|
|
|
|
// Draw dialog border
|
|
ctx.pushCommand(Command.rectOutline(
|
|
dialog_x - 1,
|
|
dialog_y - 1,
|
|
dialog_w + 2,
|
|
dialog_h + 2,
|
|
colors.border,
|
|
));
|
|
|
|
// Draw dialog background
|
|
ctx.pushCommand(Command.rect(dialog_x, dialog_y, dialog_w, dialog_h, colors.background));
|
|
|
|
// Draw title bar
|
|
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,
|
|
);
|
|
|
|
// Simple input rendering
|
|
const input_bg = Style.Color.rgb(35, 35, 40);
|
|
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),
|
|
};
|
|
|
|
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"));
|
|
}
|