From 8c218a3f0d33c1e344f6a4d007d025954c80806e Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 17:18:45 +0100 Subject: [PATCH] feat: Add ContextMenu and Tooltip widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContextMenu: - Right-click context menu support - Auto-adjust position to fit in bounds - containsPoint for hit testing - Wraps existing Menu widget Tooltip: - Single and multiline text support - Configurable positioning (above/below/left/right/auto) - Customizable style, border, padding - TooltipManager with hover delay timing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/root.zig | 6 + src/widgets/menu.zig | 233 ++++++++++++++++++++ src/widgets/tooltip.zig | 461 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 src/widgets/tooltip.zig diff --git a/src/root.zig b/src/root.zig index 5793d74..cd88a54 100644 --- a/src/root.zig +++ b/src/root.zig @@ -148,6 +148,12 @@ pub const widgets = struct { pub const MenuItemType = menu_mod.MenuItemType; pub const MenuBar = menu_mod.MenuBar; pub const MenuBarItem = menu_mod.MenuBarItem; + pub const ContextMenu = menu_mod.ContextMenu; + + pub const tooltip_mod = @import("widgets/tooltip.zig"); + pub const Tooltip = tooltip_mod.Tooltip; + pub const TooltipPosition = tooltip_mod.TooltipPosition; + pub const TooltipManager = tooltip_mod.TooltipManager; }; // Backend diff --git a/src/widgets/menu.zig b/src/widgets/menu.zig index 033535a..67272ba 100644 --- a/src/widgets/menu.zig +++ b/src/widgets/menu.zig @@ -576,6 +576,184 @@ pub const MenuBarItem = struct { } }; +// ============================================================================ +// ContextMenu +// ============================================================================ + +/// A context menu (right-click menu) that appears at a specific position. +/// +/// Wraps a Menu and handles positioning relative to mouse click location. +pub const ContextMenu = struct { + /// The underlying menu. + menu: Menu, + + /// Position where the menu should appear. + x: u16 = 0, + y: u16 = 0, + + /// Whether the context menu is visible. + visible: bool = false, + + /// Whether to adjust position to fit within bounds. + auto_adjust: bool = true, + + /// Creates a new context menu. + pub fn init() ContextMenu { + return .{ + .menu = Menu.init(), + }; + } + + /// Creates a context menu with items. + pub fn withItems(items: []const MenuItem) ContextMenu { + return .{ + .menu = Menu.init().setItems(items), + }; + } + + /// Sets the menu items. + pub fn setItems(self: ContextMenu, items: []const MenuItem) ContextMenu { + var cm = self; + cm.menu = self.menu.setItems(items); + return cm; + } + + /// Sets the menu style. + pub fn setStyle(self: ContextMenu, style: Style) ContextMenu { + var cm = self; + cm.menu = self.menu.setStyle(style); + return cm; + } + + /// Sets the selected item style. + pub fn setSelectedStyle(self: ContextMenu, style: Style) ContextMenu { + var cm = self; + cm.menu = self.menu.setSelectedStyle(style); + return cm; + } + + /// Shows the context menu at the specified position. + pub fn show(self: *ContextMenu, x: u16, y: u16) void { + self.x = x; + self.y = y; + self.visible = true; + self.menu.selected = 0; + } + + /// Shows the context menu from a mouse event position. + pub fn showAt(self: *ContextMenu, mouse_x: u16, mouse_y: u16) void { + self.show(mouse_x, mouse_y); + } + + /// Hides the context menu. + pub fn hide(self: *ContextMenu) void { + self.visible = false; + } + + /// Toggles visibility. + pub fn toggle(self: *ContextMenu, x: u16, y: u16) void { + if (self.visible) { + self.hide(); + } else { + self.show(x, y); + } + } + + /// Returns whether the menu is visible. + pub fn isVisible(self: ContextMenu) bool { + return self.visible; + } + + /// Moves selection to next item. + pub fn selectNext(self: *ContextMenu) void { + self.menu.selectNext(); + } + + /// Moves selection to previous item. + pub fn selectPrev(self: *ContextMenu) void { + self.menu.selectPrev(); + } + + /// Returns the currently selected item. + pub fn getSelectedItem(self: ContextMenu) ?MenuItem { + return self.menu.getSelectedItem(); + } + + /// Returns the selected item index. + pub fn getSelectedIndex(self: ContextMenu) usize { + return self.menu.selected; + } + + /// Checks if a point is within the menu bounds. + pub fn containsPoint(self: ContextMenu, px: u16, py: u16, bounds: Rect) bool { + const menu_area = self.getMenuArea(bounds); + return px >= menu_area.x and px < menu_area.x + menu_area.width and + py >= menu_area.y and py < menu_area.y + menu_area.height; + } + + /// Calculates the menu area, adjusting if needed to fit within bounds. + pub fn getMenuArea(self: ContextMenu, bounds: Rect) Rect { + const width = self.menu.calculateWidth(); + const height = self.menu.calculateHeight(); + + var menu_x = self.x; + var menu_y = self.y; + + if (self.auto_adjust) { + // Adjust X to fit within bounds + if (menu_x + width > bounds.x + bounds.width) { + if (width <= self.x) { + menu_x = self.x -| width; + } else { + menu_x = bounds.x + bounds.width -| width; + } + } + + // Adjust Y to fit within bounds + if (menu_y + height > bounds.y + bounds.height) { + if (height <= self.y) { + menu_y = self.y -| height; + } else { + menu_y = bounds.y + bounds.height -| height; + } + } + } + + return Rect.init(menu_x, menu_y, width, height); + } + + /// Renders the context menu if visible. + pub fn render(self: ContextMenu, bounds: Rect, buf: *Buffer) void { + if (!self.visible) return; + + const menu_area = self.getMenuArea(bounds); + + // Dim background slightly around menu (optional visual effect) + // For now, just render the menu directly + self.menu.render(menu_area, buf); + } + + /// Renders with background dimming. + pub fn renderWithDim(self: ContextMenu, bounds: Rect, buf: *Buffer, dim_style: Style) void { + if (!self.visible) return; + + // Dim entire background + var y = bounds.top(); + while (y < bounds.bottom()) : (y += 1) { + var x = bounds.left(); + while (x < bounds.right()) : (x += 1) { + if (buf.getCell(x, y)) |cell| { + cell.setStyle(dim_style); + } + } + } + + // Render menu on top + const menu_area = self.getMenuArea(bounds); + self.menu.render(menu_area, buf); + } +}; + // ============================================================================ // Tests // ============================================================================ @@ -649,3 +827,58 @@ test "MenuBar navigation" { bar.closeMenus(); try std.testing.expect(bar.open_menu == null); } + +test "ContextMenu show/hide" { + var ctx = ContextMenu.withItems(&.{ + MenuItem.action("Cut", 'x'), + MenuItem.action("Copy", 'c'), + MenuItem.action("Paste", 'v'), + }); + + try std.testing.expect(!ctx.isVisible()); + + ctx.show(10, 20); + try std.testing.expect(ctx.isVisible()); + try std.testing.expectEqual(@as(u16, 10), ctx.x); + try std.testing.expectEqual(@as(u16, 20), ctx.y); + + ctx.hide(); + try std.testing.expect(!ctx.isVisible()); +} + +test "ContextMenu auto-adjust position" { + const ctx = ContextMenu.withItems(&.{ + MenuItem.action("Item 1", null), + MenuItem.action("Item 2", null), + }); + + const bounds = Rect.init(0, 0, 80, 24); + + // Menu at corner should adjust + var ctx2 = ctx; + ctx2.x = 75; + ctx2.y = 22; + const area = ctx2.getMenuArea(bounds); + + // Should be adjusted to fit within bounds + try std.testing.expect(area.x + area.width <= bounds.width); + try std.testing.expect(area.y + area.height <= bounds.height); +} + +test "ContextMenu containsPoint" { + var ctx = ContextMenu.withItems(&.{ + MenuItem.action("A", null), + MenuItem.action("B", null), + }); + ctx.x = 10; + ctx.y = 10; + ctx.visible = true; + + const bounds = Rect.init(0, 0, 80, 24); + + // Point inside menu + try std.testing.expect(ctx.containsPoint(12, 11, bounds)); + + // Point outside menu + try std.testing.expect(!ctx.containsPoint(0, 0, bounds)); +} diff --git a/src/widgets/tooltip.zig b/src/widgets/tooltip.zig new file mode 100644 index 0000000..2b0ea59 --- /dev/null +++ b/src/widgets/tooltip.zig @@ -0,0 +1,461 @@ +//! Tooltip widget for zcatui. +//! +//! Provides hover-style information popups: +//! - Tooltip: Simple text tooltip +//! - TooltipManager: Manages tooltip timing and positioning +//! +//! ## Example +//! +//! ```zig +//! var tooltip = Tooltip.init("Click to save your document") +//! .setStyle(Style.default.bg(Color.yellow).fg(Color.black)); +//! +//! // Show at position +//! tooltip.showAt(mouse_x, mouse_y); +//! tooltip.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; + +// ============================================================================ +// Tooltip +// ============================================================================ + +/// Position of tooltip relative to target. +pub const TooltipPosition = enum { + above, + below, + left, + right, + auto, // Automatically choose best position +}; + +/// A simple tooltip widget. +pub const Tooltip = struct { + /// Tooltip text (single line). + text: []const u8 = "", + + /// Multi-line text (if set, overrides single text). + lines: []const []const u8 = &.{}, + + /// Position relative to anchor point. + x: u16 = 0, + y: u16 = 0, + + /// Whether tooltip is visible. + visible: bool = false, + + /// Preferred position relative to anchor. + position: TooltipPosition = .auto, + + /// Style for tooltip background and text. + style: Style = Style.default.bg(Color.yellow).fg(Color.black), + + /// Border style (null = no border). + border: bool = true, + border_style: Style = Style.default.fg(Color.black), + + /// Padding inside tooltip. + padding_x: u16 = 1, + padding_y: u16 = 0, + + /// Maximum width (0 = no limit). + max_width: u16 = 60, + + /// Arrow indicator character. + show_arrow: bool = false, + + /// Creates a new tooltip with text. + pub fn init(text: []const u8) Tooltip { + return .{ + .text = text, + }; + } + + /// Creates a tooltip with multiple lines. + pub fn initMultiline(lines: []const []const u8) Tooltip { + return .{ + .lines = lines, + }; + } + + /// Sets the text. + pub fn setText(self: Tooltip, text: []const u8) Tooltip { + var t = self; + t.text = text; + t.lines = &.{}; + return t; + } + + /// Sets multiple lines. + pub fn setLines(self: Tooltip, lines: []const []const u8) Tooltip { + var t = self; + t.lines = lines; + return t; + } + + /// Sets the style. + pub fn setStyle(self: Tooltip, style: Style) Tooltip { + var t = self; + t.style = style; + return t; + } + + /// Sets the border. + pub fn setBorder(self: Tooltip, border: bool) Tooltip { + var t = self; + t.border = border; + return t; + } + + /// Sets the border style. + pub fn setBorderStyle(self: Tooltip, style: Style) Tooltip { + var t = self; + t.border_style = style; + return t; + } + + /// Sets the preferred position. + pub fn setPosition(self: Tooltip, pos: TooltipPosition) Tooltip { + var t = self; + t.position = pos; + return t; + } + + /// Sets maximum width. + pub fn setMaxWidth(self: Tooltip, width: u16) Tooltip { + var t = self; + t.max_width = width; + return t; + } + + /// Shows the tooltip at a specific position. + pub fn showAt(self: *Tooltip, x: u16, y: u16) void { + self.x = x; + self.y = y; + self.visible = true; + } + + /// Shows the tooltip near an anchor point with offset. + pub fn showNear(self: *Tooltip, anchor_x: u16, anchor_y: u16, bounds: Rect) void { + const size = self.calculateSize(); + + // Calculate position based on preference + var tx: u16 = anchor_x; + var ty: u16 = anchor_y; + + switch (self.position) { + .above => { + ty = anchor_y -| size.height -| 1; + }, + .below => { + ty = anchor_y + 1; + }, + .left => { + tx = anchor_x -| size.width -| 1; + }, + .right => { + tx = anchor_x + 1; + }, + .auto => { + // Try below first, then above + if (anchor_y + 1 + size.height <= bounds.height) { + ty = anchor_y + 1; + } else if (anchor_y >= size.height + 1) { + ty = anchor_y -| size.height -| 1; + } else { + ty = anchor_y + 1; + } + }, + } + + // Adjust to fit within bounds + if (tx + size.width > bounds.x + bounds.width) { + tx = bounds.x + bounds.width -| size.width; + } + if (ty + size.height > bounds.y + bounds.height) { + ty = bounds.y + bounds.height -| size.height; + } + + self.x = tx; + self.y = ty; + self.visible = true; + } + + /// Hides the tooltip. + pub fn hide(self: *Tooltip) void { + self.visible = false; + } + + /// Returns whether visible. + pub fn isVisible(self: Tooltip) bool { + return self.visible; + } + + /// Calculates the size of the tooltip. + pub fn calculateSize(self: Tooltip) struct { width: u16, height: u16 } { + var content_width: u16 = 0; + var content_height: u16 = 0; + + if (self.lines.len > 0) { + for (self.lines) |line| { + const line_len: u16 = @intCast(@min(line.len, 65535)); + content_width = @max(content_width, line_len); + } + content_height = @intCast(self.lines.len); + } else { + content_width = @intCast(@min(self.text.len, 65535)); + content_height = 1; + } + + // Apply max width + if (self.max_width > 0 and content_width > self.max_width) { + content_width = self.max_width; + } + + // Add padding + var width = content_width + self.padding_x * 2; + var height = content_height + self.padding_y * 2; + + // Add border + if (self.border) { + width += 2; + height += 2; + } + + return .{ .width = width, .height = height }; + } + + /// Gets the tooltip area. + pub fn getArea(self: Tooltip) Rect { + const size = self.calculateSize(); + return Rect.init(self.x, self.y, size.width, size.height); + } + + /// Renders the tooltip. + pub fn render(self: Tooltip, bounds: Rect, buf: *Buffer) void { + if (!self.visible) return; + + const size = self.calculateSize(); + const tooltip_area = Rect.init( + @min(self.x, bounds.x + bounds.width -| size.width), + @min(self.y, bounds.y + bounds.height -| size.height), + size.width, + size.height, + ); + + // Render background + self.fillBackground(tooltip_area, buf); + + // Render border if enabled + var content_area = tooltip_area; + if (self.border) { + const block = Block.init() + .setBorders(Borders.all) + .style(self.border_style); + block.render(tooltip_area, buf); + content_area = block.inner(tooltip_area); + } + + // Apply padding + const text_area = Rect.init( + content_area.x + self.padding_x, + content_area.y + self.padding_y, + content_area.width -| self.padding_x * 2, + content_area.height -| self.padding_y * 2, + ); + + // Render text + if (self.lines.len > 0) { + var y = text_area.y; + for (self.lines) |line| { + if (y >= text_area.y + text_area.height) break; + _ = buf.setString(text_area.x, y, line, self.style); + y += 1; + } + } else { + _ = buf.setString(text_area.x, text_area.y, self.text, self.style); + } + } + + fn fillBackground(self: Tooltip, 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); + } + } + } + } +}; + +// ============================================================================ +// TooltipManager +// ============================================================================ + +/// Manages tooltip display with timing/delay. +pub const TooltipManager = struct { + /// Current tooltip. + tooltip: Tooltip, + + /// Hover start time (milliseconds). + hover_start_ms: i64 = 0, + + /// Delay before showing tooltip (ms). + show_delay_ms: u32 = 500, + + /// How long to show tooltip (0 = until mouse moves). + display_duration_ms: u32 = 0, + + /// Current hover position. + hover_x: u16 = 0, + hover_y: u16 = 0, + + /// Whether we're waiting to show. + pending: bool = false, + + /// Creates a new tooltip manager. + pub fn init() TooltipManager { + return .{ + .tooltip = Tooltip.init(""), + }; + } + + /// Sets the show delay. + pub fn setDelay(self: *TooltipManager, delay_ms: u32) void { + self.show_delay_ms = delay_ms; + } + + /// Called when mouse moves to a position with a tooltip. + pub fn onHover(self: *TooltipManager, x: u16, y: u16, text: []const u8, current_time_ms: i64) void { + if (self.hover_x != x or self.hover_y != y) { + // Position changed, reset timer + self.hover_x = x; + self.hover_y = y; + self.hover_start_ms = current_time_ms; + self.pending = true; + self.tooltip.hide(); + self.tooltip.text = text; + } + } + + /// Called when mouse leaves tooltip area. + pub fn onLeave(self: *TooltipManager) void { + self.pending = false; + self.tooltip.hide(); + } + + /// Updates the tooltip state, should be called each frame. + pub fn update(self: *TooltipManager, current_time_ms: i64, bounds: Rect) void { + if (self.pending) { + const elapsed = current_time_ms - self.hover_start_ms; + if (elapsed >= self.show_delay_ms) { + self.tooltip.showNear(self.hover_x, self.hover_y, bounds); + self.pending = false; + } + } + } + + /// Renders the tooltip if visible. + pub fn render(self: TooltipManager, bounds: Rect, buf: *Buffer) void { + self.tooltip.render(bounds, buf); + } + + /// Returns whether tooltip is visible. + pub fn isVisible(self: TooltipManager) bool { + return self.tooltip.isVisible(); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Tooltip basic" { + var tooltip = Tooltip.init("Hello, World!"); + + try std.testing.expect(!tooltip.isVisible()); + try std.testing.expectEqualStrings("Hello, World!", tooltip.text); + + tooltip.showAt(10, 5); + try std.testing.expect(tooltip.isVisible()); + try std.testing.expectEqual(@as(u16, 10), tooltip.x); + try std.testing.expectEqual(@as(u16, 5), tooltip.y); + + tooltip.hide(); + try std.testing.expect(!tooltip.isVisible()); +} + +test "Tooltip size calculation" { + const tooltip = Tooltip.init("Test").setBorder(true); + const size = tooltip.calculateSize(); + + // "Test" = 4 chars + 2 padding + 2 border = 8 + try std.testing.expectEqual(@as(u16, 8), size.width); + // 1 line + 0 padding + 2 border = 3 + try std.testing.expectEqual(@as(u16, 3), size.height); +} + +test "Tooltip multiline" { + const tooltip = Tooltip.initMultiline(&.{ + "Line 1", + "Longer line 2", + "L3", + }).setBorder(false); + + const size = tooltip.calculateSize(); + + // "Longer line 2" = 13 chars + 2 padding = 15 + try std.testing.expectEqual(@as(u16, 15), size.width); + // 3 lines + try std.testing.expectEqual(@as(u16, 3), size.height); +} + +test "Tooltip showNear auto position" { + var tooltip = Tooltip.init("Tip").setPosition(.auto); + const bounds = Rect.init(0, 0, 80, 24); + + // Near top - should go below + tooltip.showNear(10, 2, bounds); + try std.testing.expect(tooltip.y > 2); + + // Near bottom - should go above + tooltip.showNear(10, 22, bounds); + try std.testing.expect(tooltip.y < 22); +} + +test "TooltipManager delay" { + var manager = TooltipManager.init(); + manager.setDelay(100); + + const bounds = Rect.init(0, 0, 80, 24); + + // Start hover + manager.onHover(10, 10, "Test", 0); + try std.testing.expect(!manager.isVisible()); + try std.testing.expect(manager.pending); + + // Not enough time + manager.update(50, bounds); + try std.testing.expect(!manager.isVisible()); + + // Enough time + manager.update(100, bounds); + try std.testing.expect(manager.isVisible()); + + // Leave + manager.onLeave(); + try std.testing.expect(!manager.isVisible()); +}