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