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>
569 lines
18 KiB
Zig
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);
|
|
}
|