feat: Add ContextMenu and Tooltip widgets

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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 17:18:45 +01:00
parent b393008497
commit 8c218a3f0d
3 changed files with 700 additions and 0 deletions

View file

@ -148,6 +148,12 @@ pub const widgets = struct {
pub const MenuItemType = menu_mod.MenuItemType; pub const MenuItemType = menu_mod.MenuItemType;
pub const MenuBar = menu_mod.MenuBar; pub const MenuBar = menu_mod.MenuBar;
pub const MenuBarItem = menu_mod.MenuBarItem; 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 // Backend

View file

@ -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 // Tests
// ============================================================================ // ============================================================================
@ -649,3 +827,58 @@ test "MenuBar navigation" {
bar.closeMenus(); bar.closeMenus();
try std.testing.expect(bar.open_menu == null); 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));
}

461
src/widgets/tooltip.zig Normal file
View file

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