zcatui/src/widgets/list.zig
reugenio 560ed1b355 zcatui v1.0 - Implementacion completa de todos los widgets ratatui
Widgets implementados (13):
- Block, Paragraph, List, Table
- Gauge, LineGauge, Tabs, Sparkline
- Scrollbar, BarChart, Canvas, Chart
- Calendar (Monthly), Clear

Modulos:
- src/text.zig: Span, Line, Text, Alignment
- src/symbols/: line, border, block, bar, braille, half_block, scrollbar, marker
- src/widgets/: todos los widgets con tests

Documentacion:
- docs/ARCHITECTURE.md: arquitectura tecnica
- docs/WIDGETS.md: guia completa de widgets
- docs/API.md: referencia rapida
- CLAUDE.md: actualizado con estado v1.0

Tests: 103+ tests en widgets, todos pasan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 12:18:41 +01:00

569 lines
18 KiB
Zig

//! The List widget displays a list of items and allows selecting one.
//!
//! A List is a collection of `ListItem`s that can be rendered with optional
//! selection highlighting. It supports scrolling, different directions,
//! and customizable highlight symbols.
const std = @import("std");
const Allocator = std.mem.Allocator;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const text_mod = @import("../text.zig");
const Line = text_mod.Line;
const Text = text_mod.Text;
const Span = text_mod.Span;
const Alignment = text_mod.Alignment;
const Block = @import("block.zig").Block;
// ============================================================================
// ListState
// ============================================================================
/// State of the List widget.
///
/// This state is used to track the selected item and scroll offset.
/// When the list is rendered with state, the selected item will be
/// highlighted and the list will scroll to keep it visible.
pub const ListState = struct {
/// Index of the first visible item.
offset: usize = 0,
/// Index of the selected item (if any).
selected: ?usize = null,
pub const default: ListState = .{};
/// Creates a new ListState with the given offset.
pub fn withOffset(self: ListState, offset: usize) ListState {
var state = self;
state.offset = offset;
return state;
}
/// Creates a new ListState with the given selected index.
pub fn withSelected(self: ListState, sel: ?usize) ListState {
var state = self;
state.selected = sel;
return state;
}
/// Returns the current offset.
pub fn getOffset(self: ListState) usize {
return self.offset;
}
/// Returns a mutable pointer to the offset.
pub fn offsetMut(self: *ListState) *usize {
return &self.offset;
}
/// Returns the selected index.
pub fn getSelected(self: ListState) ?usize {
return self.selected;
}
/// Returns a mutable pointer to the selected index.
pub fn selectedMut(self: *ListState) *?usize {
return &self.selected;
}
/// Sets the selected index.
/// Setting to null also resets the offset to 0.
pub fn select(self: *ListState, index: ?usize) void {
self.selected = index;
if (index == null) {
self.offset = 0;
}
}
/// Selects the next item (or the first if none selected).
pub fn selectNext(self: *ListState) void {
const next = if (self.selected) |s| s +| 1 else 0;
self.select(next);
}
/// Selects the previous item (or the last if none selected).
pub fn selectPrevious(self: *ListState) void {
const prev = if (self.selected) |s| s -| 1 else std.math.maxInt(usize);
self.select(prev);
}
/// Selects the first item.
pub fn selectFirst(self: *ListState) void {
self.select(0);
}
/// Selects the last item.
pub fn selectLast(self: *ListState) void {
self.select(std.math.maxInt(usize));
}
/// Scrolls down by the given amount.
pub fn scrollDownBy(self: *ListState, amount: u16) void {
const current = self.selected orelse 0;
self.select(current +| @as(usize, amount));
}
/// Scrolls up by the given amount.
pub fn scrollUpBy(self: *ListState, amount: u16) void {
const current = self.selected orelse 0;
self.select(current -| @as(usize, amount));
}
};
// ============================================================================
// ListItem
// ============================================================================
/// A single item in a List.
///
/// The item's height is defined by the number of lines it contains.
pub const ListItem = struct {
/// The text content of this item.
content: []const Line,
/// Style applied to the entire item.
style: Style = Style.default,
/// Creates a new ListItem from a slice of Lines.
pub fn fromLines(lines: []const Line) ListItem {
return .{ .content = lines };
}
/// Creates a new ListItem from a single line.
pub fn fromLine(line: Line) ListItem {
return .{ .content = &.{line} };
}
/// Creates a new ListItem from raw text.
pub fn raw(text: []const u8) ListItem {
return .{
.content = &.{Line.raw(text)},
};
}
/// Sets the style of this item.
pub fn setStyle(self: ListItem, s: Style) ListItem {
var item = self;
item.style = s;
return item;
}
/// Returns the height (number of lines).
pub fn height(self: ListItem) usize {
return self.content.len;
}
/// Returns the width (max width of all lines).
pub fn width(self: ListItem) usize {
var max_width: usize = 0;
for (self.content) |line| {
const w = line.width();
if (w > max_width) max_width = w;
}
return max_width;
}
/// Convenience: set foreground color.
pub fn fg(self: ListItem, color: Color) ListItem {
var item = self;
item.style = item.style.fg(color);
return item;
}
/// Convenience: set background color.
pub fn bg(self: ListItem, color: Color) ListItem {
var item = self;
item.style = item.style.bg(color);
return item;
}
/// Convenience: set bold.
pub fn bold(self: ListItem) ListItem {
var item = self;
item.style = item.style.bold();
return item;
}
/// Convenience: set italic.
pub fn italic(self: ListItem) ListItem {
var item = self;
item.style = item.style.italic();
return item;
}
};
// ============================================================================
// HighlightSpacing
// ============================================================================
/// Defines when to allocate space for the highlight symbol.
pub const HighlightSpacing = enum {
/// Always allocate space for the highlight symbol.
always,
/// Only allocate space when an item is selected.
when_selected,
/// Never allocate space for the highlight symbol.
never,
pub const default: HighlightSpacing = .when_selected;
};
// ============================================================================
// ListDirection
// ============================================================================
/// Defines the direction in which the list will be rendered.
pub const ListDirection = enum {
/// First item at the top, going down.
top_to_bottom,
/// First item at the bottom, going up.
bottom_to_top,
pub const default: ListDirection = .top_to_bottom;
};
// ============================================================================
// List
// ============================================================================
/// A widget to display several items among which one can be selected.
///
/// A list is a collection of `ListItem`s. It supports:
/// - Selection highlighting with customizable style and symbol
/// - Scrolling (with scroll padding)
/// - Top-to-bottom or bottom-to-top rendering
/// - Optional block wrapper
pub const List = struct {
/// Items in the list.
items: []const ListItem,
/// Optional block to wrap the list.
block: ?Block = null,
/// Base style for the widget.
style: Style = Style.default,
/// Direction of rendering.
direction: ListDirection = .top_to_bottom,
/// Style for the selected item.
highlight_style: Style = Style.default,
/// Symbol shown before the selected item.
highlight_symbol: ?[]const u8 = null,
/// Whether to repeat highlight symbol for multi-line items.
repeat_highlight_symbol: bool = false,
/// When to allocate space for highlight symbol.
highlight_spacing: HighlightSpacing = .when_selected,
/// Padding around selected item during scroll.
scroll_padding: usize = 0,
/// Creates a new List with the given items.
pub fn init(items: []const ListItem) List {
return .{ .items = items };
}
/// Wraps the list in a Block.
pub fn setBlock(self: List, b: Block) List {
var list = self;
list.block = b;
return list;
}
/// Sets the base style.
pub fn setStyle(self: List, s: Style) List {
var list = self;
list.style = s;
return list;
}
/// Sets the highlight symbol.
pub fn highlightSymbol(self: List, symbol: []const u8) List {
var list = self;
list.highlight_symbol = symbol;
return list;
}
/// Sets the highlight style.
pub fn highlightStyle(self: List, s: Style) List {
var list = self;
list.highlight_style = s;
return list;
}
/// Sets whether to repeat the highlight symbol.
pub fn repeatHighlightSymbol(self: List, repeat: bool) List {
var list = self;
list.repeat_highlight_symbol = repeat;
return list;
}
/// Sets the highlight spacing mode.
pub fn setHighlightSpacing(self: List, spacing: HighlightSpacing) List {
var list = self;
list.highlight_spacing = spacing;
return list;
}
/// Sets the list direction.
pub fn setDirection(self: List, dir: ListDirection) List {
var list = self;
list.direction = dir;
return list;
}
/// Sets the scroll padding.
pub fn setScrollPadding(self: List, padding: usize) List {
var list = self;
list.scroll_padding = padding;
return list;
}
/// Returns the number of items.
pub fn len(self: List) usize {
return self.items.len;
}
/// Returns true if the list is empty.
pub fn isEmpty(self: List) bool {
return self.items.len == 0;
}
/// Convenience style setters.
pub fn fg(self: List, color: Color) List {
var list = self;
list.style = list.style.fg(color);
return list;
}
pub fn bg(self: List, color: Color) List {
var list = self;
list.style = list.style.bg(color);
return list;
}
/// Renders the list to a buffer (stateless).
pub fn render(self: List, area: Rect, buf: *Buffer) void {
var state = ListState.default;
self.renderStateful(area, buf, &state);
}
/// Renders the list to a buffer with state.
pub fn renderStateful(self: List, area: Rect, buf: *Buffer, state: *ListState) void {
if (area.isEmpty()) return;
// Apply base style
buf.setStyle(area, self.style);
// Render block if present
const list_area = if (self.block) |b| blk: {
b.render(area, buf);
break :blk b.inner(area);
} else area;
if (list_area.isEmpty() or self.items.len == 0) return;
// Calculate highlight symbol width
const highlight_symbol_width: u16 = if (self.highlight_symbol) |sym|
@intCast(text_mod.unicodeWidth(sym))
else
0;
// Determine if we should show highlight spacing
const show_highlight_spacing = switch (self.highlight_spacing) {
.always => true,
.when_selected => state.selected != null,
.never => false,
};
const prefix_width: u16 = if (show_highlight_spacing) highlight_symbol_width else 0;
const content_width = list_area.width -| prefix_width;
if (content_width == 0) return;
// Clamp selected index to valid range
if (state.selected) |sel| {
if (sel >= self.items.len) {
state.selected = self.items.len -| 1;
}
}
// Calculate which items to render
const visible_height = list_area.height;
// Update offset based on selection and scroll padding
if (state.selected) |selected| {
// Ensure selected item is visible with padding
const padding = @min(self.scroll_padding, @as(usize, visible_height / 2));
// Calculate total height of items before selected
var height_before: usize = 0;
for (self.items[0..selected]) |item| {
height_before += item.height();
}
// Calculate height of selected item
const selected_height = self.items[selected].height();
// Scroll up if needed
if (height_before < state.offset + padding) {
state.offset = height_before -| padding;
}
// Scroll down if needed
const height_after = height_before + selected_height;
if (height_after > state.offset + visible_height - padding) {
state.offset = height_after -| (visible_height - padding);
}
}
// Render items
var y: u16 = 0;
var current_height: usize = 0;
var item_index: usize = 0;
// Skip items before offset
while (item_index < self.items.len and current_height + self.items[item_index].height() <= state.offset) {
current_height += self.items[item_index].height();
item_index += 1;
}
// Render visible items
while (item_index < self.items.len and y < visible_height) {
const item = self.items[item_index];
const is_selected = if (state.selected) |sel| sel == item_index else false;
// Calculate how many lines to skip (partial visibility)
const skip_lines: usize = if (current_height < state.offset)
state.offset - current_height
else
0;
// Render each line of the item
for (item.content[skip_lines..], 0..) |line, line_idx| {
if (y >= visible_height) break;
const line_y = if (self.direction == .top_to_bottom)
list_area.y + y
else
list_area.y + (visible_height - 1) - y;
// Determine styles
var line_style = self.style.patch(item.style);
if (is_selected) {
line_style = line_style.patch(self.highlight_style);
}
// Apply style to the entire line area
const line_area = Rect.init(list_area.x, line_y, list_area.width, 1);
buf.setStyle(line_area, line_style);
// Render highlight symbol
if (show_highlight_spacing) {
const show_symbol = is_selected and (line_idx == 0 or self.repeat_highlight_symbol);
if (show_symbol) {
if (self.highlight_symbol) |sym| {
_ = buf.setString(list_area.x, line_y, sym, line_style);
}
}
}
// Render content
const content_area = Rect.init(
list_area.x + prefix_width,
line_y,
content_width,
1,
);
line.renderWithAlignment(content_area, buf, null);
y += 1;
}
current_height += item.height();
item_index += 1;
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "ListState default" {
const state = ListState.default;
try std.testing.expectEqual(@as(usize, 0), state.offset);
try std.testing.expectEqual(@as(?usize, null), state.selected);
}
test "ListState navigation" {
var state = ListState.default;
state.selectFirst();
try std.testing.expectEqual(@as(?usize, 0), state.selected);
state.selectNext();
try std.testing.expectEqual(@as(?usize, 1), state.selected);
state.selectPrevious();
try std.testing.expectEqual(@as(?usize, 0), state.selected);
state.selectPrevious();
try std.testing.expectEqual(@as(?usize, 0), state.selected); // Can't go below 0
}
test "ListState select" {
var state = ListState.default;
state.select(5);
try std.testing.expectEqual(@as(?usize, 5), state.selected);
state.select(null);
try std.testing.expectEqual(@as(?usize, null), state.selected);
try std.testing.expectEqual(@as(usize, 0), state.offset); // Reset on null
}
test "ListItem creation" {
const item = ListItem.raw("Test item");
try std.testing.expectEqual(@as(usize, 1), item.height());
}
test "ListItem style" {
const item = ListItem.raw("Test").fg(Color.red).bold();
try std.testing.expectEqual(Color.red, item.style.foreground.?);
try std.testing.expect(item.style.add_modifiers.bold);
}
test "List creation" {
const items = [_]ListItem{
ListItem.raw("Item 1"),
ListItem.raw("Item 2"),
ListItem.raw("Item 3"),
};
const list = List.init(&items);
try std.testing.expectEqual(@as(usize, 3), list.len());
try std.testing.expect(!list.isEmpty());
}
test "List empty" {
const items = [_]ListItem{};
const list = List.init(&items);
try std.testing.expect(list.isEmpty());
}
test "List styling" {
const items = [_]ListItem{};
const list = List.init(&items)
.highlightSymbol(">> ")
.highlightStyle(Style.default.fg(Color.yellow))
.setDirection(.bottom_to_top);
try std.testing.expectEqualStrings(">> ", list.highlight_symbol.?);
try std.testing.expectEqual(ListDirection.bottom_to_top, list.direction);
}
test "HighlightSpacing default" {
try std.testing.expectEqual(HighlightSpacing.when_selected, HighlightSpacing.default);
}
test "ListDirection default" {
try std.testing.expectEqual(ListDirection.top_to_bottom, ListDirection.default);
}