zcatui/examples/menu_demo.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

419 lines
12 KiB
Zig

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