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>
461 lines
13 KiB
Zig
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());
|
|
}
|