multiselect
Choose several items from a list, up to a limit
termancy tui add multiselect
Also copies what it requires: list
Choose any number of items from a list, up to an optional limit.
var pick: MultiSelect = try .init(gpa, .{ .items = &.{ "a", "b", "c" } });
defer pick.deinit();
pick.limit = 2;
// ... run ...
for (pick.list.items, 0..) |item, i| if (pick.isChosen(i)) use(item);
Keys: everything List moves with; space (or x) toggles the highlighted item; a
chooses every item (as many as the limit allows), or clears them all when that is
already done; enter submits, choosing the highlighted item when nothing else is; esc
cancels. With a limit, items past it cannot be chosen until another is cleared.
multiselect.zig
//! Choose any number of items from a list, up to an optional limit.
//!
//! ```zig
//! var pick: MultiSelect = try .init(gpa, .{ .items = &.{ "a", "b", "c" } });
//! defer pick.deinit();
//! pick.limit = 2;
//! // ... run ...
//! for (pick.list.items, 0..) |item, i| if (pick.isChosen(i)) use(item);
//! ```
//!
//! Keys: everything `List` moves with; space (or x) toggles the highlighted item; a
//! chooses every item (as many as the limit allows), or clears them all when that is
//! already done; enter submits, choosing the highlighted item when nothing else is; esc
//! cancels. With a limit, items past it cannot be chosen until another is cleared.
const std = @import("std");
const tui = @import("tui");
const Allocator = std.mem.Allocator;
const List = @import("list.zig");
pub const Status = enum { active, submitted, canceled };
const MultiSelect = @This();
gpa: Allocator,
list: List,
/// One flag per item.
chosen: []bool,
/// The most items that may be chosen; null for no limit.
limit: ?usize = null,
/// A line above the items; none when empty.
header: []const u8 = "",
chosen_mark: []const u8 = "✓ ",
unchosen_mark: []const u8 = "• ",
status: Status = .active,
styles: Styles = .{},
pub const Styles = struct {
header: tui.Style = .{ .fg = .{ .indexed = 99 }, .bold = true },
chosen: tui.Style = .{ .fg = .{ .indexed = 212 } },
count: tui.Style = .{ .fg = .{ .indexed = 240 } },
};
/// A multi-select over `list`'s items, none chosen.
pub fn init(gpa: Allocator, list: List) Allocator.Error!MultiSelect {
const chosen = try gpa.alloc(bool, list.items.len);
@memset(chosen, false);
return .{ .gpa = gpa, .list = list, .chosen = chosen };
}
/// Frees the flags.
pub fn deinit(model: *MultiSelect) void {
model.gpa.free(model.chosen);
model.* = undefined;
}
/// Whether item `index` is chosen.
pub fn isChosen(model: *const MultiSelect, index: usize) bool {
return model.chosen[index];
}
/// How many items are chosen.
pub fn count(model: *const MultiSelect) usize {
return std.mem.count(bool, model.chosen, &.{true});
}
/// Chooses or clears item `index`; choosing past the limit does nothing.
pub fn toggle(model: *MultiSelect, index: usize) void {
if (!model.chosen[index] and model.full()) return;
model.chosen[index] = !model.chosen[index];
}
fn full(model: *const MultiSelect) bool {
const limit = model.limit orelse return false;
return model.count() >= limit;
}
/// Chooses every item in order until the limit, or clears all if no more fit.
pub fn toggleAll(model: *MultiSelect) void {
const before = model.count();
for (model.chosen) |*flag| {
if (!flag.* and !model.full()) flag.* = true;
}
if (model.count() == before) @memset(model.chosen, false);
}
pub fn update(model: *MultiSelect, msg: tui.Msg) tui.Cmd {
if (msg == .mouse) {
// A click toggles the item clicked; the wheel moves.
const items_top = model.list.top + @intFromBool(model.header.len > 0);
if (model.list.handleMouse(msg.mouse, items_top)) |index| model.toggle(index);
return .none;
}
if (msg != .key) return model.list.update(msg);
const key = msg.key;
const items = model.list.items.len;
if ((key.matches("space") or key.matches("x")) and items > 0) {
model.toggle(model.list.cursor);
} else if (key.matches("a") or key.matches("ctrl+a")) {
model.toggleAll();
} else if (key.matches("enter") and items > 0) {
if (model.count() == 0) model.chosen[model.list.cursor] = true;
model.status = .submitted;
return .quit;
} else if (key.matches("esc")) {
model.status = .canceled;
return .quit;
} else return model.list.update(msg);
return .none;
}
/// Writes item `index` behind its mark, for `List.writeRow` to lay out.
fn writeItem(
model: *const MultiSelect,
w: *std.Io.Writer,
index: usize,
) std.Io.Writer.Error!void {
const chosen = model.chosen[index];
const style: tui.Style = if (chosen) model.styles.chosen else .{};
try style.paint(w, if (chosen) model.chosen_mark else model.unchosen_mark);
// The mark's reset ended the list's own style for this row; start it again.
if (chosen and index == model.list.cursor) try model.list.styles.cursor.writeSgr(w);
try w.writeAll(model.list.items[index]);
}
pub fn view(model: *const MultiSelect, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (model.header.len > 0) {
try model.styles.header.paint(w, model.header);
try w.writeByte('\n');
}
const shown = model.list.window();
var row: std.Io.Writer.Allocating = .init(model.gpa);
defer row.deinit();
for (shown.start..shown.end) |i| {
if (i > shown.start) try w.writeByte('\n');
row.clearRetainingCapacity();
try model.writeItem(&row.writer, i);
try model.list.writeRow(w, i, row.written());
}
if (model.limit) |limit| {
try w.writeByte('\n');
try model.styles.count.writeSgr(w);
try w.print("{d}/{d} chosen", .{ model.count(), limit });
if (model.styles.count.hasSgr()) try w.writeAll(tui.ansi.reset);
}
}
const testing = std.testing;
const Driver = tui.testing.Driver(MultiSelect);
const letters = [_][]const u8{ "a", "b", "c", "d" };
fn startPick(d: *Driver, limit: ?usize) !void {
var model: MultiSelect = try .init(testing.allocator, .{ .items = &letters });
model.limit = limit;
d.* = try .init(testing.allocator, model, .{});
}
fn finish(d: *Driver) void {
d.deinit();
}
test "space toggles, enter submits the chosen items" {
var d: Driver = undefined;
try startPick(&d, null);
defer finish(&d);
try d.expectFrame("> • a\n • b\n • c\n • d");
try d.press("space");
try d.press("down");
try d.press("down");
try d.press("x");
try d.expectFrame(" ✓ a\n • b\n> ✓ c\n • d");
try d.press("space");
try d.press("enter");
try testing.expect(d.done());
try testing.expectEqual(Status.submitted, d.model().status);
try testing.expectEqual(1, d.model().count());
try testing.expect(d.model().isChosen(0));
}
test "enter with nothing chosen chooses the highlighted item" {
var d: Driver = undefined;
try startPick(&d, null);
defer finish(&d);
try d.press("end");
try d.press("enter");
try testing.expect(d.model().isChosen(3));
try testing.expectEqual(1, d.model().count());
}
test "the limit caps choosing" {
var d: Driver = undefined;
try startPick(&d, 2);
defer finish(&d);
try d.expectFrameContains("0/2 chosen");
for (0..4) |_| {
try d.press("space");
try d.press("down");
}
try testing.expectEqual(2, d.model().count());
try testing.expect(!d.model().isChosen(2));
try d.expectFrameContains("2/2 chosen");
try d.press("home");
try d.press("space"); // clear a
try d.press("end");
try d.press("space"); // now d fits
try testing.expect(d.model().isChosen(3));
}
test "a chooses all that fit, then clears" {
var d: Driver = undefined;
try startPick(&d, 3);
defer finish(&d);
try d.press("a");
try testing.expectEqual(3, d.model().count());
try testing.expect(!d.model().isChosen(3));
try d.press("a");
try testing.expectEqual(0, d.model().count());
try d.press("esc");
try testing.expectEqual(Status.canceled, d.model().status);
}
test "the mouse: a click toggles the item clicked" {
var d: Driver = undefined;
try startPick(&d, null);
defer finish(&d);
try d.send(.{ .mouse = .{ .x = 2, .y = 2, .button = .left, .action = .press } });
try testing.expect(d.model().isChosen(2));
try d.send(.{ .mouse = .{ .x = 2, .y = 2, .button = .left, .action = .press } });
try testing.expect(!d.model().isChosen(2));
try testing.expect(!d.done());
}