//! 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) { ctx.pushCommand(Command.shadowFloat(dialog_x, dialog_y, dialog_w, dialog_h, 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")); }