//! Popup and Modal widgets for zcatui. //! //! Provides overlay widgets that render on top of other content: //! - Popup: Simple overlay with content //! - Modal: Popup with title, optional buttons, and backdrop //! //! ## Example //! //! ```zig //! const popup = Popup.init() //! .setContent(myWidget) //! .setSize(40, 10) //! .center(); //! //! popup.render(area, buf); //! ``` const std = @import("std"); const buffer_mod = @import("../buffer.zig"); const Buffer = buffer_mod.Buffer; const Rect = buffer_mod.Rect; const Cell = buffer_mod.Cell; const style_mod = @import("../style.zig"); const Style = style_mod.Style; const Color = style_mod.Color; const block_mod = @import("block.zig"); const Block = block_mod.Block; const Borders = block_mod.Borders; const text_mod = @import("../text.zig"); const Line = text_mod.Line; const Span = text_mod.Span; const Alignment = text_mod.Alignment; // ============================================================================ // Popup // ============================================================================ /// A simple popup overlay. /// /// Renders content in a floating box that can be positioned anywhere /// on screen. Supports optional backdrop dimming. pub const Popup = struct { /// Content area dimensions. width: u16 = 40, height: u16 = 10, /// Position (if null, will be centered). x: ?u16 = null, y: ?u16 = null, /// Block wrapper for the popup. block: ?Block = null, /// Style for the popup content area. content_style: Style = Style.default, /// Whether to dim the background. dim_background: bool = true, /// Background dim character and style. dim_char: u21 = ' ', dim_style: Style = Style.default.bg(Color.black), /// Content render function. render_content: ?*const fn (Rect, *Buffer) void = null, /// Creates a new popup. pub fn init() Popup { return .{}; } /// Sets the popup dimensions. pub fn setSize(self: Popup, width: u16, height: u16) Popup { var p = self; p.width = width; p.height = height; return p; } /// Sets the popup position. pub fn setPosition(self: Popup, x: u16, y: u16) Popup { var p = self; p.x = x; p.y = y; return p; } /// Centers the popup (clears explicit position). pub fn center(self: Popup) Popup { var p = self; p.x = null; p.y = null; return p; } /// Sets the block wrapper. pub fn setBlock(self: Popup, block: Block) Popup { var p = self; p.block = block; return p; } /// Sets the content style. pub fn setContentStyle(self: Popup, style: Style) Popup { var p = self; p.content_style = style; return p; } /// Enables/disables background dimming. pub fn setDimBackground(self: Popup, dim: bool) Popup { var p = self; p.dim_background = dim; return p; } /// Sets the dim style. pub fn setDimStyle(self: Popup, style: Style) Popup { var p = self; p.dim_style = style; return p; } /// Sets the content render function. pub fn setRenderContent(self: Popup, render_fn: *const fn (Rect, *Buffer) void) Popup { var p = self; p.render_content = render_fn; return p; } /// Calculates the popup area within the given container. pub fn getPopupArea(self: Popup, container: Rect) Rect { const w = @min(self.width, container.width); const h = @min(self.height, container.height); const px = if (self.x) |x| @min(x, container.width -| w) else container.x + (container.width -| w) / 2; const py = if (self.y) |y| @min(y, container.height -| h) else container.y + (container.height -| h) / 2; return Rect.init(px, py, w, h); } /// Renders the popup. pub fn render(self: Popup, area: Rect, buf: *Buffer) void { // Dim background if enabled if (self.dim_background) { self.renderDimBackground(area, buf); } // Calculate popup position const popup_area = self.getPopupArea(area); // Clear popup area with content style self.clearArea(popup_area, buf); // Render block if set var content_area = popup_area; if (self.block) |block| { block.render(popup_area, buf); content_area = block.inner(popup_area); } // Render content if function provided if (self.render_content) |render_fn| { render_fn(content_area, buf); } } fn renderDimBackground(self: Popup, area: Rect, buf: *Buffer) void { var y = area.top(); while (y < area.bottom()) : (y += 1) { var x = area.left(); while (x < area.right()) : (x += 1) { if (buf.getCell(x, y)) |cell| { // Keep the character but apply dim style cell.setStyle(self.dim_style); } } } } fn clearArea(self: Popup, area: Rect, buf: *Buffer) void { var y = area.top(); while (y < area.bottom()) : (y += 1) { var x = area.left(); while (x < area.right()) : (x += 1) { if (buf.getCell(x, y)) |cell| { cell.setChar(' '); cell.setStyle(self.content_style); } } } } }; // ============================================================================ // Modal // ============================================================================ /// Button definition for modal dialogs. pub const ModalButton = struct { label: []const u8, style: Style = Style.default, focused_style: Style = Style.default.bg(Color.blue).fg(Color.white), }; /// A modal dialog with title, message, and buttons. /// /// Provides a complete dialog experience with: /// - Title bar /// - Message content /// - Action buttons (OK, Cancel, etc.) /// - Keyboard navigation support pub const Modal = struct { /// Dialog title. title: []const u8 = "Dialog", /// Message lines. message: []const []const u8 = &.{}, /// Buttons. buttons: []const ModalButton = &.{}, /// Currently focused button index. focused_button: usize = 0, /// Popup dimensions. width: u16 = 50, height: u16 = 0, // Auto-calculated if 0 /// Styles. title_style: Style = Style.default.fg(Color.cyan).bold(), message_style: Style = Style.default, border_style: Style = Style.default.fg(Color.white), background_style: Style = Style.default, /// Whether to dim background. dim_background: bool = true, /// Creates a new modal. pub fn init() Modal { return .{}; } /// Sets the title. pub fn setTitle(self: Modal, title: []const u8) Modal { var m = self; m.title = title; return m; } /// Sets the message lines. pub fn setMessage(self: Modal, message: []const []const u8) Modal { var m = self; m.message = message; return m; } /// Sets the buttons. pub fn setButtons(self: Modal, buttons: []const ModalButton) Modal { var m = self; m.buttons = buttons; return m; } /// Sets the focused button index. pub fn setFocusedButton(self: Modal, index: usize) Modal { var m = self; m.focused_button = @min(index, if (self.buttons.len > 0) self.buttons.len - 1 else 0); return m; } /// Sets the width. pub fn setWidth(self: Modal, width: u16) Modal { var m = self; m.width = width; return m; } /// Sets explicit height (0 = auto). pub fn setHeight(self: Modal, height: u16) Modal { var m = self; m.height = height; return m; } /// Sets the title style. pub fn setTitleStyle(self: Modal, style: Style) Modal { var m = self; m.title_style = style; return m; } /// Sets the message style. pub fn setMessageStyle(self: Modal, style: Style) Modal { var m = self; m.message_style = style; return m; } /// Sets the border style. pub fn setBorderStyle(self: Modal, style: Style) Modal { var m = self; m.border_style = style; return m; } /// Enables/disables background dimming. pub fn setDimBackground(self: Modal, dim: bool) Modal { var m = self; m.dim_background = dim; return m; } /// Focuses the next button. pub fn focusNext(self: *Modal) void { if (self.buttons.len > 0) { self.focused_button = (self.focused_button + 1) % self.buttons.len; } } /// Focuses the previous button. pub fn focusPrev(self: *Modal) void { if (self.buttons.len > 0) { if (self.focused_button == 0) { self.focused_button = self.buttons.len - 1; } else { self.focused_button -= 1; } } } /// Returns the currently focused button index. pub fn getFocusedButton(self: Modal) usize { return self.focused_button; } /// Calculates the required height. fn calculateHeight(self: Modal) u16 { if (self.height > 0) return self.height; // Title (1) + border (2) + message lines + blank + buttons (1) + padding const message_lines: u16 = @intCast(self.message.len); const button_line: u16 = if (self.buttons.len > 0) 1 else 0; const padding: u16 = 2; // Top and bottom padding inside border return 2 + padding + message_lines + 1 + button_line; } /// Renders the modal. pub fn render(self: Modal, area: Rect, buf: *Buffer) void { const height = self.calculateHeight(); const width = @min(self.width, area.width); // Create popup var popup = Popup.init() .setSize(width, height) .setDimBackground(self.dim_background) .center(); // Create block with title const block = Block.init() .title(self.title) .titleStyle(self.title_style) .setBorders(Borders.all) .style(self.border_style); popup = popup.setBlock(block); popup = popup.setContentStyle(self.background_style); // Render popup frame if (self.dim_background) { popup.renderDimBackground(area, buf); } const popup_area = popup.getPopupArea(area); popup.clearArea(popup_area, buf); block.render(popup_area, buf); const content_area = block.inner(popup_area); // Render message self.renderMessage(content_area, buf); // Render buttons if (self.buttons.len > 0) { self.renderButtons(content_area, buf); } } fn renderMessage(self: Modal, area: Rect, buf: *Buffer) void { var y = area.top(); for (self.message) |line| { if (y >= area.bottom() -| 2) break; // Leave room for buttons // Center the message const line_len: u16 = @intCast(@min(line.len, area.width)); const x = area.left() + (area.width -| line_len) / 2; _ = buf.setString(x, y, line, self.message_style); y += 1; } } fn renderButtons(self: Modal, area: Rect, buf: *Buffer) void { // Calculate total button width var total_width: u16 = 0; for (self.buttons) |button| { total_width += @as(u16, @intCast(button.label.len)) + 4; // [ label ] } total_width += @as(u16, @intCast(self.buttons.len)) - 1; // Spaces between // Position at bottom, centered const y = area.bottom() -| 1; var x = area.left() + (area.width -| total_width) / 2; for (self.buttons, 0..) |button, i| { const is_focused = i == self.focused_button; const btn_style = if (is_focused) button.focused_style else button.style; // Render button: [ label ] _ = buf.setString(x, y, "[ ", btn_style); x += 2; _ = buf.setString(x, y, button.label, btn_style); x += @intCast(button.label.len); _ = buf.setString(x, y, " ]", btn_style); x += 2; if (i < self.buttons.len - 1) { x += 1; // Space between buttons } } } }; // ============================================================================ // Confirm Dialog Helper // ============================================================================ /// Creates a standard confirmation modal with OK/Cancel buttons. pub fn confirmDialog(title: []const u8, message: []const []const u8) Modal { return Modal.init() .setTitle(title) .setMessage(message) .setButtons(&.{ .{ .label = "OK", .focused_style = Style.default.bg(Color.green).fg(Color.white) }, .{ .label = "Cancel", .focused_style = Style.default.bg(Color.red).fg(Color.white) }, }); } /// Creates a standard alert modal with just an OK button. pub fn alertDialog(title: []const u8, message: []const []const u8) Modal { return Modal.init() .setTitle(title) .setMessage(message) .setButtons(&.{ .{ .label = "OK", .focused_style = Style.default.bg(Color.blue).fg(Color.white) }, }); } /// Creates a yes/no/cancel modal. pub fn yesNoCancelDialog(title: []const u8, message: []const []const u8) Modal { return Modal.init() .setTitle(title) .setMessage(message) .setButtons(&.{ .{ .label = "Yes", .focused_style = Style.default.bg(Color.green).fg(Color.white) }, .{ .label = "No", .focused_style = Style.default.bg(Color.yellow).fg(Color.black) }, .{ .label = "Cancel", .focused_style = Style.default.bg(Color.red).fg(Color.white) }, }); } // ============================================================================ // Tests // ============================================================================ test "Popup basic" { const popup = Popup.init() .setSize(20, 10) .center(); const container = Rect.init(0, 0, 80, 24); const popup_area = popup.getPopupArea(container); // Should be centered try std.testing.expectEqual(@as(u16, 30), popup_area.x); // (80-20)/2 try std.testing.expectEqual(@as(u16, 7), popup_area.y); // (24-10)/2 try std.testing.expectEqual(@as(u16, 20), popup_area.width); try std.testing.expectEqual(@as(u16, 10), popup_area.height); } test "Popup positioned" { const popup = Popup.init() .setSize(20, 10) .setPosition(5, 3); const container = Rect.init(0, 0, 80, 24); const popup_area = popup.getPopupArea(container); try std.testing.expectEqual(@as(u16, 5), popup_area.x); try std.testing.expectEqual(@as(u16, 3), popup_area.y); } test "Modal focus navigation" { var modal = Modal.init() .setButtons(&.{ .{ .label = "A" }, .{ .label = "B" }, .{ .label = "C" }, }); try std.testing.expectEqual(@as(usize, 0), modal.getFocusedButton()); modal.focusNext(); try std.testing.expectEqual(@as(usize, 1), modal.getFocusedButton()); modal.focusNext(); try std.testing.expectEqual(@as(usize, 2), modal.getFocusedButton()); modal.focusNext(); // Wraps around try std.testing.expectEqual(@as(usize, 0), modal.getFocusedButton()); modal.focusPrev(); // Wraps back try std.testing.expectEqual(@as(usize, 2), modal.getFocusedButton()); } test "confirmDialog creates correct buttons" { const dialog = confirmDialog("Test", &.{"Message"}); try std.testing.expectEqual(@as(usize, 2), dialog.buttons.len); try std.testing.expectEqualStrings("OK", dialog.buttons[0].label); try std.testing.expectEqualStrings("Cancel", dialog.buttons[1].label); } test "alertDialog creates single button" { const dialog = alertDialog("Alert", &.{"Warning!"}); try std.testing.expectEqual(@as(usize, 1), dialog.buttons.len); try std.testing.expectEqualStrings("OK", dialog.buttons[0].label); }