zcatui/src/widgets/tooltip.zig
reugenio 8c218a3f0d 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>
2025-12-08 17:18:45 +01:00

461 lines
13 KiB
Zig

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