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>
546 lines
16 KiB
Zig
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);
|
|
}
|