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:
parent
3ec75f6264
commit
e7045097e5
5 changed files with 1650 additions and 0 deletions
19
build.zig
19
build.zig
|
|
@ -175,4 +175,23 @@ pub fn build(b: *std.Build) void {
|
||||||
run_clipboard_demo.step.dependOn(b.getInstallStep());
|
run_clipboard_demo.step.dependOn(b.getInstallStep());
|
||||||
const clipboard_demo_step = b.step("clipboard-demo", "Run clipboard demo");
|
const clipboard_demo_step = b.step("clipboard-demo", "Run clipboard demo");
|
||||||
clipboard_demo_step.dependOn(&run_clipboard_demo.step);
|
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
419
examples/menu_demo.zig
Normal 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());
|
||||||
|
}
|
||||||
15
src/root.zig
15
src/root.zig
|
|
@ -133,6 +133,21 @@ pub const widgets = struct {
|
||||||
pub const input_mod = @import("widgets/input.zig");
|
pub const input_mod = @import("widgets/input.zig");
|
||||||
pub const Input = input_mod.Input;
|
pub const Input = input_mod.Input;
|
||||||
pub const InputState = input_mod.InputState;
|
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
|
// Backend
|
||||||
|
|
|
||||||
651
src/widgets/menu.zig
Normal file
651
src/widgets/menu.zig
Normal 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
546
src/widgets/popup.zig
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue