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