zcatui/src/widgets/popup.zig
reugenio e7045097e5 Add Popup/Modal and Menu widgets
Popup widget:
- Floating overlay with optional backdrop dimming
- Configurable size and position (or centered)
- Block wrapper support

Modal widget:
- Title, message, and action buttons
- Keyboard navigation (Tab/arrows to switch buttons)
- Helper functions: confirmDialog, alertDialog, yesNoCancelDialog

Menu widgets:
- MenuItem: action, separator, submenu, toggle types
- Menu: Dropdown menu with selection navigation
- MenuBar: Horizontal menu bar with dropdown support
- Shortcut display support
- Enabled/disabled state

Example: menu_demo.zig with interactive menu bar and dialogs

Tests: 11 tests for popup/menu, all pass

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 16:52:38 +01:00

546 lines
16 KiB
Zig

//! 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);
}