zcatui/src/widgets/select.zig
reugenio 79c0bb1a58 feat: Add form widgets, status bar, toast system, and documentation
Form widgets:
- Checkbox and CheckboxGroup for boolean inputs
- RadioGroup for single-selection options
- Select dropdown with keyboard navigation
- Slider and RangeSlider for numeric inputs
- TextArea for multi-line text input

UI utilities:
- StatusBar for bottom-of-screen information
- Toast and ToastManager for notifications

Examples:
- form_demo.zig: Interactive form widgets showcase
- panel_demo.zig: Docking panel system demo

Documentation:
- Complete README.md with Quick Start, widget examples, and API reference

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

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

527 lines
16 KiB
Zig

//! Select/Dropdown widget for zcatui.
//!
//! Provides dropdown selection from a list of options.
//!
//! ## Example
//!
//! ```zig
//! var select = Select.init(&.{"Option 1", "Option 2", "Option 3"})
//! .setPlaceholder("Choose an option...")
//! .setSelected(0);
//!
//! // Toggle dropdown
//! if (event.key.code == .enter) {
//! select.toggle();
//! }
//!
//! select.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
// ============================================================================
// Select
// ============================================================================
/// A dropdown select widget.
pub const Select = struct {
/// Available options.
options: []const []const u8,
/// Currently selected index (null = none).
selected: ?usize = null,
/// Highlighted index when open.
highlighted: usize = 0,
/// Whether dropdown is open.
open: bool = false,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Placeholder text when nothing selected.
placeholder: []const u8 = "Select...",
/// Maximum visible items when open.
max_visible: u16 = 8,
/// Scroll offset when list is long.
scroll_offset: usize = 0,
/// Base style.
style: Style = Style.default,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Selected option style.
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Disabled style.
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Dropdown arrow.
arrow_down: []const u8 = "",
arrow_up: []const u8 = "",
/// Creates a new select.
pub fn init(options: []const []const u8) Select {
return .{
.options = options,
};
}
/// Sets placeholder text.
pub fn setPlaceholder(self: Select, placeholder: []const u8) Select {
var s = self;
s.placeholder = placeholder;
return s;
}
/// Sets selected index.
pub fn setSelected(self: Select, index: ?usize) Select {
var s = self;
if (index) |i| {
if (i < self.options.len) {
s.selected = i;
}
} else {
s.selected = null;
}
return s;
}
/// Sets focused state.
pub fn setFocused(self: Select, focused: bool) Select {
var s = self;
s.focused = focused;
return s;
}
/// Sets max visible items.
pub fn setMaxVisible(self: Select, max: u16) Select {
var s = self;
s.max_visible = max;
return s;
}
/// Opens the dropdown.
pub fn openDropdown(self: *Select) void {
if (!self.disabled and self.options.len > 0) {
self.open = true;
self.highlighted = self.selected orelse 0;
self.adjustScroll();
}
}
/// Closes the dropdown.
pub fn close(self: *Select) void {
self.open = false;
}
/// Toggles the dropdown.
pub fn toggle(self: *Select) void {
if (self.open) {
self.close();
} else {
self.openDropdown();
}
}
/// Moves highlight up.
pub fn highlightPrev(self: *Select) void {
if (!self.open or self.options.len == 0) return;
if (self.highlighted > 0) {
self.highlighted -= 1;
} else {
self.highlighted = self.options.len - 1;
}
self.adjustScroll();
}
/// Moves highlight down.
pub fn highlightNext(self: *Select) void {
if (!self.open or self.options.len == 0) return;
self.highlighted = (self.highlighted + 1) % self.options.len;
self.adjustScroll();
}
/// Confirms current highlight as selection.
pub fn confirm(self: *Select) void {
if (self.open and self.highlighted < self.options.len) {
self.selected = self.highlighted;
self.close();
}
}
/// Gets selected option text.
pub fn getSelectedText(self: Select) ?[]const u8 {
if (self.selected) |i| {
if (i < self.options.len) {
return self.options[i];
}
}
return null;
}
/// Adjusts scroll to keep highlighted visible.
fn adjustScroll(self: *Select) void {
if (self.highlighted < self.scroll_offset) {
self.scroll_offset = self.highlighted;
} else if (self.highlighted >= self.scroll_offset + self.max_visible) {
self.scroll_offset = self.highlighted - self.max_visible + 1;
}
}
/// Gets the dropdown area.
pub fn getDropdownArea(self: Select, trigger_area: Rect) Rect {
const visible_count: u16 = @intCast(@min(self.options.len, self.max_visible));
return Rect.init(
trigger_area.x,
trigger_area.y + 1,
trigger_area.width,
visible_count + 2, // +2 for border
);
}
/// Renders the select widget.
pub fn render(self: *Select, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
// Render trigger
self.renderTrigger(area, buf);
// Render dropdown if open
if (self.open) {
self.renderDropdown(area, buf);
}
}
fn renderTrigger(self: *Select, area: Rect, buf: *Buffer) void {
// Determine style
var trigger_style = self.style;
if (self.disabled) {
trigger_style = self.disabled_style;
} else if (self.focused) {
trigger_style = self.focused_style;
}
// Border
const block = Block.init()
.setBorders(Borders.all)
.style(trigger_style);
block.render(Rect.init(area.x, area.y, area.width, 3), buf);
// Text
const inner = block.inner(Rect.init(area.x, area.y, area.width, 3));
const text = self.getSelectedText() orelse self.placeholder;
const text_style = if (self.selected == null and !self.disabled)
Style.default.fg(Color.indexed(8))
else
trigger_style;
const max_text = inner.width -| 2; // Space for arrow
const text_len = @min(text.len, max_text);
_ = buf.setString(inner.x, inner.y, text[0..text_len], text_style);
// Arrow
const arrow = if (self.open) self.arrow_up else self.arrow_down;
_ = buf.setString(inner.x + inner.width - 1, inner.y, arrow, trigger_style);
}
fn renderDropdown(self: *Select, trigger_area: Rect, buf: *Buffer) void {
const dropdown_area = self.getDropdownArea(trigger_area);
// Background
const block = Block.init()
.setBorders(Borders.all)
.style(self.style);
block.render(dropdown_area, buf);
// Fill background
const inner = block.inner(dropdown_area);
var y = inner.y;
while (y < inner.y + inner.height) : (y += 1) {
var x = inner.x;
while (x < inner.x + inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setChar(' ');
cell.setStyle(self.style);
}
}
}
// Render visible options
const visible_count = @min(self.options.len - self.scroll_offset, self.max_visible);
y = inner.y;
for (0..visible_count) |i| {
const option_idx = self.scroll_offset + i;
if (option_idx >= self.options.len) break;
const option = self.options[option_idx];
const is_highlighted = option_idx == self.highlighted;
const is_selected = self.selected != null and option_idx == self.selected.?;
var option_style = self.style;
if (is_highlighted) {
option_style = self.selected_style;
// Fill line background
var x = inner.x;
while (x < inner.x + inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setStyle(option_style);
}
}
}
// Selected indicator
const prefix: []const u8 = if (is_selected) "" else " ";
const x = buf.setString(inner.x, y, prefix, option_style);
// Option text
const max_text = inner.width -| 2;
const text_len = @min(option.len, max_text);
_ = buf.setString(x, y, option[0..text_len], option_style);
y += 1;
}
// Scroll indicators
if (self.scroll_offset > 0) {
_ = buf.setString(inner.x + inner.width - 1, inner.y, "", self.style);
}
if (self.scroll_offset + visible_count < self.options.len) {
_ = buf.setString(inner.x + inner.width - 1, inner.y + inner.height - 1, "", self.style);
}
}
};
// ============================================================================
// MultiSelect
// ============================================================================
/// A multi-select dropdown widget.
pub const MultiSelect = struct {
/// Available options.
options: []const []const u8,
/// Selected indices (bitmask for up to 64 options).
selected: u64 = 0,
/// Highlighted index when open.
highlighted: usize = 0,
/// Whether dropdown is open.
open: bool = false,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Placeholder text.
placeholder: []const u8 = "Select...",
/// Max visible items.
max_visible: u16 = 8,
/// Scroll offset.
scroll_offset: usize = 0,
/// Styles.
style: Style = Style.default,
focused_style: Style = Style.default.fg(Color.cyan),
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Creates a new multi-select.
pub fn init(options: []const []const u8) MultiSelect {
return .{
.options = options,
};
}
/// Checks if option is selected.
pub fn isSelected(self: MultiSelect, index: usize) bool {
if (index >= 64) return false;
return (self.selected & (@as(u64, 1) << @intCast(index))) != 0;
}
/// Toggles option selection.
pub fn toggleOption(self: *MultiSelect, index: usize) void {
if (index >= 64 or self.disabled) return;
const mask = @as(u64, 1) << @intCast(index);
self.selected ^= mask;
}
/// Toggles highlighted option.
pub fn toggleHighlighted(self: *MultiSelect) void {
self.toggleOption(self.highlighted);
}
/// Opens dropdown.
pub fn openDropdown(self: *MultiSelect) void {
if (!self.disabled and self.options.len > 0) {
self.open = true;
}
}
/// Closes dropdown.
pub fn close(self: *MultiSelect) void {
self.open = false;
}
/// Toggles dropdown.
pub fn toggle(self: *MultiSelect) void {
if (self.open) {
self.close();
} else {
self.openDropdown();
}
}
/// Moves highlight up.
pub fn highlightPrev(self: *MultiSelect) void {
if (!self.open or self.options.len == 0) return;
if (self.highlighted > 0) {
self.highlighted -= 1;
} else {
self.highlighted = self.options.len - 1;
}
}
/// Moves highlight down.
pub fn highlightNext(self: *MultiSelect) void {
if (!self.open or self.options.len == 0) return;
self.highlighted = (self.highlighted + 1) % self.options.len;
}
/// Gets count of selected options.
pub fn getSelectedCount(self: MultiSelect) usize {
return @popCount(self.selected);
}
/// Renders the multi-select.
pub fn render(self: *MultiSelect, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
// Render trigger
var trigger_style = self.style;
if (self.focused) trigger_style = self.focused_style;
const block = Block.init()
.setBorders(Borders.all)
.style(trigger_style);
block.render(Rect.init(area.x, area.y, area.width, 3), buf);
const inner = block.inner(Rect.init(area.x, area.y, area.width, 3));
// Display text
const count = self.getSelectedCount();
var text_buf: [64]u8 = undefined;
const text = if (count == 0)
self.placeholder
else blk: {
const result = std.fmt.bufPrint(&text_buf, "{d} selected", .{count}) catch self.placeholder;
break :blk result;
};
_ = buf.setString(inner.x, inner.y, text, trigger_style);
_ = buf.setString(inner.x + inner.width - 1, inner.y, if (self.open) "" else "", trigger_style);
// Dropdown
if (self.open) {
const dropdown_y = area.y + 3;
const visible: u16 = @intCast(@min(self.options.len, self.max_visible));
const dropdown_area = Rect.init(area.x, dropdown_y, area.width, visible + 2);
const dd_block = Block.init().setBorders(Borders.all).style(self.style);
dd_block.render(dropdown_area, buf);
const dd_inner = dd_block.inner(dropdown_area);
var y = dd_inner.y;
for (self.options, 0..) |option, i| {
if (y >= dd_inner.y + dd_inner.height) break;
const is_sel = self.isSelected(i);
const is_hl = i == self.highlighted;
var opt_style = self.style;
if (is_hl) {
opt_style = self.selected_style;
var x = dd_inner.x;
while (x < dd_inner.x + dd_inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setStyle(opt_style);
}
}
}
const check: []const u8 = if (is_sel) "[x] " else "[ ] ";
const x = buf.setString(dd_inner.x, y, check, opt_style);
const max_text = dd_inner.width -| 4;
const text_len = @min(option.len, max_text);
_ = buf.setString(x, y, option[0..text_len], opt_style);
y += 1;
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "Select basic" {
var sel = Select.init(&.{ "A", "B", "C" });
try std.testing.expectEqual(@as(?usize, null), sel.selected);
try std.testing.expect(!sel.open);
sel.openDropdown();
try std.testing.expect(sel.open);
sel.highlightNext();
sel.confirm();
try std.testing.expectEqual(@as(?usize, 1), sel.selected);
try std.testing.expect(!sel.open);
}
test "Select getSelectedText" {
var sel = Select.init(&.{ "Apple", "Banana", "Cherry" }).setSelected(1);
try std.testing.expectEqualStrings("Banana", sel.getSelectedText().?);
}
test "MultiSelect" {
var ms = MultiSelect.init(&.{ "A", "B", "C", "D" });
try std.testing.expectEqual(@as(usize, 0), ms.getSelectedCount());
ms.toggleOption(0);
ms.toggleOption(2);
try std.testing.expect(ms.isSelected(0));
try std.testing.expect(!ms.isSelected(1));
try std.testing.expect(ms.isSelected(2));
try std.testing.expectEqual(@as(usize, 2), ms.getSelectedCount());
}