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>
527 lines
16 KiB
Zig
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());
|
|
}
|