Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046//! Feature-rich list of items: pagination, filtering (substring), help,//! status bar, optional spinner, status messages.//!//! Items expose themselves via the `Item` vtable; how each item renders is//! controlled by the `Delegate` vtable. `DefaultDelegate` (in this file)//! is provided for items that also implement `title()` / `description()`.//!//! Skipped from bubbles for v1: fuzzy filtering (this port does a simple//! case-insensitive substring match), per-character match highlight//! indices, AdditionalShortHelpKeys / AdditionalFullHelpKeys, the spinner//! placement-on-the-left logic (the spinner always renders on the right//! when shown).const std = @import("std");const matcha = @import("matcha");const blush = @import("blush");const key = @import("key.zig");const util = @import("util.zig");const Paginator = @import("Paginator.zig");const Spinner = @import("Spinner.zig");const TextInput = @import("TextInput.zig");const Help = @import("Help.zig");
const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const Cmd = matcha.Cmd;const Msg = matcha.Msg;const ArrayList = std.ArrayList;
const List = @This();
// ---- Item / Delegate ----
pub const Item = struct { ctx: *anyopaque, vtable: *const VTable,
pub const VTable = struct { filter_value: *const fn (ctx: *anyopaque) []const u8, title: ?*const fn (ctx: *anyopaque) []const u8 = null, description: ?*const fn (ctx: *anyopaque) []const u8 = null, };
pub fn filterValue(self: Item) []const u8 { return self.vtable.filter_value(self.ctx); } pub fn title(self: Item) ?[]const u8 { const f = self.vtable.title orelse return null; return f(self.ctx); } pub fn description(self: Item) ?[]const u8 { const f = self.vtable.description orelse return null; return f(self.ctx); }};
pub const Delegate = struct { ctx: *anyopaque, vtable: *const VTable,
pub const VTable = struct { render: *const fn ( ctx: *anyopaque, w: *Writer, list: *const List, idx: usize, item: Item, ) Writer.Error!void, height: *const fn (ctx: *anyopaque) u16, spacing: *const fn (ctx: *anyopaque) u16, update: ?*const fn ( ctx: *anyopaque, allocator: Allocator, msg: Msg, list: *List, ) ?Cmd = null, };
pub fn render( self: Delegate, w: *Writer, list: *const List, idx: usize, item: Item, ) Writer.Error!void { try self.vtable.render(self.ctx, w, list, idx, item); } pub fn height(self: Delegate) u16 { return self.vtable.height(self.ctx); } pub fn spacing(self: Delegate) u16 { return self.vtable.spacing(self.ctx); }};
// ---- Keymap ----
pub const KeyMap = struct { cursor_up: key.Binding = .{ .keys = &.{ key.special(.up), key.rune('k') }, .help = .{ .key = "↑/k", .desc = "up" }, }, cursor_down: key.Binding = .{ .keys = &.{ key.special(.down), key.rune('j') }, .help = .{ .key = "↓/j", .desc = "down" }, }, prev_page: key.Binding = .{ .keys = &.{ key.special(.left), key.rune('h'), key.special(.page_up), key.rune('b'), key.rune('u') }, .help = .{ .key = "←/h/pgup", .desc = "prev page" }, }, next_page: key.Binding = .{ .keys = &.{ key.special(.right), key.rune('l'), key.special(.page_down), key.rune('f'), key.rune('d') }, .help = .{ .key = "→/l/pgdn", .desc = "next page" }, }, goto_start: key.Binding = .{ .keys = &.{ key.special(.home), key.rune('g') }, .help = .{ .key = "g/home", .desc = "go to start" }, }, goto_end: key.Binding = .{ .keys = &.{ key.special(.end), key.shift('G') }, .help = .{ .key = "G/end", .desc = "go to end" }, }, filter: key.Binding = .{ .keys = &.{key.rune('/')}, .help = .{ .key = "/", .desc = "filter" }, }, clear_filter: key.Binding = .{ .keys = &.{key.special(.escape)}, .help = .{ .key = "esc", .desc = "clear filter" }, }, cancel_while_filtering: key.Binding = .{ .keys = &.{key.special(.escape)}, .help = .{ .key = "esc", .desc = "cancel" }, .disabled = true, }, accept_while_filtering: key.Binding = .{ .keys = &.{ key.special(.enter), key.special(.tab), key.special(.up), key.special(.down) }, .help = .{ .key = "enter", .desc = "apply filter" }, .disabled = true, }, show_full_help: key.Binding = .{ .keys = &.{key.rune('?')}, .help = .{ .key = "?", .desc = "more" }, }, close_full_help: key.Binding = .{ .keys = &.{key.rune('?')}, .help = .{ .key = "?", .desc = "close help" }, }, quit: key.Binding = .{ .keys = &.{ key.rune('q'), key.special(.escape) }, .help = .{ .key = "q", .desc = "quit" }, }, force_quit: key.Binding = .{ .keys = &.{key.ctrl('c')} },};
// ---- Styles ----
pub const Styles = struct { title_bar: ?blush.Style = null, title: ?blush.Style = null, spinner: ?blush.Style = null, status_bar: ?blush.Style = null, status_empty: ?blush.Style = null, status_filter_count: ?blush.Style = null, no_items: ?blush.Style = null, pagination: ?blush.Style = null, help: ?blush.Style = null, divider_dot: ?blush.Style = null, filter_match: ?blush.Style = null,};
pub const default_styles: Styles = .{ .title = .{ .background = .{ .ansi256 = 62 }, .foreground = .{ .ansi256 = 230 }, }, .spinner = .{ .foreground = .{ .rgb = .{ .r = 0x74, .g = 0x73, .b = 0x73 } } }, .status_bar = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, .status_empty = .{ .foreground = .{ .rgb = .{ .r = 0x5C, .g = 0x5C, .b = 0x5C } } }, .status_filter_count = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, .no_items = .{ .foreground = .{ .rgb = .{ .r = 0x62, .g = 0x62, .b = 0x62 } } }, .divider_dot = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, .filter_match = .{ .underline = .single },};
// ---- Filter ----
pub const FilterState = enum { unfiltered, filtering, filter_applied };
pub const FilteredItem = struct { index: usize, /// Codepoint index in `filterValue` where the match starts. match_start: usize, /// Codepoint length of the match. match_len: usize,};
pub const Filter = enum { substring };
// ---- Status timeout ----
var status_timeout_marker: u8 = 0;fn statusTimeoutTypeId() usize { return @intFromPtr(&status_timeout_marker);}
// ---- Model ----
model: matcha.Model = .{ .vtable = &vtable },
title_text: []const u8 = "List",item_name_singular: []const u8 = "item",item_name_plural: []const u8 = "items",
show_title: bool = true,show_filter: bool = true,show_status_bar: bool = true,show_pagination: bool = true,show_help: bool = true,filtering_enabled: bool = true,infinite_scrolling: bool = false,disable_quit_keybindings: bool = false,
filter_state: FilterState = .unfiltered,filter_kind: Filter = .substring,
key_map: KeyMap = .{},styles: Styles = default_styles,
width: u16 = 0,height: u16 = 0,
items: []const Item = &.{},filtered: ArrayList(FilteredItem) = .empty,
cursor: u16 = 0,
paginator: Paginator = .{ .type = .dots, .per_page = 1 },spinner: Spinner = .{},show_spinner: bool = false,
filter_input: TextInput = .{ .prompt = "Filter: ", .char_limit = 64 },
help: Help = .{},
status_message: []const u8 = "",/// How long status messages stay visible. Default 1 second.status_message_lifetime_ns: u64 = std.time.ns_per_s,/// Generation counter so stale timeouts get ignored.status_message_generation: u32 = 0,
delegate: ?Delegate = null,
const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView,};
fn vtableInit(_: *matcha.Model) ?Cmd { return null;}
fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *List = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *List = @fieldParentPtr("model", m); try self.view(w); return .{};}
pub fn deinit(self: *List, allocator: Allocator) void { self.filtered.deinit(allocator); self.filter_input.deinit(allocator);}
// ---- accessors ----
pub fn setItems(self: *List, items: []const Item) void { self.items = items; self.filtered.clearRetainingCapacity(); // If filter active, caller should rerun setFilterText or filter cmd. self.updatePagination(); self.updateKeybindings();}
pub fn setDelegate(self: *List, d: Delegate) void { self.delegate = d; self.updatePagination();}
pub fn setSize(self: *List, w: u16, h: u16) void { self.width = w; self.height = h; self.help.width = w; // Reserve title prompt width for the filter input. const prompt_w = blush.measure.cellWidth(self.filter_input.prompt); const spinner_w: u16 = if (self.show_spinner) blush.measure.cellWidth(self.spinner.preset.frames[0]) else 0; self.filter_input.setWidth(if (w > prompt_w + spinner_w) w - prompt_w - spinner_w else 0); self.updatePagination(); self.updateKeybindings();}
pub fn setWidth(self: *List, w: u16) void { self.setSize(w, self.height);}
pub fn setHeight(self: *List, h: u16) void { self.setSize(self.width, h);}
pub fn visibleItems(self: *const List) usize { return if (self.filter_state == .unfiltered) self.items.len else self.filtered.items.len;}
pub fn itemAt(self: *const List, idx: usize) ?Item { if (self.filter_state == .unfiltered) { if (idx >= self.items.len) return null; return self.items[idx]; } if (idx >= self.filtered.items.len) return null; return self.items[self.filtered.items[idx].index];}
pub fn selectedItem(self: *const List) ?Item { return self.itemAt(self.index());}
pub fn index(self: *const List) usize { return @as(usize, self.paginator.page) * @as(usize, self.paginator.per_page) + self.cursor;}
pub fn globalIndex(self: *const List) usize { const i = self.index(); if (self.filter_state == .unfiltered) return i; if (i >= self.filtered.items.len) return i; return self.filtered.items[i].index;}
pub fn matchesForItem(self: *const List, idx: usize) ?FilteredItem { if (self.filter_state == .unfiltered) return null; if (idx >= self.filtered.items.len) return null; return self.filtered.items[idx];}
/// Borrows a temporary encoding of the current filter text into `out`./// Returns the encoded slice, or empty on failure.pub fn filterValueInto(self: *const List, out: []u8) []const u8 { return self.filter_input.encodeValue(out) catch out[0..0];}
// ---- cursor / paging ----
pub fn cursorUp(self: *List) void { if (self.cursor > 0) { self.cursor -= 1; return; } if (self.paginator.onFirstPage()) { if (self.infinite_scrolling) { self.gotoEnd(); } return; } self.paginator.prevPage(); self.cursor = self.maxCursorIndex();}
pub fn cursorDown(self: *List) void { const max_idx = self.maxCursorIndex(); if (self.cursor < max_idx) { self.cursor += 1; return; } if (!self.paginator.onLastPage()) { self.paginator.nextPage(); self.cursor = 0; return; } if (self.infinite_scrolling) self.gotoStart();}
pub fn gotoStart(self: *List) void { self.paginator.page = 0; self.cursor = 0;}
pub fn gotoEnd(self: *List) void { if (self.paginator.total_pages == 0) { self.paginator.page = 0; } else { self.paginator.page = self.paginator.total_pages - 1; } self.cursor = self.maxCursorIndex();}
fn maxCursorIndex(self: *const List) u16 { const items = self.paginator.itemsOnPage(@intCast(@min(self.visibleItems(), std.math.maxInt(u16)))); return if (items == 0) 0 else items - 1;}
// ---- filtering ----
pub fn resetFilter(self: *List, allocator: Allocator) void { if (self.filter_state == .unfiltered) return; self.filter_state = .unfiltered; self.filter_input.reset(); self.filtered.clearRetainingCapacity(); _ = allocator; self.updatePagination(); self.updateKeybindings();}
pub fn setFilterText(self: *List, allocator: Allocator, term: []const u8) !void { self.filter_state = .filtering; try self.filter_input.setValue(allocator, term); try self.refilter(allocator); self.filter_state = .filter_applied; self.gotoStart(); self.filter_input.cursorEnd(); self.updatePagination(); self.updateKeybindings();}
/// Refresh `filtered` from the current filter input.fn refilter(self: *List, allocator: Allocator) !void { self.filtered.clearRetainingCapacity();
// Encode filter input to a UTF-8 buffer once. var term_buf: [256]u8 = undefined; const term = self.filter_input.encodeValue(&term_buf) catch term_buf[0..0]; if (term.len == 0) { for (self.items, 0..) |_, i| { try self.filtered.append(allocator, .{ .index = i, .match_start = 0, .match_len = 0 }); } return; } for (self.items, 0..) |it, i| { const fv = it.filterValue(); if (substringMatch(fv, term)) |m| { try self.filtered.append(allocator, .{ .index = i, .match_start = m.start, .match_len = m.len, }); } }}
const SubMatch = struct { start: usize, len: usize };
fn substringMatch(haystack: []const u8, needle: []const u8) ?SubMatch { if (needle.len == 0) return SubMatch{ .start = 0, .len = 0 }; // ASCII case-insensitive search. Find byte position, then translate to // codepoint position so DefaultDelegate's underline lines up correctly. var byte_idx: usize = 0; while (byte_idx + needle.len <= haystack.len) : (byte_idx += 1) { if (asciiEqlNoCase(haystack[byte_idx .. byte_idx + needle.len], needle)) { const start_cp = codepointIndex(haystack, byte_idx); const len_cp = codepointCount(needle); return SubMatch{ .start = start_cp, .len = len_cp }; } } return null;}
fn asciiEqlNoCase(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| { const lx = if (x >= 'A' and x <= 'Z') x + 32 else x; const ly = if (y >= 'A' and y <= 'Z') y + 32 else y; if (lx != ly) return false; } return true;}
fn codepointIndex(text: []const u8, byte_at: usize) usize { var cp: usize = 0; var i: usize = 0; while (i < byte_at and i < text.len) { const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; i += len; cp += 1; } return cp;}
fn codepointCount(text: []const u8) usize { var n: usize = 0; var i: usize = 0; while (i < text.len) { const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; i += len; n += 1; } return n;}
// ---- pagination / keybindings ----
fn updatePagination(self: *List) void { const cur = self.index(); var avail = self.height; if (self.show_title or (self.show_filter and self.filtering_enabled)) avail -|= 2; // title + pad if (self.show_status_bar) avail -|= 2; if (self.show_pagination) avail -|= 1; if (self.show_help) avail -|= 2;
const dh = if (self.delegate) |d| d.height() else 1; const ds = if (self.delegate) |d| d.spacing() else 0; const row = @max(dh +| ds, 1); self.paginator.per_page = @max(1, avail / row);
const total: u16 = @intCast(@min(self.visibleItems(), std.math.maxInt(u16))); _ = self.paginator.setTotalPages(if (total == 0) 1 else total);
// Restore cursor. if (self.paginator.per_page > 0) { self.paginator.page = @intCast(cur / self.paginator.per_page); self.cursor = @intCast(cur % self.paginator.per_page); } if (self.paginator.total_pages > 0 and self.paginator.page >= self.paginator.total_pages) { self.paginator.page = self.paginator.total_pages - 1; }}
fn updateKeybindings(self: *List) void { const has_items = self.items.len > 0; const has_pages = self.paginator.total_pages > 1; const filtering = self.filter_state == .filtering;
self.key_map.cursor_up.disabled = filtering or !has_items; self.key_map.cursor_down.disabled = filtering or !has_items; self.key_map.prev_page.disabled = filtering or !has_pages; self.key_map.next_page.disabled = filtering or !has_pages; self.key_map.goto_start.disabled = filtering or !has_items; self.key_map.goto_end.disabled = filtering or !has_items; self.key_map.filter.disabled = filtering or !self.filtering_enabled or !has_items; self.key_map.clear_filter.disabled = filtering or self.filter_state != .filter_applied; self.key_map.cancel_while_filtering.disabled = !filtering; self.key_map.accept_while_filtering.disabled = !filtering or self.filter_input.value.items.len == 0; self.key_map.quit.disabled = self.disable_quit_keybindings or filtering;}
// ---- update ----
pub fn update(self: *List, allocator: Allocator, msg: Msg) ?Cmd { // Force quit always wins. if (msg == .key_press and self.key_map.force_quit.matches(msg.key_press)) return .quit;
// Status message timeout (gated by generation). if (msg == .custom and msg.custom.type_id == statusTimeoutTypeId()) { const payload_ptr = msg.custom.payload orelse return null; const gen: u32 = @intCast(@intFromPtr(payload_ptr)); if (gen == self.status_message_generation) self.status_message = ""; return null; }
if (self.filter_state == .filtering) return self.handleFiltering(allocator, msg); return self.handleBrowsing(allocator, msg);}
fn handleBrowsing(self: *List, allocator: Allocator, msg: Msg) ?Cmd { switch (msg) { .key_press => |k| { if (self.key_map.clear_filter.matches(k)) { self.resetFilter(allocator); return null; } if (self.key_map.quit.matches(k)) return .quit; if (self.key_map.cursor_up.matches(k)) self.cursorUp(); if (self.key_map.cursor_down.matches(k)) self.cursorDown(); if (self.key_map.prev_page.matches(k)) self.paginator.prevPage(); if (self.key_map.next_page.matches(k)) self.paginator.nextPage(); if (self.key_map.goto_start.matches(k)) self.gotoStart(); if (self.key_map.goto_end.matches(k)) self.gotoEnd(); if (self.key_map.filter.matches(k)) { self.status_message = ""; self.gotoStart(); self.filter_state = .filtering; self.refilter(allocator) catch {}; self.filter_input.cursorEnd(); self.updateKeybindings(); return self.filter_input.focus(allocator); } if (self.key_map.show_full_help.matches(k) or self.key_map.close_full_help.matches(k)) { self.help.show_all = !self.help.show_all; self.updatePagination(); } }, else => {}, }
// Forward msg to delegate's optional update fn. var cmd: ?Cmd = null; if (self.delegate) |d| if (d.vtable.update) |fnp| { cmd = fnp(d.ctx, allocator, msg, self); };
// Clamp cursor. const max_idx = self.maxCursorIndex(); if (self.cursor > max_idx) self.cursor = max_idx; return cmd;}
fn handleFiltering(self: *List, allocator: Allocator, msg: Msg) ?Cmd { switch (msg) { .key_press => |k| { if (self.key_map.cancel_while_filtering.matches(k)) { self.resetFilter(allocator); return null; } if (self.key_map.accept_while_filtering.matches(k)) { self.status_message = ""; if (self.items.len == 0) return null; if (self.visibleItems() == 0) { self.resetFilter(allocator); return null; } self.filter_input.blur(); self.filter_state = .filter_applied; self.updateKeybindings(); if (self.filter_input.value.items.len == 0) self.resetFilter(allocator); return null; } }, else => {}, }
// Forward to filter input. If value changed, refilter. const before_len = self.filter_input.value.items.len; const sub_cmd = self.filter_input.update(allocator, msg); const changed = self.filter_input.value.items.len != before_len or (before_len > 0 and self.filter_input.value.items.len > 0); // Re-filter unconditionally — cheap for v1; refine later if needed. _ = changed; self.refilter(allocator) catch {}; self.updateKeybindings(); self.updatePagination(); return sub_cmd;}
/// Replace the status message with `msg` (borrowed) for the configured/// lifetime. Returns a Cmd that clears the message on expiry.pub fn newStatusMessage(self: *List, allocator: Allocator, msg: []const u8) ?Cmd { self.status_message = msg; self.status_message_generation +%= 1; const gen_ptr: *anyopaque = @ptrFromInt(@as(usize, self.status_message_generation)); return Cmd.tick(allocator, self.status_message_lifetime_ns, .{ .custom = .{ .type_id = statusTimeoutTypeId(), .payload = gen_ptr }, }) catch null;}
// ---- view ----
pub fn view(self: *List, w: *Writer) Writer.Error!void { var avail: u16 = self.height; if (self.show_title or (self.show_filter and self.filtering_enabled)) { try self.viewTitle(w); try w.writeByte('\n'); avail -|= 1; } if (self.show_status_bar) { try self.viewStatus(w); try w.writeByte('\n'); avail -|= 1; }
if (self.show_pagination) avail -|= 1; if (self.show_help) avail -|= 2;
try self.viewItems(w, avail);
if (self.show_pagination) { try w.writeByte('\n'); try self.viewPagination(w); } if (self.show_help) { try w.writeByte('\n'); try self.viewHelp(w); }}
fn viewTitle(self: *List, w: *Writer) Writer.Error!void { if (self.show_filter and self.filter_state == .filtering) { try self.filter_input.view(w); return; } if (self.show_title) { try util.writeInline(w, self.styles.title, " "); try util.writeInline(w, self.styles.title, self.title_text); try util.writeInline(w, self.styles.title, " "); if (self.status_message.len > 0) { try w.writeAll(" "); try w.writeAll(self.status_message); } } if (self.show_spinner) { try w.writeAll(" "); try self.spinner.view(w); }}
fn viewStatus(self: *List, w: *Writer) Writer.Error!void { var buf: [256]u8 = undefined; var sw: Writer = .fixed(&buf); const visible = self.visibleItems(); const total = self.items.len; const item_name = if (visible == 1) self.item_name_singular else self.item_name_plural;
if (self.filter_state == .filtering) { if (visible == 0) { try util.writeInline(&sw, self.styles.status_empty, "Nothing matched"); } else { try sw.print("{d} {s}", .{ visible, item_name }); } } else if (total == 0) { try util.writeInline(&sw, self.styles.status_empty, "No "); try util.writeInline(&sw, self.styles.status_empty, self.item_name_plural); } else { if (self.filter_state == .filter_applied) { var ti_buf: [64]u8 = undefined; const ti_text = self.filter_input.encodeValue(&ti_buf) catch ti_buf[0..0]; try sw.print("\u{201C}{s}\u{201D} ", .{ti_text}); } try sw.print("{d} {s}", .{ visible, item_name }); }
const num_filtered = total -| visible; if (num_filtered > 0) { try util.writeInline(&sw, self.styles.divider_dot, " • "); try sw.print("{d} filtered", .{num_filtered}); }
try util.writeInline(w, self.styles.status_bar, sw.buffered());}
fn viewPagination(self: *List, w: *Writer) Writer.Error!void { if (self.paginator.total_pages < 2) return; try util.writeInline(w, self.styles.pagination, " "); try self.paginator.view(w);}
fn viewHelp(self: *List, w: *Writer) Writer.Error!void { try w.writeByte('\n'); try util.writeInline(w, self.styles.help, " "); if (self.help.show_all) { const cursor = [_]key.Binding{ self.key_map.cursor_up, self.key_map.cursor_down, self.key_map.next_page, self.key_map.prev_page, self.key_map.goto_start, self.key_map.goto_end, }; const flt = [_]key.Binding{ self.key_map.filter, self.key_map.clear_filter, self.key_map.accept_while_filtering, self.key_map.cancel_while_filtering, }; const meta = [_]key.Binding{ self.key_map.quit, self.key_map.close_full_help, }; const groups = [_][]const key.Binding{ &cursor, &flt, &meta }; try self.help.fullView(w, &groups); } else { const bindings = [_]key.Binding{ self.key_map.cursor_up, self.key_map.cursor_down, self.key_map.filter, self.key_map.clear_filter, self.key_map.accept_while_filtering, self.key_map.cancel_while_filtering, self.key_map.quit, self.key_map.show_full_help, }; try self.help.shortView(w, &bindings); }}
fn viewItems(self: *List, w: *Writer, avail: u16) Writer.Error!void { const total = self.visibleItems(); if (total == 0) { if (self.filter_state == .filtering) return; try util.writeInline(w, self.styles.no_items, "No "); try util.writeInline(w, self.styles.no_items, self.item_name_plural); try util.writeInline(w, self.styles.no_items, "."); return; } const d = self.delegate orelse return; const start = self.paginator.page * self.paginator.per_page; const end = @min(start + self.paginator.per_page, total); const items_on_page = self.paginator.itemsOnPage(@intCast(@min(total, std.math.maxInt(u16))));
var i: usize = start; while (i < end) : (i += 1) { const it = self.itemAt(i) orelse continue; try d.render(w, self, i, it); if (i + 1 < end) { var s: u16 = 0; while (s < d.spacing() + 1) : (s += 1) try w.writeByte('\n'); } } if (items_on_page < self.paginator.per_page) { const missing = self.paginator.per_page - items_on_page; const rows = missing * (d.height() + d.spacing()); var k: u16 = 0; while (k < rows) : (k += 1) try w.writeByte('\n'); } _ = avail;}
// ---- DefaultDelegate ----
pub const DefaultItemStyles = struct { normal_title: ?blush.Style = null, normal_desc: ?blush.Style = null, selected_title: ?blush.Style = null, selected_desc: ?blush.Style = null, dimmed_title: ?blush.Style = null, dimmed_desc: ?blush.Style = null, filter_match: ?blush.Style = null,};
pub const default_item_styles: DefaultItemStyles = .{ .normal_title = .{ .foreground = .{ .rgb = .{ .r = 0xDD, .g = 0xDD, .b = 0xDD } } }, .normal_desc = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, .selected_title = .{ .foreground = .{ .rgb = .{ .r = 0xEE, .g = 0x6F, .b = 0xF8 } }, .bold = true }, .selected_desc = .{ .foreground = .{ .rgb = .{ .r = 0xAD, .g = 0x58, .b = 0xB4 } } }, .dimmed_title = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, .dimmed_desc = .{ .foreground = .{ .rgb = .{ .r = 0x4D, .g = 0x4D, .b = 0x4D } } }, .filter_match = .{ .underline = .single },};
pub const DefaultDelegate = struct { show_description: bool = true, height_value: u16 = 2, spacing_value: u16 = 1, /// Optional left-edge mark for the selected row (a colored bar). selected_mark: []const u8 = "▎ ", normal_padding_left: u16 = 2, styles: DefaultItemStyles = default_item_styles,
var vt: Delegate.VTable = .{ .render = ddRender, .height = ddHeight, .spacing = ddSpacing, };
pub fn delegate(self: *DefaultDelegate) Delegate { return .{ .ctx = self, .vtable = &vt }; }
fn ddHeight(ctx: *anyopaque) u16 { const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); return if (self.show_description) self.height_value else 1; }
fn ddSpacing(ctx: *anyopaque) u16 { const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); return self.spacing_value; }
fn ddRender( ctx: *anyopaque, w: *Writer, list: *const List, idx: usize, item: Item, ) Writer.Error!void { const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); const title_text = item.title() orelse return; const desc_text = if (self.show_description) (item.description() orelse "") else "";
const is_selected = idx == list.index(); const is_filtering = list.filter_state == .filtering; const empty_filter = is_filtering and list.filter_input.value.items.len == 0; const is_filtered_view = list.filter_state == .filtering or list.filter_state == .filter_applied;
const title_style: ?blush.Style = if (empty_filter) self.styles.dimmed_title else if (is_selected and !is_filtering) self.styles.selected_title else self.styles.normal_title; const desc_style: ?blush.Style = if (empty_filter) self.styles.dimmed_desc else if (is_selected and !is_filtering) self.styles.selected_desc else self.styles.normal_desc;
const pad_l: u16 = if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) 0 else self.normal_padding_left;
// Title line. if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) { try util.writeInline(w, self.styles.selected_title, self.selected_mark); } else { var i: u16 = 0; while (i < pad_l) : (i += 1) try w.writeByte(' '); } const match_info: ?FilteredItem = if (is_filtered_view) list.matchesForItem(idx) else null; try writeMaybeMatched(w, title_text, title_style, self.styles.filter_match, match_info);
if (self.show_description) { try w.writeByte('\n'); if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) { try util.writeInline(w, self.styles.selected_desc, self.selected_mark); } else { var i: u16 = 0; while (i < pad_l) : (i += 1) try w.writeByte(' '); } try util.writeInline(w, desc_style, desc_text); } }};
fn writeMaybeMatched( w: *Writer, text: []const u8, base: ?blush.Style, match_style: ?blush.Style, info: ?FilteredItem,) Writer.Error!void { if (info == null or match_style == null or info.?.match_len == 0) { try util.writeInline(w, base, text); return; } const m = info.?; var iter = (std.unicode.Utf8View.init(text) catch return util.writeInline(w, base, text)).iterator(); var cp_idx: usize = 0; var buf: [4]u8 = undefined; while (iter.nextCodepoint()) |cp| { const in_match = cp_idx >= m.match_start and cp_idx < m.match_start + m.match_len; const len = std.unicode.utf8Encode(cp, &buf) catch 1; const style = if (in_match) blk: { // Merge base + match underline so we don't lose fg color. var merged: blush.Style = base orelse .{}; const ms = match_style.?; if (ms.bold) merged.bold = true; if (ms.italic) merged.italic = true; if (ms.underline != .none) merged.underline = ms.underline; if (ms.faint) merged.faint = true; break :blk merged; } else (base orelse blush.Style{}); try util.writeInline(w, style, buf[0..len]); cp_idx += 1; }}
// ---- tests ----
const testing = std.testing;
const TestItem = struct { title_text: []const u8, desc_text: []const u8 = "",
var vt: Item.VTable = .{ .filter_value = filterValue, .title = titleFn, .description = descFn, };
fn filterValue(ctx: *anyopaque) []const u8 { const self: *const TestItem = @ptrCast(@alignCast(ctx)); return self.title_text; } fn titleFn(ctx: *anyopaque) []const u8 { const self: *const TestItem = @ptrCast(@alignCast(ctx)); return self.title_text; } fn descFn(ctx: *anyopaque) []const u8 { const self: *const TestItem = @ptrCast(@alignCast(ctx)); return self.desc_text; } fn item(self: *TestItem) Item { return .{ .ctx = self, .vtable = &vt }; }};
test "substring filter matches case-insensitive" { var a: TestItem = .{ .title_text = "Apple" }; var b: TestItem = .{ .title_text = "Banana" }; var c: TestItem = .{ .title_text = "appliance" }; var items = [_]Item{ a.item(), b.item(), c.item() };
var list: List = .{}; defer list.deinit(testing.allocator); list.setItems(&items); list.filter_state = .filtering; try list.filter_input.setValue(testing.allocator, "AP"); try list.refilter(testing.allocator); try testing.expectEqual(@as(usize, 2), list.filtered.items.len); try testing.expectEqual(@as(usize, 0), list.filtered.items[0].index); try testing.expectEqual(@as(usize, 2), list.filtered.items[1].index);}
test "cursorDown wraps to next page" { var a: TestItem = .{ .title_text = "1" }; var b: TestItem = .{ .title_text = "2" }; var c: TestItem = .{ .title_text = "3" }; var items = [_]Item{ a.item(), b.item(), c.item() };
var list: List = .{}; defer list.deinit(testing.allocator); list.setItems(&items); list.paginator.per_page = 2; _ = list.paginator.setTotalPages(3);
list.cursorDown(); try testing.expectEqual(@as(u16, 1), list.cursor); try testing.expectEqual(@as(u16, 0), list.paginator.page); list.cursorDown(); try testing.expectEqual(@as(u16, 0), list.cursor); try testing.expectEqual(@as(u16, 1), list.paginator.page);}
test "entering filter mode lists all items and returns cursor tick cmd" { var a: TestItem = .{ .title_text = "1" }; var b: TestItem = .{ .title_text = "2" }; var c: TestItem = .{ .title_text = "3" }; var items = [_]Item{ a.item(), b.item(), c.item() };
var list: List = .{}; defer list.deinit(testing.allocator); list.setItems(&items);
const slash: Msg = .{ .key_press = .{ .kind = .rune, .rune = '/' } }; const cmd = list.update(testing.allocator, slash); if (cmd) |cm| if (cm.free_ctx) |f| { if (cm.ctx) |ctx| f(testing.allocator, ctx); }; try testing.expect(cmd != null); try testing.expect(list.filter_state == .filtering); try testing.expectEqual(@as(usize, 3), list.visibleItems());}