From 34dfcfce1814d0b492f86cafe91086b7f7b99d17 Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:27:21 +0100 Subject: [PATCH] feat: zcatgui v0.10.0 - Phase 4 Text & Navigation Widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Widgets (3): - NumberEntry: Numeric input with spinner buttons, min/max limits, prefix/suffix, validation - RichText: Styled text display with bold, italic, underline, strikethrough, colors, clickable links, simple markdown parsing - Breadcrumb: Navigation path display with clickable segments, separators, home icon, collapse support Widget count: 30 widgets Test count: 200 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/widgets/breadcrumb.zig | 303 ++++++++++++++++++++++++ src/widgets/numberentry.zig | 445 ++++++++++++++++++++++++++++++++++++ src/widgets/richtext.zig | 428 ++++++++++++++++++++++++++++++++++ src/widgets/widgets.zig | 27 +++ 4 files changed, 1203 insertions(+) create mode 100644 src/widgets/breadcrumb.zig create mode 100644 src/widgets/numberentry.zig create mode 100644 src/widgets/richtext.zig diff --git a/src/widgets/breadcrumb.zig b/src/widgets/breadcrumb.zig new file mode 100644 index 0000000..888427d --- /dev/null +++ b/src/widgets/breadcrumb.zig @@ -0,0 +1,303 @@ +//! Breadcrumb Widget - Navigation path display +//! +//! A horizontal path display for hierarchical navigation. +//! Shows clickable path segments with separators. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Breadcrumb item +pub const Item = struct { + /// Display label + label: []const u8, + /// Optional icon (single character) + icon: ?u8 = null, + /// Associated data/path + data: ?[]const u8 = null, + /// Is this item disabled/unclickable + disabled: bool = false, +}; + +/// Breadcrumb configuration +pub const Config = struct { + /// Separator between items + separator: []const u8 = " > ", + /// Maximum visible items (0 = unlimited) + max_items: usize = 0, + /// Collapse to "..." when exceeding max + collapse_middle: bool = true, + /// Show home icon for first item + show_home_icon: bool = false, + /// Padding + padding: u32 = 4, +}; + +/// Breadcrumb colors +pub const Colors = struct { + background: ?Style.Color = null, + text: Style.Color = Style.Color.rgba(180, 180, 180, 255), + text_current: Style.Color = Style.Color.rgba(220, 220, 220, 255), + text_hover: Style.Color = Style.Color.rgba(255, 255, 255, 255), + separator: Style.Color = Style.Color.rgba(120, 120, 120, 255), + disabled: Style.Color = Style.Color.rgba(100, 100, 100, 255), + icon: Style.Color = Style.Color.rgba(150, 150, 150, 255), + + pub fn fromTheme(theme: Style.Theme) Colors { + return .{ + .text = theme.secondary, + .text_current = theme.foreground, + .text_hover = theme.primary, + .separator = theme.secondary.darken(20), + .disabled = theme.secondary.darken(30), + .icon = theme.secondary, + }; + } +}; + +/// Breadcrumb result +pub const Result = struct { + /// Index of clicked item (if any) + clicked: ?usize = null, + /// Data of clicked item + clicked_data: ?[]const u8 = null, + /// Hovered item index + hovered: ?usize = null, +}; + +/// Draw breadcrumbs +pub fn breadcrumb(ctx: *Context, items: []const Item) Result { + return breadcrumbEx(ctx, items, .{}, .{}); +} + +/// Draw breadcrumbs with configuration +pub fn breadcrumbEx( + ctx: *Context, + items: []const Item, + config: Config, + colors: Colors, +) Result { + const bounds = ctx.layout.nextRect(); + return breadcrumbRect(ctx, bounds, items, config, colors); +} + +/// Draw breadcrumbs in specific rectangle +pub fn breadcrumbRect( + ctx: *Context, + bounds: Layout.Rect, + items: []const Item, + config: Config, + colors: Colors, +) Result { + var result = Result{}; + + if (bounds.isEmpty() or items.len == 0) return result; + + const mouse = ctx.input.mousePos(); + const mouse_pressed = ctx.input.mousePressed(.left); + + // Draw background if specified + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + const char_width: u32 = 8; + const char_height: u32 = 8; + const padding = config.padding; + + var x = bounds.x + @as(i32, @intCast(padding)); + const y = bounds.y + @as(i32, @intCast((bounds.h - char_height) / 2)); + const max_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(padding)); + + // Determine which items to show + var start_idx: usize = 0; + var show_ellipsis = false; + + if (config.max_items > 0 and items.len > config.max_items) { + if (config.collapse_middle) { + // Show first, ..., last few items + show_ellipsis = true; + start_idx = items.len - config.max_items + 1; + } + } + + // Draw items + var item_idx: usize = 0; + while (item_idx < items.len) : (item_idx += 1) { + // Handle ellipsis for collapsed middle + if (show_ellipsis and item_idx == 1) { + // Draw ellipsis + ctx.pushCommand(Command.text(x, y, "...", colors.separator)); + x += 3 * @as(i32, @intCast(char_width)); + + // Draw separator + ctx.pushCommand(Command.text(x, y, config.separator, colors.separator)); + x += @as(i32, @intCast(config.separator.len * char_width)); + + // Skip to end items + item_idx = start_idx; + continue; + } + + // Skip middle items if collapsed + if (show_ellipsis and item_idx > 0 and item_idx < start_idx) { + continue; + } + + const item = items[item_idx]; + const is_last = item_idx == items.len - 1; + + // Calculate item width + var item_width: u32 = @intCast(item.label.len * char_width); + if (item.icon != null) { + item_width += char_width + 4; + } + if (config.show_home_icon and item_idx == 0) { + item_width += char_width + 4; + } + + // Check if we have room + if (x + @as(i32, @intCast(item_width)) > max_x and !is_last) { + // No room, show ellipsis and skip to last + ctx.pushCommand(Command.text(x, y, "...", colors.separator)); + item_idx = items.len - 2; + x += 3 * @as(i32, @intCast(char_width)); + continue; + } + + // Item bounds + const item_rect = Layout.Rect.init(x - 2, bounds.y, item_width + 4, bounds.h); + const is_hovered = item_rect.contains(mouse.x, mouse.y); + + if (is_hovered) { + result.hovered = item_idx; + } + + // Handle click + if (is_hovered and mouse_pressed and !item.disabled and !is_last) { + result.clicked = item_idx; + result.clicked_data = item.data; + } + + // Determine color + var text_color: Style.Color = undefined; + if (item.disabled) { + text_color = colors.disabled; + } else if (is_last) { + text_color = colors.text_current; + } else if (is_hovered) { + text_color = colors.text_hover; + } else { + text_color = colors.text; + } + + // Draw home icon + if (config.show_home_icon and item_idx == 0) { + ctx.pushCommand(Command.text(x, y, "~", colors.icon)); + x += @as(i32, @intCast(char_width)) + 4; + } + + // Draw icon + if (item.icon) |icon| { + const icon_str = &[_]u8{icon}; + ctx.pushCommand(Command.text(x, y, icon_str, colors.icon)); + x += @as(i32, @intCast(char_width)) + 4; + } + + // Draw label + ctx.pushCommand(Command.text(x, y, item.label, text_color)); + x += @as(i32, @intCast(item.label.len * char_width)); + + // Draw separator (except for last item) + if (!is_last) { + ctx.pushCommand(Command.text(x, y, config.separator, colors.separator)); + x += @as(i32, @intCast(config.separator.len * char_width)); + } + } + + return result; +} + +/// Create a path from a slash-separated string +pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) ![]Item { + var items: std.ArrayListUnmanaged(Item) = .{}; + errdefer items.deinit(allocator); + + var start: usize = 0; + var i: usize = 0; + + while (i <= path.len) : (i += 1) { + const at_sep = i < path.len and path[i] == '/'; + const at_end = i == path.len; + + if (at_sep or at_end) { + if (i > start) { + try items.append(allocator, .{ + .label = path[start..i], + .data = path[0..i], + }); + } + start = i + 1; + } + } + + return items.toOwnedSlice(allocator); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Item creation" { + const item = Item{ + .label = "Home", + .icon = '~', + .data = "/home", + }; + + try std.testing.expectEqualStrings("Home", item.label); + try std.testing.expectEqual(@as(?u8, '~'), item.icon); +} + +test "breadcrumb generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + const items = [_]Item{ + .{ .label = "Home" }, + .{ .label = "Documents" }, + .{ .label = "File.txt" }, + }; + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = breadcrumb(&ctx, &items); + + // Should have text commands for labels and separators + try std.testing.expect(ctx.commands.items.len >= 3); + + ctx.endFrame(); +} + +test "fromPath" { + const items = try fromPath(std.testing.allocator, "/home/user/docs"); + defer std.testing.allocator.free(items); + + try std.testing.expectEqual(@as(usize, 3), items.len); + try std.testing.expectEqualStrings("home", items[0].label); + try std.testing.expectEqualStrings("user", items[1].label); + try std.testing.expectEqualStrings("docs", items[2].label); +} + +test "fromPath with data" { + const items = try fromPath(std.testing.allocator, "a/b/c"); + defer std.testing.allocator.free(items); + + try std.testing.expectEqualStrings("a", items[0].data.?); + try std.testing.expectEqualStrings("a/b", items[1].data.?); + try std.testing.expectEqualStrings("a/b/c", items[2].data.?); +} diff --git a/src/widgets/numberentry.zig b/src/widgets/numberentry.zig new file mode 100644 index 0000000..2153a64 --- /dev/null +++ b/src/widgets/numberentry.zig @@ -0,0 +1,445 @@ +//! NumberEntry Widget - Numeric input with validation +//! +//! A specialized text input for numbers with spinner buttons, +//! min/max limits, and formatting options. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Number type +pub const NumberType = enum { + integer, + float, + currency, + percentage, +}; + +/// Number entry state +pub const State = struct { + /// Current numeric value + value: f64 = 0, + /// Text buffer for editing + text_buf: [64]u8 = [_]u8{0} ** 64, + /// Text length + text_len: usize = 0, + /// Currently editing text (vs value) + editing: bool = false, + /// Cursor position + cursor: usize = 0, + /// Has focus + focused: bool = false, + /// Is valid + valid: bool = true, + + const Self = @This(); + + /// Initialize with value + pub fn init(value: f64) Self { + var state = Self{ .value = value }; + state.updateText(); + return state; + } + + /// Set value programmatically + pub fn setValue(self: *Self, value: f64) void { + self.value = value; + self.updateText(); + self.valid = true; + } + + /// Get current value + pub fn getValue(self: Self) f64 { + return self.value; + } + + /// Get as integer + pub fn getInt(self: Self) i64 { + return @intFromFloat(self.value); + } + + /// Update text from value + fn updateText(self: *Self) void { + const result = std.fmt.bufPrint(&self.text_buf, "{d:.2}", .{self.value}) catch { + self.text_len = 0; + return; + }; + self.text_len = result.len; + self.cursor = self.text_len; + } + + /// Try to parse text as number + fn parseText(self: *Self) bool { + if (self.text_len == 0) { + self.value = 0; + return true; + } + + const txt = self.text_buf[0..self.text_len]; + + // Try parsing as float + self.value = std.fmt.parseFloat(f64, txt) catch { + return false; + }; + + return true; + } + + /// Get text slice + pub fn text(self: Self) []const u8 { + return self.text_buf[0..self.text_len]; + } + + /// Insert character + pub fn insert(self: *Self, char: u8) void { + if (self.text_len >= self.text_buf.len - 1) return; + + // Validate character + const valid_chars = "0123456789.-+eE"; + var is_valid = false; + for (valid_chars) |c| { + if (c == char) { + is_valid = true; + break; + } + } + if (!is_valid) return; + + // Shift text after cursor + if (self.cursor < self.text_len) { + var i = self.text_len; + while (i > self.cursor) : (i -= 1) { + self.text_buf[i] = self.text_buf[i - 1]; + } + } + + self.text_buf[self.cursor] = char; + self.cursor += 1; + self.text_len += 1; + self.editing = true; + } + + /// Delete character before cursor + pub fn deleteBack(self: *Self) void { + if (self.cursor == 0) return; + + // Shift text + var i = self.cursor - 1; + while (i < self.text_len - 1) : (i += 1) { + self.text_buf[i] = self.text_buf[i + 1]; + } + + self.cursor -= 1; + self.text_len -= 1; + self.editing = true; + } + + /// Finalize editing + pub fn finishEditing(self: *Self, config: Config) void { + self.editing = false; + self.valid = self.parseText(); + + if (self.valid) { + // Apply limits + if (config.min) |min| { + self.value = @max(self.value, min); + } + if (config.max) |max| { + self.value = @min(self.value, max); + } + + // Update text with formatted value + self.updateText(); + } + } +}; + +/// Number entry configuration +pub const Config = struct { + /// Number type + number_type: NumberType = .float, + /// Minimum value + min: ?f64 = null, + /// Maximum value + max: ?f64 = null, + /// Step for spinner buttons + step: f64 = 1.0, + /// Decimal precision + precision: u8 = 2, + /// Prefix (e.g., "$", "€") + prefix: ?[]const u8 = null, + /// Suffix (e.g., "%", "kg") + suffix: ?[]const u8 = null, + /// Show spinner buttons + spinner: bool = true, + /// Allow negative numbers + allow_negative: bool = true, + /// Thousand separator + thousand_separator: bool = false, +}; + +/// Number entry colors +pub const Colors = struct { + background: Style.Color = Style.Color.rgba(35, 35, 35, 255), + background_focused: Style.Color = Style.Color.rgba(45, 45, 45, 255), + text: Style.Color = Style.Color.rgba(220, 220, 220, 255), + text_invalid: Style.Color = Style.Color.rgba(255, 100, 100, 255), + border: Style.Color = Style.Color.rgba(80, 80, 80, 255), + border_focused: Style.Color = Style.Color.rgba(100, 149, 237, 255), + border_invalid: Style.Color = Style.Color.rgba(200, 80, 80, 255), + cursor: Style.Color = Style.Color.rgba(255, 255, 255, 255), + spinner_bg: Style.Color = Style.Color.rgba(50, 50, 50, 255), + spinner_fg: Style.Color = Style.Color.rgba(180, 180, 180, 255), + prefix_suffix: Style.Color = Style.Color.rgba(150, 150, 150, 255), + + pub fn fromTheme(theme: Style.Theme) Colors { + return .{ + .background = theme.input_bg, + .background_focused = theme.input_bg.lighten(10), + .text = theme.input_fg, + .text_invalid = theme.error_color, + .border = theme.input_border, + .border_focused = theme.primary, + .border_invalid = theme.error_color, + .cursor = theme.foreground, + .spinner_bg = theme.background.lighten(15), + .spinner_fg = theme.foreground, + .prefix_suffix = theme.secondary, + }; + } +}; + +/// Number entry result +pub const Result = struct { + /// Value changed + changed: bool = false, + /// Current value + value: f64 = 0, + /// Is valid + valid: bool = true, + /// Enter pressed + submitted: bool = false, +}; + +/// Draw a number entry +pub fn numberEntry(ctx: *Context, state: *State) Result { + return numberEntryEx(ctx, state, .{}, .{}); +} + +/// Draw a number entry with configuration +pub fn numberEntryEx( + ctx: *Context, + state: *State, + config: Config, + colors: Colors, +) Result { + const bounds = ctx.layout.nextRect(); + return numberEntryRect(ctx, bounds, state, config, colors); +} + +/// Draw a number entry in specific rectangle +pub fn numberEntryRect( + ctx: *Context, + bounds: Layout.Rect, + state: *State, + config: Config, + colors: Colors, +) Result { + var result = Result{ + .value = state.value, + .valid = state.valid, + }; + + if (bounds.isEmpty()) return result; + + const mouse = ctx.input.mousePos(); + const mouse_pressed = ctx.input.mousePressed(.left); + const hovered = bounds.contains(mouse.x, mouse.y); + + if (hovered and mouse_pressed) { + state.focused = true; + } + + // Calculate areas + const spinner_w: u32 = if (config.spinner) 20 else 0; + const prefix_w: u32 = if (config.prefix) |p| @as(u32, @intCast(p.len * 8 + 4)) else 0; + const suffix_w: u32 = if (config.suffix) |s| @as(u32, @intCast(s.len * 8 + 4)) else 0; + + const input_x = bounds.x + @as(i32, @intCast(prefix_w)); + _ = bounds.w -| prefix_w -| suffix_w -| (spinner_w * 2); // input_w available for future use + + // Colors + const bg_color = if (state.focused) colors.background_focused else colors.background; + const border_color = if (!state.valid) + colors.border_invalid + else if (state.focused) + colors.border_focused + else + colors.border; + const text_color = if (state.valid) colors.text else colors.text_invalid; + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Draw prefix + if (config.prefix) |prefix| { + const prefix_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2)); + ctx.pushCommand(Command.text(bounds.x + 4, prefix_y, prefix, colors.prefix_suffix)); + } + + // Draw text + const text_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2)); + ctx.pushCommand(Command.text(input_x + 4, text_y, state.text(), text_color)); + + // Draw cursor if focused + if (state.focused) { + const cursor_x = input_x + 4 + @as(i32, @intCast(state.cursor * 8)); + ctx.pushCommand(Command.rect(cursor_x, bounds.y + 4, 2, bounds.h - 8, colors.cursor)); + } + + // Draw suffix + if (config.suffix) |suffix| { + const suffix_x = bounds.x + @as(i32, @intCast(bounds.w - suffix_w - spinner_w * 2)); + const suffix_y = bounds.y + @as(i32, @intCast((bounds.h - 8) / 2)); + ctx.pushCommand(Command.text(suffix_x, suffix_y, suffix, colors.prefix_suffix)); + } + + // Draw spinner buttons + if (config.spinner) { + const btn_w = spinner_w; + const btn_h = bounds.h / 2; + + // Up button + const up_x = bounds.x + @as(i32, @intCast(bounds.w - btn_w)); + const up_rect = Layout.Rect.init(up_x, bounds.y, btn_w, btn_h); + ctx.pushCommand(Command.rect(up_rect.x, up_rect.y, up_rect.w, up_rect.h, colors.spinner_bg)); + ctx.pushCommand(Command.text(up_x + 6, bounds.y + 2, "+", colors.spinner_fg)); + + if (up_rect.contains(mouse.x, mouse.y) and mouse_pressed) { + state.value += config.step; + if (config.max) |max| { + state.value = @min(state.value, max); + } + state.updateText(); + result.changed = true; + result.value = state.value; + } + + // Down button + const down_y = bounds.y + @as(i32, @intCast(btn_h)); + const down_rect = Layout.Rect.init(up_x, down_y, btn_w, btn_h); + ctx.pushCommand(Command.rect(down_rect.x, down_rect.y, down_rect.w, down_rect.h, colors.spinner_bg)); + ctx.pushCommand(Command.text(up_x + 6, down_y + 2, "-", colors.spinner_fg)); + + if (down_rect.contains(mouse.x, mouse.y) and mouse_pressed) { + state.value -= config.step; + if (config.min) |min| { + state.value = @max(state.value, min); + } + if (!config.allow_negative and state.value < 0) { + state.value = 0; + } + state.updateText(); + result.changed = true; + result.value = state.value; + } + } + + // Handle text input + if (state.focused) { + const text_input = ctx.input.getTextInput(); + for (text_input) |char| { + state.insert(char); + } + + if (text_input.len > 0) { + state.valid = state.parseText(); + if (state.valid) { + result.changed = true; + result.value = state.value; + } + } + } + + result.valid = state.valid; + + return result; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "State init" { + const state = State.init(42.5); + try std.testing.expectEqual(@as(f64, 42.5), state.value); + try std.testing.expect(state.text_len > 0); +} + +test "State setValue" { + var state = State.init(0); + state.setValue(123.45); + try std.testing.expectEqual(@as(f64, 123.45), state.value); +} + +test "State insert and parse" { + var state = State.init(0); + state.text_len = 0; + state.cursor = 0; + + state.insert('1'); + state.insert('2'); + state.insert('3'); + + try std.testing.expectEqual(@as(usize, 3), state.text_len); + try std.testing.expect(state.parseText()); + try std.testing.expectEqual(@as(f64, 123), state.value); +} + +test "State with decimal" { + var state = State.init(0); + state.text_len = 0; + state.cursor = 0; + + state.insert('3'); + state.insert('.'); + state.insert('1'); + state.insert('4'); + + try std.testing.expect(state.parseText()); + try std.testing.expect(state.value > 3.13 and state.value < 3.15); +} + +test "State finishEditing with limits" { + var state = State.init(0); + state.setValue(150); + + const config = Config{ + .min = 0, + .max = 100, + }; + + state.finishEditing(config); + try std.testing.expectEqual(@as(f64, 100), state.value); +} + +test "numberEntry generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = State.init(42); + + ctx.beginFrame(); + ctx.layout.row_height = 30; + + _ = numberEntry(&ctx, &state); + + // Should have background + border + text + try std.testing.expect(ctx.commands.items.len >= 2); + + ctx.endFrame(); +} diff --git a/src/widgets/richtext.zig b/src/widgets/richtext.zig new file mode 100644 index 0000000..ec9d0bc --- /dev/null +++ b/src/widgets/richtext.zig @@ -0,0 +1,428 @@ +//! RichText Widget - Styled text display +//! +//! Display text with multiple styles (bold, italic, colors, links). +//! Supports inline formatting and clickable links. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Command = @import("../core/command.zig"); +const Layout = @import("../core/layout.zig"); +const Style = @import("../core/style.zig"); +const Input = @import("../core/input.zig"); + +/// Text style for a span +pub const TextStyle = struct { + /// Text color (null = inherit) + color: ?Style.Color = null, + /// Bold text + bold: bool = false, + /// Italic text (rendered as slant) + italic: bool = false, + /// Underline + underline: bool = false, + /// Strikethrough + strikethrough: bool = false, + /// Font size multiplier (1.0 = normal) + size: f32 = 1.0, + /// Link URL (makes text clickable) + link: ?[]const u8 = null, + /// Background color + background: ?Style.Color = null, + + const Self = @This(); + + /// Create a bold style + pub fn makeBold() Self { + return .{ .bold = true }; + } + + /// Create an italic style + pub fn makeItalic() Self { + return .{ .italic = true }; + } + + /// Create a colored style + pub fn withColor(color: Style.Color) Self { + return .{ .color = color }; + } + + /// Create a link style + pub fn makeLink(url: []const u8) Self { + return .{ + .link = url, + .color = Style.Color.rgba(100, 149, 237, 255), + .underline = true, + }; + } + + /// Merge with another style (other takes precedence) + pub fn merge(self: Self, other: Self) Self { + return .{ + .color = other.color orelse self.color, + .bold = other.bold or self.bold, + .italic = other.italic or self.italic, + .underline = other.underline or self.underline, + .strikethrough = other.strikethrough or self.strikethrough, + .size = if (other.size != 1.0) other.size else self.size, + .link = other.link orelse self.link, + .background = other.background orelse self.background, + }; + } +}; + +/// A span of styled text +pub const TextSpan = struct { + /// Text content + text: []const u8, + /// Style for this span + style: TextStyle = .{}, + + /// Create a plain text span + pub fn plain(text: []const u8) TextSpan { + return .{ .text = text }; + } + + /// Create a bold text span + pub fn bold(text: []const u8) TextSpan { + return .{ .text = text, .style = TextStyle.makeBold() }; + } + + /// Create an italic text span + pub fn italic(text: []const u8) TextSpan { + return .{ .text = text, .style = TextStyle.makeItalic() }; + } + + /// Create a colored text span + pub fn colored(text: []const u8, color: Style.Color) TextSpan { + return .{ .text = text, .style = TextStyle.withColor(color) }; + } + + /// Create a link span + pub fn link(text: []const u8, url: []const u8) TextSpan { + return .{ .text = text, .style = TextStyle.makeLink(url) }; + } +}; + +/// Rich text configuration +pub const Config = struct { + /// Default text color + default_color: Style.Color = Style.Color.rgba(220, 220, 220, 255), + /// Line height multiplier + line_height: f32 = 1.2, + /// Word wrap + word_wrap: bool = true, + /// Horizontal alignment + alignment: Alignment = .left, + /// Padding + padding: u32 = 4, +}; + +/// Text alignment +pub const Alignment = enum { + left, + center, + right, +}; + +/// Rich text colors +pub const Colors = struct { + background: ?Style.Color = null, + link: Style.Color = Style.Color.rgba(100, 149, 237, 255), + link_hover: Style.Color = Style.Color.rgba(130, 170, 255, 255), + selection: Style.Color = Style.Color.rgba(50, 100, 150, 128), + + pub fn fromTheme(theme: Style.Theme) Colors { + return .{ + .link = theme.primary, + .link_hover = theme.primary.lighten(20), + .selection = theme.selection_bg, + }; + } +}; + +/// Rich text result +pub const Result = struct { + /// A link was clicked + clicked_link: ?[]const u8 = null, + /// Mouse is hovering over a link + hovered_link: ?[]const u8 = null, + /// Total height of rendered text + height: u32 = 0, +}; + +/// Draw rich text +pub fn richText(ctx: *Context, spans: []const TextSpan) Result { + return richTextEx(ctx, spans, .{}, .{}); +} + +/// Draw rich text with configuration +pub fn richTextEx( + ctx: *Context, + spans: []const TextSpan, + config: Config, + colors: Colors, +) Result { + const bounds = ctx.layout.nextRect(); + return richTextRect(ctx, bounds, spans, config, colors); +} + +/// Draw rich text in specific rectangle +pub fn richTextRect( + ctx: *Context, + bounds: Layout.Rect, + spans: []const TextSpan, + config: Config, + colors: Colors, +) Result { + var result = Result{}; + + if (bounds.isEmpty() or spans.len == 0) return result; + + const mouse = ctx.input.mousePos(); + const mouse_pressed = ctx.input.mousePressed(.left); + + // Draw background if specified + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + const inner = bounds.shrink(config.padding); + if (inner.isEmpty()) return result; + + const char_width: u32 = 8; + const char_height: u32 = 8; + const line_height: u32 = @intFromFloat(@as(f32, @floatFromInt(char_height)) * config.line_height); + + var x = inner.x; + var y = inner.y; + const max_x = inner.x + @as(i32, @intCast(inner.w)); + + // Process each span + for (spans) |span| { + const span_color = span.style.color orelse config.default_color; + const is_link = span.style.link != null; + + // Check if this span is hovered (for links) + var span_hovered = false; + + // Process text character by character for word wrap + var word_start: usize = 0; + var i: usize = 0; + + while (i <= span.text.len) : (i += 1) { + const at_end = i == span.text.len; + const at_space = !at_end and (span.text[i] == ' ' or span.text[i] == '\n'); + const at_newline = !at_end and span.text[i] == '\n'; + + if (at_end or at_space) { + // Render word + const word = span.text[word_start..i]; + const word_width = @as(i32, @intCast(word.len * char_width)); + + // Check if word fits on current line + if (config.word_wrap and x + word_width > max_x and x > inner.x) { + // Wrap to next line + x = inner.x; + y += @as(i32, @intCast(line_height)); + } + + // Calculate word bounds for link detection + const word_bounds = Layout.Rect.init( + x, + y, + @intCast(word.len * char_width), + line_height, + ); + + // Check hover for links + if (is_link and word_bounds.contains(mouse.x, mouse.y)) { + span_hovered = true; + result.hovered_link = span.style.link; + + if (mouse_pressed) { + result.clicked_link = span.style.link; + } + } + + // Draw background if specified + if (span.style.background) |bg| { + ctx.pushCommand(Command.rect(x, y, word_bounds.w, line_height, bg)); + } + + // Determine text color + var text_color = span_color; + if (is_link and span_hovered) { + text_color = colors.link_hover; + } + + // Draw text + if (word.len > 0) { + ctx.pushCommand(Command.text(x, y, word, text_color)); + } + + // Draw underline + if (span.style.underline) { + const underline_y = y + @as(i32, @intCast(char_height)); + ctx.pushCommand(Command.rect( + x, + underline_y, + word_bounds.w, + 1, + text_color, + )); + } + + // Draw strikethrough + if (span.style.strikethrough) { + const strike_y = y + @as(i32, @intCast(char_height / 2)); + ctx.pushCommand(Command.rect( + x, + strike_y, + word_bounds.w, + 1, + text_color, + )); + } + + x += word_width; + + // Handle newline + if (at_newline) { + x = inner.x; + y += @as(i32, @intCast(line_height)); + } else if (at_space and !at_end) { + // Add space + x += @as(i32, @intCast(char_width)); + } + + word_start = i + 1; + } + } + } + + // Calculate total height + result.height = @intCast(y - inner.y + @as(i32, @intCast(line_height))); + + return result; +} + +/// Parse simple markdown-like text into spans +pub fn parseSimple(allocator: std.mem.Allocator, text: []const u8) ![]TextSpan { + var spans: std.ArrayListUnmanaged(TextSpan) = .{}; + errdefer spans.deinit(allocator); + + var i: usize = 0; + var span_start: usize = 0; + var current_style = TextStyle{}; + + while (i < text.len) { + // Check for **bold** + if (i + 1 < text.len and text[i] == '*' and text[i + 1] == '*') { + // Emit current span + if (i > span_start) { + try spans.append(allocator, .{ .text = text[span_start..i], .style = current_style }); + } + + // Toggle bold + current_style.bold = !current_style.bold; + i += 2; + span_start = i; + continue; + } + + // Check for *italic* + if (text[i] == '*' and (i + 1 >= text.len or text[i + 1] != '*')) { + // Emit current span + if (i > span_start) { + try spans.append(allocator, .{ .text = text[span_start..i], .style = current_style }); + } + + // Toggle italic + current_style.italic = !current_style.italic; + i += 1; + span_start = i; + continue; + } + + // Check for __underline__ + if (i + 1 < text.len and text[i] == '_' and text[i + 1] == '_') { + // Emit current span + if (i > span_start) { + try spans.append(allocator, .{ .text = text[span_start..i], .style = current_style }); + } + + // Toggle underline + current_style.underline = !current_style.underline; + i += 2; + span_start = i; + continue; + } + + i += 1; + } + + // Emit remaining text + if (i > span_start) { + try spans.append(allocator, .{ .text = text[span_start..i], .style = current_style }); + } + + return spans.toOwnedSlice(allocator); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "TextStyle merge" { + const base = TextStyle{ .color = Style.Color.rgba(255, 0, 0, 255) }; + const overlay = TextStyle{ .bold = true }; + const merged = base.merge(overlay); + + try std.testing.expect(merged.bold); + try std.testing.expect(merged.color != null); +} + +test "TextSpan constructors" { + const plain_span = TextSpan.plain("hello"); + try std.testing.expectEqualStrings("hello", plain_span.text); + + const bold_span = TextSpan.bold("world"); + try std.testing.expect(bold_span.style.bold); + + const link_span = TextSpan.link("click", "http://example.com"); + try std.testing.expect(link_span.style.link != null); + try std.testing.expect(link_span.style.underline); +} + +test "richText generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + const spans = [_]TextSpan{ + TextSpan.plain("Hello "), + TextSpan.bold("World"), + }; + + ctx.beginFrame(); + ctx.layout.row_height = 50; + + _ = richText(&ctx, &spans); + + try std.testing.expect(ctx.commands.items.len >= 2); + + ctx.endFrame(); +} + +test "parseSimple basic" { + const text = "Hello **bold** world"; + const spans = try parseSimple(std.testing.allocator, text); + defer std.testing.allocator.free(spans); + + try std.testing.expectEqual(@as(usize, 3), spans.len); + try std.testing.expectEqualStrings("Hello ", spans[0].text); + try std.testing.expect(!spans[0].style.bold); + try std.testing.expectEqualStrings("bold", spans[1].text); + try std.testing.expect(spans[1].style.bold); + try std.testing.expectEqualStrings(" world", spans[2].text); + try std.testing.expect(!spans[2].style.bold); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 338fac9..0617a2f 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -35,6 +35,9 @@ pub const img = @import("image.zig"); pub const reorderable = @import("reorderable.zig"); pub const colorpicker = @import("colorpicker.zig"); pub const datepicker = @import("datepicker.zig"); +pub const numberentry = @import("numberentry.zig"); +pub const richtext = @import("richtext.zig"); +pub const breadcrumb = @import("breadcrumb.zig"); // ============================================================================= // Re-exports for convenience @@ -261,6 +264,30 @@ pub const DatePickerConfig = datepicker.Config; pub const DatePickerColors = datepicker.Colors; pub const DatePickerResult = datepicker.Result; +// NumberEntry +pub const NumberEntry = numberentry; +pub const NumberEntryState = numberentry.State; +pub const NumberType = numberentry.NumberType; +pub const NumberEntryConfig = numberentry.Config; +pub const NumberEntryColors = numberentry.Colors; +pub const NumberEntryResult = numberentry.Result; + +// RichText +pub const RichText = richtext; +pub const TextStyle = richtext.TextStyle; +pub const TextSpan = richtext.TextSpan; +pub const RichTextConfig = richtext.Config; +pub const RichTextColors = richtext.Colors; +pub const RichTextResult = richtext.Result; +pub const TextAlignment = richtext.Alignment; + +// Breadcrumb +pub const Breadcrumb = breadcrumb; +pub const BreadcrumbItem = breadcrumb.Item; +pub const BreadcrumbConfig = breadcrumb.Config; +pub const BreadcrumbColors = breadcrumb.Colors; +pub const BreadcrumbResult = breadcrumb.Result; + // ============================================================================= // Tests // =============================================================================