diff --git a/build.zig b/build.zig index ff7fc11..4bf3fab 100644 --- a/build.zig +++ b/build.zig @@ -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); } diff --git a/examples/menu_demo.zig b/examples/menu_demo.zig new file mode 100644 index 0000000..7b8183c --- /dev/null +++ b/examples/menu_demo.zig @@ -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()); +} diff --git a/src/root.zig b/src/root.zig index 152f6d0..5793d74 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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 diff --git a/src/widgets/menu.zig b/src/widgets/menu.zig new file mode 100644 index 0000000..033535a --- /dev/null +++ b/src/widgets/menu.zig @@ -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); +} diff --git a/src/widgets/popup.zig b/src/widgets/popup.zig new file mode 100644 index 0000000..b77abf0 --- /dev/null +++ b/src/widgets/popup.zig @@ -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); +}