//! 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); }