feat: zcatgui v0.10.0 - Phase 4 Text & Navigation Widgets

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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-09 13:27:21 +01:00
parent a75827f70b
commit 34dfcfce18
4 changed files with 1203 additions and 0 deletions

303
src/widgets/breadcrumb.zig Normal file
View file

@ -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.?);
}

445
src/widgets/numberentry.zig Normal file
View file

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

428
src/widgets/richtext.zig Normal file
View file

@ -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);
}

View file

@ -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
// =============================================================================