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