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>
This commit is contained in:
reugenio 2025-12-08 16:52:38 +01:00
parent 3ec75f6264
commit e7045097e5
5 changed files with 1650 additions and 0 deletions

View file

@ -175,4 +175,23 @@ pub fn build(b: *std.Build) void {
run_clipboard_demo.step.dependOn(b.getInstallStep());
const clipboard_demo_step = b.step("clipboard-demo", "Run clipboard demo");
clipboard_demo_step.dependOn(&run_clipboard_demo.step);
// Ejemplo: menu_demo
const menu_demo_exe = b.addExecutable(.{
.name = "menu-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/menu_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(menu_demo_exe);
const run_menu_demo = b.addRunArtifact(menu_demo_exe);
run_menu_demo.step.dependOn(b.getInstallStep());
const menu_demo_step = b.step("menu-demo", "Run menu demo");
menu_demo_step.dependOn(&run_menu_demo.step);
}

419
examples/menu_demo.zig Normal file
View file

@ -0,0 +1,419 @@
//! Menu and Popup demo for zcatui.
//!
//! Demonstrates:
//! - MenuBar with dropdown menus
//! - Menu navigation (arrow keys, Enter)
//! - Modal dialogs (confirm, alert)
//! - Popup overlays
//!
//! Run with: zig build menu-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Buffer = zcatui.Buffer;
const Rect = zcatui.Rect;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Event = zcatui.Event;
const KeyCode = zcatui.KeyCode;
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Paragraph = zcatui.widgets.Paragraph;
const Menu = zcatui.widgets.Menu;
const MenuItem = zcatui.widgets.MenuItem;
const MenuBar = zcatui.widgets.MenuBar;
const MenuBarItem = zcatui.widgets.MenuBarItem;
const Modal = zcatui.widgets.Modal;
const Popup = zcatui.widgets.Popup;
const confirmDialog = zcatui.widgets.confirmDialog;
const alertDialog = zcatui.widgets.alertDialog;
const AppMode = enum {
normal,
menu_open,
modal_open,
popup_open,
};
const AppState = struct {
running: bool = true,
mode: AppMode = .normal,
// Menu bar
menu_bar: MenuBar,
// Dropdown menus
file_menu: Menu,
edit_menu: Menu,
view_menu: Menu,
help_menu: Menu,
// Modal dialog
modal: Modal,
// Status message
status: []const u8 = "Press Alt+F to open File menu, F1 for help",
fn init() AppState {
// File menu
const file_menu = Menu.init().setItems(&.{
MenuItem.action("New", 'n').setShortcut("Ctrl+", 'N'),
MenuItem.action("Open...", 'o').setShortcut("Ctrl+", 'O'),
MenuItem.action("Save", 's').setShortcut("Ctrl+", 'S'),
MenuItem.action("Save As...", null),
MenuItem.separator(),
MenuItem.action("Exit", 'q').setShortcut("Ctrl+", 'Q'),
});
// Edit menu
const edit_menu = Menu.init().setItems(&.{
MenuItem.action("Undo", 'u').setShortcut("Ctrl+", 'Z'),
MenuItem.action("Redo", 'r').setShortcut("Ctrl+", 'Y'),
MenuItem.separator(),
MenuItem.action("Cut", 'x').setShortcut("Ctrl+", 'X'),
MenuItem.action("Copy", 'c').setShortcut("Ctrl+", 'C'),
MenuItem.action("Paste", 'v').setShortcut("Ctrl+", 'V'),
MenuItem.separator(),
MenuItem.action("Select All", 'a').setShortcut("Ctrl+", 'A'),
});
// View menu
const view_menu = Menu.init().setItems(&.{
MenuItem.toggle("Show Toolbar", true),
MenuItem.toggle("Show Statusbar", true),
MenuItem.separator(),
MenuItem.toggle("Word Wrap", false),
MenuItem.toggle("Line Numbers", true),
MenuItem.separator(),
MenuItem.action("Zoom In", '+').setShortcut("Ctrl+", '+'),
MenuItem.action("Zoom Out", '-').setShortcut("Ctrl+", '-'),
});
// Help menu
const help_menu = Menu.init().setItems(&.{
MenuItem.action("Documentation", null).setShortcut("", 'F').setShortcut("", '1'),
MenuItem.action("Keyboard Shortcuts", null),
MenuItem.separator(),
MenuItem.action("About", null),
});
// Menu bar
const menu_bar = MenuBar.init().setItems(&.{
MenuBarItem.init("File", file_menu),
MenuBarItem.init("Edit", edit_menu),
MenuBarItem.init("View", view_menu),
MenuBarItem.init("Help", help_menu),
});
return .{
.menu_bar = menu_bar,
.file_menu = file_menu,
.edit_menu = edit_menu,
.view_menu = view_menu,
.help_menu = help_menu,
.modal = Modal.init(),
};
}
fn getCurrentMenu(self: *AppState) *Menu {
return switch (self.menu_bar.selected) {
0 => &self.file_menu,
1 => &self.edit_menu,
2 => &self.view_menu,
3 => &self.help_menu,
else => &self.file_menu,
};
}
fn showAlert(self: *AppState, title: []const u8, message: []const u8) void {
self.modal = alertDialog(title, &.{message});
self.mode = .modal_open;
}
fn showConfirm(self: *AppState, title: []const u8, message: []const u8) void {
self.modal = confirmDialog(title, &.{message});
self.mode = .modal_open;
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState.init();
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (state.mode) {
.normal => handleNormalMode(state, key.code),
.menu_open => handleMenuMode(state, key.code),
.modal_open => handleModalMode(state, key.code),
.popup_open => handlePopupMode(state, key.code),
}
},
else => {},
}
}
fn handleNormalMode(state: *AppState, code: KeyCode) void {
switch (code) {
.esc => state.running = false,
.char => |c| {
if (c == '?') {
state.showAlert("Help", "This is the menu demo. Use letter keys to open menus.");
return;
}
switch (c) {
'q', 'Q' => state.running = false,
'f', 'F' => {
// Alt+F to open File menu
state.menu_bar.selected = 0;
state.menu_bar.openSelected();
state.mode = .menu_open;
state.status = "File menu open - use arrows to navigate";
},
'e', 'E' => {
state.menu_bar.selected = 1;
state.menu_bar.openSelected();
state.mode = .menu_open;
state.status = "Edit menu open";
},
'v', 'V' => {
state.menu_bar.selected = 2;
state.menu_bar.openSelected();
state.mode = .menu_open;
state.status = "View menu open";
},
'h', 'H' => {
state.menu_bar.selected = 3;
state.menu_bar.openSelected();
state.mode = .menu_open;
state.status = "Help menu open";
},
'm', 'M' => {
state.showConfirm("Confirm", "Do you want to continue?");
},
'p', 'P' => {
state.mode = .popup_open;
state.status = "Popup open - press Esc to close";
},
else => {},
}
},
else => {},
}
}
fn handleMenuMode(state: *AppState, code: KeyCode) void {
switch (code) {
.esc => {
state.menu_bar.closeMenus();
state.mode = .normal;
state.status = "Menu closed";
},
.left => {
state.menu_bar.selectPrev();
},
.right => {
state.menu_bar.selectNext();
},
.up => {
var menu = state.getCurrentMenu();
menu.selectPrev();
},
.down => {
var menu = state.getCurrentMenu();
menu.selectNext();
},
.enter => {
const menu = state.getCurrentMenu();
if (menu.getSelectedItem()) |item| {
state.menu_bar.closeMenus();
state.mode = .normal;
state.status = item.label;
// Handle special items
if (std.mem.eql(u8, item.label, "Exit")) {
state.showConfirm("Exit", "Are you sure you want to exit?");
} else if (std.mem.eql(u8, item.label, "About")) {
state.showAlert("About", "zcatui Menu Demo v1.0");
}
}
},
else => {},
}
}
fn handleModalMode(state: *AppState, code: KeyCode) void {
switch (code) {
.esc => {
state.mode = .normal;
state.status = "Modal cancelled";
},
.left, .tab => {
state.modal.focusPrev();
},
.right => {
state.modal.focusNext();
},
.enter => {
const btn_idx = state.modal.getFocusedButton();
state.mode = .normal;
if (state.modal.buttons.len > 0) {
const label = state.modal.buttons[btn_idx].label;
if (std.mem.eql(u8, label, "OK")) {
if (std.mem.eql(u8, state.modal.title, "Exit")) {
state.running = false;
} else {
state.status = "OK pressed";
}
} else if (std.mem.eql(u8, label, "Cancel")) {
state.status = "Cancelled";
}
}
},
else => {},
}
}
fn handlePopupMode(state: *AppState, code: KeyCode) void {
switch (code) {
.esc, .enter => {
state.mode = .normal;
state.status = "Popup closed";
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Main layout
const chunks = Layout.vertical(&.{
Constraint.length(1), // Menu bar
Constraint.min(0), // Content
Constraint.length(1), // Status bar
}).split(area);
// Render menu bar
state.menu_bar.render(chunks.get(0), buf);
// Render main content
renderContent(state, chunks.get(1), buf);
// Render status bar
renderStatusBar(state, chunks.get(2), buf);
// Render dropdown if menu is open
if (state.mode == .menu_open) {
if (state.menu_bar.open_menu) |menu_idx| {
const dropdown_area = state.menu_bar.getDropdownArea(chunks.get(0), menu_idx);
const menu = state.getCurrentMenu();
menu.render(dropdown_area, buf);
}
}
// Render modal if open
if (state.mode == .modal_open) {
state.modal.render(area, buf);
}
// Render popup if open
if (state.mode == .popup_open) {
renderPopup(area, buf);
}
}
fn renderContent(state: *AppState, area: Rect, buf: *Buffer) void {
_ = state;
const block = Block.init()
.title(" Menu Demo ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan));
block.render(area, buf);
const inner = block.inner(area);
var y = inner.top();
const lines = [_][]const u8{
"",
" Keyboard shortcuts:",
"",
" f - Open File menu",
" e - Open Edit menu",
" v - Open View menu",
" h - Open Help menu",
"",
" m - Show modal dialog",
" p - Show popup",
"",
" Arrow keys - Navigate menus",
" Enter - Select menu item",
" Esc - Close menu/dialog",
"",
" q - Quit",
};
for (lines) |line| {
if (y < inner.bottom()) {
_ = buf.setString(inner.left(), y, line, Style.default);
y += 1;
}
}
}
fn renderStatusBar(state: *AppState, area: Rect, buf: *Buffer) void {
// Fill background
var x = area.left();
while (x < area.right()) : (x += 1) {
if (buf.getCell(x, area.top())) |cell| {
cell.setChar(' ');
cell.setStyle(Style.default.bg(Color.blue).fg(Color.white));
}
}
// Status text
_ = buf.setString(area.left() + 1, area.top(), state.status, Style.default.bg(Color.blue).fg(Color.white));
}
fn renderPopup(area: Rect, buf: *Buffer) void {
const popup = Popup.init()
.setSize(40, 8)
.setBlock(
Block.init()
.title(" Information ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.yellow)),
)
.center();
popup.render(area, buf);
// Get content area
const popup_area = popup.getPopupArea(area);
const block = Block.init().setBorders(Borders.all);
const inner = block.inner(popup_area);
// Render content
_ = buf.setString(inner.left() + 1, inner.top() + 1, "This is a simple popup!", Style.default);
_ = buf.setString(inner.left() + 1, inner.top() + 3, "Press Enter or Esc to close.", Style.default.dim());
}

View file

@ -133,6 +133,21 @@ pub const widgets = struct {
pub const input_mod = @import("widgets/input.zig");
pub const Input = input_mod.Input;
pub const InputState = input_mod.InputState;
pub const popup_mod = @import("widgets/popup.zig");
pub const Popup = popup_mod.Popup;
pub const Modal = popup_mod.Modal;
pub const ModalButton = popup_mod.ModalButton;
pub const confirmDialog = popup_mod.confirmDialog;
pub const alertDialog = popup_mod.alertDialog;
pub const yesNoCancelDialog = popup_mod.yesNoCancelDialog;
pub const menu_mod = @import("widgets/menu.zig");
pub const Menu = menu_mod.Menu;
pub const MenuItem = menu_mod.MenuItem;
pub const MenuItemType = menu_mod.MenuItemType;
pub const MenuBar = menu_mod.MenuBar;
pub const MenuBarItem = menu_mod.MenuBarItem;
};
// Backend

651
src/widgets/menu.zig Normal file
View file

@ -0,0 +1,651 @@
//! Menu widget for zcatui.
//!
//! Provides dropdown and context menu functionality:
//! - Menu: Vertical list of menu items
//! - MenuBar: Horizontal menu bar with dropdown menus
//! - MenuItem: Individual menu entries (can be nested)
//!
//! ## Example
//!
//! ```zig
//! const menu = Menu.init()
//! .addItem(MenuItem.action("New", 'n'))
//! .addItem(MenuItem.action("Open", 'o'))
//! .addItem(MenuItem.separator())
//! .addItem(MenuItem.action("Exit", 'q'));
//!
//! menu.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
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;
// ============================================================================
// MenuItem
// ============================================================================
/// Type of menu item.
pub const MenuItemType = enum {
action, // Clickable action
separator, // Visual separator line
submenu, // Opens a submenu
toggle, // Toggleable option
};
/// A single menu item.
pub const MenuItem = struct {
/// Item type.
item_type: MenuItemType = .action,
/// Display label.
label: []const u8 = "",
/// Keyboard shortcut character (shown on right).
shortcut: ?u8 = null,
/// Shortcut modifier display (e.g., "Ctrl+").
shortcut_modifier: []const u8 = "",
/// Whether item is enabled.
enabled: bool = true,
/// Whether item is checked (for toggle type).
checked: bool = false,
/// Submenu items (for submenu type).
submenu_items: []const MenuItem = &.{},
/// User data for identifying the item.
id: usize = 0,
/// Creates an action menu item.
pub fn action(label: []const u8, shortcut: ?u8) MenuItem {
return .{
.item_type = .action,
.label = label,
.shortcut = shortcut,
};
}
/// Creates an action with ID.
pub fn actionWithId(label: []const u8, id: usize) MenuItem {
return .{
.item_type = .action,
.label = label,
.id = id,
};
}
/// Creates a separator.
pub fn separator() MenuItem {
return .{
.item_type = .separator,
};
}
/// Creates a submenu item.
pub fn submenu(label: []const u8, items: []const MenuItem) MenuItem {
return .{
.item_type = .submenu,
.label = label,
.submenu_items = items,
};
}
/// Creates a toggle item.
pub fn toggle(label: []const u8, checked: bool) MenuItem {
return .{
.item_type = .toggle,
.label = label,
.checked = checked,
};
}
/// Sets the shortcut with modifier.
pub fn setShortcut(self: MenuItem, modifier: []const u8, key: u8) MenuItem {
var item = self;
item.shortcut_modifier = modifier;
item.shortcut = key;
return item;
}
/// Sets enabled state.
pub fn setEnabled(self: MenuItem, enabled: bool) MenuItem {
var item = self;
item.enabled = enabled;
return item;
}
/// Sets the ID.
pub fn setId(self: MenuItem, id: usize) MenuItem {
var item = self;
item.id = id;
return item;
}
/// Returns true if this item is selectable.
pub fn isSelectable(self: MenuItem) bool {
return self.item_type != .separator and self.enabled;
}
};
// ============================================================================
// Menu
// ============================================================================
/// A dropdown/context menu.
pub const Menu = struct {
/// Menu items.
items: []const MenuItem = &.{},
/// Currently selected index.
selected: usize = 0,
/// Whether menu is open/visible.
open: bool = true,
/// Styles.
style: Style = Style.default,
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
disabled_style: Style = Style.default.fg(Color.indexed(8)), // Gray
separator_style: Style = Style.default.fg(Color.indexed(8)),
shortcut_style: Style = Style.default.fg(Color.cyan),
/// Border style.
border: bool = true,
border_style: Style = Style.default,
/// Minimum width.
min_width: u16 = 10,
/// Creates a new menu.
pub fn init() Menu {
return .{};
}
/// Sets the menu items.
pub fn setItems(self: Menu, items: []const MenuItem) Menu {
var m = self;
m.items = items;
return m;
}
/// Sets the selected index.
pub fn setSelected(self: Menu, index: usize) Menu {
var m = self;
m.selected = @min(index, if (self.items.len > 0) self.items.len - 1 else 0);
return m;
}
/// Sets the style.
pub fn setStyle(self: Menu, style: Style) Menu {
var m = self;
m.style = style;
return m;
}
/// Sets the selected item style.
pub fn setSelectedStyle(self: Menu, style: Style) Menu {
var m = self;
m.selected_style = style;
return m;
}
/// Sets the disabled item style.
pub fn setDisabledStyle(self: Menu, style: Style) Menu {
var m = self;
m.disabled_style = style;
return m;
}
/// Sets the border.
pub fn setBorder(self: Menu, border: bool) Menu {
var m = self;
m.border = border;
return m;
}
/// Sets minimum width.
pub fn setMinWidth(self: Menu, width: u16) Menu {
var m = self;
m.min_width = width;
return m;
}
/// Moves selection to next selectable item.
pub fn selectNext(self: *Menu) void {
if (self.items.len == 0) return;
var attempts: usize = 0;
var idx = self.selected;
while (attempts < self.items.len) {
idx = (idx + 1) % self.items.len;
if (self.items[idx].isSelectable()) {
self.selected = idx;
return;
}
attempts += 1;
}
}
/// Moves selection to previous selectable item.
pub fn selectPrev(self: *Menu) void {
if (self.items.len == 0) return;
var attempts: usize = 0;
var idx = self.selected;
while (attempts < self.items.len) {
if (idx == 0) {
idx = self.items.len - 1;
} else {
idx -= 1;
}
if (self.items[idx].isSelectable()) {
self.selected = idx;
return;
}
attempts += 1;
}
}
/// Returns the currently selected item.
pub fn getSelectedItem(self: Menu) ?MenuItem {
if (self.items.len == 0) return null;
return self.items[self.selected];
}
/// Calculates the required width for the menu.
pub fn calculateWidth(self: Menu) u16 {
var max_label: u16 = 0;
var max_shortcut: u16 = 0;
for (self.items) |item| {
if (item.item_type == .separator) continue;
const label_len: u16 = @intCast(item.label.len);
max_label = @max(max_label, label_len);
if (item.shortcut) |_| {
const shortcut_len: u16 = @intCast(item.shortcut_modifier.len + 1);
max_shortcut = @max(max_shortcut, shortcut_len);
}
}
// label + padding + shortcut + borders
var width = max_label + 4;
if (max_shortcut > 0) {
width += max_shortcut + 2;
}
return @max(width, self.min_width);
}
/// Calculates the required height.
pub fn calculateHeight(self: Menu) u16 {
const items_height: u16 = @intCast(self.items.len);
if (self.border) {
return items_height + 2;
}
return items_height;
}
/// Renders the menu.
pub fn render(self: Menu, area: Rect, buf: *Buffer) void {
if (!self.open) return;
var content_area = area;
// Render border if enabled
if (self.border) {
const block = Block.init()
.setBorders(Borders.all)
.style(self.border_style);
block.render(area, buf);
content_area = block.inner(area);
}
// Clear content area
self.clearArea(content_area, buf);
// Render items
var y = content_area.top();
for (self.items, 0..) |item, i| {
if (y >= content_area.bottom()) break;
self.renderItem(item, i, content_area.left(), y, content_area.width, buf);
y += 1;
}
}
fn clearArea(self: Menu, 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.style);
}
}
}
}
fn renderItem(self: Menu, item: MenuItem, index: usize, x: u16, y: u16, width: u16, buf: *Buffer) void {
const is_selected = index == self.selected;
switch (item.item_type) {
.separator => {
// Render separator line
var sx = x;
while (sx < x + width) : (sx += 1) {
if (buf.getCell(sx, y)) |cell| {
cell.setChar(0x2500); // '─'
cell.setStyle(self.separator_style);
}
}
},
else => {
// Determine style
const item_style = if (!item.enabled)
self.disabled_style
else if (is_selected)
self.selected_style
else
self.style;
// Fill background
var fx = x;
while (fx < x + width) : (fx += 1) {
if (buf.getCell(fx, y)) |cell| {
cell.setChar(' ');
cell.setStyle(item_style);
}
}
// Render prefix (checkbox for toggle, arrow for submenu)
var px = x + 1;
if (item.item_type == .toggle) {
const check_char: []const u8 = if (item.checked) "[x]" else "[ ]";
_ = buf.setString(px, y, check_char, item_style);
px += 4;
} else if (item.item_type == .submenu) {
// No prefix for submenu, but add arrow at end
}
// Render label
_ = buf.setString(px, y, item.label, item_style);
// Render shortcut or submenu arrow
if (item.item_type == .submenu) {
// Submenu arrow on right
_ = buf.setString(x + width - 2, y, "", item_style);
} else if (item.shortcut) |key| {
// Shortcut on right
const shortcut_x = x + width - @as(u16, @intCast(item.shortcut_modifier.len)) - 2;
var shortcut_buf: [16]u8 = undefined;
const shortcut_str = std.fmt.bufPrint(&shortcut_buf, "{s}{c}", .{ item.shortcut_modifier, key }) catch "";
const style = if (is_selected) item_style else self.shortcut_style;
_ = buf.setString(shortcut_x, y, shortcut_str, style);
}
},
}
}
};
// ============================================================================
// MenuBar
// ============================================================================
/// A horizontal menu bar with dropdown menus.
pub const MenuBar = struct {
/// Menu bar items (each opens a dropdown).
items: []const MenuBarItem = &.{},
/// Currently selected menu index.
selected: usize = 0,
/// Index of the open menu (-1 if none).
open_menu: ?usize = null,
/// Styles.
style: Style = Style.default.bg(Color.blue).fg(Color.white),
selected_style: Style = Style.default.bg(Color.white).fg(Color.black),
inactive_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Creates a new menu bar.
pub fn init() MenuBar {
return .{};
}
/// Sets the menu bar items.
pub fn setItems(self: MenuBar, items: []const MenuBarItem) MenuBar {
var mb = self;
mb.items = items;
return mb;
}
/// Sets the style.
pub fn setStyle(self: MenuBar, style: Style) MenuBar {
var mb = self;
mb.style = style;
return mb;
}
/// Sets the selected style.
pub fn setSelectedStyle(self: MenuBar, style: Style) MenuBar {
var mb = self;
mb.selected_style = style;
return mb;
}
/// Selects next menu.
pub fn selectNext(self: *MenuBar) void {
if (self.items.len == 0) return;
self.selected = (self.selected + 1) % self.items.len;
if (self.open_menu != null) {
self.open_menu = self.selected;
}
}
/// Selects previous menu.
pub fn selectPrev(self: *MenuBar) void {
if (self.items.len == 0) return;
if (self.selected == 0) {
self.selected = self.items.len - 1;
} else {
self.selected -= 1;
}
if (self.open_menu != null) {
self.open_menu = self.selected;
}
}
/// Opens the currently selected menu.
pub fn openSelected(self: *MenuBar) void {
self.open_menu = self.selected;
}
/// Closes all menus.
pub fn closeMenus(self: *MenuBar) void {
self.open_menu = null;
}
/// Toggles the selected menu.
pub fn toggleSelected(self: *MenuBar) void {
if (self.open_menu != null) {
self.open_menu = null;
} else {
self.open_menu = self.selected;
}
}
/// Returns the open menu if any.
pub fn getOpenMenu(self: MenuBar) ?Menu {
if (self.open_menu) |idx| {
if (idx < self.items.len) {
return self.items[idx].menu;
}
}
return null;
}
/// Calculates item positions.
fn getItemPositions(self: MenuBar, area: Rect) []const ItemPosition {
_ = self;
_ = area;
// This would need allocation, so we compute on-the-fly in render
return &.{};
}
/// Renders the menu bar (just the bar, not dropdowns).
pub fn render(self: MenuBar, area: Rect, buf: *Buffer) void {
// Fill background
var x = area.left();
while (x < area.right()) : (x += 1) {
if (buf.getCell(x, area.top())) |cell| {
cell.setChar(' ');
cell.setStyle(self.style);
}
}
// Render menu titles
x = area.left() + 1;
for (self.items, 0..) |item, i| {
if (x >= area.right()) break;
const is_selected = i == self.selected;
const is_open = self.open_menu == i;
const item_style = if (is_selected or is_open) self.selected_style else self.style;
// Render with padding
_ = buf.setString(x, area.top(), " ", item_style);
x += 1;
_ = buf.setString(x, area.top(), item.label, item_style);
x += @intCast(item.label.len);
_ = buf.setString(x, area.top(), " ", item_style);
x += 2; // Extra space between items
}
}
/// Gets the dropdown area for a menu item.
pub fn getDropdownArea(self: MenuBar, area: Rect, menu_index: usize) Rect {
// Calculate x position for the dropdown
var x = area.left() + 1;
for (self.items[0..menu_index]) |item| {
x += @as(u16, @intCast(item.label.len)) + 3;
}
if (menu_index < self.items.len) {
const menu = self.items[menu_index].menu;
const width = menu.calculateWidth();
const height = menu.calculateHeight();
return Rect.init(x, area.top() + 1, width, height);
}
return Rect.init(x, area.top() + 1, 0, 0);
}
};
/// Position info for menu bar item.
const ItemPosition = struct {
x: u16,
width: u16,
};
/// A menu bar item (title + dropdown menu).
pub const MenuBarItem = struct {
label: []const u8,
menu: Menu,
pub fn init(label: []const u8, menu: Menu) MenuBarItem {
return .{
.label = label,
.menu = menu,
};
}
};
// ============================================================================
// Tests
// ============================================================================
test "MenuItem creation" {
const item = MenuItem.action("Open", 'o');
try std.testing.expectEqualStrings("Open", item.label);
try std.testing.expectEqual(@as(?u8, 'o'), item.shortcut);
try std.testing.expect(item.isSelectable());
}
test "MenuItem separator not selectable" {
const sep = MenuItem.separator();
try std.testing.expect(!sep.isSelectable());
}
test "Menu navigation" {
var menu = Menu.init().setItems(&.{
MenuItem.action("First", null),
MenuItem.separator(),
MenuItem.action("Second", null),
MenuItem.action("Third", null),
});
try std.testing.expectEqual(@as(usize, 0), menu.selected);
menu.selectNext();
try std.testing.expectEqual(@as(usize, 2), menu.selected); // Skips separator
menu.selectNext();
try std.testing.expectEqual(@as(usize, 3), menu.selected);
menu.selectNext();
try std.testing.expectEqual(@as(usize, 0), menu.selected); // Wraps
menu.selectPrev();
try std.testing.expectEqual(@as(usize, 3), menu.selected); // Wraps back
}
test "Menu width calculation" {
const menu = Menu.init().setItems(&.{
MenuItem.action("Short", null),
MenuItem.action("A longer label", null),
MenuItem.action("Med", 'x').setShortcut("Ctrl+", 'x'),
});
const width = menu.calculateWidth();
try std.testing.expect(width >= 14); // "A longer label" = 14
}
test "MenuBar navigation" {
var bar = MenuBar.init().setItems(&.{
MenuBarItem.init("File", Menu.init()),
MenuBarItem.init("Edit", Menu.init()),
MenuBarItem.init("View", Menu.init()),
});
try std.testing.expectEqual(@as(usize, 0), bar.selected);
try std.testing.expect(bar.open_menu == null);
bar.selectNext();
try std.testing.expectEqual(@as(usize, 1), bar.selected);
bar.openSelected();
try std.testing.expectEqual(@as(?usize, 1), bar.open_menu);
bar.selectNext();
try std.testing.expectEqual(@as(usize, 2), bar.selected);
try std.testing.expectEqual(@as(?usize, 2), bar.open_menu); // Follows selection
bar.closeMenus();
try std.testing.expect(bar.open_menu == null);
}

546
src/widgets/popup.zig Normal file
View file

@ -0,0 +1,546 @@
//! 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);
}