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>
419 lines
12 KiB
Zig
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());
|
|
}
|