From 9efa1ff5804a757a033e1c4508038202ce36b326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 05:33:20 +0100 Subject: [PATCH 1/8] feat: add Modal/Popup/Overlay component with presets, backdrop, and focus support --- build.zig | 2 + examples/modal.zig | 153 +++++++++ src/components/modal.zig | 704 +++++++++++++++++++++++++++++++++++++++ src/root.zig | 3 + tests/modal_tests.zig | 312 +++++++++++++++++ 5 files changed, 1174 insertions(+) create mode 100644 examples/modal.zig create mode 100644 src/components/modal.zig create mode 100644 tests/modal_tests.zig diff --git a/build.zig b/build.zig index 892720c..8338447 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,7 @@ pub fn build(b: *std.Build) void { "dashboard", "showcase", "focus_form", + "modal", }; for (examples) |example_name| { @@ -58,6 +59,7 @@ pub fn build(b: *std.Build) void { "tests/unicode_tests.zig", "tests/program_tests.zig", "tests/focus_tests.zig", + "tests/modal_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/examples/modal.zig b/examples/modal.zig new file mode 100644 index 0000000..51c194c --- /dev/null +++ b/examples/modal.zig @@ -0,0 +1,153 @@ +//! ZigZag Modal Example +//! Demonstrates the Modal component with different dialog types. +//! +//! Keys: +//! 1 — Show info modal +//! 2 — Show confirm modal +//! 3 — Show warning modal +//! 4 — Show error modal +//! 5 — Show custom modal +//! q — Quit + +const std = @import("std"); +const zz = @import("zigzag"); + +const Model = struct { + modal: zz.Modal, + last_result: []const u8, + status: []const u8, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + }; + + pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { + self.modal = zz.Modal.init(); + self.last_result = "None"; + self.status = "Press 1-5 to open a modal"; + return .none; + } + + pub fn update(self: *Model, m: Msg, ctx: *zz.Context) zz.Cmd(Msg) { + switch (m) { + .key => |k| { + // If modal is visible, let it handle keys + if (self.modal.isVisible()) { + self.modal.handleKey(k); + + // Check for result + if (self.modal.getResult()) |res| { + self.last_result = switch (res) { + .button_pressed => |idx| std.fmt.allocPrint( + ctx.persistent_allocator, + "Button {d} pressed", + .{idx}, + ) catch "Button pressed", + .dismissed => "Dismissed (Escape)", + }; + self.status = "Press 1-5 to open another modal"; + } + return .none; + } + + switch (k.key) { + .char => |c| switch (c) { + 'q' => return .quit, + '1' => { + self.modal = zz.Modal.info("Information", "This is an informational message.\nEverything is working correctly."); + self.modal.backdrop = .{}; + self.modal.show(); + self.status = "Info modal open"; + }, + '2' => { + self.modal = zz.Modal.confirm("Confirm Action", "Are you sure you want to proceed?\nThis action cannot be undone."); + self.modal.backdrop = .{}; + self.modal.show(); + self.status = "Confirm modal open"; + }, + '3' => { + self.modal = zz.Modal.warning("Warning", "Low disk space remaining.\nConsider freeing up some space."); + self.modal.backdrop = .{}; + self.modal.show(); + self.status = "Warning modal open"; + }, + '4' => { + self.modal = zz.Modal.err("Error", "Failed to save file.\nPermission denied."); + self.modal.backdrop = .{}; + self.modal.show(); + self.status = "Error modal open"; + }, + '5' => { + self.modal = zz.Modal.init(); + self.modal.title = "Custom Dialog"; + self.modal.body = "This is a fully customized modal.\nWith multiple lines of content.\nAnd custom buttons below."; + self.modal.footer = "Use Tab/arrows to navigate, Enter to select"; + self.modal.width = .{ .fixed = 50 }; + self.modal.border_chars = zz.Border.double; + self.modal.border_fg = zz.Color.magenta(); + self.modal.title_style = blk: { + var s = zz.Style{}; + s = s.bold(true).fg(zz.Color.magenta()).inline_style(true); + break :blk s; + }; + self.modal.content_bg = zz.Color.gray(2); + self.modal.backdrop = .{}; + self.modal.addButton("Save", .{ .char = 's' }); + self.modal.addButton("Discard", .{ .char = 'd' }); + self.modal.addButton("Cancel", null); + self.modal.show(); + self.status = "Custom modal open"; + }, + else => {}, + }, + .escape => return .quit, + else => {}, + } + }, + } + return .none; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + const alloc = ctx.allocator; + + // If modal is visible, render it with backdrop + if (self.modal.isVisible()) { + return self.modal.viewWithBackdrop(alloc, ctx.width, ctx.height) catch "Error"; + } + + // Main view + var title_s = zz.Style{}; + title_s = title_s.bold(true).fg(zz.Color.hex("#FF6B6B")).inline_style(true); + + var hint_s = zz.Style{}; + hint_s = hint_s.fg(zz.Color.gray(14)).inline_style(true); + + var result_s = zz.Style{}; + result_s = result_s.fg(zz.Color.cyan()).inline_style(true); + + var status_s = zz.Style{}; + status_s = status_s.fg(zz.Color.gray(12)).inline_style(true); + + const title = title_s.render(alloc, "Modal Component Demo") catch "Modal Component Demo"; + const hint = hint_s.render(alloc, "1: Info 2: Confirm 3: Warning 4: Error 5: Custom q: Quit") catch ""; + const result_label = result_s.render(alloc, std.fmt.allocPrint(alloc, "Last result: {s}", .{self.last_result}) catch "") catch ""; + const status = status_s.render(alloc, self.status) catch ""; + + const content = std.fmt.allocPrint(alloc, "{s}\n\n{s}\n\n{s}\n{s}", .{ + title, hint, result_label, status, + }) catch "Error"; + + return zz.place.place(alloc, ctx.width, ctx.height, .center, .middle, content) catch content; + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + + var prog = try zz.Program(Model).init(gpa.allocator()); + defer prog.deinit(); + + try prog.run(); +} diff --git a/src/components/modal.zig b/src/components/modal.zig new file mode 100644 index 0000000..4bc088d --- /dev/null +++ b/src/components/modal.zig @@ -0,0 +1,704 @@ +//! Modal/Popup/Overlay component. +//! State-managed dialog that captures focus, renders on top, and returns a result. +//! Supports confirmation dialogs, input prompts, selection menus, error popups, +//! and fully custom content. +//! +//! ## Quick Start +//! +//! ```zig +//! // Create an info modal +//! var modal = Modal.info("Notice", "Operation completed successfully."); +//! modal.show(); +//! +//! // In your update function: +//! modal.handleKey(key_event); +//! if (modal.getResult()) |res| { +//! switch (res) { +//! .button_pressed => |idx| { /* button at idx was pressed */ }, +//! .dismissed => { /* user pressed Escape */ }, +//! } +//! } +//! +//! // In your view function: +//! if (modal.isVisible()) { +//! return modal.viewWithBackdrop(allocator, ctx.width, ctx.height); +//! } +//! ``` +//! +//! ## Presets +//! +//! - `Modal.info(title, body)` — informational dialog with OK button (cyan border) +//! - `Modal.confirm(title, body)` — yes/no confirmation (yellow border) +//! - `Modal.warning(title, body)` — warning with OK button (yellow border) +//! - `Modal.err(title, body)` — error with OK button (red border) +//! - `Modal.init()` — blank modal for full custom configuration +//! +//! ## Focus Protocol +//! +//! Modal satisfies the focusable protocol (`focused`, `focus()`, `blur()`) and +//! can be registered with a `FocusGroup`. + +const std = @import("std"); +const keys = @import("../input/keys.zig"); +const style_mod = @import("../style/style.zig"); +const border_mod = @import("../style/border.zig"); +const Color = @import("../style/color.zig").Color; +const measure = @import("../layout/measure.zig"); +const place = @import("../layout/place.zig"); + +const max_buttons = 8; + +pub const Modal = struct { + // ── State ────────────────────────────────────────────────────────── + + visible: bool = false, + focused: bool = false, + result: ?Result = null, + + // ── Content ──────────────────────────────────────────────────────── + + title: []const u8 = "", + body: []const u8 = "", + footer: ?[]const u8 = null, + + // ── Buttons ──────────────────────────────────────────────────────── + + buttons: [max_buttons]?Button = [_]?Button{null} ** max_buttons, + button_count: usize = 0, + selected_button: usize = 0, + + // ── Layout ───────────────────────────────────────────────────────── + + width: Size = .{ .percent = 0.5 }, + height: Size = .auto, + h_position: f32 = 0.5, + v_position: f32 = 0.5, + padding: Padding = .{ .top = 1, .right = 2, .bottom = 1, .left = 2 }, + button_align: ButtonAlign = .right, + + // ── Behavior ─────────────────────────────────────────────────────── + + close_on_escape: bool = true, + + // ── Styling ──────────────────────────────────────────────────────── + + border_chars: border_mod.BorderChars = border_mod.Border.rounded, + border_fg: Color = Color.gray(18), + content_bg: Color = .none, + title_style: style_mod.Style = makeStyle(.{ .bold_v = true, .fg_color = Color.white() }), + body_style: style_mod.Style = makeStyle(.{ .fg_color = Color.gray(20) }), + footer_style: style_mod.Style = makeStyle(.{ .fg_color = Color.gray(12), .italic_v = true }), + button_active_style: style_mod.Style = makeStyle(.{ .bold_v = true, .fg_color = Color.white(), .bg_color = Color.cyan() }), + button_inactive_style: style_mod.Style = makeStyle(.{ .fg_color = Color.gray(14) }), + backdrop: ?Backdrop = null, + + // ── Types ────────────────────────────────────────────────────────── + + pub const Result = union(enum) { + button_pressed: usize, + dismissed: void, + }; + + pub const Button = struct { + label: []const u8, + shortcut: ?keys.Key = null, + }; + + pub const Size = union(enum) { + fixed: u16, + percent: f32, + auto: void, + }; + + pub const Padding = struct { + top: u16 = 0, + right: u16 = 0, + bottom: u16 = 0, + left: u16 = 0, + + pub fn all(n: u16) Padding { + return .{ .top = n, .right = n, .bottom = n, .left = n }; + } + + pub fn symmetric(vert: u16, horiz: u16) Padding { + return .{ .top = vert, .right = horiz, .bottom = vert, .left = horiz }; + } + }; + + pub const ButtonAlign = enum { left, center, right }; + + pub const Backdrop = struct { + char: u8 = ' ', + style: style_mod.Style = makeStyle(.{ .bg_color = Color.gray(3) }), + }; + + // ── Preset Constructors ──────────────────────────────────────────── + + /// Informational dialog with a single OK button and cyan accent. + pub fn info(title: []const u8, body: []const u8) Modal { + var m: Modal = .{ + .title = title, + .body = body, + .border_fg = Color.cyan(), + .title_style = makeStyle(.{ .bold_v = true, .fg_color = Color.cyan() }), + }; + m.addButton("OK", .enter); + return m; + } + + /// Yes/No confirmation dialog with yellow accent. + pub fn confirm(title: []const u8, body: []const u8) Modal { + var m: Modal = .{ + .title = title, + .body = body, + .border_fg = Color.yellow(), + .title_style = makeStyle(.{ .bold_v = true, .fg_color = Color.yellow() }), + }; + m.addButton("Yes", .{ .char = 'y' }); + m.addButton("No", .{ .char = 'n' }); + return m; + } + + /// Warning dialog with a single OK button and yellow accent. + pub fn warning(title: []const u8, body: []const u8) Modal { + var m: Modal = .{ + .title = title, + .body = body, + .border_fg = Color.yellow(), + .title_style = makeStyle(.{ .bold_v = true, .fg_color = Color.yellow() }), + }; + m.addButton("OK", .enter); + return m; + } + + /// Error dialog with a single OK button and red accent. + pub fn err(title: []const u8, body: []const u8) Modal { + var m: Modal = .{ + .title = title, + .body = body, + .border_fg = Color.red(), + .title_style = makeStyle(.{ .bold_v = true, .fg_color = Color.red() }), + }; + m.addButton("OK", .enter); + return m; + } + + /// Blank modal with no preset content — configure everything yourself. + pub fn init() Modal { + return .{}; + } + + // ── Button Management ────────────────────────────────────────────── + + /// Add a button with an optional keyboard shortcut. + pub fn addButton(self: *Modal, label: []const u8, shortcut: ?keys.Key) void { + if (self.button_count >= max_buttons) return; + self.buttons[self.button_count] = .{ + .label = label, + .shortcut = shortcut, + }; + self.button_count += 1; + } + + /// Remove all buttons. + pub fn clearButtons(self: *Modal) void { + self.buttons = [_]?Button{null} ** max_buttons; + self.button_count = 0; + self.selected_button = 0; + } + + // ── State Management ─────────────────────────────────────────────── + + /// Show the modal and reset its result. + pub fn show(self: *Modal) void { + self.visible = true; + self.focused = true; + self.result = null; + self.selected_button = 0; + } + + /// Hide the modal without setting a result. + pub fn hide(self: *Modal) void { + self.visible = false; + self.focused = false; + } + + pub fn isVisible(self: *const Modal) bool { + return self.visible; + } + + /// Returns the result once the modal has been closed. + pub fn getResult(self: *const Modal) ?Result { + return self.result; + } + + /// Reset visibility, focus, and result. + pub fn reset(self: *Modal) void { + self.visible = false; + self.focused = false; + self.result = null; + self.selected_button = 0; + } + + // ── Focusable Protocol ───────────────────────────────────────────── + + pub fn focus(self: *Modal) void { + self.focused = true; + } + + pub fn blur(self: *Modal) void { + self.focused = false; + } + + // ── Input Handling ───────────────────────────────────────────────── + + /// Process a key event. Only acts when visible and focused. + pub fn handleKey(self: *Modal, key: keys.KeyEvent) void { + if (!self.visible or !self.focused) return; + + // Check button shortcuts first + for (self.buttons[0..self.button_count], 0..) |maybe_btn, i| { + if (maybe_btn) |btn| { + if (btn.shortcut) |sc| { + if (sc.eql(key.key)) { + self.result = .{ .button_pressed = i }; + self.visible = false; + return; + } + } + } + } + + switch (key.key) { + .escape => { + if (self.close_on_escape) { + self.result = .dismissed; + self.visible = false; + } + }, + .enter => { + if (self.button_count > 0) { + self.result = .{ .button_pressed = self.selected_button }; + self.visible = false; + } + }, + .tab => { + if (self.button_count > 1) { + if (key.modifiers.shift) { + self.selected_button = if (self.selected_button > 0) + self.selected_button - 1 + else + self.button_count - 1; + } else { + self.selected_button = if (self.selected_button + 1 < self.button_count) + self.selected_button + 1 + else + 0; + } + } + }, + .left => { + if (self.button_count > 1 and self.selected_button > 0) { + self.selected_button -= 1; + } + }, + .right => { + if (self.button_count > 1 and self.selected_button + 1 < self.button_count) { + self.selected_button += 1; + } + }, + else => {}, + } + } + + // ── Rendering ────────────────────────────────────────────────────── + + /// Render the modal box centered on a transparent (space-filled) canvas. + /// Returns empty string if not visible. + pub fn view(self: *const Modal, allocator: std.mem.Allocator, term_width: usize, term_height: usize) ![]const u8 { + if (!self.visible) return try allocator.dupe(u8, ""); + + const box = try self.renderBox(allocator, term_width, term_height); + return try place.placeFloat(allocator, term_width, term_height, self.h_position, self.v_position, box); + } + + /// Render the modal with a styled backdrop filling the entire terminal. + /// Returns empty string if not visible. + pub fn viewWithBackdrop(self: *const Modal, allocator: std.mem.Allocator, term_width: usize, term_height: usize) ![]const u8 { + if (!self.visible) return try allocator.dupe(u8, ""); + + const bd = self.backdrop orelse Backdrop{}; + const box = try self.renderBox(allocator, term_width, term_height); + const box_w = measure.maxLineWidth(box); + const box_h = measure.height(box); + + // Position + const h_space = if (term_width > box_w) term_width - box_w else 0; + const v_space = if (term_height > box_h) term_height - box_h else 0; + const modal_x: usize = @intFromFloat(@as(f32, @floatFromInt(h_space)) * clamp01(self.h_position)); + const modal_y: usize = @intFromFloat(@as(f32, @floatFromInt(v_space)) * clamp01(self.v_position)); + + // Collect modal lines + var modal_lines_list = std.array_list.Managed([]const u8).init(allocator); + defer modal_lines_list.deinit(); + var box_iter = std.mem.splitScalar(u8, box, '\n'); + while (box_iter.next()) |line| try modal_lines_list.append(line); + const modal_lines = modal_lines_list.items; + + // Build full-screen output + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + for (0..term_height) |row| { + if (row > 0) try writer.writeByte('\n'); + + const in_modal = row >= modal_y and row < modal_y + box_h; + if (in_modal) { + const mline_idx = row - modal_y; + if (mline_idx < modal_lines.len) { + // Left backdrop + if (modal_x > 0) { + try writer.writeAll(try renderBackdropSegment(allocator, bd, modal_x)); + } + // Modal line + try writer.writeAll(modal_lines[mline_idx]); + // Right backdrop + const mline_w = measure.width(modal_lines[mline_idx]); + const right_start = modal_x + mline_w; + if (right_start < term_width) { + try writer.writeAll(try renderBackdropSegment(allocator, bd, term_width - right_start)); + } + } else { + try writer.writeAll(try renderBackdropSegment(allocator, bd, term_width)); + } + } else { + try writer.writeAll(try renderBackdropSegment(allocator, bd, term_width)); + } + } + + return result.toOwnedSlice(); + } + + /// Render just the modal box (no positioning or backdrop). + pub fn renderBox(self: *const Modal, allocator: std.mem.Allocator, term_width: usize, term_height: usize) ![]const u8 { + const bc = self.border_chars; + const box_w = self.computeWidth(term_width); + const inner_w: usize = if (box_w >= 2) box_w - 2 else 0; + const pad_h: usize = @as(usize, self.padding.left) + @as(usize, self.padding.right); + const content_w: usize = if (inner_w >= pad_h) inner_w - pad_h else 0; + + // Compute height constraints + const box_h = self.computeHeight(term_height, inner_w); + const extras = self.computeExtras(); + const max_body_lines: usize = if (box_h > extras) box_h - extras else 0; + + // Inline styles for border and padding segments + var bdr_s = style_mod.Style{}; + bdr_s = bdr_s.fg(self.border_fg).inline_style(true); + if (!self.content_bg.isNone()) bdr_s = bdr_s.bg(self.content_bg); + + var pad_s = style_mod.Style{}; + pad_s = pad_s.inline_style(true); + if (!self.content_bg.isNone()) pad_s = pad_s.bg(self.content_bg); + + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + // ── Top border ── + try writer.writeAll(try bdr_s.render(allocator, bc.top_left)); + if (self.title.len > 0) { + const title_w = measure.width(self.title); + // top + space + title + space + remaining top chars + const used: usize = 3 + title_w; // 1 top + 1 space + title + 1 space + const remaining: usize = if (inner_w > used) inner_w - used else 0; + + try writer.writeAll(try bdr_s.render(allocator, bc.horizontal)); + try writer.writeAll(try bdr_s.render(allocator, " ")); + try writer.writeAll(try self.title_style.inline_style(true).render(allocator, self.title)); + try writer.writeAll(try bdr_s.render(allocator, " ")); + try writer.writeAll(try repeatStr(allocator, bdr_s, bc.horizontal, remaining)); + } else { + try writer.writeAll(try repeatStr(allocator, bdr_s, bc.horizontal, inner_w)); + } + try writer.writeAll(try bdr_s.render(allocator, bc.top_right)); + + // Helpers for inner lines + const styled_left = try bdr_s.render(allocator, bc.vertical); + const styled_right = try bdr_s.render(allocator, bc.vertical); + + // ── Top padding ── + for (0..self.padding.top) |_| { + try writer.writeByte('\n'); + try self.writeEmptyInnerLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + } + + // ── Body lines ── + var body_iter = std.mem.splitScalar(u8, self.body, '\n'); + var body_line_count: usize = 0; + while (body_iter.next()) |line| { + if (body_line_count >= max_body_lines) break; + body_line_count += 1; + + try writer.writeByte('\n'); + try writer.writeAll(styled_left); + + // Left padding + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, self.padding.left))); + + // Body text + var merged_body = self.body_style.inline_style(true); + if (!self.content_bg.isNone() and merged_body.background.isNone()) { + merged_body = merged_body.bg(self.content_bg); + } + try writer.writeAll(try merged_body.render(allocator, line)); + + // Right fill + const line_w = measure.width(line); + const fill: usize = if (content_w > line_w) content_w - line_w else 0; + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, fill + self.padding.right))); + + try writer.writeAll(styled_right); + } + + // Pad body if fixed/percent height has more room + while (body_line_count < max_body_lines) : (body_line_count += 1) { + try writer.writeByte('\n'); + try self.writeEmptyInnerLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + } + + // ── Footer ── + if (self.footer) |footer_text| { + // Separator line + try writer.writeByte('\n'); + try self.writeEmptyInnerLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + + try writer.writeByte('\n'); + try writer.writeAll(styled_left); + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, self.padding.left))); + + var merged_footer = self.footer_style.inline_style(true); + if (!self.content_bg.isNone() and merged_footer.background.isNone()) { + merged_footer = merged_footer.bg(self.content_bg); + } + try writer.writeAll(try merged_footer.render(allocator, footer_text)); + + const fw = measure.width(footer_text); + const fill: usize = if (content_w > fw) content_w - fw else 0; + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, fill + self.padding.right))); + try writer.writeAll(styled_right); + } + + // ── Buttons ── + if (self.button_count > 0) { + // Separator + try writer.writeByte('\n'); + try self.writeEmptyInnerLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + + // Render button row content + var btn_buf = std.array_list.Managed(u8).init(allocator); + const btn_writer = btn_buf.writer(); + + for (self.buttons[0..self.button_count], 0..) |maybe_btn, i| { + if (maybe_btn) |btn| { + if (i > 0) { + try btn_writer.writeAll(try pad_s.render(allocator, " ")); + } + const label = try std.fmt.allocPrint(allocator, " {s} ", .{btn.label}); + var btn_style = if (i == self.selected_button) self.button_active_style else self.button_inactive_style; + btn_style = btn_style.inline_style(true); + if (!self.content_bg.isNone() and i != self.selected_button and btn_style.background.isNone()) { + btn_style = btn_style.bg(self.content_bg); + } + try btn_writer.writeAll(try btn_style.render(allocator, label)); + } + } + const btn_row = try btn_buf.toOwnedSlice(); + const btn_row_w = measure.width(btn_row); + + try writer.writeByte('\n'); + try writer.writeAll(styled_left); + + // Align buttons within inner_w + const left_spaces: usize = switch (self.button_align) { + .left => self.padding.left, + .center => if (inner_w > btn_row_w) (inner_w - btn_row_w) / 2 else 0, + .right => if (inner_w > btn_row_w + self.padding.right) + inner_w - btn_row_w - self.padding.right + else + 0, + }; + const right_spaces: usize = if (inner_w > left_spaces + btn_row_w) + inner_w - left_spaces - btn_row_w + else + 0; + + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, left_spaces))); + try writer.writeAll(btn_row); + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, right_spaces))); + + try writer.writeAll(styled_right); + } + + // ── Bottom padding ── + for (0..self.padding.bottom) |_| { + try writer.writeByte('\n'); + try self.writeEmptyInnerLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + } + + // ── Bottom border ── + try writer.writeByte('\n'); + try writer.writeAll(try bdr_s.render(allocator, bc.bottom_left)); + try writer.writeAll(try repeatStr(allocator, bdr_s, bc.horizontal, inner_w)); + try writer.writeAll(try bdr_s.render(allocator, bc.bottom_right)); + + return result.toOwnedSlice(); + } + + // ── Private Helpers ──────────────────────────────────────────────── + + fn writeEmptyInnerLine( + self: *const Modal, + allocator: std.mem.Allocator, + writer: anytype, + styled_left: []const u8, + styled_right: []const u8, + pad_s: style_mod.Style, + inner_w: usize, + ) !void { + _ = self; + try writer.writeAll(styled_left); + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, inner_w))); + try writer.writeAll(styled_right); + } + + fn computeWidth(self: *const Modal, term_width: usize) usize { + return switch (self.width) { + .fixed => |w| @min(@as(usize, w), term_width), + .percent => |p| @intFromFloat(@as(f32, @floatFromInt(term_width)) * clamp01(p)), + .auto => blk: { + const pad_h: usize = @as(usize, self.padding.left) + @as(usize, self.padding.right); + var max_inner: usize = 0; + + // Title (in border): needs title_w + 4 + if (self.title.len > 0) { + max_inner = @max(max_inner, measure.width(self.title) + 4); + } + + // Content-area items need content_w + padding_h + var max_content: usize = 0; + + var body_iter = std.mem.splitScalar(u8, self.body, '\n'); + while (body_iter.next()) |line| { + max_content = @max(max_content, measure.width(line)); + } + + if (self.footer) |ft| { + max_content = @max(max_content, measure.width(ft)); + } + + var btn_w: usize = 0; + for (self.buttons[0..self.button_count]) |maybe_btn| { + if (maybe_btn) |btn| { + if (btn_w > 0) btn_w += 2; // gap + btn_w += measure.width(btn.label) + 2; // " Label " + } + } + max_content = @max(max_content, btn_w); + + max_inner = @max(max_inner, max_content + pad_h); + break :blk @min(max_inner + 2, term_width); // +2 for borders + }, + }; + } + + fn computeHeight(self: *const Modal, term_height: usize, inner_w: usize) usize { + _ = inner_w; + return switch (self.height) { + .fixed => |h| @min(@as(usize, h), term_height), + .percent => |p| @intFromFloat(@as(f32, @floatFromInt(term_height)) * clamp01(p)), + .auto => blk: { + var h: usize = 2; // top + bottom border + h += self.padding.top; + h += self.padding.bottom; + + // Body lines + var body_lines: usize = 0; + var iter = std.mem.splitScalar(u8, self.body, '\n'); + while (iter.next()) |_| body_lines += 1; + h += body_lines; + + if (self.footer != null) h += 2; // separator + footer + if (self.button_count > 0) h += 2; // separator + button row + + break :blk @min(h, term_height); + }, + }; + } + + fn computeExtras(self: *const Modal) usize { + var e: usize = 2; // borders + e += self.padding.top; + e += self.padding.bottom; + if (self.footer != null) e += 2; + if (self.button_count > 0) e += 2; + return e; + } + + fn renderBackdropSegment(allocator: std.mem.Allocator, bd: Backdrop, count: usize) ![]const u8 { + if (count == 0) return try allocator.dupe(u8, ""); + const segment = try nChars(allocator, bd.char, count); + return try bd.style.inline_style(true).render(allocator, segment); + } + + fn repeatStr(allocator: std.mem.Allocator, s: style_mod.Style, str: []const u8, count: usize) ![]const u8 { + if (count == 0 or str.len == 0) return try allocator.dupe(u8, ""); + // Build repeated plain string, then style once + const buf = try allocator.alloc(u8, str.len * count); + for (0..count) |i| { + @memcpy(buf[i * str.len ..][0..str.len], str); + } + return try s.render(allocator, buf); + } + + fn nSpaces(allocator: std.mem.Allocator, count: anytype) ![]const u8 { + const n: usize = switch (@typeInfo(@TypeOf(count))) { + .int, .comptime_int => @intCast(count), + else => count, + }; + if (n == 0) return try allocator.dupe(u8, ""); + const buf = try allocator.alloc(u8, n); + @memset(buf, ' '); + return buf; + } + + fn nChars(allocator: std.mem.Allocator, ch: u8, count: usize) ![]const u8 { + if (count == 0) return try allocator.dupe(u8, ""); + const buf = try allocator.alloc(u8, count); + @memset(buf, ch); + return buf; + } + + fn clamp01(v: f32) f32 { + return @max(0.0, @min(1.0, v)); + } + + // Comptime style builder to avoid runtime block initialization + const StyleOpts = struct { + bold_v: ?bool = null, + dim_v: ?bool = null, + italic_v: ?bool = null, + fg_color: Color = .none, + bg_color: Color = .none, + }; + + fn makeStyle(opts: StyleOpts) style_mod.Style { + var s = style_mod.Style{}; + if (opts.bold_v) |v| s.bold_attr = v; + if (opts.dim_v) |v| s.dim_attr = v; + if (opts.italic_v) |v| s.italic_attr = v; + if (!opts.fg_color.isNone()) s.foreground = opts.fg_color; + if (!opts.bg_color.isNone()) s.background = opts.bg_color; + s.inline_mode = true; + return s; + } +}; diff --git a/src/root.zig b/src/root.zig index 4261d23..1e50fed 100644 --- a/src/root.zig +++ b/src/root.zig @@ -114,6 +114,8 @@ pub const components = struct { pub const notification = @import("components/notification.zig"); pub const Notification = notification.Notification; pub const Confirm = @import("components/confirm.zig").Confirm; + pub const modal = @import("components/modal.zig"); + pub const Modal = modal.Modal; pub const focus = @import("components/focus.zig"); }; @@ -130,6 +132,7 @@ pub const StyledList = components.StyledList; pub const Sparkline = components.Sparkline; pub const Notification = components.Notification; pub const Confirm = components.Confirm; +pub const Modal = components.Modal; // Focus management pub const FocusGroup = components.focus.FocusGroup; diff --git a/tests/modal_tests.zig b/tests/modal_tests.zig new file mode 100644 index 0000000..eaac63d --- /dev/null +++ b/tests/modal_tests.zig @@ -0,0 +1,312 @@ +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); +const Modal = zz.Modal; + +// --------------------------------------------------------------------------- +// Preset constructors +// --------------------------------------------------------------------------- + +test "info preset — single OK button, cyan border" { + const m = Modal.info("Notice", "Hello"); + try testing.expectEqualStrings("Notice", m.title); + try testing.expectEqualStrings("Hello", m.body); + try testing.expectEqual(@as(usize, 1), m.button_count); + try testing.expect(!m.visible); +} + +test "confirm preset — two buttons" { + const m = Modal.confirm("Sure?", "Delete file?"); + try testing.expectEqual(@as(usize, 2), m.button_count); + try testing.expectEqualStrings("Yes", m.buttons[0].?.label); + try testing.expectEqualStrings("No", m.buttons[1].?.label); +} + +test "warning preset" { + const m = Modal.warning("Caution", "Low disk space"); + try testing.expectEqual(@as(usize, 1), m.button_count); +} + +test "err preset" { + const m = Modal.err("Error", "File not found"); + try testing.expectEqual(@as(usize, 1), m.button_count); +} + +test "init — blank modal" { + const m = Modal.init(); + try testing.expectEqual(@as(usize, 0), m.button_count); + try testing.expectEqualStrings("", m.title); + try testing.expectEqualStrings("", m.body); +} + +// --------------------------------------------------------------------------- +// State management +// --------------------------------------------------------------------------- + +test "show sets visible and resets result" { + var m = Modal.info("T", "B"); + m.result = .dismissed; + m.show(); + try testing.expect(m.visible); + try testing.expect(m.focused); + try testing.expectEqual(@as(?Modal.Result, null), m.result); + try testing.expectEqual(@as(usize, 0), m.selected_button); +} + +test "hide clears visibility" { + var m = Modal.info("T", "B"); + m.show(); + m.hide(); + try testing.expect(!m.visible); + try testing.expect(!m.focused); +} + +test "reset clears everything" { + var m = Modal.info("T", "B"); + m.show(); + m.result = .{ .button_pressed = 0 }; + m.reset(); + try testing.expect(!m.visible); + try testing.expectEqual(@as(?Modal.Result, null), m.result); +} + +// --------------------------------------------------------------------------- +// Focusable protocol +// --------------------------------------------------------------------------- + +test "focus / blur protocol" { + var m = Modal.init(); + try testing.expect(!m.focused); + m.focus(); + try testing.expect(m.focused); + m.blur(); + try testing.expect(!m.focused); +} + +test "isFocusable check" { + try testing.expect(zz.isFocusable(Modal)); +} + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +test "handleKey — escape dismisses" { + var m = Modal.info("T", "B"); + m.show(); + m.handleKey(.{ .key = .escape, .modifiers = .{} }); + try testing.expect(!m.visible); + try testing.expectEqual(Modal.Result.dismissed, m.result.?); +} + +test "handleKey — escape does nothing when close_on_escape is false" { + var m = Modal.info("T", "B"); + m.close_on_escape = false; + m.show(); + m.handleKey(.{ .key = .escape, .modifiers = .{} }); + try testing.expect(m.visible); +} + +test "handleKey — enter confirms selected button" { + var m = Modal.confirm("T", "B"); + m.show(); + // Default selected = 0 (Yes) + m.handleKey(.{ .key = .enter, .modifiers = .{} }); + try testing.expect(!m.visible); + try testing.expectEqual(Modal.Result{ .button_pressed = 0 }, m.result.?); +} + +test "handleKey — shortcut triggers specific button" { + var m = Modal.confirm("T", "B"); + m.show(); + // 'n' is shortcut for No (index 1) + m.handleKey(.{ .key = .{ .char = 'n' }, .modifiers = .{} }); + try testing.expect(!m.visible); + try testing.expectEqual(Modal.Result{ .button_pressed = 1 }, m.result.?); +} + +test "handleKey — tab cycles buttons forward" { + var m = Modal.confirm("T", "B"); + m.show(); + try testing.expectEqual(@as(usize, 0), m.selected_button); + m.handleKey(.{ .key = .tab, .modifiers = .{} }); + try testing.expectEqual(@as(usize, 1), m.selected_button); + // Wraps around + m.handleKey(.{ .key = .tab, .modifiers = .{} }); + try testing.expectEqual(@as(usize, 0), m.selected_button); +} + +test "handleKey — shift+tab cycles backward" { + var m = Modal.confirm("T", "B"); + m.show(); + // From 0, shift+tab wraps to last + m.handleKey(.{ .key = .tab, .modifiers = .{ .shift = true } }); + try testing.expectEqual(@as(usize, 1), m.selected_button); +} + +test "handleKey — left/right arrows move between buttons" { + var m = Modal.confirm("T", "B"); + m.show(); + m.handleKey(.{ .key = .right, .modifiers = .{} }); + try testing.expectEqual(@as(usize, 1), m.selected_button); + m.handleKey(.{ .key = .left, .modifiers = .{} }); + try testing.expectEqual(@as(usize, 0), m.selected_button); + // Left at 0 stays at 0 + m.handleKey(.{ .key = .left, .modifiers = .{} }); + try testing.expectEqual(@as(usize, 0), m.selected_button); +} + +test "handleKey — ignored when not visible" { + var m = Modal.info("T", "B"); + m.handleKey(.{ .key = .escape, .modifiers = .{} }); + try testing.expectEqual(@as(?Modal.Result, null), m.result); +} + +test "handleKey — ignored when not focused" { + var m = Modal.info("T", "B"); + m.show(); + m.focused = false; + m.handleKey(.{ .key = .escape, .modifiers = .{} }); + try testing.expect(m.visible); +} + +// --------------------------------------------------------------------------- +// Button management +// --------------------------------------------------------------------------- + +test "addButton / clearButtons" { + var m = Modal.init(); + m.addButton("A", null); + m.addButton("B", .enter); + try testing.expectEqual(@as(usize, 2), m.button_count); + try testing.expectEqualStrings("A", m.buttons[0].?.label); + try testing.expectEqualStrings("B", m.buttons[1].?.label); + + m.clearButtons(); + try testing.expectEqual(@as(usize, 0), m.button_count); +} + +test "addButton respects max_buttons limit" { + var m = Modal.init(); + for (0..10) |_| { + m.addButton("X", null); + } + try testing.expectEqual(@as(usize, 8), m.button_count); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +test "view returns empty when not visible" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const m = Modal.info("T", "B"); + const output = try m.view(alloc, 80, 24); + try testing.expectEqual(@as(usize, 0), output.len); +} + +test "view produces output when visible" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.info("Test", "Hello world"); + m.show(); + const output = try m.view(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +test "viewWithBackdrop produces full-screen output" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.info("Test", "Hello"); + m.show(); + const output = try m.viewWithBackdrop(alloc, 40, 12); + try testing.expect(output.len > 0); +} + +test "renderBox respects auto width" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.init(); + m.title = "Title"; + m.body = "Short"; + m.width = .auto; + m.show(); + const box = try m.renderBox(alloc, 80, 24); + try testing.expect(box.len > 0); + // Auto width should be much less than 80 + const w = zz.measure.maxLineWidth(box); + try testing.expect(w < 40); +} + +test "renderBox respects fixed width" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.init(); + m.body = "Test"; + m.width = .{ .fixed = 30 }; + m.show(); + const box = try m.renderBox(alloc, 80, 24); + const w = zz.measure.maxLineWidth(box); + try testing.expectEqual(@as(usize, 30), w); +} + +test "renderBox contains title" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.info("MyTitle", "body"); + m.show(); + const box = try m.renderBox(alloc, 80, 24); + // The box should contain the title text somewhere + try testing.expect(std.mem.indexOf(u8, box, "MyTitle") != null); +} + +test "renderBox with footer" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.info("T", "Body"); + m.footer = "Press Enter"; + m.show(); + const box = try m.renderBox(alloc, 80, 24); + try testing.expect(std.mem.indexOf(u8, box, "Press Enter") != null); +} + +test "renderBox with no buttons" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.init(); + m.body = "Just text"; + m.show(); + const box = try m.renderBox(alloc, 80, 24); + try testing.expect(box.len > 0); +} + +test "renderBox multi-line body" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var m = Modal.init(); + m.body = "Line 1\nLine 2\nLine 3"; + m.width = .{ .fixed = 30 }; + m.show(); + const box = try m.renderBox(alloc, 80, 24); + try testing.expect(std.mem.indexOf(u8, box, "Line 1") != null); + try testing.expect(std.mem.indexOf(u8, box, "Line 3") != null); +} From 782771ed8f925a76dd9b48bdf36dbea48ee0de42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 05:40:40 +0100 Subject: [PATCH 2/8] feat: add backdrop presets and UTF-8 fill char support for Modal --- src/components/modal.zig | 62 ++++++++++++++++++++++++++++++++++++++-- tests/modal_tests.zig | 30 +++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/components/modal.zig b/src/components/modal.zig index 4bc088d..4b00745 100644 --- a/src/components/modal.zig +++ b/src/components/modal.zig @@ -128,8 +128,59 @@ pub const Modal = struct { pub const ButtonAlign = enum { left, center, right }; pub const Backdrop = struct { - char: u8 = ' ', + /// Fill character (UTF-8 string, e.g. " ", "░", "▒", "▓", "█"). + char: []const u8 = " ", + /// Style applied to the backdrop fill. style: style_mod.Style = makeStyle(.{ .bg_color = Color.gray(3) }), + + /// Dark semi-transparent backdrop (default). + pub const dark = Backdrop{}; + + /// Slightly lighter backdrop. + pub const medium = Backdrop{ + .style = makeStyle(.{ .bg_color = Color.gray(5) }), + }; + + /// Light backdrop. + pub const light = Backdrop{ + .style = makeStyle(.{ .bg_color = Color.gray(8) }), + }; + + /// Clear backdrop — uses the terminal's default background color. + pub const clear = Backdrop{ + .style = makeStyle(.{}), + }; + + /// Light shade fill character (░). + pub const shade_light = Backdrop{ + .char = "░", + .style = makeStyle(.{ .fg_color = Color.gray(5) }), + }; + + /// Medium shade fill character (▒). + pub const shade_medium = Backdrop{ + .char = "▒", + .style = makeStyle(.{ .fg_color = Color.gray(5) }), + }; + + /// Dense shade fill character (▓). + pub const shade_dense = Backdrop{ + .char = "▓", + .style = makeStyle(.{ .fg_color = Color.gray(4) }), + }; + + /// Create a solid-color backdrop. + pub fn solid(bg_color: Color) Backdrop { + return .{ .style = makeStyle(.{ .bg_color = bg_color }) }; + } + + /// Create a backdrop with a custom fill character and foreground color. + pub fn custom(char_str: []const u8, fg_color: Color, bg_color: Color) Backdrop { + return .{ + .char = char_str, + .style = makeStyle(.{ .fg_color = fg_color, .bg_color = bg_color }), + }; + } }; // ── Preset Constructors ──────────────────────────────────────────── @@ -646,8 +697,13 @@ pub const Modal = struct { fn renderBackdropSegment(allocator: std.mem.Allocator, bd: Backdrop, count: usize) ![]const u8 { if (count == 0) return try allocator.dupe(u8, ""); - const segment = try nChars(allocator, bd.char, count); - return try bd.style.inline_style(true).render(allocator, segment); + const ch = bd.char; + if (ch.len == 0) return try allocator.dupe(u8, ""); + const buf = try allocator.alloc(u8, ch.len * count); + for (0..count) |i| { + @memcpy(buf[i * ch.len ..][0..ch.len], ch); + } + return try bd.style.inline_style(true).render(allocator, buf); } fn repeatStr(allocator: std.mem.Allocator, s: style_mod.Style, str: []const u8, count: usize) ![]const u8 { diff --git a/tests/modal_tests.zig b/tests/modal_tests.zig index eaac63d..056f8fe 100644 --- a/tests/modal_tests.zig +++ b/tests/modal_tests.zig @@ -310,3 +310,33 @@ test "renderBox multi-line body" { try testing.expect(std.mem.indexOf(u8, box, "Line 1") != null); try testing.expect(std.mem.indexOf(u8, box, "Line 3") != null); } + +// --------------------------------------------------------------------------- +// Backdrop presets +// --------------------------------------------------------------------------- + +test "backdrop presets render without error" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const presets = [_]Modal.Backdrop{ + Modal.Backdrop.dark, + Modal.Backdrop.medium, + Modal.Backdrop.light, + Modal.Backdrop.clear, + Modal.Backdrop.shade_light, + Modal.Backdrop.shade_medium, + Modal.Backdrop.shade_dense, + Modal.Backdrop.solid(zz.Color.blue()), + Modal.Backdrop.custom("*", zz.Color.red(), zz.Color.black()), + }; + + for (presets) |preset| { + var m = Modal.info("T", "B"); + m.backdrop = preset; + m.show(); + const output = try m.viewWithBackdrop(alloc, 40, 12); + try testing.expect(output.len > 0); + } +} From 0794ad689bc3be3da903c843c73edf20da5f477d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 06:00:14 +0100 Subject: [PATCH 3/8] feat: add Tooltip component with placements, presets, and overlay support --- build.zig | 2 + examples/tooltip.zig | 149 +++++++++ src/components/tooltip.zig | 656 +++++++++++++++++++++++++++++++++++++ src/root.zig | 3 + tests/tooltip_tests.zig | 251 ++++++++++++++ 5 files changed, 1061 insertions(+) create mode 100644 examples/tooltip.zig create mode 100644 src/components/tooltip.zig create mode 100644 tests/tooltip_tests.zig diff --git a/build.zig b/build.zig index 8338447..715a746 100644 --- a/build.zig +++ b/build.zig @@ -22,6 +22,7 @@ pub fn build(b: *std.Build) void { "showcase", "focus_form", "modal", + "tooltip", }; for (examples) |example_name| { @@ -60,6 +61,7 @@ pub fn build(b: *std.Build) void { "tests/program_tests.zig", "tests/focus_tests.zig", "tests/modal_tests.zig", + "tests/tooltip_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/examples/tooltip.zig b/examples/tooltip.zig new file mode 100644 index 0000000..186f92c --- /dev/null +++ b/examples/tooltip.zig @@ -0,0 +1,149 @@ +//! ZigZag Tooltip Example +//! Demonstrates the Tooltip component with different placements and presets. +//! +//! Keys: +//! 1-4 — Show tooltip with different placements (bottom/top/right/left) +//! 5 — Show titled tooltip +//! 6 — Show help-style tooltip +//! 7 — Show shortcut tooltip +//! h — Hide tooltip +//! q — Quit + +const std = @import("std"); +const zz = @import("zigzag"); + +const Model = struct { + tooltip: zz.Tooltip, + status: []const u8, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + }; + + pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { + self.tooltip = zz.Tooltip.init("This is a tooltip!"); + self.status = "Press 1-7 to show tooltips, h to hide, q to quit"; + return .none; + } + + pub fn update(self: *Model, m: Msg, _: *zz.Context) zz.Cmd(Msg) { + switch (m) { + .key => |k| { + switch (k.key) { + .char => |c| switch (c) { + 'q' => return .quit, + '1' => { + self.tooltip = zz.Tooltip.init("Bottom placement tooltip"); + self.tooltip.target_x = 20; + self.tooltip.target_y = 5; + self.tooltip.target_width = 6; + self.tooltip.placement = .bottom; + self.tooltip.show(); + self.status = "Showing: bottom placement"; + }, + '2' => { + self.tooltip = zz.Tooltip.init("Top placement tooltip"); + self.tooltip.target_x = 20; + self.tooltip.target_y = 12; + self.tooltip.target_width = 6; + self.tooltip.placement = .top; + self.tooltip.show(); + self.status = "Showing: top placement"; + }, + '3' => { + self.tooltip = zz.Tooltip.init("Right placement"); + self.tooltip.target_x = 10; + self.tooltip.target_y = 8; + self.tooltip.target_width = 6; + self.tooltip.placement = .right; + self.tooltip.show(); + self.status = "Showing: right placement"; + }, + '4' => { + self.tooltip = zz.Tooltip.init("Left placement"); + self.tooltip.target_x = 50; + self.tooltip.target_y = 8; + self.tooltip.target_width = 6; + self.tooltip.placement = .left; + self.tooltip.show(); + self.status = "Showing: left placement"; + }, + '5' => { + self.tooltip = zz.Tooltip.titled("File Info", "Size: 1.2 MB\nModified: Today\nType: Document"); + self.tooltip.target_x = 20; + self.tooltip.target_y = 5; + self.tooltip.target_width = 8; + self.tooltip.show(); + self.status = "Showing: titled tooltip"; + }, + '6' => { + self.tooltip = zz.Tooltip.help("Press Enter to confirm your selection"); + self.tooltip.target_x = 20; + self.tooltip.target_y = 5; + self.tooltip.target_width = 10; + self.tooltip.show(); + self.status = "Showing: help-style tooltip"; + }, + '7' => { + self.tooltip = zz.Tooltip.shortcut("Save", "Ctrl+S"); + self.tooltip.target_x = 20; + self.tooltip.target_y = 5; + self.tooltip.target_width = 4; + self.tooltip.show(); + self.status = "Showing: shortcut tooltip"; + }, + 'h' => { + self.tooltip.hide(); + self.status = "Tooltip hidden"; + }, + else => {}, + }, + .escape => return .quit, + else => {}, + } + }, + } + return .none; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + const alloc = ctx.allocator; + + // Build a base view + var title_s = zz.Style{}; + title_s = title_s.bold(true).fg(zz.Color.hex("#FF6B6B")).inline_style(true); + + var hint_s = zz.Style{}; + hint_s = hint_s.fg(zz.Color.gray(14)).inline_style(true); + + var status_s = zz.Style{}; + status_s = status_s.fg(zz.Color.gray(12)).inline_style(true); + + const title = title_s.render(alloc, "Tooltip Component Demo") catch "Tooltip Component Demo"; + const hint = hint_s.render(alloc, "1-4: Placements 5: Titled 6: Help 7: Shortcut h: Hide q: Quit") catch ""; + const status = status_s.render(alloc, self.status) catch ""; + + const content = std.fmt.allocPrint(alloc, "{s}\n\n{s}\n\n{s}", .{ + title, hint, status, + }) catch "Error"; + + const base = zz.place.place(alloc, ctx.width, ctx.height, .center, .middle, content) catch content; + + // Overlay tooltip if visible + if (self.tooltip.isVisible()) { + return self.tooltip.overlay(alloc, base, ctx.width, ctx.height) catch base; + } + + return base; + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + + var prog = try zz.Program(Model).init(gpa.allocator()); + defer prog.deinit(); + + try prog.run(); +} diff --git a/src/components/tooltip.zig b/src/components/tooltip.zig new file mode 100644 index 0000000..e4cfb68 --- /dev/null +++ b/src/components/tooltip.zig @@ -0,0 +1,656 @@ +//! Tooltip component for displaying contextual hints near a target position. +//! +//! ## Quick Start +//! +//! ```zig +//! // Create a tooltip +//! var tip = Tooltip.init("Save the current document"); +//! +//! // Position it relative to a target +//! tip.target_x = 10; +//! tip.target_y = 5; +//! tip.placement = .bottom; +//! tip.show(); +//! +//! // In your view function: +//! const output = try tip.render(allocator, term_width, term_height); +//! ``` +//! +//! ## Placements +//! +//! - `.top` — above the target, arrow pointing down +//! - `.bottom` — below the target, arrow pointing up +//! - `.left` — to the left of the target, arrow pointing right +//! - `.right` — to the right of the target, arrow pointing left +//! +//! ## Presets +//! +//! - `Tooltip.init(text)` — simple tooltip with default style +//! - `Tooltip.titled(title, text)` — tooltip with a bold title line +//! - `Tooltip.help(text)` — dim, italic help-style tooltip +//! - `Tooltip.shortcut(label, key)` — "Label Ctrl+S" style tooltip + +const std = @import("std"); +const style_mod = @import("../style/style.zig"); +const border_mod = @import("../style/border.zig"); +const Color = @import("../style/color.zig").Color; +const measure = @import("../layout/measure.zig"); + +pub const Tooltip = struct { + // ── State ────────────────────────────────────────────────────────── + + visible: bool = false, + + // ── Content ──────────────────────────────────────────────────────── + + text: []const u8 = "", + title: ?[]const u8 = null, + + // ── Position ────────────────────────────────────────────────────── + + /// X coordinate of the target element (display column). + target_x: usize = 0, + /// Y coordinate of the target element (display row). + target_y: usize = 0, + /// Width of the target element (used for centering arrows). + target_width: usize = 1, + /// Where to place the tooltip relative to the target. + placement: Placement = .bottom, + /// Gap between tooltip and target (in cells). + gap: usize = 0, + + // ── Sizing ───────────────────────────────────────────────────────── + + /// Maximum width of the tooltip content area (excluding border/padding). + max_width: usize = 40, + /// Padding inside the tooltip box. + padding: Padding = .{ .top = 0, .right = 1, .bottom = 0, .left = 1 }, + + // ── Styling ──────────────────────────────────────────────────────── + + border_chars: border_mod.BorderChars = border_mod.Border.rounded, + border_fg: Color = Color.gray(14), + content_bg: Color = .none, + text_style: style_mod.Style = makeStyle(.{ .fg_color = Color.gray(20) }), + title_style: style_mod.Style = makeStyle(.{ .bold_v = true, .fg_color = Color.white() }), + + /// Show an arrow pointing from tooltip toward the target. + show_arrow: bool = true, + arrow_fg: Color = Color.gray(14), + + // ── Types ────────────────────────────────────────────────────────── + + pub const Placement = enum { top, bottom, left, right }; + + pub const Padding = struct { + top: u16 = 0, + right: u16 = 0, + bottom: u16 = 0, + left: u16 = 0, + + pub fn all(n: u16) Padding { + return .{ .top = n, .right = n, .bottom = n, .left = n }; + } + + pub fn symmetric(vert: u16, horiz: u16) Padding { + return .{ .top = vert, .right = horiz, .bottom = vert, .left = horiz }; + } + }; + + pub const Arrow = struct { + pub const up = "▲"; + pub const down = "▼"; + pub const left_arrow = "◀"; + pub const right_arrow = "▶"; + }; + + // ── Preset Constructors ──────────────────────────────────────────── + + /// Simple tooltip with text content. + pub fn init(text: []const u8) Tooltip { + return .{ .text = text }; + } + + /// Tooltip with a bold title line above the text. + pub fn titled(title_text: []const u8, body: []const u8) Tooltip { + return .{ + .text = body, + .title = title_text, + }; + } + + /// Help-style tooltip with dim italic text. + pub fn help(text: []const u8) Tooltip { + return .{ + .text = text, + .text_style = makeStyle(.{ .dim_v = true, .italic_v = true, .fg_color = Color.gray(16) }), + .border_fg = Color.gray(10), + .arrow_fg = Color.gray(10), + }; + } + + /// Shortcut tooltip showing "Label Key". + pub fn shortcut(label: []const u8, key: []const u8) Tooltip { + return .{ + .text = key, + .title = label, + .title_style = makeStyle(.{ .fg_color = Color.gray(18) }), + .text_style = makeStyle(.{ .bold_v = true, .fg_color = Color.cyan() }), + }; + } + + // ── State Management ─────────────────────────────────────────────── + + pub fn show(self: *Tooltip) void { + self.visible = true; + } + + pub fn hide(self: *Tooltip) void { + self.visible = false; + } + + pub fn toggle(self: *Tooltip) void { + self.visible = !self.visible; + } + + pub fn isVisible(self: *const Tooltip) bool { + return self.visible; + } + + // ── Rendering ────────────────────────────────────────────────────── + + /// Render the tooltip box (no positioning). Returns the box string. + pub fn renderBox(self: *const Tooltip, allocator: std.mem.Allocator) ![]const u8 { + const bc = self.border_chars; + + // Compute content lines + var content_lines = std.array_list.Managed([]const u8).init(allocator); + defer content_lines.deinit(); + + if (self.title) |t| { + try content_lines.append(t); + } + + var text_iter = std.mem.splitScalar(u8, self.text, '\n'); + while (text_iter.next()) |line| { + try content_lines.append(line); + } + + // Compute inner width + var max_content_w: usize = 0; + for (content_lines.items) |line| { + max_content_w = @max(max_content_w, measure.width(line)); + } + max_content_w = @min(max_content_w, self.max_width); + + const pad_h: usize = @as(usize, self.padding.left) + @as(usize, self.padding.right); + const inner_w: usize = max_content_w + pad_h; + + // Inline styles + var bdr_s = style_mod.Style{}; + bdr_s = bdr_s.fg(self.border_fg).inline_style(true); + if (!self.content_bg.isNone()) bdr_s = bdr_s.bg(self.content_bg); + + var pad_s = style_mod.Style{}; + pad_s = pad_s.inline_style(true); + if (!self.content_bg.isNone()) pad_s = pad_s.bg(self.content_bg); + + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + // ── Top border ── + try writer.writeAll(try bdr_s.render(allocator, bc.top_left)); + try writer.writeAll(try repeatStr(allocator, bdr_s, bc.horizontal, inner_w)); + try writer.writeAll(try bdr_s.render(allocator, bc.top_right)); + + const styled_left = try bdr_s.render(allocator, bc.vertical); + const styled_right = try bdr_s.render(allocator, bc.vertical); + + // ── Top padding ── + for (0..self.padding.top) |_| { + try writer.writeByte('\n'); + try writeEmptyLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + } + + // ── Content lines ── + for (content_lines.items, 0..) |line, idx| { + try writer.writeByte('\n'); + try writer.writeAll(styled_left); + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, self.padding.left))); + + // Pick style: title or body + const is_title_line = self.title != null and idx == 0; + var line_style = if (is_title_line) self.title_style else self.text_style; + line_style = line_style.inline_style(true); + if (!self.content_bg.isNone() and line_style.background.isNone()) { + line_style = line_style.bg(self.content_bg); + } + try writer.writeAll(try line_style.render(allocator, line)); + + const line_w = measure.width(line); + const fill: usize = if (max_content_w > line_w) max_content_w - line_w else 0; + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, fill + self.padding.right))); + try writer.writeAll(styled_right); + } + + // ── Bottom padding ── + for (0..self.padding.bottom) |_| { + try writer.writeByte('\n'); + try writeEmptyLine(allocator, writer, styled_left, styled_right, pad_s, inner_w); + } + + // ── Bottom border ── + try writer.writeByte('\n'); + try writer.writeAll(try bdr_s.render(allocator, bc.bottom_left)); + try writer.writeAll(try repeatStr(allocator, bdr_s, bc.horizontal, inner_w)); + try writer.writeAll(try bdr_s.render(allocator, bc.bottom_right)); + + return result.toOwnedSlice(); + } + + /// Render the tooltip positioned on a full-screen canvas. + /// Returns empty string if not visible. + pub fn render(self: *const Tooltip, allocator: std.mem.Allocator, term_width: usize, term_height: usize) ![]const u8 { + if (!self.visible) return try allocator.dupe(u8, ""); + + const box = try self.renderBox(allocator); + const box_w = measure.maxLineWidth(box); + const box_h = measure.height(box); + + // Compute arrow position and tooltip position + const pos = self.computePosition(box_w, box_h, term_width, term_height); + + // Build full-screen output line by line + var result = std.array_list.Managed(u8).init(allocator); + const wr = result.writer(); + + // Collect box lines + var box_lines = std.array_list.Managed([]const u8).init(allocator); + defer box_lines.deinit(); + var box_iter = std.mem.splitScalar(u8, box, '\n'); + while (box_iter.next()) |line| try box_lines.append(line); + + for (0..term_height) |row| { + if (row > 0) try wr.writeByte('\n'); + + // Check if this row is the arrow row + if (self.show_arrow and row == pos.arrow_y) { + try self.writeArrowRow(allocator, wr, pos, term_width, box_lines.items, box_w, box_h, row); + continue; + } + + // Check if this row is in the box + if (row >= pos.box_y and row < pos.box_y + box_h) { + const box_line_idx = row - pos.box_y; + try self.writeBoxRow(allocator, wr, pos, term_width, box_lines.items, box_w, box_line_idx); + } else { + try wr.writeAll(try nSpaces(allocator, term_width)); + } + } + + return result.toOwnedSlice(); + } + + /// Render just the tooltip box with arrow, composited onto the given base content. + /// Uses line-by-line splicing (same approach as Modal's viewWithBackdrop). + pub fn overlay(self: *const Tooltip, allocator: std.mem.Allocator, base: []const u8, term_width: usize, term_height: usize) ![]const u8 { + if (!self.visible) return try allocator.dupe(u8, base); + + const box = try self.renderBox(allocator); + const box_w = measure.maxLineWidth(box); + const box_h = measure.height(box); + const pos = self.computePosition(box_w, box_h, term_width, term_height); + + // Collect box lines + var box_lines = std.array_list.Managed([]const u8).init(allocator); + defer box_lines.deinit(); + var box_iter = std.mem.splitScalar(u8, box, '\n'); + while (box_iter.next()) |line| try box_lines.append(line); + + // Collect base lines + var base_lines = std.array_list.Managed([]const u8).init(allocator); + defer base_lines.deinit(); + var base_iter = std.mem.splitScalar(u8, base, '\n'); + while (base_iter.next()) |line| try base_lines.append(line); + + var result = std.array_list.Managed(u8).init(allocator); + const wr = result.writer(); + + for (0..term_height) |row| { + if (row > 0) try wr.writeByte('\n'); + + const base_line = if (row < base_lines.items.len) base_lines.items[row] else ""; + + // Arrow row + if (self.show_arrow and row == pos.arrow_y and !(row >= pos.box_y and row < pos.box_y + box_h)) { + // Write base left of arrow, then arrow, then base right + try self.writeSplicedArrowRow(allocator, wr, pos, base_line, term_width); + continue; + } + + // Box row + if (row >= pos.box_y and row < pos.box_y + box_h) { + const box_line_idx = row - pos.box_y; + const box_line = if (box_line_idx < box_lines.items.len) box_lines.items[box_line_idx] else ""; + try self.writeSplicedBoxRow(allocator, wr, pos, base_line, box_line, box_w, term_width); + continue; + } + + // Plain base line + try wr.writeAll(base_line); + } + + return result.toOwnedSlice(); + } + + // ── Position Computation ────────────────────────────────────────── + + const Position = struct { + box_x: usize, + box_y: usize, + arrow_x: usize, + arrow_y: usize, + }; + + fn computePosition(self: *const Tooltip, box_w: usize, box_h: usize, tw: usize, th: usize) Position { + var pos: Position = .{ .box_x = 0, .box_y = 0, .arrow_x = 0, .arrow_y = 0 }; + + const arrow_offset: usize = if (self.show_arrow) 1 else 0; + + switch (self.placement) { + .bottom => { + // Box below target + pos.arrow_y = self.target_y + 1 + self.gap; + pos.box_y = pos.arrow_y + arrow_offset; + // Center horizontally on target + const target_center = self.target_x + self.target_width / 2; + pos.box_x = if (target_center >= box_w / 2) target_center - box_w / 2 else 0; + pos.arrow_x = target_center; + }, + .top => { + // Box above target + const total_h = box_h + arrow_offset; + pos.box_y = if (self.target_y >= total_h + self.gap) self.target_y - total_h - self.gap else 0; + pos.arrow_y = pos.box_y + box_h; + const target_center = self.target_x + self.target_width / 2; + pos.box_x = if (target_center >= box_w / 2) target_center - box_w / 2 else 0; + pos.arrow_x = target_center; + }, + .right => { + // Box to the right of target + pos.box_x = self.target_x + self.target_width + self.gap + arrow_offset; + pos.arrow_x = self.target_x + self.target_width + self.gap; + // Center vertically on target + pos.box_y = if (self.target_y >= box_h / 2) self.target_y - box_h / 2 else 0; + pos.arrow_y = self.target_y; + }, + .left => { + // Box to the left of target + const total_w = box_w + arrow_offset; + pos.box_x = if (self.target_x >= total_w + self.gap) self.target_x - total_w - self.gap else 0; + pos.arrow_x = pos.box_x + box_w; + pos.box_y = if (self.target_y >= box_h / 2) self.target_y - box_h / 2 else 0; + pos.arrow_y = self.target_y; + }, + } + + // Clamp to terminal bounds + if (pos.box_x + box_w > tw) pos.box_x = if (tw >= box_w) tw - box_w else 0; + if (pos.box_y + box_h > th) pos.box_y = if (th >= box_h) th - box_h else 0; + if (pos.arrow_x >= tw) pos.arrow_x = tw -| 1; + if (pos.arrow_y >= th) pos.arrow_y = th -| 1; + + return pos; + } + + fn arrowChar(self: *const Tooltip) []const u8 { + return switch (self.placement) { + .bottom => Arrow.up, + .top => Arrow.down, + .left => Arrow.right_arrow, + .right => Arrow.left_arrow, + }; + } + + // ── Render helpers (full-screen canvas) ──────────────────────────── + + fn writeArrowRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, tw: usize, box_lines: []const []const u8, box_w: usize, box_h: usize, row: usize) !void { + // If this row also overlaps with the box, splice both + if (row >= pos.box_y and row < pos.box_y + box_h) { + const box_line_idx = row - pos.box_y; + const box_line = if (box_line_idx < box_lines.len) box_lines[box_line_idx] else ""; + // Write spaces up to box, then box line, then spaces + try writer.writeAll(try nSpaces(allocator, pos.box_x)); + try writer.writeAll(box_line); + const right = pos.box_x + measure.width(box_line); + if (right < tw) try writer.writeAll(try nSpaces(allocator, tw - right)); + return; + } + + _ = box_w; + + // Arrow only row + try writer.writeAll(try nSpaces(allocator, pos.arrow_x)); + + var arrow_s = style_mod.Style{}; + arrow_s = arrow_s.fg(self.arrow_fg).inline_style(true); + try writer.writeAll(try arrow_s.render(allocator, self.arrowChar())); + + if (pos.arrow_x + 1 < tw) { + try writer.writeAll(try nSpaces(allocator, tw - pos.arrow_x - 1)); + } + } + + fn writeBoxRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, tw: usize, box_lines: []const []const u8, box_w: usize, box_line_idx: usize) !void { + _ = self; + _ = box_w; + const box_line = if (box_line_idx < box_lines.len) box_lines[box_line_idx] else ""; + try writer.writeAll(try nSpaces(allocator, pos.box_x)); + try writer.writeAll(box_line); + const right = pos.box_x + measure.width(box_line); + if (right < tw) try writer.writeAll(try nSpaces(allocator, tw - right)); + } + + // ── Render helpers (overlay/splice) ──────────────────────────────── + + fn writeSplicedArrowRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, tw: usize) !void { + // Left part from base + const left = try truncateToWidth(allocator, base_line, pos.arrow_x); + try writer.writeAll(left); + const left_w = measure.width(left); + if (left_w < pos.arrow_x) try writer.writeAll(try nSpaces(allocator, pos.arrow_x - left_w)); + + // Arrow + var arrow_s = style_mod.Style{}; + arrow_s = arrow_s.fg(self.arrow_fg).inline_style(true); + try writer.writeAll(try arrow_s.render(allocator, self.arrowChar())); + + // Right part from base (skip arrow_x + 1 columns) + const skip = pos.arrow_x + 1; + const right = try skipColumns(allocator, base_line, skip); + try writer.writeAll(right); + const total_w = pos.arrow_x + 1 + measure.width(right); + if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); + } + + fn writeSplicedBoxRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, box_line: []const u8, box_w: usize, tw: usize) !void { + _ = self; + // Left part from base + const left = try truncateToWidth(allocator, base_line, pos.box_x); + try writer.writeAll(left); + const left_w = measure.width(left); + if (left_w < pos.box_x) try writer.writeAll(try nSpaces(allocator, pos.box_x - left_w)); + + // Box line + try writer.writeAll(box_line); + const bw = measure.width(box_line); + + // Right part from base (skip box area) + const skip = pos.box_x + @max(bw, box_w); + const right = try skipColumns(allocator, base_line, skip); + try writer.writeAll(right); + const total_w = pos.box_x + bw + measure.width(right); + if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); + } + + // ── Private Helpers ─────────────────────────────────────────────── + + fn writeEmptyLine(allocator: std.mem.Allocator, writer: anytype, styled_left: []const u8, styled_right: []const u8, pad_s: style_mod.Style, inner_w: usize) !void { + try writer.writeAll(styled_left); + try writer.writeAll(try pad_s.render(allocator, try nSpaces(allocator, inner_w))); + try writer.writeAll(styled_right); + } + + fn repeatStr(allocator: std.mem.Allocator, s: style_mod.Style, str: []const u8, count: usize) ![]const u8 { + if (count == 0 or str.len == 0) return try allocator.dupe(u8, ""); + const buf = try allocator.alloc(u8, str.len * count); + for (0..count) |i| { + @memcpy(buf[i * str.len ..][0..str.len], str); + } + return try s.render(allocator, buf); + } + + fn nSpaces(allocator: std.mem.Allocator, count: anytype) ![]const u8 { + const n: usize = switch (@typeInfo(@TypeOf(count))) { + .int, .comptime_int => @intCast(count), + else => count, + }; + if (n == 0) return try allocator.dupe(u8, ""); + const buf = try allocator.alloc(u8, n); + @memset(buf, ' '); + return buf; + } + + /// Truncate a string (potentially with ANSI) to at most `max_w` display columns. + /// Returns a new slice with only the content up to that width. + fn truncateToWidth(allocator: std.mem.Allocator, str: []const u8, max_w: usize) ![]const u8 { + if (max_w == 0) return try allocator.dupe(u8, ""); + if (measure.width(str) <= max_w) return try allocator.dupe(u8, str); + + var buf = std.array_list.Managed(u8).init(allocator); + var w: usize = 0; + var i: usize = 0; + var in_escape = false; + var escape_bracket = false; + + while (i < str.len and w < max_w) { + const c = str[i]; + + if (c == 0x1b) { + in_escape = true; + escape_bracket = false; + try buf.append(c); + i += 1; + continue; + } + + if (in_escape) { + try buf.append(c); + if (c == '[') { + escape_bracket = true; + } else if (escape_bracket) { + if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { + in_escape = false; + escape_bracket = false; + } + } else { + in_escape = false; + } + i += 1; + continue; + } + + const byte_len = std.unicode.utf8ByteSequenceLength(c) catch 1; + if (i + byte_len <= str.len) { + const cp = std.unicode.utf8Decode(str[i..][0..byte_len]) catch { + try buf.append(c); + w += 1; + i += 1; + continue; + }; + const cw = @import("../unicode.zig").charWidth(cp); + if (w + cw > max_w) break; + try buf.appendSlice(str[i..][0..byte_len]); + w += cw; + i += byte_len; + } else { + try buf.append(c); + w += 1; + i += 1; + } + } + + return buf.toOwnedSlice(); + } + + /// Skip the first `skip_cols` display columns and return the rest of the string. + fn skipColumns(allocator: std.mem.Allocator, str: []const u8, skip_cols: usize) ![]const u8 { + var w: usize = 0; + var i: usize = 0; + var in_escape = false; + var escape_bracket = false; + + while (i < str.len and w < skip_cols) { + const c = str[i]; + + if (c == 0x1b) { + in_escape = true; + escape_bracket = false; + i += 1; + continue; + } + + if (in_escape) { + if (c == '[') { + escape_bracket = true; + } else if (escape_bracket) { + if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { + in_escape = false; + escape_bracket = false; + } + } else { + in_escape = false; + } + i += 1; + continue; + } + + const byte_len = std.unicode.utf8ByteSequenceLength(c) catch 1; + if (i + byte_len <= str.len) { + const cp = std.unicode.utf8Decode(str[i..][0..byte_len]) catch { + w += 1; + i += 1; + continue; + }; + w += @import("../unicode.zig").charWidth(cp); + i += byte_len; + } else { + w += 1; + i += 1; + } + } + + if (i >= str.len) return try allocator.dupe(u8, ""); + return try allocator.dupe(u8, str[i..]); + } + + // Comptime style builder + const StyleOpts = struct { + bold_v: ?bool = null, + dim_v: ?bool = null, + italic_v: ?bool = null, + fg_color: Color = .none, + bg_color: Color = .none, + }; + + fn makeStyle(opts: StyleOpts) style_mod.Style { + var s = style_mod.Style{}; + if (opts.bold_v) |v| s.bold_attr = v; + if (opts.dim_v) |v| s.dim_attr = v; + if (opts.italic_v) |v| s.italic_attr = v; + if (!opts.fg_color.isNone()) s.foreground = opts.fg_color; + if (!opts.bg_color.isNone()) s.background = opts.bg_color; + s.inline_mode = true; + return s; + } +}; diff --git a/src/root.zig b/src/root.zig index 1e50fed..4220e73 100644 --- a/src/root.zig +++ b/src/root.zig @@ -116,6 +116,8 @@ pub const components = struct { pub const Confirm = @import("components/confirm.zig").Confirm; pub const modal = @import("components/modal.zig"); pub const Modal = modal.Modal; + pub const tooltip = @import("components/tooltip.zig"); + pub const Tooltip = tooltip.Tooltip; pub const focus = @import("components/focus.zig"); }; @@ -133,6 +135,7 @@ pub const Sparkline = components.Sparkline; pub const Notification = components.Notification; pub const Confirm = components.Confirm; pub const Modal = components.Modal; +pub const Tooltip = components.Tooltip; // Focus management pub const FocusGroup = components.focus.FocusGroup; diff --git a/tests/tooltip_tests.zig b/tests/tooltip_tests.zig new file mode 100644 index 0000000..71cb29b --- /dev/null +++ b/tests/tooltip_tests.zig @@ -0,0 +1,251 @@ +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); +const Tooltip = zz.Tooltip; + +// --------------------------------------------------------------------------- +// Preset constructors +// --------------------------------------------------------------------------- + +test "init — simple text tooltip" { + const t = Tooltip.init("Hello"); + try testing.expectEqualStrings("Hello", t.text); + try testing.expect(t.title == null); + try testing.expect(!t.visible); +} + +test "titled — tooltip with title" { + const t = Tooltip.titled("Info", "Some detail"); + try testing.expectEqualStrings("Info", t.title.?); + try testing.expectEqualStrings("Some detail", t.text); +} + +test "help — dim italic preset" { + const t = Tooltip.help("Press Enter to confirm"); + try testing.expectEqualStrings("Press Enter to confirm", t.text); +} + +test "shortcut — label + key preset" { + const t = Tooltip.shortcut("Save", "Ctrl+S"); + try testing.expectEqualStrings("Save", t.title.?); + try testing.expectEqualStrings("Ctrl+S", t.text); +} + +// --------------------------------------------------------------------------- +// State management +// --------------------------------------------------------------------------- + +test "show / hide / toggle" { + var t = Tooltip.init("Tip"); + try testing.expect(!t.visible); + + t.show(); + try testing.expect(t.visible); + try testing.expect(t.isVisible()); + + t.hide(); + try testing.expect(!t.visible); + + t.toggle(); + try testing.expect(t.visible); + t.toggle(); + try testing.expect(!t.visible); +} + +// --------------------------------------------------------------------------- +// Rendering — renderBox +// --------------------------------------------------------------------------- + +test "renderBox produces output" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const t = Tooltip.init("Hello world"); + const box = try t.renderBox(alloc); + try testing.expect(box.len > 0); + // Should contain the text + try testing.expect(std.mem.indexOf(u8, box, "Hello world") != null); +} + +test "renderBox with title" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const t = Tooltip.titled("Title", "Body text"); + const box = try t.renderBox(alloc); + try testing.expect(std.mem.indexOf(u8, box, "Title") != null); + try testing.expect(std.mem.indexOf(u8, box, "Body text") != null); +} + +test "renderBox multi-line" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Line 1\nLine 2"); + t.padding = .{ .top = 0, .right = 1, .bottom = 0, .left = 1 }; + const box = try t.renderBox(alloc); + try testing.expect(std.mem.indexOf(u8, box, "Line 1") != null); + try testing.expect(std.mem.indexOf(u8, box, "Line 2") != null); +} + +test "renderBox respects max_width" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Short"); + t.max_width = 20; + t.padding = .{ .top = 0, .right = 0, .bottom = 0, .left = 0 }; + const box = try t.renderBox(alloc); + const w = zz.measure.maxLineWidth(box); + // box_w = content_w + 2 (borders) + padding. Short = 5, so 5+2 = 7 + try testing.expect(w <= 22); // max_width + borders +} + +// --------------------------------------------------------------------------- +// Rendering — render (full canvas) +// --------------------------------------------------------------------------- + +test "render returns empty when not visible" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const t = Tooltip.init("Tip"); + const output = try t.render(alloc, 80, 24); + try testing.expectEqual(@as(usize, 0), output.len); +} + +test "render produces output when visible" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Hello"); + t.target_x = 10; + t.target_y = 5; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +test "render with different placements" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const placements = [_]Tooltip.Placement{ .top, .bottom, .left, .right }; + for (placements) |p| { + var t = Tooltip.init("Tip"); + t.target_x = 20; + t.target_y = 12; + t.placement = p; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); + } +} + +test "render without arrow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("No arrow"); + t.show_arrow = false; + t.target_x = 10; + t.target_y = 5; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +// --------------------------------------------------------------------------- +// Rendering — overlay +// --------------------------------------------------------------------------- + +test "overlay returns base when not visible" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const t = Tooltip.init("Tip"); + const base = "Hello world"; + const output = try t.overlay(alloc, base, 80, 24); + try testing.expectEqualStrings(base, output); +} + +test "overlay composites tooltip onto base" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Tip"); + t.target_x = 5; + t.target_y = 1; + t.show(); + const base = "Line one text here\nLine two text here\nLine three here"; + const output = try t.overlay(alloc, base, 40, 10); + try testing.expect(output.len > 0); +} + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test "tooltip at edge of terminal" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Edge test"); + t.target_x = 78; + t.target_y = 23; + t.placement = .bottom; + t.show(); + // Should not crash — position clamped to bounds + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +test "tooltip at origin" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Origin"); + t.target_x = 0; + t.target_y = 0; + t.placement = .top; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +test "tooltip with gap" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Gapped"); + t.target_x = 10; + t.target_y = 10; + t.gap = 2; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} + +test "empty tooltip text" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init(""); + t.show(); + const box = try t.renderBox(alloc); + try testing.expect(box.len > 0); // Still renders box frame +} From 7520a108b79b1da8b619b58fd5d00b3e9d208535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 06:06:50 +0100 Subject: [PATCH 4/8] fix: show arrow for left/right placement and add per-direction arrow customization --- examples/tooltip.zig | 132 +++++++++++++++++------ src/components/tooltip.zig | 213 ++++++++++++++++++++++++------------- tests/tooltip_tests.zig | 79 ++++++++++++++ 3 files changed, 322 insertions(+), 102 deletions(-) diff --git a/examples/tooltip.zig b/examples/tooltip.zig index 186f92c..64222fd 100644 --- a/examples/tooltip.zig +++ b/examples/tooltip.zig @@ -16,6 +16,15 @@ const Model = struct { tooltip: zz.Tooltip, status: []const u8, + // Button positions (computed in view, used for tooltip targeting) + btn_positions: [7]ButtonPos = [_]ButtonPos{.{}} ** 7, + + const ButtonPos = struct { + x: usize = 0, + y: usize = 0, + w: usize = 0, + }; + pub const Msg = union(enum) { key: zz.KeyEvent, }; @@ -34,61 +43,61 @@ const Model = struct { 'q' => return .quit, '1' => { self.tooltip = zz.Tooltip.init("Bottom placement tooltip"); - self.tooltip.target_x = 20; - self.tooltip.target_y = 5; - self.tooltip.target_width = 6; + self.tooltip.target_x = self.btn_positions[0].x; + self.tooltip.target_y = self.btn_positions[0].y; + self.tooltip.target_width = self.btn_positions[0].w; self.tooltip.placement = .bottom; self.tooltip.show(); self.status = "Showing: bottom placement"; }, '2' => { self.tooltip = zz.Tooltip.init("Top placement tooltip"); - self.tooltip.target_x = 20; - self.tooltip.target_y = 12; - self.tooltip.target_width = 6; + self.tooltip.target_x = self.btn_positions[1].x; + self.tooltip.target_y = self.btn_positions[1].y; + self.tooltip.target_width = self.btn_positions[1].w; self.tooltip.placement = .top; self.tooltip.show(); self.status = "Showing: top placement"; }, '3' => { self.tooltip = zz.Tooltip.init("Right placement"); - self.tooltip.target_x = 10; - self.tooltip.target_y = 8; - self.tooltip.target_width = 6; + self.tooltip.target_x = self.btn_positions[2].x; + self.tooltip.target_y = self.btn_positions[2].y; + self.tooltip.target_width = self.btn_positions[2].w; self.tooltip.placement = .right; self.tooltip.show(); self.status = "Showing: right placement"; }, '4' => { self.tooltip = zz.Tooltip.init("Left placement"); - self.tooltip.target_x = 50; - self.tooltip.target_y = 8; - self.tooltip.target_width = 6; + self.tooltip.target_x = self.btn_positions[3].x; + self.tooltip.target_y = self.btn_positions[3].y; + self.tooltip.target_width = self.btn_positions[3].w; self.tooltip.placement = .left; self.tooltip.show(); self.status = "Showing: left placement"; }, '5' => { self.tooltip = zz.Tooltip.titled("File Info", "Size: 1.2 MB\nModified: Today\nType: Document"); - self.tooltip.target_x = 20; - self.tooltip.target_y = 5; - self.tooltip.target_width = 8; + self.tooltip.target_x = self.btn_positions[4].x; + self.tooltip.target_y = self.btn_positions[4].y; + self.tooltip.target_width = self.btn_positions[4].w; self.tooltip.show(); self.status = "Showing: titled tooltip"; }, '6' => { self.tooltip = zz.Tooltip.help("Press Enter to confirm your selection"); - self.tooltip.target_x = 20; - self.tooltip.target_y = 5; - self.tooltip.target_width = 10; + self.tooltip.target_x = self.btn_positions[5].x; + self.tooltip.target_y = self.btn_positions[5].y; + self.tooltip.target_width = self.btn_positions[5].w; self.tooltip.show(); self.status = "Showing: help-style tooltip"; }, '7' => { self.tooltip = zz.Tooltip.shortcut("Save", "Ctrl+S"); - self.tooltip.target_x = 20; - self.tooltip.target_y = 5; - self.tooltip.target_width = 4; + self.tooltip.target_x = self.btn_positions[6].x; + self.tooltip.target_y = self.btn_positions[6].y; + self.tooltip.target_width = self.btn_positions[6].w; self.tooltip.show(); self.status = "Showing: shortcut tooltip"; }, @@ -106,29 +115,90 @@ const Model = struct { return .none; } - pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + pub fn view(self: *Model, ctx: *const zz.Context) []const u8 { const alloc = ctx.allocator; - // Build a base view + // Title var title_s = zz.Style{}; title_s = title_s.bold(true).fg(zz.Color.hex("#FF6B6B")).inline_style(true); + const title = title_s.render(alloc, "Tooltip Component Demo") catch "Tooltip Component Demo"; - var hint_s = zz.Style{}; - hint_s = hint_s.fg(zz.Color.gray(14)).inline_style(true); - + // Status var status_s = zz.Style{}; status_s = status_s.fg(zz.Color.gray(12)).inline_style(true); - - const title = title_s.render(alloc, "Tooltip Component Demo") catch "Tooltip Component Demo"; - const hint = hint_s.render(alloc, "1-4: Placements 5: Titled 6: Help 7: Shortcut h: Hide q: Quit") catch ""; const status = status_s.render(alloc, self.status) catch ""; - const content = std.fmt.allocPrint(alloc, "{s}\n\n{s}\n\n{s}", .{ - title, hint, status, + // Button labels + const labels = [_][]const u8{ + "[1] Bottom", + "[2] Top", + "[3] Right", + "[4] Left", + "[5] Titled", + "[6] Help", + "[7] Shortcut", + }; + + // Render buttons in a row + var btn_s = zz.Style{}; + btn_s = btn_s.fg(zz.Color.white()).bg(zz.Color.gray(5)).inline_style(true); + + var btn_parts: [7][]const u8 = undefined; + for (labels, 0..) |label, i| { + const padded = std.fmt.allocPrint(alloc, " {s} ", .{label}) catch label; + btn_parts[i] = btn_s.render(alloc, padded) catch padded; + } + + // Join buttons with gaps + const row1 = std.fmt.allocPrint(alloc, "{s} {s} {s} {s}", .{ + btn_parts[0], btn_parts[1], btn_parts[2], btn_parts[3], + }) catch ""; + const row2 = std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ + btn_parts[4], btn_parts[5], btn_parts[6], + }) catch ""; + + const content = std.fmt.allocPrint(alloc, "{s}\n\n{s}\n{s}\n\n{s}", .{ + title, row1, row2, status, }) catch "Error"; + // Center content + const content_w = zz.measure.maxLineWidth(content); + const content_h = zz.measure.height(content); + const h_pad = if (ctx.width > content_w) (ctx.width - content_w) / 2 else 0; + const v_pad = if (ctx.height > content_h) (ctx.height - content_h) / 2 else 0; + const base = zz.place.place(alloc, ctx.width, ctx.height, .center, .middle, content) catch content; + // Compute button positions for tooltip targeting + // Row 1 buttons: title line is at v_pad, blank line, then row1 at v_pad+2 + const row1_y = v_pad + 2; + const row2_y = v_pad + 3; + + // Button widths (plain text width of " [N] Label ") + const btn_widths = [7]usize{ + zz.measure.width(" [1] Bottom "), + zz.measure.width(" [2] Top "), + zz.measure.width(" [3] Right "), + zz.measure.width(" [4] Left "), + zz.measure.width(" [5] Titled "), + zz.measure.width(" [6] Help "), + zz.measure.width(" [7] Shortcut "), + }; + + // Row 1 x positions + var x = h_pad; + for (0..4) |i| { + self.btn_positions[i] = .{ .x = x, .y = row1_y, .w = btn_widths[i] }; + x += btn_widths[i] + 2; // +2 for gap + } + + // Row 2 x positions + x = h_pad; + for (4..7) |i| { + self.btn_positions[i] = .{ .x = x, .y = row2_y, .w = btn_widths[i] }; + x += btn_widths[i] + 2; + } + // Overlay tooltip if visible if (self.tooltip.isVisible()) { return self.tooltip.overlay(alloc, base, ctx.width, ctx.height) catch base; diff --git a/src/components/tooltip.zig b/src/components/tooltip.zig index e4cfb68..e2dcd1e 100644 --- a/src/components/tooltip.zig +++ b/src/components/tooltip.zig @@ -76,7 +76,14 @@ pub const Tooltip = struct { /// Show an arrow pointing from tooltip toward the target. show_arrow: bool = true, + /// Color of the arrow character. arrow_fg: Color = Color.gray(14), + /// Custom arrow characters per direction (what's shown when the tooltip + /// is placed in that direction). Set to "" to hide a specific arrow. + arrow_up: []const u8 = "▲", + arrow_down: []const u8 = "▼", + arrow_left: []const u8 = "◀", + arrow_right: []const u8 = "▶", // ── Types ────────────────────────────────────────────────────────── @@ -97,13 +104,6 @@ pub const Tooltip = struct { } }; - pub const Arrow = struct { - pub const up = "▲"; - pub const down = "▼"; - pub const left_arrow = "◀"; - pub const right_arrow = "▶"; - }; - // ── Preset Constructors ──────────────────────────────────────────── /// Simple tooltip with text content. @@ -270,19 +270,29 @@ pub const Tooltip = struct { var box_iter = std.mem.splitScalar(u8, box, '\n'); while (box_iter.next()) |line| try box_lines.append(line); + const is_horizontal = self.placement == .left or self.placement == .right; + for (0..term_height) |row| { if (row > 0) try wr.writeByte('\n'); - // Check if this row is the arrow row - if (self.show_arrow and row == pos.arrow_y) { - try self.writeArrowRow(allocator, wr, pos, term_width, box_lines.items, box_w, box_h, row); - continue; - } + const in_box = row >= pos.box_y and row < pos.box_y + box_h; + const is_arrow_row = self.show_arrow and row == pos.arrow_y; - // Check if this row is in the box - if (row >= pos.box_y and row < pos.box_y + box_h) { + if (in_box) { const box_line_idx = row - pos.box_y; - try self.writeBoxRow(allocator, wr, pos, term_width, box_lines.items, box_w, box_line_idx); + const box_line = if (box_line_idx < box_lines.items.len) box_lines.items[box_line_idx] else ""; + // For left/right placement, include arrow on the same row as the box + if (is_arrow_row and is_horizontal) { + try self.writeBoxRowWithSideArrow(allocator, wr, pos, box_line, term_width); + } else { + try wr.writeAll(try nSpaces(allocator, pos.box_x)); + try wr.writeAll(box_line); + const right = pos.box_x + measure.width(box_line); + if (right < term_width) try wr.writeAll(try nSpaces(allocator, term_width - right)); + } + } else if (is_arrow_row) { + // Top/bottom arrow on its own row + try self.writeArrowOnlyRow(allocator, wr, pos, term_width); } else { try wr.writeAll(try nSpaces(allocator, term_width)); } @@ -316,28 +326,28 @@ pub const Tooltip = struct { var result = std.array_list.Managed(u8).init(allocator); const wr = result.writer(); + const is_horizontal = self.placement == .left or self.placement == .right; + for (0..term_height) |row| { if (row > 0) try wr.writeByte('\n'); const base_line = if (row < base_lines.items.len) base_lines.items[row] else ""; + const in_box = row >= pos.box_y and row < pos.box_y + box_h; + const is_arrow_row = self.show_arrow and row == pos.arrow_y; - // Arrow row - if (self.show_arrow and row == pos.arrow_y and !(row >= pos.box_y and row < pos.box_y + box_h)) { - // Write base left of arrow, then arrow, then base right - try self.writeSplicedArrowRow(allocator, wr, pos, base_line, term_width); - continue; - } - - // Box row - if (row >= pos.box_y and row < pos.box_y + box_h) { + if (in_box) { const box_line_idx = row - pos.box_y; const box_line = if (box_line_idx < box_lines.items.len) box_lines.items[box_line_idx] else ""; - try self.writeSplicedBoxRow(allocator, wr, pos, base_line, box_line, box_w, term_width); - continue; + if (is_arrow_row and is_horizontal) { + try self.writeSplicedBoxRowWithSideArrow(allocator, wr, pos, base_line, box_line, box_w, term_width); + } else { + try self.writeSplicedBoxRow(allocator, wr, pos, base_line, box_line, box_w, term_width); + } + } else if (is_arrow_row) { + try self.writeSplicedArrowRow(allocator, wr, pos, base_line, term_width); + } else { + try wr.writeAll(base_line); } - - // Plain base line - try wr.writeAll(base_line); } return result.toOwnedSlice(); @@ -405,55 +415,72 @@ pub const Tooltip = struct { fn arrowChar(self: *const Tooltip) []const u8 { return switch (self.placement) { - .bottom => Arrow.up, - .top => Arrow.down, - .left => Arrow.right_arrow, - .right => Arrow.left_arrow, + .bottom => self.arrow_up, + .top => self.arrow_down, + .left => self.arrow_right, + .right => self.arrow_left, }; } - // ── Render helpers (full-screen canvas) ──────────────────────────── + fn renderStyledArrow(self: *const Tooltip, allocator: std.mem.Allocator) ![]const u8 { + const ch = self.arrowChar(); + if (ch.len == 0) return try allocator.dupe(u8, ""); + var arrow_s = style_mod.Style{}; + arrow_s = arrow_s.fg(self.arrow_fg).inline_style(true); + return try arrow_s.render(allocator, ch); + } - fn writeArrowRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, tw: usize, box_lines: []const []const u8, box_w: usize, box_h: usize, row: usize) !void { - // If this row also overlaps with the box, splice both - if (row >= pos.box_y and row < pos.box_y + box_h) { - const box_line_idx = row - pos.box_y; - const box_line = if (box_line_idx < box_lines.len) box_lines[box_line_idx] else ""; - // Write spaces up to box, then box line, then spaces - try writer.writeAll(try nSpaces(allocator, pos.box_x)); - try writer.writeAll(box_line); - const right = pos.box_x + measure.width(box_line); - if (right < tw) try writer.writeAll(try nSpaces(allocator, tw - right)); - return; - } + fn arrowDisplayWidth(self: *const Tooltip) usize { + const ch = self.arrowChar(); + if (ch.len == 0) return 0; + return measure.width(ch); + } - _ = box_w; + // ── Render helpers (full-screen canvas) ──────────────────────────── - // Arrow only row + /// Arrow on its own row (top/bottom placement). + fn writeArrowOnlyRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, tw: usize) !void { try writer.writeAll(try nSpaces(allocator, pos.arrow_x)); - - var arrow_s = style_mod.Style{}; - arrow_s = arrow_s.fg(self.arrow_fg).inline_style(true); - try writer.writeAll(try arrow_s.render(allocator, self.arrowChar())); - - if (pos.arrow_x + 1 < tw) { - try writer.writeAll(try nSpaces(allocator, tw - pos.arrow_x - 1)); - } + try writer.writeAll(try self.renderStyledArrow(allocator)); + const aw = self.arrowDisplayWidth(); + const used = pos.arrow_x + aw; + if (used < tw) try writer.writeAll(try nSpaces(allocator, tw - used)); } - fn writeBoxRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, tw: usize, box_lines: []const []const u8, box_w: usize, box_line_idx: usize) !void { - _ = self; - _ = box_w; - const box_line = if (box_line_idx < box_lines.len) box_lines[box_line_idx] else ""; - try writer.writeAll(try nSpaces(allocator, pos.box_x)); - try writer.writeAll(box_line); - const right = pos.box_x + measure.width(box_line); - if (right < tw) try writer.writeAll(try nSpaces(allocator, tw - right)); + /// Box row that also has a side arrow (left/right placement). + fn writeBoxRowWithSideArrow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, box_line: []const u8, tw: usize) !void { + const box_line_w = measure.width(box_line); + const aw = self.arrowDisplayWidth(); + const styled_arrow = try self.renderStyledArrow(allocator); + + if (self.placement == .right) { + // Layout: [spaces] [arrow] [box_line] [spaces] + try writer.writeAll(try nSpaces(allocator, pos.arrow_x)); + try writer.writeAll(styled_arrow); + // box_x should be arrow_x + aw, but use pos.box_x + const gap_between = if (pos.box_x > pos.arrow_x + aw) pos.box_x - pos.arrow_x - aw else 0; + try writer.writeAll(try nSpaces(allocator, gap_between)); + try writer.writeAll(box_line); + const used = pos.arrow_x + aw + gap_between + box_line_w; + if (used < tw) try writer.writeAll(try nSpaces(allocator, tw - used)); + } else { + // .left — Layout: [spaces] [box_line] [arrow] [spaces] + try writer.writeAll(try nSpaces(allocator, pos.box_x)); + try writer.writeAll(box_line); + const gap_between = if (pos.arrow_x > pos.box_x + box_line_w) pos.arrow_x - pos.box_x - box_line_w else 0; + try writer.writeAll(try nSpaces(allocator, gap_between)); + try writer.writeAll(styled_arrow); + const used = pos.box_x + box_line_w + gap_between + aw; + if (used < tw) try writer.writeAll(try nSpaces(allocator, tw - used)); + } } // ── Render helpers (overlay/splice) ──────────────────────────────── + /// Splice arrow-only row onto base line (top/bottom placement). fn writeSplicedArrowRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, tw: usize) !void { + const aw = self.arrowDisplayWidth(); + // Left part from base const left = try truncateToWidth(allocator, base_line, pos.arrow_x); try writer.writeAll(left); @@ -461,18 +488,17 @@ pub const Tooltip = struct { if (left_w < pos.arrow_x) try writer.writeAll(try nSpaces(allocator, pos.arrow_x - left_w)); // Arrow - var arrow_s = style_mod.Style{}; - arrow_s = arrow_s.fg(self.arrow_fg).inline_style(true); - try writer.writeAll(try arrow_s.render(allocator, self.arrowChar())); + try writer.writeAll(try self.renderStyledArrow(allocator)); - // Right part from base (skip arrow_x + 1 columns) - const skip = pos.arrow_x + 1; + // Right part from base + const skip = pos.arrow_x + aw; const right = try skipColumns(allocator, base_line, skip); try writer.writeAll(right); - const total_w = pos.arrow_x + 1 + measure.width(right); + const total_w = pos.arrow_x + aw + measure.width(right); if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); } + /// Splice box-only row onto base line. fn writeSplicedBoxRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, box_line: []const u8, box_w: usize, tw: usize) !void { _ = self; // Left part from base @@ -493,6 +519,52 @@ pub const Tooltip = struct { if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); } + /// Splice box row + side arrow onto base line (left/right placement). + fn writeSplicedBoxRowWithSideArrow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, box_line: []const u8, box_w: usize, tw: usize) !void { + const box_line_w = measure.width(box_line); + const aw = self.arrowDisplayWidth(); + const styled_arrow = try self.renderStyledArrow(allocator); + + if (self.placement == .right) { + // Layout: [base] [arrow] [box_line] [base] + // The leftmost replaced column is arrow_x + const splice_start = pos.arrow_x; + const left = try truncateToWidth(allocator, base_line, splice_start); + try writer.writeAll(left); + const left_w = measure.width(left); + if (left_w < splice_start) try writer.writeAll(try nSpaces(allocator, splice_start - left_w)); + + try writer.writeAll(styled_arrow); + const gap_between = if (pos.box_x > pos.arrow_x + aw) pos.box_x - pos.arrow_x - aw else 0; + try writer.writeAll(try nSpaces(allocator, gap_between)); + try writer.writeAll(box_line); + + const splice_end = pos.box_x + @max(box_line_w, box_w); + const right = try skipColumns(allocator, base_line, splice_end); + try writer.writeAll(right); + const total_w = splice_start + aw + gap_between + box_line_w + measure.width(right); + if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); + } else { + // .left — Layout: [base] [box_line] [arrow] [base] + const splice_start = pos.box_x; + const left = try truncateToWidth(allocator, base_line, splice_start); + try writer.writeAll(left); + const left_w = measure.width(left); + if (left_w < splice_start) try writer.writeAll(try nSpaces(allocator, splice_start - left_w)); + + try writer.writeAll(box_line); + const gap_between = if (pos.arrow_x > pos.box_x + box_line_w) pos.arrow_x - pos.box_x - box_line_w else 0; + try writer.writeAll(try nSpaces(allocator, gap_between)); + try writer.writeAll(styled_arrow); + + const splice_end = pos.arrow_x + aw; + const right = try skipColumns(allocator, base_line, splice_end); + try writer.writeAll(right); + const total_w = splice_start + box_line_w + gap_between + aw + measure.width(right); + if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); + } + } + // ── Private Helpers ─────────────────────────────────────────────── fn writeEmptyLine(allocator: std.mem.Allocator, writer: anytype, styled_left: []const u8, styled_right: []const u8, pad_s: style_mod.Style, inner_w: usize) !void { @@ -522,7 +594,6 @@ pub const Tooltip = struct { } /// Truncate a string (potentially with ANSI) to at most `max_w` display columns. - /// Returns a new slice with only the content up to that width. fn truncateToWidth(allocator: std.mem.Allocator, str: []const u8, max_w: usize) ![]const u8 { if (max_w == 0) return try allocator.dupe(u8, ""); if (measure.width(str) <= max_w) return try allocator.dupe(u8, str); diff --git a/tests/tooltip_tests.zig b/tests/tooltip_tests.zig index 71cb29b..d77d2a3 100644 --- a/tests/tooltip_tests.zig +++ b/tests/tooltip_tests.zig @@ -249,3 +249,82 @@ test "empty tooltip text" { const box = try t.renderBox(alloc); try testing.expect(box.len > 0); // Still renders box frame } + +// --------------------------------------------------------------------------- +// Arrow customization +// --------------------------------------------------------------------------- + +test "custom arrow characters" { + var t = Tooltip.init("Custom arrows"); + t.arrow_up = "^"; + t.arrow_down = "v"; + t.arrow_left = "<"; + t.arrow_right = ">"; + try testing.expectEqualStrings("^", t.arrow_up); + try testing.expectEqualStrings("v", t.arrow_down); +} + +test "render with custom arrow chars" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Custom"); + t.arrow_up = "^"; + t.target_x = 10; + t.target_y = 5; + t.placement = .bottom; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); + try testing.expect(std.mem.indexOf(u8, output, "^") != null); +} + +test "render left/right placement shows arrow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Right placement + var t = Tooltip.init("Right"); + t.arrow_left = "<"; + t.target_x = 10; + t.target_y = 12; + t.target_width = 4; + t.placement = .right; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(std.mem.indexOf(u8, output, "<") != null); +} + +test "overlay left/right placement shows arrow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Left"); + t.arrow_right = ">"; + t.target_x = 40; + t.target_y = 5; + t.target_width = 4; + t.placement = .left; + t.show(); + const base = "Some base content that is wide enough for the test overlay to work"; + const output = try t.overlay(alloc, base, 80, 12); + try testing.expect(std.mem.indexOf(u8, output, ">") != null); +} + +test "empty arrow string hides arrow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("No arrow char"); + t.arrow_up = ""; + t.target_x = 10; + t.target_y = 5; + t.placement = .bottom; + t.show(); + const output = try t.render(alloc, 80, 24); + try testing.expect(output.len > 0); +} From 8fca9ecb23d34d596f900b1f5cf5a35cf7ce2f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 06:13:20 +0100 Subject: [PATCH 5/8] fix: preserve ANSI style context across overlay splice boundaries --- src/components/tooltip.zig | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/components/tooltip.zig b/src/components/tooltip.zig index e2dcd1e..d337f4f 100644 --- a/src/components/tooltip.zig +++ b/src/components/tooltip.zig @@ -594,6 +594,8 @@ pub const Tooltip = struct { } /// Truncate a string (potentially with ANSI) to at most `max_w` display columns. + /// Appends an ANSI reset if the truncated portion contained escape sequences, + /// so the left part's style doesn't bleed into spliced content. fn truncateToWidth(allocator: std.mem.Allocator, str: []const u8, max_w: usize) ![]const u8 { if (max_w == 0) return try allocator.dupe(u8, ""); if (measure.width(str) <= max_w) return try allocator.dupe(u8, str); @@ -603,6 +605,7 @@ pub const Tooltip = struct { var i: usize = 0; var in_escape = false; var escape_bracket = false; + var has_ansi = false; while (i < str.len and w < max_w) { const c = str[i]; @@ -610,6 +613,7 @@ pub const Tooltip = struct { if (c == 0x1b) { in_escape = true; escape_bracket = false; + has_ansi = true; try buf.append(c); i += 1; continue; @@ -651,27 +655,42 @@ pub const Tooltip = struct { } } + // Reset styles so truncated left part doesn't bleed into spliced content + if (has_ansi) { + try buf.appendSlice("\x1b[0m"); + } + return buf.toOwnedSlice(); } /// Skip the first `skip_cols` display columns and return the rest of the string. + /// Preserves ANSI escape sequences encountered during skipping so that the + /// returned remainder retains its original styling context. fn skipColumns(allocator: std.mem.Allocator, str: []const u8, skip_cols: usize) ![]const u8 { var w: usize = 0; var i: usize = 0; var in_escape = false; var escape_bracket = false; + // Collect ANSI sequences encountered while skipping so we can + // replay them before the returned remainder. + var ansi_state = std.array_list.Managed(u8).init(allocator); + defer ansi_state.deinit(); + while (i < str.len and w < skip_cols) { const c = str[i]; if (c == 0x1b) { in_escape = true; escape_bracket = false; + // Start capturing this escape sequence + try ansi_state.append(c); i += 1; continue; } if (in_escape) { + try ansi_state.append(c); if (c == '[') { escape_bracket = true; } else if (escape_bracket) { @@ -702,6 +721,15 @@ pub const Tooltip = struct { } if (i >= str.len) return try allocator.dupe(u8, ""); + + // Prepend collected ANSI state to the remainder so styling is restored + if (ansi_state.items.len > 0) { + var result = std.array_list.Managed(u8).init(allocator); + try result.appendSlice(ansi_state.items); + try result.appendSlice(str[i..]); + return result.toOwnedSlice(); + } + return try allocator.dupe(u8, str[i..]); } From 5670978d77dc66c7c1de130e694b21ec92ac8c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 08:09:11 +0100 Subject: [PATCH 6/8] refactor: rewrite tooltip overlay with cell-based compositing --- src/components/tooltip.zig | 646 +++++++++++++++++++++++-------------- tests/tooltip_tests.zig | 138 ++++++++ 2 files changed, 536 insertions(+), 248 deletions(-) diff --git a/src/components/tooltip.zig b/src/components/tooltip.zig index d337f4f..01e4c9a 100644 --- a/src/components/tooltip.zig +++ b/src/components/tooltip.zig @@ -34,7 +34,9 @@ const std = @import("std"); const style_mod = @import("../style/style.zig"); const border_mod = @import("../style/border.zig"); const Color = @import("../style/color.zig").Color; +const AnsiColor = @import("../style/color.zig").AnsiColor; const measure = @import("../layout/measure.zig"); +const unicode = @import("../unicode.zig"); pub const Tooltip = struct { // ── State ────────────────────────────────────────────────────────── @@ -85,6 +87,10 @@ pub const Tooltip = struct { arrow_left: []const u8 = "◀", arrow_right: []const u8 = "▶", + /// When true in overlay mode, tooltip elements (arrow, border) inherit + /// the background color from the underlying base content. + inherit_bg: bool = true, + // ── Types ────────────────────────────────────────────────────────── pub const Placement = enum { top, bottom, left, right }; @@ -301,8 +307,12 @@ pub const Tooltip = struct { return result.toOwnedSlice(); } - /// Render just the tooltip box with arrow, composited onto the given base content. - /// Uses line-by-line splicing (same approach as Modal's viewWithBackdrop). + /// Render the tooltip composited onto the given base content. + /// + /// Uses **cell-based compositing** (like Ratatui, Textual, Cursive): + /// both base and tooltip are parsed into a cell grid, tooltip cells are + /// painted on top, then the grid is serialized back to an ANSI string. + /// This completely avoids ANSI-splice style bleeding. pub fn overlay(self: *const Tooltip, allocator: std.mem.Allocator, base: []const u8, term_width: usize, term_height: usize) ![]const u8 { if (!self.visible) return try allocator.dupe(u8, base); @@ -311,46 +321,67 @@ pub const Tooltip = struct { const box_h = measure.height(box); const pos = self.computePosition(box_w, box_h, term_width, term_height); - // Collect box lines - var box_lines = std.array_list.Managed([]const u8).init(allocator); - defer box_lines.deinit(); - var box_iter = std.mem.splitScalar(u8, box, '\n'); - while (box_iter.next()) |line| try box_lines.append(line); - - // Collect base lines - var base_lines = std.array_list.Managed([]const u8).init(allocator); - defer base_lines.deinit(); - var base_iter = std.mem.splitScalar(u8, base, '\n'); - while (base_iter.next()) |line| try base_lines.append(line); - - var result = std.array_list.Managed(u8).init(allocator); - const wr = result.writer(); - - const is_horizontal = self.placement == .left or self.placement == .right; - - for (0..term_height) |row| { - if (row > 0) try wr.writeByte('\n'); + // 1. Parse base content into cell grid + var grid = try CellGrid.parse(allocator, base, term_width, term_height); + + // 2. Parse tooltip box into cell grid + var box_grid = try CellGrid.parse(allocator, box, box_w, box_h); + + // 3. Paint tooltip box cells onto base grid + for (0..box_h) |r| { + const dst_r = pos.box_y + r; + if (dst_r >= term_height) break; + var c: usize = 0; + while (c < box_w) { + const dst_c = pos.box_x + c; + if (dst_c >= term_width) break; + + const src_cell = box_grid.get(r, c); + if (self.inherit_bg and src_cell.style.bg.eql(.none)) { + // Tooltip cell has no bg → inherit from base + var merged = src_cell; + merged.style.bg = grid.get(dst_r, dst_c).style.bg; + grid.set(dst_r, dst_c, merged); + } else { + grid.set(dst_r, dst_c, src_cell); + } - const base_line = if (row < base_lines.items.len) base_lines.items[row] else ""; - const in_box = row >= pos.box_y and row < pos.box_y + box_h; - const is_arrow_row = self.show_arrow and row == pos.arrow_y; + // Skip continuation cells of wide characters + const w = if (src_cell.width > 1) src_cell.width else 1; + c += w; + } + } - if (in_box) { - const box_line_idx = row - pos.box_y; - const box_line = if (box_line_idx < box_lines.items.len) box_lines.items[box_line_idx] else ""; - if (is_arrow_row and is_horizontal) { - try self.writeSplicedBoxRowWithSideArrow(allocator, wr, pos, base_line, box_line, box_w, term_width); - } else { - try self.writeSplicedBoxRow(allocator, wr, pos, base_line, box_line, box_w, term_width); + // 4. Paint arrow cell + if (self.show_arrow) { + const arrow_ch = self.arrowChar(); + if (arrow_ch.len > 0 and pos.arrow_y < term_height and pos.arrow_x < term_width) { + const aw = self.arrowDisplayWidth(); + var arrow_style = CellStyle{}; + arrow_style.fg = colorToCellColor(self.arrow_fg); + if (self.inherit_bg) { + arrow_style.bg = grid.get(pos.arrow_y, pos.arrow_x).style.bg; + } + grid.set(pos.arrow_y, pos.arrow_x, .{ + .char = arrow_ch, + .style = arrow_style, + .width = @intCast(aw), + }); + // Clear continuation cells for wide arrow + for (1..aw) |dx| { + if (pos.arrow_x + dx < term_width) { + grid.set(pos.arrow_y, pos.arrow_x + dx, .{ + .char = "", + .style = arrow_style, + .width = 0, + }); + } } - } else if (is_arrow_row) { - try self.writeSplicedArrowRow(allocator, wr, pos, base_line, term_width); - } else { - try wr.writeAll(base_line); } } - return result.toOwnedSlice(); + // 5. Serialize cell grid back to ANSI string + return grid.render(allocator); } // ── Position Computation ────────────────────────────────────────── @@ -457,7 +488,6 @@ pub const Tooltip = struct { // Layout: [spaces] [arrow] [box_line] [spaces] try writer.writeAll(try nSpaces(allocator, pos.arrow_x)); try writer.writeAll(styled_arrow); - // box_x should be arrow_x + aw, but use pos.box_x const gap_between = if (pos.box_x > pos.arrow_x + aw) pos.box_x - pos.arrow_x - aw else 0; try writer.writeAll(try nSpaces(allocator, gap_between)); try writer.writeAll(box_line); @@ -475,96 +505,6 @@ pub const Tooltip = struct { } } - // ── Render helpers (overlay/splice) ──────────────────────────────── - - /// Splice arrow-only row onto base line (top/bottom placement). - fn writeSplicedArrowRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, tw: usize) !void { - const aw = self.arrowDisplayWidth(); - - // Left part from base - const left = try truncateToWidth(allocator, base_line, pos.arrow_x); - try writer.writeAll(left); - const left_w = measure.width(left); - if (left_w < pos.arrow_x) try writer.writeAll(try nSpaces(allocator, pos.arrow_x - left_w)); - - // Arrow - try writer.writeAll(try self.renderStyledArrow(allocator)); - - // Right part from base - const skip = pos.arrow_x + aw; - const right = try skipColumns(allocator, base_line, skip); - try writer.writeAll(right); - const total_w = pos.arrow_x + aw + measure.width(right); - if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); - } - - /// Splice box-only row onto base line. - fn writeSplicedBoxRow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, box_line: []const u8, box_w: usize, tw: usize) !void { - _ = self; - // Left part from base - const left = try truncateToWidth(allocator, base_line, pos.box_x); - try writer.writeAll(left); - const left_w = measure.width(left); - if (left_w < pos.box_x) try writer.writeAll(try nSpaces(allocator, pos.box_x - left_w)); - - // Box line - try writer.writeAll(box_line); - const bw = measure.width(box_line); - - // Right part from base (skip box area) - const skip = pos.box_x + @max(bw, box_w); - const right = try skipColumns(allocator, base_line, skip); - try writer.writeAll(right); - const total_w = pos.box_x + bw + measure.width(right); - if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); - } - - /// Splice box row + side arrow onto base line (left/right placement). - fn writeSplicedBoxRowWithSideArrow(self: *const Tooltip, allocator: std.mem.Allocator, writer: anytype, pos: Position, base_line: []const u8, box_line: []const u8, box_w: usize, tw: usize) !void { - const box_line_w = measure.width(box_line); - const aw = self.arrowDisplayWidth(); - const styled_arrow = try self.renderStyledArrow(allocator); - - if (self.placement == .right) { - // Layout: [base] [arrow] [box_line] [base] - // The leftmost replaced column is arrow_x - const splice_start = pos.arrow_x; - const left = try truncateToWidth(allocator, base_line, splice_start); - try writer.writeAll(left); - const left_w = measure.width(left); - if (left_w < splice_start) try writer.writeAll(try nSpaces(allocator, splice_start - left_w)); - - try writer.writeAll(styled_arrow); - const gap_between = if (pos.box_x > pos.arrow_x + aw) pos.box_x - pos.arrow_x - aw else 0; - try writer.writeAll(try nSpaces(allocator, gap_between)); - try writer.writeAll(box_line); - - const splice_end = pos.box_x + @max(box_line_w, box_w); - const right = try skipColumns(allocator, base_line, splice_end); - try writer.writeAll(right); - const total_w = splice_start + aw + gap_between + box_line_w + measure.width(right); - if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); - } else { - // .left — Layout: [base] [box_line] [arrow] [base] - const splice_start = pos.box_x; - const left = try truncateToWidth(allocator, base_line, splice_start); - try writer.writeAll(left); - const left_w = measure.width(left); - if (left_w < splice_start) try writer.writeAll(try nSpaces(allocator, splice_start - left_w)); - - try writer.writeAll(box_line); - const gap_between = if (pos.arrow_x > pos.box_x + box_line_w) pos.arrow_x - pos.box_x - box_line_w else 0; - try writer.writeAll(try nSpaces(allocator, gap_between)); - try writer.writeAll(styled_arrow); - - const splice_end = pos.arrow_x + aw; - const right = try skipColumns(allocator, base_line, splice_end); - try writer.writeAll(right); - const total_w = splice_start + box_line_w + gap_between + aw + measure.width(right); - if (total_w < tw) try writer.writeAll(try nSpaces(allocator, tw - total_w)); - } - } - // ── Private Helpers ─────────────────────────────────────────────── fn writeEmptyLine(allocator: std.mem.Allocator, writer: anytype, styled_left: []const u8, styled_right: []const u8, pad_s: style_mod.Style, inner_w: usize) !void { @@ -593,163 +533,373 @@ pub const Tooltip = struct { return buf; } - /// Truncate a string (potentially with ANSI) to at most `max_w` display columns. - /// Appends an ANSI reset if the truncated portion contained escape sequences, - /// so the left part's style doesn't bleed into spliced content. - fn truncateToWidth(allocator: std.mem.Allocator, str: []const u8, max_w: usize) ![]const u8 { - if (max_w == 0) return try allocator.dupe(u8, ""); - if (measure.width(str) <= max_w) return try allocator.dupe(u8, str); + // Comptime style builder + const StyleOpts = struct { + bold_v: ?bool = null, + dim_v: ?bool = null, + italic_v: ?bool = null, + fg_color: Color = .none, + bg_color: Color = .none, + }; - var buf = std.array_list.Managed(u8).init(allocator); - var w: usize = 0; + fn makeStyle(opts: StyleOpts) style_mod.Style { + var s = style_mod.Style{}; + if (opts.bold_v) |v| s.bold_attr = v; + if (opts.dim_v) |v| s.dim_attr = v; + if (opts.italic_v) |v| s.italic_attr = v; + if (!opts.fg_color.isNone()) s.foreground = opts.fg_color; + if (!opts.bg_color.isNone()) s.background = opts.bg_color; + s.inline_mode = true; + return s; + } + + /// Convert a framework Color to a CellColor for cell-based compositing. + fn colorToCellColor(c: Color) CellColor { + return switch (c) { + .none => .none, + .ansi => |a| .{ .ansi = a }, + .ansi256 => |n| .{ .ansi256 = n }, + .rgb => |v| .{ .rgb = .{ v.r, v.g, v.b } }, + }; + } +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Cell-based compositing types (à la Ratatui Buffer / Textual Segment) +// ═══════════════════════════════════════════════════════════════════════ + +/// Color as parsed from raw ANSI SGR — independent of the framework Color type. +const CellColor = union(enum) { + none, + ansi: AnsiColor, + ansi256: u8, + rgb: [3]u8, + + fn eql(a: CellColor, b: CellColor) bool { + const tag_a = @intFromEnum(std.meta.activeTag(a)); + const tag_b = @intFromEnum(std.meta.activeTag(b)); + if (tag_a != tag_b) return false; + return switch (a) { + .none => true, + .ansi => |va| va == b.ansi, + .ansi256 => |va| va == b.ansi256, + .rgb => |va| va[0] == b.rgb[0] and va[1] == b.rgb[1] and va[2] == b.rgb[2], + }; + } + + fn writeFg(self: CellColor, wr: anytype) !void { + switch (self) { + .none => try wr.writeAll("\x1b[39m"), + .ansi => |a| try wr.print("\x1b[{d}m", .{a.fgCode()}), + .ansi256 => |n| try wr.print("\x1b[38;5;{d}m", .{n}), + .rgb => |c| try wr.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] }), + } + } + + fn writeBg(self: CellColor, wr: anytype) !void { + switch (self) { + .none => try wr.writeAll("\x1b[49m"), + .ansi => |a| try wr.print("\x1b[{d}m", .{a.bgCode()}), + .ansi256 => |n| try wr.print("\x1b[48;5;{d}m", .{n}), + .rgb => |c| try wr.print("\x1b[48;2;{d};{d};{d}m", .{ c[0], c[1], c[2] }), + } + } +}; + +/// Per-cell style parsed from ANSI SGR sequences. +const CellStyle = struct { + fg: CellColor = .none, + bg: CellColor = .none, + bold: bool = false, + dim: bool = false, + italic: bool = false, + underline: bool = false, + strikethrough: bool = false, + + fn eql(a: CellStyle, b: CellStyle) bool { + return a.fg.eql(b.fg) and a.bg.eql(b.bg) and + a.bold == b.bold and a.dim == b.dim and + a.italic == b.italic and a.underline == b.underline and + a.strikethrough == b.strikethrough; + } +}; + +/// A single terminal cell. +const Cell = struct { + char: []const u8 = " ", + style: CellStyle = .{}, + width: u8 = 1, // display width; 0 = continuation cell of wide char +}; + +/// 2D grid of cells — the intermediate buffer for compositing. +const CellGrid = struct { + cells: []Cell, + w: usize, + h: usize, + + fn get(self: *const CellGrid, row: usize, col: usize) Cell { + if (row >= self.h or col >= self.w) return Cell{}; + return self.cells[row * self.w + col]; + } + + fn set(self: *CellGrid, row: usize, col: usize, cell: Cell) void { + if (row >= self.h or col >= self.w) return; + self.cells[row * self.w + col] = cell; + } + + /// Parse an ANSI-encoded string into a cell grid. + fn parse(allocator: std.mem.Allocator, str: []const u8, width: usize, height: usize) !CellGrid { + const total = width * height; + const cells = try allocator.alloc(Cell, total); + for (cells) |*c| c.* = Cell{}; + + var cur_style = CellStyle{}; + var row: usize = 0; + var col: usize = 0; var i: usize = 0; - var in_escape = false; - var escape_bracket = false; - var has_ansi = false; - while (i < str.len and w < max_w) { + while (i < str.len) { const c = str[i]; - if (c == 0x1b) { - in_escape = true; - escape_bracket = false; - has_ansi = true; - try buf.append(c); + // Newline → next row + if (c == '\n') { + row += 1; + col = 0; i += 1; continue; } - if (in_escape) { - try buf.append(c); - if (c == '[') { - escape_bracket = true; - } else if (escape_bracket) { - if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { - in_escape = false; - escape_bracket = false; + // ESC sequence + if (c == 0x1b and i + 1 < str.len and str[i + 1] == '[') { + i += 2; // skip ESC [ + const params_start = i; + // Scan to final byte (letter) + while (i < str.len and !isCSIFinal(str[i])) : (i += 1) {} + if (i < str.len) { + const final = str[i]; + i += 1; + if (final == 'm') { + // SGR — update current style + const params = str[params_start .. i - 1]; + applySgr(&cur_style, params); } - } else { - in_escape = false; + // Other CSI sequences are silently consumed } + continue; + } + + // Bare ESC (non-CSI) + if (c == 0x1b) { + i += 1; + continue; + } + + // Control chars (except newline handled above) + if (c < 0x20) { + i += 1; + continue; + } + + // Visible character + if (row >= height) { i += 1; continue; } const byte_len = std.unicode.utf8ByteSequenceLength(c) catch 1; + const end = @min(i + byte_len, str.len); + const ch_slice = str[i..end]; + + var cw: usize = 1; if (i + byte_len <= str.len) { - const cp = std.unicode.utf8Decode(str[i..][0..byte_len]) catch { - try buf.append(c); - w += 1; - i += 1; - continue; + if (std.unicode.utf8Decode(str[i..][0..byte_len])) |cp| { + cw = unicode.charWidth(cp); + } else |_| {} + } + + if (col < width) { + cells[row * width + col] = .{ + .char = ch_slice, + .style = cur_style, + .width = @intCast(cw), }; - const cw = @import("../unicode.zig").charWidth(cp); - if (w + cw > max_w) break; - try buf.appendSlice(str[i..][0..byte_len]); - w += cw; - i += byte_len; - } else { - try buf.append(c); - w += 1; - i += 1; + // Mark continuation cells for wide characters + for (1..cw) |dx| { + if (col + dx < width) { + cells[row * width + col + dx] = .{ + .char = "", + .style = cur_style, + .width = 0, + }; + } + } + col += cw; } - } - // Reset styles so truncated left part doesn't bleed into spliced content - if (has_ansi) { - try buf.appendSlice("\x1b[0m"); + i = end; } - return buf.toOwnedSlice(); + return .{ .cells = cells, .w = width, .h = height }; } - /// Skip the first `skip_cols` display columns and return the rest of the string. - /// Preserves ANSI escape sequences encountered during skipping so that the - /// returned remainder retains its original styling context. - fn skipColumns(allocator: std.mem.Allocator, str: []const u8, skip_cols: usize) ![]const u8 { - var w: usize = 0; - var i: usize = 0; - var in_escape = false; - var escape_bracket = false; + /// Serialize the cell grid back to an ANSI string. + /// Emits SGR sequences only when the style changes between cells + /// (like Ratatui's Buffer::diff), producing minimal output. + fn render(self: *const CellGrid, allocator: std.mem.Allocator) ![]const u8 { + var buf = std.array_list.Managed(u8).init(allocator); + const wr = buf.writer(); - // Collect ANSI sequences encountered while skipping so we can - // replay them before the returned remainder. - var ansi_state = std.array_list.Managed(u8).init(allocator); - defer ansi_state.deinit(); + var prev_style = CellStyle{}; - while (i < str.len and w < skip_cols) { - const c = str[i]; + for (0..self.h) |row| { + if (row > 0) try wr.writeByte('\n'); - if (c == 0x1b) { - in_escape = true; - escape_bracket = false; - // Start capturing this escape sequence - try ansi_state.append(c); - i += 1; - continue; - } + // Track trailing spaces to avoid emitting them + for (0..self.w) |col| { + const cell = self.cells[row * self.w + col]; - if (in_escape) { - try ansi_state.append(c); - if (c == '[') { - escape_bracket = true; - } else if (escape_bracket) { - if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { - in_escape = false; - escape_bracket = false; - } - } else { - in_escape = false; + // Skip continuation cells + if (cell.width == 0) continue; + + // Emit style change if needed + if (!cell.style.eql(prev_style)) { + try emitStyleDiff(wr, prev_style, cell.style); + prev_style = cell.style; } - i += 1; - continue; - } - const byte_len = std.unicode.utf8ByteSequenceLength(c) catch 1; - if (i + byte_len <= str.len) { - const cp = std.unicode.utf8Decode(str[i..][0..byte_len]) catch { - w += 1; - i += 1; - continue; - }; - w += @import("../unicode.zig").charWidth(cp); - i += byte_len; - } else { - w += 1; - i += 1; + // Emit character + if (cell.char.len > 0) { + try wr.writeAll(cell.char); + } } } - if (i >= str.len) return try allocator.dupe(u8, ""); + // Final reset + try wr.writeAll("\x1b[0m"); - // Prepend collected ANSI state to the remainder so styling is restored - if (ansi_state.items.len > 0) { - var result = std.array_list.Managed(u8).init(allocator); - try result.appendSlice(ansi_state.items); - try result.appendSlice(str[i..]); - return result.toOwnedSlice(); - } + return buf.toOwnedSlice(); + } +}; - return try allocator.dupe(u8, str[i..]); +/// Check if a byte is a CSI final byte (letter). +fn isCSIFinal(c: u8) bool { + return (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z'); +} + +/// Apply an SGR parameter string to a CellStyle. +fn applySgr(style: *CellStyle, params: []const u8) void { + // Empty params = reset + if (params.len == 0) { + style.* = CellStyle{}; + return; } - // Comptime style builder - const StyleOpts = struct { - bold_v: ?bool = null, - dim_v: ?bool = null, - italic_v: ?bool = null, - fg_color: Color = .none, - bg_color: Color = .none, - }; + var iter = std.mem.splitScalar(u8, params, ';'); + while (iter.next()) |param| { + const n = std.fmt.parseInt(u32, param, 10) catch continue; + switch (n) { + 0 => style.* = CellStyle{}, + 1 => style.bold = true, + 2 => style.dim = true, + 3 => style.italic = true, + 4 => style.underline = true, + 9 => style.strikethrough = true, + 22 => { + style.bold = false; + style.dim = false; + }, + 23 => style.italic = false, + 24 => style.underline = false, + 29 => style.strikethrough = false, + // Foreground basic colors + 30...37 => { + style.fg = .{ .ansi = @enumFromInt(n - 30) }; + }, + 39 => style.fg = .none, + // Background basic colors + 40...47 => { + style.bg = .{ .ansi = @enumFromInt(n - 40) }; + }, + 49 => style.bg = .none, + // Extended foreground + 38 => { + const sub_str = iter.next() orelse return; + const sub = std.fmt.parseInt(u32, sub_str, 10) catch return; + if (sub == 5) { + const ci_str = iter.next() orelse return; + const ci = std.fmt.parseInt(u8, ci_str, 10) catch return; + style.fg = .{ .ansi256 = ci }; + } else if (sub == 2) { + const r = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + const g = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + const b = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + style.fg = .{ .rgb = .{ r, g, b } }; + } + }, + // Extended background + 48 => { + const sub_str = iter.next() orelse return; + const sub = std.fmt.parseInt(u32, sub_str, 10) catch return; + if (sub == 5) { + const ci_str = iter.next() orelse return; + const ci = std.fmt.parseInt(u8, ci_str, 10) catch return; + style.bg = .{ .ansi256 = ci }; + } else if (sub == 2) { + const r = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + const g = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + const b = std.fmt.parseInt(u8, iter.next() orelse return, 10) catch return; + style.bg = .{ .rgb = .{ r, g, b } }; + } + }, + // Bright foreground + 90...97 => { + style.fg = .{ .ansi = @enumFromInt(n - 90 + 8) }; + }, + // Bright background + 100...107 => { + style.bg = .{ .ansi = @enumFromInt(n - 100 + 8) }; + }, + else => {}, + } + } +} + +/// Emit the minimal SGR diff to transition from one style to another. +fn emitStyleDiff(wr: anytype, prev: CellStyle, next: CellStyle) !void { + // If the new style is default, just reset + const default_style = CellStyle{}; + if (next.eql(default_style)) { + try wr.writeAll("\x1b[0m"); + return; + } - fn makeStyle(opts: StyleOpts) style_mod.Style { - var s = style_mod.Style{}; - if (opts.bold_v) |v| s.bold_attr = v; - if (opts.dim_v) |v| s.dim_attr = v; - if (opts.italic_v) |v| s.italic_attr = v; - if (!opts.fg_color.isNone()) s.foreground = opts.fg_color; - if (!opts.bg_color.isNone()) s.background = opts.bg_color; - s.inline_mode = true; - return s; + // If any attribute was turned off (true→false), we need a reset first + // since SGR doesn't have individual "off" codes for all attributes reliably. + const needs_reset = (prev.bold and !next.bold) or + (prev.dim and !next.dim) or + (prev.italic and !next.italic) or + (prev.underline and !next.underline) or + (prev.strikethrough and !next.strikethrough); + + if (needs_reset) { + try wr.writeAll("\x1b[0m"); + // After reset, emit all attributes of the new style + if (next.bold) try wr.writeAll("\x1b[1m"); + if (next.dim) try wr.writeAll("\x1b[2m"); + if (next.italic) try wr.writeAll("\x1b[3m"); + if (next.underline) try wr.writeAll("\x1b[4m"); + if (next.strikethrough) try wr.writeAll("\x1b[9m"); + if (!next.fg.eql(.none)) try next.fg.writeFg(wr); + if (!next.bg.eql(.none)) try next.bg.writeBg(wr); + return; } -}; + + // Otherwise, emit only what changed + if (!prev.fg.eql(next.fg)) try next.fg.writeFg(wr); + if (!prev.bg.eql(next.bg)) try next.bg.writeBg(wr); + if (!prev.bold and next.bold) try wr.writeAll("\x1b[1m"); + if (!prev.dim and next.dim) try wr.writeAll("\x1b[2m"); + if (!prev.italic and next.italic) try wr.writeAll("\x1b[3m"); + if (!prev.underline and next.underline) try wr.writeAll("\x1b[4m"); + if (!prev.strikethrough and next.strikethrough) try wr.writeAll("\x1b[9m"); +} diff --git a/tests/tooltip_tests.zig b/tests/tooltip_tests.zig index d77d2a3..cd3b5cb 100644 --- a/tests/tooltip_tests.zig +++ b/tests/tooltip_tests.zig @@ -328,3 +328,141 @@ test "empty arrow string hides arrow" { const output = try t.render(alloc, 80, 24); try testing.expect(output.len > 0); } + +// ── inherit_bg tests ────────────────────────────────────────────── + +test "inherit_bg false does not inject base background" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Tip"); + t.target_x = 5; + t.target_y = 0; + t.placement = .bottom; + t.inherit_bg = false; + t.show(); + + // Base line with a true-color bg on the first row + const base = "\x1b[48;2;100;100;100mColored Background\x1b[0m"; + const output = try t.overlay(alloc, base, 40, 10); + try testing.expect(output.len > 0); + // With inherit_bg off, the tooltip renders without injecting base bg. + // Just verify it produces valid output. + try testing.expect(std.mem.indexOf(u8, output, "\n") != null); +} + +test "inherit_bg true injects base background into arrow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Tip"); + t.target_x = 5; + t.target_y = 0; + t.placement = .bottom; + t.inherit_bg = true; + t.show(); + + // Base with a true-color bg covering the arrow position + const base = "\x1b[48;2;200;50;50mRed Background Here!!\x1b[0m"; + const output = try t.overlay(alloc, base, 40, 10); + try testing.expect(output.len > 0); + + // The arrow row should contain the base bg sequence (48;2;200;50;50) + // injected before the arrow character + try testing.expect(std.mem.indexOf(u8, output, "48;2;200;50;50") != null); +} + +test "inherit_bg true injects base background into box border" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Hi"); + t.target_x = 3; + t.target_y = 0; + t.placement = .bottom; + t.inherit_bg = true; + t.show(); + + // Base with a 256-color bg + const base = "\x1b[48;5;22mGreen row content here long enough\x1b[0m"; + const output = try t.overlay(alloc, base, 50, 10); + try testing.expect(output.len > 0); + // The box rows should contain the 256-color bg injected + try testing.expect(std.mem.indexOf(u8, output, "48;5;22") != null); +} + +test "inherit_bg with no base background is harmless" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Plain"); + t.target_x = 2; + t.target_y = 0; + t.placement = .bottom; + t.inherit_bg = true; + t.show(); + + // Plain base with no ANSI at all + const base = "Hello World, no colors here at all!!"; + const output = try t.overlay(alloc, base, 50, 10); + try testing.expect(output.len > 0); + // Should still render correctly — no crash, no garbage +} + +test "inherit_bg with bg reset before target column" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Tip"); + t.target_x = 12; + t.target_y = 0; + t.placement = .bottom; + t.inherit_bg = true; + t.show(); + + // BG set then reset before the target column + const base = "\x1b[48;2;255;0;0mRedPart\x1b[0m PlainPart after reset here"; + const output = try t.overlay(alloc, base, 50, 10); + try testing.expect(output.len > 0); + // At column 12 the bg has been reset, so no bg should be injected. + // The red bg (255;0;0) should NOT appear in tooltip elements. + // It may still appear in the preserved base left portion though, + // so we just verify no crash and valid output. +} + +test "inherit_bg with left/right placement" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Side"); + t.target_x = 20; + t.target_y = 3; + t.target_width = 4; + t.placement = .right; + t.inherit_bg = true; + t.show(); + + // Build multi-line base with bg on relevant rows + var base_buf = std.array_list.Managed(u8).init(alloc); + for (0..8) |row| { + if (row > 0) try base_buf.append('\n'); + if (row == 3) { + try base_buf.appendSlice("\x1b[48;2;0;128;255m"); + try base_buf.appendSlice("Blue row with enough content for tooltip overlay test!!"); + try base_buf.appendSlice("\x1b[0m"); + } else { + try base_buf.appendSlice("Normal row with some plain text content for testing!!"); + } + } + const base = try base_buf.toOwnedSlice(); + const output = try t.overlay(alloc, base, 60, 8); + try testing.expect(output.len > 0); + // The blue bg should be injected into the arrow on row 3 + try testing.expect(std.mem.indexOf(u8, output, "48;2;0;128;255") != null); +} From 7706363431dde9b5b69485fd28072a4dcea7190c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 08:28:58 +0100 Subject: [PATCH 7/8] feat: add border_bg, arrow_bg and clear background support to tooltip --- examples/tooltip.zig | 55 +++++++++++++++--- src/components/tooltip.zig | 23 +++++++- tests/tooltip_tests.zig | 114 +++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) diff --git a/examples/tooltip.zig b/examples/tooltip.zig index 64222fd..4466b89 100644 --- a/examples/tooltip.zig +++ b/examples/tooltip.zig @@ -30,7 +30,7 @@ const Model = struct { }; pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { - self.tooltip = zz.Tooltip.init("This is a tooltip!"); + self.tooltip = clearTooltip("This is a tooltip!"); self.status = "Press 1-7 to show tooltips, h to hide, q to quit"; return .none; } @@ -42,7 +42,7 @@ const Model = struct { .char => |c| switch (c) { 'q' => return .quit, '1' => { - self.tooltip = zz.Tooltip.init("Bottom placement tooltip"); + self.tooltip = clearTooltip("Bottom placement tooltip"); self.tooltip.target_x = self.btn_positions[0].x; self.tooltip.target_y = self.btn_positions[0].y; self.tooltip.target_width = self.btn_positions[0].w; @@ -51,7 +51,7 @@ const Model = struct { self.status = "Showing: bottom placement"; }, '2' => { - self.tooltip = zz.Tooltip.init("Top placement tooltip"); + self.tooltip = clearTooltip("Top placement tooltip"); self.tooltip.target_x = self.btn_positions[1].x; self.tooltip.target_y = self.btn_positions[1].y; self.tooltip.target_width = self.btn_positions[1].w; @@ -60,7 +60,7 @@ const Model = struct { self.status = "Showing: top placement"; }, '3' => { - self.tooltip = zz.Tooltip.init("Right placement"); + self.tooltip = clearTooltip("Right placement"); self.tooltip.target_x = self.btn_positions[2].x; self.tooltip.target_y = self.btn_positions[2].y; self.tooltip.target_width = self.btn_positions[2].w; @@ -69,7 +69,7 @@ const Model = struct { self.status = "Showing: right placement"; }, '4' => { - self.tooltip = zz.Tooltip.init("Left placement"); + self.tooltip = clearTooltip("Left placement"); self.tooltip.target_x = self.btn_positions[3].x; self.tooltip.target_y = self.btn_positions[3].y; self.tooltip.target_width = self.btn_positions[3].w; @@ -78,7 +78,7 @@ const Model = struct { self.status = "Showing: left placement"; }, '5' => { - self.tooltip = zz.Tooltip.titled("File Info", "Size: 1.2 MB\nModified: Today\nType: Document"); + self.tooltip = clearTitled("File Info", "Size: 1.2 MB\nModified: Today\nType: Document"); self.tooltip.target_x = self.btn_positions[4].x; self.tooltip.target_y = self.btn_positions[4].y; self.tooltip.target_width = self.btn_positions[4].w; @@ -86,7 +86,7 @@ const Model = struct { self.status = "Showing: titled tooltip"; }, '6' => { - self.tooltip = zz.Tooltip.help("Press Enter to confirm your selection"); + self.tooltip = clearHelp("Press Enter to confirm your selection"); self.tooltip.target_x = self.btn_positions[5].x; self.tooltip.target_y = self.btn_positions[5].y; self.tooltip.target_width = self.btn_positions[5].w; @@ -94,7 +94,7 @@ const Model = struct { self.status = "Showing: help-style tooltip"; }, '7' => { - self.tooltip = zz.Tooltip.shortcut("Save", "Ctrl+S"); + self.tooltip = clearShortcut("Save", "Ctrl+S"); self.tooltip.target_x = self.btn_positions[6].x; self.tooltip.target_y = self.btn_positions[6].y; self.tooltip.target_width = self.btn_positions[6].w; @@ -141,7 +141,7 @@ const Model = struct { // Render buttons in a row var btn_s = zz.Style{}; - btn_s = btn_s.fg(zz.Color.white()).bg(zz.Color.gray(5)).inline_style(true); + btn_s = btn_s.fg(zz.Color.white()).inline_style(true); var btn_parts: [7][]const u8 = undefined; for (labels, 0..) |label, i| { @@ -208,6 +208,43 @@ const Model = struct { } }; +/// Create a tooltip with clear terminal background (no bg color, no inheritance). +fn clearTooltip(text: []const u8) zz.Tooltip { + var t = zz.Tooltip.init(text); + t.content_bg = .none; + t.border_bg = .none; + t.arrow_bg = .none; + t.inherit_bg = false; + return t; +} + +fn clearTitled(title: []const u8, text: []const u8) zz.Tooltip { + var t = zz.Tooltip.titled(title, text); + t.content_bg = .none; + t.border_bg = .none; + t.arrow_bg = .none; + t.inherit_bg = false; + return t; +} + +fn clearHelp(text: []const u8) zz.Tooltip { + var t = zz.Tooltip.help(text); + t.content_bg = .none; + t.border_bg = .none; + t.arrow_bg = .none; + t.inherit_bg = false; + return t; +} + +fn clearShortcut(label: []const u8, key: []const u8) zz.Tooltip { + var t = zz.Tooltip.shortcut(label, key); + t.content_bg = .none; + t.border_bg = .none; + t.arrow_bg = .none; + t.inherit_bg = false; + return t; +} + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); diff --git a/src/components/tooltip.zig b/src/components/tooltip.zig index 01e4c9a..4a8b486 100644 --- a/src/components/tooltip.zig +++ b/src/components/tooltip.zig @@ -72,7 +72,13 @@ pub const Tooltip = struct { border_chars: border_mod.BorderChars = border_mod.Border.rounded, border_fg: Color = Color.gray(14), + /// Background for the content area (padding + text) inside the border. content_bg: Color = .none, + /// Background for border characters. Three-state via `?Color`: + /// - `null` (default) — falls back to `content_bg` (same bg everywhere). + /// - `.none` — explicitly no bg; with `inherit_bg` the base shows through. + /// - any color — use that color for borders only. + border_bg: ?Color = null, text_style: style_mod.Style = makeStyle(.{ .fg_color = Color.gray(20) }), title_style: style_mod.Style = makeStyle(.{ .bold_v = true, .fg_color = Color.white() }), @@ -80,6 +86,11 @@ pub const Tooltip = struct { show_arrow: bool = true, /// Color of the arrow character. arrow_fg: Color = Color.gray(14), + /// Background for the arrow character. Three-state via `?Color`: + /// - `null` (default) — no bg; with `inherit_bg` the base shows through. + /// - `.none` — explicitly no bg, even when `inherit_bg` is on. + /// - any color — use that color. + arrow_bg: ?Color = null, /// Custom arrow characters per direction (what's shown when the tooltip /// is placed in that direction). Set to "" to hide a specific arrow. arrow_up: []const u8 = "▲", @@ -193,9 +204,13 @@ pub const Tooltip = struct { const inner_w: usize = max_content_w + pad_h; // Inline styles + // border_bg: null → fall back to content_bg (backward compat) + // .none → explicitly no bg (transparent) + // color → use that color + const effective_border_bg = if (self.border_bg) |b| b else self.content_bg; var bdr_s = style_mod.Style{}; bdr_s = bdr_s.fg(self.border_fg).inline_style(true); - if (!self.content_bg.isNone()) bdr_s = bdr_s.bg(self.content_bg); + if (!effective_border_bg.isNone()) bdr_s = bdr_s.bg(effective_border_bg); var pad_s = style_mod.Style{}; pad_s = pad_s.inline_style(true); @@ -359,7 +374,11 @@ pub const Tooltip = struct { const aw = self.arrowDisplayWidth(); var arrow_style = CellStyle{}; arrow_style.fg = colorToCellColor(self.arrow_fg); - if (self.inherit_bg) { + if (self.arrow_bg) |abg| { + // Explicitly set → use it (even .none = explicitly no bg) + if (!abg.isNone()) arrow_style.bg = colorToCellColor(abg); + } else if (self.inherit_bg) { + // Not specified → inherit from base when enabled arrow_style.bg = grid.get(pos.arrow_y, pos.arrow_x).style.bg; } grid.set(pos.arrow_y, pos.arrow_x, .{ diff --git a/tests/tooltip_tests.zig b/tests/tooltip_tests.zig index cd3b5cb..ae098ea 100644 --- a/tests/tooltip_tests.zig +++ b/tests/tooltip_tests.zig @@ -466,3 +466,117 @@ test "inherit_bg with left/right placement" { // The blue bg should be injected into the arrow on row 3 try testing.expect(std.mem.indexOf(u8, output, "48;2;0;128;255") != null); } + +// ── border_bg / arrow_bg tests ──────────────────────────────────── + +test "border_bg null falls back to content_bg (backward compat)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Color = zz.Color; + var t = Tooltip.init("Hi"); + t.content_bg = Color.fromRgb(30, 30, 30); + // border_bg defaults to null → should use content_bg for borders + t.show(); + const box = try t.renderBox(alloc); + // The border should contain the content_bg sequence + try testing.expect(std.mem.indexOf(u8, box, "48;2;30;30;30") != null); +} + +test "border_bg .none gives transparent border" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Color = zz.Color; + var t = Tooltip.init("Hi"); + t.content_bg = Color.fromRgb(30, 30, 30); + t.border_bg = .none; // explicitly no bg for borders + t.show(); + const box = try t.renderBox(alloc); + // Content should still have the bg + try testing.expect(std.mem.indexOf(u8, box, "48;2;30;30;30") != null); + // Box should still render + try testing.expect(box.len > 0); +} + +test "border_bg explicit color differs from content_bg" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Color = zz.Color; + var t = Tooltip.init("Test"); + t.content_bg = Color.fromRgb(10, 10, 10); + t.border_bg = Color.fromRgb(50, 50, 50); + t.show(); + const box = try t.renderBox(alloc); + // Both color sequences should appear + try testing.expect(std.mem.indexOf(u8, box, "48;2;10;10;10") != null); + try testing.expect(std.mem.indexOf(u8, box, "48;2;50;50;50") != null); +} + +test "border_bg .none with inherit_bg shows base through border" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Color = zz.Color; + var t = Tooltip.init("Hi"); + t.content_bg = Color.fromRgb(30, 30, 30); // solid interior + t.border_bg = .none; // transparent border + t.inherit_bg = true; + t.target_x = 3; + t.target_y = 0; + t.placement = .bottom; + t.show(); + + const base = "\x1b[48;2;200;100;50mOrange background row here long enough\x1b[0m"; + const output = try t.overlay(alloc, base, 50, 10); + try testing.expect(output.len > 0); + // The base orange bg should appear (inherited by border cells) + try testing.expect(std.mem.indexOf(u8, output, "48;2;200;100;50") != null); + // The content bg should also appear + try testing.expect(std.mem.indexOf(u8, output, "48;2;30;30;30") != null); +} + +test "arrow_bg explicit color overrides inherit_bg" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Color = zz.Color; + var t = Tooltip.init("Tip"); + t.arrow_bg = Color.fromRgb(255, 0, 0); // explicit red arrow bg + t.inherit_bg = true; + t.target_x = 5; + t.target_y = 0; + t.placement = .bottom; + t.show(); + + const base = "\x1b[48;2;0;0;255mBlue background here long!!!\x1b[0m"; + const output = try t.overlay(alloc, base, 40, 10); + try testing.expect(output.len > 0); + // The arrow should have red bg, not blue (explicit overrides inherit) + try testing.expect(std.mem.indexOf(u8, output, "48;2;255;0;0") != null); +} + +test "arrow_bg .none prevents inheritance" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var t = Tooltip.init("Tip"); + t.arrow_bg = .none; // explicitly no bg + t.inherit_bg = true; + t.target_x = 5; + t.target_y = 0; + t.placement = .bottom; + t.show(); + + const base = "\x1b[48;2;0;255;0mGreen background here long!!\x1b[0m"; + const output = try t.overlay(alloc, base, 40, 10); + try testing.expect(output.len > 0); + // Should render without crash; arrow should NOT have green bg +} From fccb246752626e752d284eaabf1c476a1f1de420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 08:33:15 +0100 Subject: [PATCH 8/8] docs: add Modal and Tooltip sections to README --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8fd10d3..f98a14e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A delightful TUI framework for Zig, inspired by [Bubble Tea](https://github.com/ - **Elm Architecture** - Model-Update-View pattern for predictable state management - **Rich Styling** - Comprehensive styling system with colors, borders, padding, margin backgrounds, per-side border colors, tab width control, style ranges, full style inheritance, text transforms, whitespace formatting controls, and unset methods -- **16 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Notification/Toast, Confirm dialog, Help, Paginator, Timer, FilePicker +- **18 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Notification/Toast, Confirm dialog, Modal/Popup, Tooltip, Help, Paginator, Timer, FilePicker - **Focus Management** - `FocusGroup` with Tab/Shift+Tab cycling, comptime focusable protocol, `FocusStyle` for visual focus ring indicators - **Keybinding Management** - Structured `KeyBinding`/`KeyMap` with matching, display formatting, and Help component integration - **Color System** - ANSI 16, 256, and TrueColor with adaptive colors, color profile detection, and dark background detection @@ -419,6 +419,50 @@ if (confirm.result()) |yes| { } ``` +### Modal + +Dialog overlay with buttons, backdrop, and focus support: + +```zig +var modal = zz.Modal.info("Notice", "Operation completed successfully."); +modal.show(); + +// In update: +modal.handleKey(key_event); +if (modal.getResult()) |res| { + switch (res) { + .button_pressed => |idx| { /* button at idx was pressed */ }, + .dismissed => { /* user pressed Escape */ }, + } +} + +// In view: +if (modal.isVisible()) { + return modal.viewWithBackdrop(allocator, ctx.width, ctx.height); +} +``` + +Presets: `Modal.info()`, `Modal.confirm()`, `Modal.warning()`, `Modal.err()`, or `Modal.init()` for full custom. + +### Tooltip + +Contextual hint positioned near a target element with cell-based overlay compositing: + +```zig +var tip = zz.Tooltip.init("Save the current document"); +tip.target_x = 10; +tip.target_y = 5; +tip.placement = .bottom; // .top, .bottom, .left, .right +tip.show(); + +// In view — overlays onto existing content: +if (tip.isVisible()) { + return tip.overlay(allocator, base_view, ctx.width, ctx.height); +} +``` + +Presets: `Tooltip.init(text)`, `Tooltip.titled(title, text)`, `Tooltip.help(text)`, `Tooltip.shortcut(label, key)`. Supports `border_bg`, `arrow_bg`, `content_bg`, and `inherit_bg` for full background control. + ### More Components - **Help** - Display key bindings with responsive truncation