Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539//! Feature-rich list of items: pagination, fuzzy filtering, 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()`.//!//! The default filter is a synchronous port of `sahilm/fuzzy` (bubbles'//! `DefaultFilter`), scoring and ordering included; per-character match//! highlight indices are exposed via `matchesForItem`. `.substring` remains//! available as `filter_kind` for the old behavior.//!//! Skipped from bubbles for v1: swappable async FilterFunc ///! FilterMatchestea.Msg (filtering is synchronous here),//! AdditionalShortHelpKeys / AdditionalFullHelpKeys, the spinner//! placement-on-the-left logic (the spinner always renders on the right//! when shown).const std = @import("std");const tea = @import("blacktea");const matte = @import("matte");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 t = std.testing;const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const ArrayList = std.ArrayList;
const List = @This();
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 = .fuzzy,
key_map: KeyMap = .{},styles: Styles = .{},
width: u16 = 0,height: u16 = 0,
items: []const Item = &.{},filtered: ArrayList(FilteredItem) = .empty,/// Matched codepoint indexes (ascending) backing `filtered` entries and/// `FuzzyRank`s; rebuilt on every refilter.matched_buf: ArrayList(usize) = .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,
model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, },},
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, writer: *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: tea.Msg, list: *List, ) ?tea.Cmd = null, };
pub fn render( self: Delegate, writer: *Writer, list: *const List, idx: usize, item: Item, ) Writer.Error!void { try self.vtable.render(self.ctx, writer, 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); }};
const KeyMap = struct { cursor_up: key.Binding = .up, cursor_down: key.Binding = .down, prev_page: key.Binding = .new(&.{ .left, .h, .page_up, .b, .u }, "prev page", .show(3)), next_page: key.Binding = .new(&.{ .right, .l, .page_down, .f, .d }, "next page", .show(3)), goto_start: key.Binding = .new(&.{ .home, .g }, "go to start", .{}), goto_end: key.Binding = .new(&.{ .end, .G }, "go to end", .{}), filter: key.Binding = .new(&.{.rune('/')}, "filter", .{}), clear_filter: key.Binding = .new(&.{.escape}, "clear filter", .{}), cancel_while_filtering: key.Binding = .new(&.{.escape}, "cancel", .{}), accept_while_filtering: key.Binding = .new( &.{ .enter, .tab, .shift(.tab), .ctrl(.k), .up, .ctrl(.j), .down }, "apply filter", .{ .max_shown_keys = 1, .disabled = true }, ), show_full_help: key.Binding = .new(&.{.@"?"}, "more", .{}), close_full_help: key.Binding = .new(&.{.@"?"}, "close help", .{}), quit: key.Binding = .new(&.{ .q, .escape }, "quit", .show(1)), force_quit: key.Binding = .force_quit,};
const Styles = struct { const style = matte.style; title_bar: matte.Style = .empty, title: matte.Style = style.bg(.ansi256(62)).fg(.ansi256(230)), spinner: matte.Style = style.fg(.rgb(0x74, 0x73, 0x73)), status_bar: matte.Style = style.fg(.rgb(0x77, 0x77, 0x77)), status_empty: matte.Style = style.fg(.rgb(0x5C, 0x5C, 0x5C)), status_filter_count: matte.Style = style.fg(.rgb(0x3C, 0x3C, 0x3C)), no_items: matte.Style = style.fg(.rgb(0x62, 0x62, 0x62)), pagination: matte.Style = .empty, help: matte.Style = .empty, divider_dot: matte.Style = style.fg(.rgb(0x3C, 0x3C, 0x3C)), filter_match: matte.Style = style.ul(.single),};
const FilterState = enum { unfiltered, filtering, filter_applied };
const FilteredItem = struct { index: usize, /// Offset into the List's `matched_buf` where this item's matched /// codepoint indexes (ascending) live. Only meaningful while /// `match_len > 0` and until the next `refilter`. match_off: usize, /// Number of matched codepoints (0 = no specific match info). match_len: usize,};
const Filter = enum { fuzzy, substring };
/// One fuzzy filter result, mirroring sahilm/fuzzy's `Match`.const FuzzyRank = struct { /// Index of the item in the unfiltered list. index: usize, /// Match score; higher is better. score: i32, /// Offset of this item's matched codepoint indexes within the buffer /// passed to `fuzzyFind`. match_off: usize, /// Number of matched codepoints. match_len: usize,};
var status_timeout_marker: u8 = 0;
fn statusTimeoutTypeId() usize { return @intFromPtr(&status_timeout_marker);}
fn vtableInit(_: *tea.Model) ?tea.Cmd { return null;}
fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *List = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn vtableView(m: *tea.Model, writer: *Writer) Writer.Error!tea.View { const self: *List = @fieldParentPtr("model", m); try self.view(writer); return .{};}
pub fn deinit(self: *List, allocator: Allocator) void { self.filtered.deinit(allocator); self.matched_buf.deinit(allocator); self.filter_input.deinit(allocator);}
pub fn setItems(self: *List, items: []const Item) void { self.items = items; self.filtered.clearRetainingCapacity(); self.matched_buf.clearRetainingCapacity(); // If filter active, caller should rerun setFilterText or filter cmd. self.updateKeybindings(); self.updatePagination();}
pub fn setDelegate(self: *List, delegate: Delegate) void { self.delegate = delegate; self.updatePagination();}
pub fn setSize(self: *List, width: u16, height: u16) void { self.width = width; self.height = height; self.help.width = width; // Reserve title prompt width for the filter input. const prompt_w = matte.measure.cellWidth(self.filter_input.prompt); const spinner_w: u16 = if (self.show_spinner) matte.measure.cellWidth(self.spinner.frames.slices[0]) else 0; self.filter_input.setWidth(if (width > prompt_w + spinner_w) width - prompt_w - spinner_w else 0); self.updateKeybindings(); self.updatePagination();}
pub fn setWidth(self: *List, width: u16) void { self.setSize(width, self.height);}
pub fn setHeight(self: *List, height: u16) void { self.setSize(self.width, height);}
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;}
/// Codepoint indexes matched by the current filter for the item displayed at/// `idx`, if any. Use this to style runes matched by the active filter./// Returns null when the list is not filtered, and an empty slice when the/// item carries no specific match info (e.g. empty filter term).pub fn matchesForItem(self: *const List, idx: usize) ?[]const usize { if (self.filter_state == .unfiltered) return null; if (idx >= self.filtered.items.len) return null; const fi = self.filtered.items[idx]; if (fi.match_len == 0) return &.{}; if (fi.match_off + fi.match_len > self.matched_buf.items.len) return &.{}; return self.matched_buf.items[fi.match_off..][0..fi.match_len];}
/// 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];}
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;}
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(); self.matched_buf.clearRetainingCapacity(); _ = allocator; // Keybindings before pagination: help height depends on which bindings // are enabled, and per_page is derived from that height. self.updateKeybindings(); self.updatePagination();}
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.updateKeybindings(); self.updatePagination();}
/// Refresh `filtered` from the current filter input.fn refilter(self: *List, allocator: Allocator) !void { self.filtered.clearRetainingCapacity(); self.matched_buf.clearRetainingCapacity();
// Encode filter input to a UTF-8 buffer once. `char_limit` is 64 // codepoints, so 512 bytes always fit. var term_buf: [512]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_off = 0, .match_len = 0 }); } return; } switch (self.filter_kind) { .substring => try self.refilterSubstring(allocator, term), .fuzzy => try self.refilterFuzzy(allocator, term), }}
fn refilterSubstring(self: *List, allocator: Allocator, term: []const u8) !void { for (self.items, 0..) |it, i| { const fv = it.filterValue(); if (substringMatch(fv, term)) |m| { const off = self.matched_buf.items.len; var k: usize = 0; while (k < m.len) : (k += 1) { try self.matched_buf.append(allocator, m.start + k); } try self.filtered.append(allocator, .{ .index = i, .match_off = off, .match_len = m.len, }); } }}
fn refilterFuzzy(self: *List, allocator: Allocator, term: []const u8) !void { const targets = try allocator.alloc([]const u8, self.items.len); defer allocator.free(targets); for (self.items, 0..) |it, i| targets[i] = it.filterValue();
const ranks = try fuzzyFind(allocator, term, targets, &self.matched_buf); defer allocator.free(ranks); for (ranks) |r| { try self.filtered.append(allocator, .{ .index = r.index, .match_off = r.match_off, .match_len = r.match_len, }); }}
const SubMatch = struct { start: usize, len: usize };
const fuzzy = struct { const first_char_match_bonus: i32 = 10; const match_following_separator_bonus: i32 = 20; const camel_case_match_bonus: i32 = 20; const adjacent_match_bonus: i32 = 5; const unmatched_leading_char_penalty: i32 = -5; const max_unmatched_leading_char_penalty: i32 = -15;
const separators = [_]u21{ '/', '-', '_', ' ', '.', '\\' };
fn isSeparator(c: u21) bool { for (separators) |s| { if (s == c) return true; } return false; }
fn isUpperAscii(c: u21) bool { return c >= 'A' and c <= 'Z'; }
/// ASCII case-insensitive rune equality. Go folds via /// `unicode.SimpleFold`; here non-ASCII runes must match exactly. fn equalFold(a: u21, b: u21) bool { if (a == b) return true; const la = if (isUpperAscii(a)) a + 32 else a; const lb = if (isUpperAscii(b)) b + 32 else b; return la == lb; }
fn adjacentCharBonus(i: usize, last_match: usize, current_bonus: i32) i32 { if (last_match == i) { return current_bonus * 2 + adjacent_match_bonus; } return 0; }
const Rune = struct { cp: u21, size: usize };
/// Mirrors Go's `utf8.DecodeRuneInString` error path: invalid sequences /// decode to U+FFFD and advance one byte. fn decodeRune(s: []const u8, i: usize) ?Rune { if (i >= s.len) return null; const b = s[i]; if (b < 0x80) return .{ .cp = b, .size = 1 }; const len = std.unicode.utf8ByteSequenceLength(b) catch return .{ .cp = 0xFFFD, .size = 1 }; if (i + len > s.len) return .{ .cp = 0xFFFD, .size = 1 }; const cp = std.unicode.utf8Decode(s[i..][0..len]) catch return .{ .cp = 0xFFFD, .size = 1 }; return .{ .cp = cp, .size = len }; }};
/// Port of sahilm/fuzzy's `FindFromNoSort` + stable score sort (its `Find`):/// matches `pattern` against each target, appends each match's codepoint/// indexes (ascending) to `matched_buf`, and returns ranks sorted by score/// (best first; ties keep target order). Caller frees the returned slice;/// the matched indexes are only valid until `matched_buf` changes.pub fn fuzzyFind( allocator: Allocator, pattern: []const u8, targets: []const []const u8, matched_buf: *ArrayList(usize),) ![]FuzzyRank { matched_buf.clearRetainingCapacity();
var pattern_runes = ArrayList(u21).empty; defer pattern_runes.deinit(allocator); var pit = (std.unicode.Utf8View.init(pattern) catch return try allocator.alloc(FuzzyRank, 0)).iterator(); while (pit.nextCodepoint()) |cp| try pattern_runes.append(allocator, cp); const runes = pattern_runes.items;
var ranks = ArrayList(FuzzyRank).empty; errdefer ranks.deinit(allocator); for (targets, 0..) |target, ti| { const base = matched_buf.items.len; if (try fuzzyMatchTarget(allocator, runes, target, matched_buf, base)) |score| { try ranks.append(allocator, .{ .index = ti, .score = score, .match_off = base, .match_len = matched_buf.items.len - base, }); } else { matched_buf.shrinkRetainingCapacity(base); } }
// Stable sort, best score first (Go `sort.Stable(matches)`, Less: `>`). std.mem.sort(FuzzyRank, ranks.items, {}, struct { fn less(_: void, a: FuzzyRank, b: FuzzyRank) bool { return a.score > b.score; } }.less); return try ranks.toOwnedSlice(allocator);}
/// Port of sahilm/fuzzy's single-target match loop. On success returns the/// score and appends the matched codepoint indexes (ascending) to/// `matched_buf` beyond `base`; on failure returns null (buffer truncated/// back to `base`).fn fuzzyMatchTarget( allocator: Allocator, pattern: []const u21, target: []const u8, matched_buf: *ArrayList(usize), base: usize,) Allocator.Error!?i32 { if (pattern.len == 0) return null; // Limit matching to the first NUL byte, if any (sahilm/fuzzy). const clean = if (std.mem.findScalar(u8, target, 0)) |nul| target[0..nul] else target;
var total_score: i32 = 0; var pattern_index: usize = 0; var best_score: i32 = -1; var matched_index: i32 = -1; var curr_adjacent_match_bonus: i32 = 0; var last: u21 = 0; var last_index: usize = 0; // codepoint index of previous candidate
var byte_i: usize = 0; var j: usize = 0; // codepoint index of candidate var next = fuzzy.decodeRune(clean, 0); while (next) |cand| { const cur = cand; const next_byte = byte_i + cur.size; next = fuzzy.decodeRune(clean, next_byte);
if (fuzzy.equalFold(cur.cp, pattern[pattern_index])) { var score: i32 = 0; if (j == 0) { score += fuzzy.first_char_match_bonus; } if (last >= 'a' and last <= 'z' and fuzzy.isUpperAscii(cur.cp)) { score += fuzzy.camel_case_match_bonus; } if (j != 0 and fuzzy.isSeparator(last)) { score += fuzzy.match_following_separator_bonus; } if (matched_buf.items.len > base) { const last_match = matched_buf.items[matched_buf.items.len - 1]; const bonus = fuzzy.adjacentCharBonus(last_index, last_match, curr_adjacent_match_bonus); score += bonus; // Adjacent bonuses compound, so track the running bonus. curr_adjacent_match_bonus += bonus; } if (score > best_score) { best_score = score; matched_index = @intCast(j); } } const nextp: u21 = if (pattern_index + 1 < pattern.len) pattern[pattern_index + 1] else 0; // Commit the best candidate when the next char starts the next // pattern rune (or the string ends); this finds the best match // exhaustively rather than the first one. if (next == null or fuzzy.equalFold(nextp, next.?.cp)) { if (matched_index > -1) { if (matched_buf.items.len == base) { const penalty: i32 = matched_index * fuzzy.unmatched_leading_char_penalty; best_score += @max(penalty, fuzzy.max_unmatched_leading_char_penalty); } total_score += best_score; try matched_buf.append(allocator, @intCast(matched_index)); best_score = -1; pattern_index += 1; } } last_index = j; last = cur.cp; j += 1; byte_i = next_byte; }
const matched_count = matched_buf.items.len - base; if (matched_count == pattern.len) { // Penalty for each unmatched character. Go computes this against the // target's *byte* length, so multibyte runes cost extra; replicated // for exact parity. const penalty: i32 = @as(i32, @intCast(matched_count)) - @as(i32, @intCast(clean.len)); return total_score + penalty; } matched_buf.shrinkRetainingCapacity(base); return null;}
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;}
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; // content + bottom padding if (self.show_status_bar) avail -|= 2; // content + bottom padding if (self.show_pagination and self.paginator.total_pages > 1) avail -|= 1; avail -|= self.helpRows();
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; self.key_map.show_full_help.disabled = filtering; self.key_map.close_full_help.disabled = filtering;}
pub fn update(self: *List, allocator: Allocator, msg: tea.Msg) ?tea.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: tea.Msg) ?tea.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: ?tea.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: tea.Msg) ?tea.Cmd { // Handle keys. Like bubbles, neither branch returns early: the common // tail below must run so updateKeybindings (which restores browsing-mode // flags) is applied before updatePagination re-measures the help block. // Otherwise per_page is computed against the shrunken filtering-mode // help and the view overflows the terminal once the help expands again. if (msg == .key_press) { const k = msg.key_press; if (self.key_map.cancel_while_filtering.matches(k)) { self.resetFilter(allocator); } else if (self.key_map.accept_while_filtering.matches(k)) { self.status_message = ""; if (self.items.len != 0) { // If we've filtered down to nothing, clear the filter. if (self.visibleItems() == 0) { self.resetFilter(allocator); } else { self.filter_input.blur(); self.filter_state = .filter_applied; if (self.filter_input.value.items.len == 0) self.resetFilter(allocator); } } } }
// Forward to filter input; re-filter only when the value changed. const len_before = self.filter_input.value.items.len; const sub_cmd = self.filter_input.update(allocator, msg); if (self.filter_input.value.items.len != len_before) { self.refilter(allocator) catch {}; } self.updateKeybindings(); self.updatePagination(); return sub_cmd;}
/// Replace the status message with `msg` (borrowed) for the configured/// lifetime. Returns a tea.Cmd that clears the message on expiry.pub fn newStatusMessage(self: *List, allocator: Allocator, msg: []const u8) ?tea.Cmd { self.status_message = msg; self.status_message_generation +%= 1; const gen_ptr: *anyopaque = @ptrFromInt(@as(usize, self.status_message_generation)); return tea.Cmd.tick(allocator, self.status_message_lifetime_ns, .{ .custom = .{ .type_id = statusTimeoutTypeId(), .payload = gen_ptr }, }) catch null;}
pub fn view(self: *List, writer: *Writer) Writer.Error!void { const title_on = self.show_title or (self.show_filter and self.filtering_enabled); const help_rows = self.helpRows(); const pagination_rows: u16 = if (self.show_pagination and self.paginator.total_pages > 1) 1 else 0;
var avail: u16 = self.height; if (title_on) avail -|= 2; // content row + bottom padding if (self.show_status_bar) avail -|= 2; // content row + bottom padding avail -|= pagination_rows; avail -|= help_rows;
if (title_on) { try self.viewTitle(writer); try writer.writeByte('\n'); try writer.writeByte('\n'); } if (self.show_status_bar) { try self.viewStatus(writer); try writer.writeByte('\n'); try writer.writeByte('\n'); }
try self.viewItems(writer, avail);
if (pagination_rows > 0) { try self.viewPagination(writer); try writer.writeByte('\n'); } if (help_rows > 0) { try self.viewHelp(writer); }}
fn viewTitle(self: *List, writer: *Writer) Writer.Error!void { if (self.show_filter and self.filter_state == .filtering) { try writer.writeAll(" "); try self.filter_input.view(writer); return; } // TitleBar left padding (bubbles TitleBar.Padding(0,0,1,2)). try writer.writeAll(" "); if (self.show_title) { try util.writeInline(writer, self.styles.title, " "); try util.writeInline(writer, self.styles.title, self.title_text); try util.writeInline(writer, self.styles.title, " "); if (self.status_message.len > 0) { try writer.writeAll(" "); try writer.writeAll(self.status_message); } } if (self.show_spinner) { try writer.writeAll(" "); try self.spinner.view(writer); }}
fn viewStatus(self: *List, writer: *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}); }
// StatusBar left padding (bubbles StatusBar.Padding(0,0,1,2)). try writer.writeAll(" "); try util.writeInline(writer, self.styles.status_bar, sw.buffered());}
fn viewPagination(self: *List, writer: *Writer) Writer.Error!void { if (self.paginator.total_pages < 2) return; try util.writeInline(writer, self.styles.pagination, " ");
// Measure the paginator; if the dot form is wider than the list, fall // back to the arabic paginator (list.go:1188-1193). var scratch: [512]u8 = undefined; var sw: Writer = .fixed(&scratch); if (self.paginator.view(&sw)) |_| { const rendered = sw.buffered(); if (self.width > 0 and matte.measure.cellWidth(rendered) > self.width) { try self.viewPaginationArabic(writer); } else { try writer.writeAll(rendered); } } else |_| { // Scratch overflow: the dot form is definitely too wide. try self.viewPaginationArabic(writer); }}
fn viewPaginationArabic(self: *List, writer: *Writer) Writer.Error!void { const saved = self.paginator.type; self.paginator.type = .arabic; defer self.paginator.type = saved; try self.paginator.view(writer);}
fn helpContent(self: *List, writer: *Writer) Writer.Error!void { 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(writer, &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(writer, &bindings); }}
/// Height of the help section in rows, including the 1-row top padding/// (bubbles `HelpStyle.Padding(1,0,0,2)`).fn helpRows(self: *List) u16 { if (!self.show_help) return 0; var scratch: [4096]u8 = undefined; var sw: Writer = .fixed(&scratch); self.helpContent(&sw) catch return 2; return 1 + matte.measure.lineCount(sw.buffered());}
fn viewHelp(self: *List, writer: *Writer) Writer.Error!void { var scratch: [4096]u8 = undefined; var sw: Writer = .fixed(&scratch); try self.helpContent(&sw);
// HelpStyle top padding (1 blank row) + left padding (2 cells) applied to // every row of the help block, matching bubbles HelpStyle.Padding(1,0,0,2). try writer.writeByte('\n'); var it = std.mem.splitScalar(u8, sw.buffered(), '\n'); var first = true; while (it.next()) |line| { if (!first) try writer.writeByte('\n'); try util.writeInline(writer, self.styles.help, " "); try writer.writeAll(line); first = false; }}
fn viewItems(self: *List, writer: *Writer, avail: u16) Writer.Error!void { const total = self.visibleItems(); if (total == 0) { if (self.filter_state != .filtering) { try util.writeInline(writer, self.styles.no_items, "No "); try util.writeInline(writer, self.styles.no_items, self.item_name_plural); try util.writeInline(writer, self.styles.no_items, "."); } // Pad to `avail` rows. The "No items." line (if any) is not yet // newline-terminated, so this terminates it and fills the rest. var r: u16 = 0; while (r < avail) : (r += 1) try writer.writeByte('\n'); 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 n: u16 = @intCast(end - start);
var i: usize = start; while (i < end) : (i += 1) { const it = self.itemAt(i) orelse continue; try d.render(writer, self, i, it); if (i + 1 < end) { var s: u16 = 0; while (s < d.spacing() + 1) : (s += 1) try writer.writeByte('\n'); } }
// The last rendered row is not newline-terminated; terminate it, then pad // blank rows so the items section is exactly `avail` rows (bubbles wraps // populatedView in Height(availHeight)). const content_rows = n * d.height() + (if (n > 0) n - 1 else 0) * d.spacing(); const needed: u16 = 1 +| (avail -| content_rows); var r: u16 = 0; while (r < needed) : (r += 1) try writer.writeByte('\n');}
const DefaultItemStyles = struct { normal_title: ?matte.Style = .new(.{ .foreground = .rgb(0xDD, 0xDD, 0xDD) }), normal_desc: ?matte.Style = .new(.{ .foreground = .rgb(0x77, 0x77, 0x77) }), selected_title: ?matte.Style = .new(.{ .foreground = .rgb(0xEE, 0x6F, 0xF8) }), selected_desc: ?matte.Style = .new(.{ .foreground = .rgb(0xAD, 0x58, 0xB4) }), /// Foreground for the selected row's left border mark (bubbles' /// `SelectedTitle.BorderForeground`). selected_border: ?matte.Style = .new(.{ .foreground = .rgb(0xAD, 0x58, 0xB4) }), dimmed_title: ?matte.Style = .new(.{ .foreground = .rgb(0x77, 0x77, 0x77) }), dimmed_desc: ?matte.Style = .new(.{ .foreground = .rgb(0x4D, 0x4D, 0x4D) }), filter_match: ?matte.Style = .new(.{ .underline = .single }),};
pub const DefaultDelegate = struct { show_description: bool = true, height_value: u16 = 2, spacing_value: u16 = 1, /// Left-edge mark for the selected row (bubbles renders the left border /// glyph of `lipgloss.NormalBorder` followed by one padding cell). selected_mark: []const u8 = "│ ", normal_padding_left: u16 = 2, styles: DefaultItemStyles = .{},
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, writer: *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: ?matte.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: ?matte.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(writer, self.styles.selected_border, self.selected_mark); } else { var i: u16 = 0; while (i < pad_l) : (i += 1) try writer.writeByte(' '); } const match_info: []const usize = if (is_filtered_view) (list.matchesForItem(idx) orelse &.{}) else &.{}; try writeMaybeMatched(writer, title_text, title_style, self.styles.filter_match, match_info);
if (self.show_description) { try writer.writeByte('\n'); if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) { try util.writeInline(writer, self.styles.selected_border, self.selected_mark); } else { var i: u16 = 0; while (i < pad_l) : (i += 1) try writer.writeByte(' '); } try util.writeInline(writer, desc_style, desc_text); } }};
fn writeMaybeMatched( writer: *Writer, text: []const u8, base: ?matte.Style, match_style: ?matte.Style, matched: []const usize,) Writer.Error!void { if (matched.len == 0 or match_style == null) { try util.writeInline(writer, base, text); return; } var iter = (std.unicode.Utf8View.init(text) catch return util.writeInline(writer, base, text)).iterator(); var cp_idx: usize = 0; var mi: usize = 0; // cursor into `matched` (ascending) var buf: [4]u8 = undefined; while (iter.nextCodepoint()) |cp| { const in_match = mi < matched.len and matched[mi] == cp_idx; if (in_match) mi += 1; 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: matte.Style = base orelse matte.Style.new(.{}); const ms = match_style.?; if (ms.internal.bold) merged.internal.bold = true; if (ms.internal.italic) merged.internal.italic = true; if (ms.internal.underline.style != .none) merged.internal.underline = ms.internal.underline; if (ms.internal.faint) merged.internal.faint = true; break :blk merged; } else (base orelse matte.Style.new(.{})); try util.writeInline(writer, style, buf[0..len]); cp_idx += 1; }}
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(t.allocator); list.setItems(&items); list.filter_kind = .substring; list.filter_state = .filtering; try list.filter_input.setValue(t.allocator, "AP"); try list.refilter(t.allocator); try t.expectEqual(@as(usize, 2), list.filtered.items.len); try t.expectEqual(@as(usize, 0), list.filtered.items[0].index); try t.expectEqual(@as(usize, 2), list.filtered.items[1].index);}
// Golden parity data captured from `fuzzy.Find` of sahilm/fuzzy v0.1.3, the// library behind bubbles' DefaultFilter. Indexes refer to `golden_targets`.const golden_targets = [_][]const u8{ "Raspberry Pi's", "Nutella", "Bitter melon", "Nice socks", "Eight hours of sleep", "Cats", "Plantasia, the album", "Pour over coffee", "VR", "Noguchi Lamps", "Linux", "Business school", "Pottery", "Shampoo", "Table tennis", "Milk crates", "Afternoon tea", "Stickers", "20° Weather", "Warm light", "The vernal equinox", "Gaffer's tape", "Terrycloth", "The Black Knight", "The blah Knight", "mellow", "Melon", "CamelCaseText", "camel_case_text",};
const GoldenMatch = struct { idx: usize, score: i32, matched: []const usize };
test "fuzzyFind matches sahilm/fuzzy golden data" { const cases = [_]struct { pattern: []const u8, expected: []const GoldenMatch }{ .{ .pattern = "melo", .expected = &.{ .{ .idx = 26, .score = 74, .matched = &.{ 0, 1, 2, 3 } }, .{ .idx = 2, .score = 62, .matched = &.{ 7, 8, 9, 10 } }, .{ .idx = 25, .score = 28, .matched = &.{ 0, 1, 2, 4 } }, } }, .{ .pattern = "tk", .expected = &.{ .{ .idx = 24, .score = 17, .matched = &.{ 0, 9 } }, .{ .idx = 23, .score = 16, .matched = &.{ 0, 10 } }, .{ .idx = 17, .score = -11, .matched = &.{ 1, 4 } }, } }, .{ .pattern = "cat", .expected = &.{ .{ .idx = 5, .score = 29, .matched = &.{ 0, 1, 2 } }, .{ .idx = 27, .score = 25, .matched = &.{ 0, 1, 9 } }, .{ .idx = 28, .score = 23, .matched = &.{ 0, 1, 11 } }, .{ .idx = 15, .score = 2, .matched = &.{ 5, 7, 8 } }, } }, .{ .pattern = "tea", .expected = &.{ .{ .idx = 6, .score = 8, .matched = &.{ 11, 13, 15 } }, .{ .idx = 24, .score = -2, .matched = &.{ 0, 2, 6 } }, .{ .idx = 23, .score = -3, .matched = &.{ 0, 2, 6 } }, .{ .idx = 20, .score = -5, .matched = &.{ 0, 2, 8 } }, .{ .idx = 1, .score = -9, .matched = &.{ 2, 3, 6 } }, .{ .idx = 16, .score = -15, .matched = &.{ 2, 3, 12 } }, } }, .{ .pattern = "po c", .expected = &.{ .{ .idx = 7, .score = 23, .matched = &.{ 0, 1, 4, 10 } }, } }, .{ .pattern = "CC", .expected = &.{ .{ .idx = 27, .score = 19, .matched = &.{ 0, 5 } }, .{ .idx = 28, .score = 17, .matched = &.{ 0, 6 } }, .{ .idx = 3, .score = -18, .matched = &.{ 2, 7 } }, } }, .{ .pattern = "20", .expected = &.{ .{ .idx = 18, .score = 5, .matched = &.{ 0, 1 } }, } }, .{ .pattern = "zzz", .expected = &.{} }, };
for (cases) |case| { var buf: ArrayList(usize) = .empty; defer buf.deinit(t.allocator); const ranks = try fuzzyFind(t.allocator, case.pattern, &golden_targets, &buf); defer t.allocator.free(ranks); try t.expectEqual(case.expected.len, ranks.len); for (ranks, case.expected) |r, e| { try t.expectEqual(e.idx, r.index); try t.expectEqual(e.score, r.score); try t.expectEqualSlices(usize, e.matched, buf.items[r.match_off..][0..r.match_len]); } }}
test "fuzzy filter through the list orders by score and exposes matches" { var a: TestItem = .{ .title_text = "Bitter melon" }; var b: TestItem = .{ .title_text = "Melon" }; var c: TestItem = .{ .title_text = "mellow" }; var items = [_]Item{ a.item(), b.item(), c.item() };
var list: List = .{}; defer list.deinit(t.allocator); list.setItems(&items); list.filter_state = .filtering; try list.filter_input.setValue(t.allocator, "melo"); try list.refilter(t.allocator);
try t.expectEqual(@as(usize, 3), list.filtered.items.len); // Sorted best-first: Melon, Bitter melon, mellow. try t.expectEqual(@as(usize, 1), list.filtered.items[0].index); try t.expectEqual(@as(usize, 0), list.filtered.items[1].index); try t.expectEqual(@as(usize, 2), list.filtered.items[2].index); try t.expectEqualStrings("Melon", list.itemAt(0).?.title().?); try t.expectEqualSlices(usize, &.{ 7, 8, 9, 10 }, list.matchesForItem(1).?); // Matches need not be contiguous: mellow → m,e,l,o at 0,1,2,4. try t.expectEqualSlices(usize, &.{ 0, 1, 2, 4 }, list.matchesForItem(2).?); // Matched indexes vanish once the filter is reset. list.resetFilter(t.allocator); try t.expect(list.matchesForItem(0) == null);}
test "DefaultDelegate underlines all matched runes while filtering" { var a: TestItem = .{ .title_text = "mellow" }; var items = [_]Item{a.item()}; var list: List = .{ .width = 40, .height = 12 }; defer list.deinit(t.allocator); var dd: DefaultDelegate = .{}; list.setItems(&items); list.setDelegate(dd.delegate()); list.filter_state = .filtering; try list.filter_input.setValue(t.allocator, "melo"); try list.refilter(t.allocator); try t.expectEqualSlices(usize, &.{ 0, 1, 2, 4 }, list.matchesForItem(0).?);
var buf: [4096]u8 = undefined; var w: Writer = .fixed(&buf); try list.delegate.?.render(&w, &list, 0, items[0]); const rendered = w.buffered();
// Exact render: title runes m,e,l,o (codepoints 0,1,2,4) underlined, the // second l (3) and w (5) not; then the empty description row. const title_sgr = "\x1b[38;2;221;221;221m"; // dimmed→normal title fg while filtering const desc_sgr = "\x1b[38;2;119;119;119m"; var want: ArrayList(u8) = .empty; defer want.deinit(t.allocator); try want.appendSlice(t.allocator, " "); // normal_padding_left for ([_]struct { ch: u8, matched: bool }{ .{ .ch = 'm', .matched = true }, .{ .ch = 'e', .matched = true }, .{ .ch = 'l', .matched = true }, .{ .ch = 'l', .matched = false }, .{ .ch = 'o', .matched = true }, .{ .ch = 'w', .matched = false }, }) |r| { if (r.matched) try want.appendSlice(t.allocator, "\x1b[4m"); // filter_match try want.appendSlice(t.allocator, title_sgr); try want.append(t.allocator, r.ch); try want.appendSlice(t.allocator, "\x1b[0m"); } try want.appendSlice(t.allocator, "\n " ++ desc_sgr ++ "\x1b[0m"); try t.expectEqualStrings(want.items, rendered);}
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(t.allocator); list.setItems(&items); list.paginator.per_page = 2; _ = list.paginator.setTotalPages(3);
list.cursorDown(); try t.expectEqual(@as(u16, 1), list.cursor); try t.expectEqual(@as(u16, 0), list.paginator.page); list.cursorDown(); try t.expectEqual(@as(u16, 0), list.cursor); try t.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(t.allocator); list.setItems(&items);
const slash: tea.Msg = .{ .key_press = .{ .kind = .rune, .codepoint = '/' } }; const cmd = list.update(t.allocator, slash); if (cmd) |cm| if (cm.free_ctx) |f| { if (cm.ctx) |ctx| f(t.allocator, ctx); }; try t.expect(cmd != null); try t.expect(list.filter_state == .filtering); try t.expectEqual(@as(usize, 3), list.visibleItems());}
test "pagination falls back to arabic when dots exceed width" { var list: List = .{}; defer list.deinit(t.allocator); list.width = 5; list.paginator.type = .dots; list.paginator.per_page = 1; _ = list.paginator.setTotalPages(20);
var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try list.viewPagination(&w); // 20 dots (20 cells) exceed width 5, so the arabic form is used. try t.expect(std.mem.indexOf(u8, w.buffered(), "1/20") != null); // And the paginator type is restored to dots afterward. try t.expect(list.paginator.type == .dots);}
test "quit key is 'q', not 'v'" { var a: TestItem = .{ .title_text = "1" }; var items = [_]Item{a.item()}; var list: List = .{}; defer list.deinit(t.allocator); list.setItems(&items);
// 'q' quits. const q: tea.Msg = .{ .key_press = .q }; const cmd1 = list.update(t.allocator, q); try t.expect(cmd1 != null); try t.expect(cmd1.?.run_fn == tea.Cmd.quit.run_fn);
// 'v' does not quit. const v: tea.Msg = .{ .key_press = .v }; try t.expect(list.update(t.allocator, v) == null);}
test "view height matches configured height (short and full help)" { var a: TestItem = .{ .title_text = "Apple", .desc_text = "a fruit" }; var b: TestItem = .{ .title_text = "Banana", .desc_text = "b fruit" }; var c: TestItem = .{ .title_text = "Cherry", .desc_text = "c fruit" }; var items = [_]Item{ a.item(), b.item(), c.item() }; var list: List = .{ .width = 60, .height = 20 }; defer list.deinit(t.allocator); var dd: DefaultDelegate = .{}; list.setItems(&items); list.setDelegate(dd.delegate()); list.updateKeybindings(); list.updatePagination();
var buf: [8192]u8 = undefined; var w: Writer = .fixed(&buf); try list.view(&w); try t.expectEqual(@as(u16, 20), matte.measure.lineCount(w.buffered()));
list.help.show_all = true; list.updatePagination(); var buf2: [8192]u8 = undefined; var w2: Writer = .fixed(&buf2); try list.view(&w2); try t.expectEqual(@as(u16, 20), matte.measure.lineCount(w2.buffered()));}
test "? + / + esc with full help open keeps the view within height" { // Regression: cancelling a filter while full help is open used to leave // per_page measured against the shrunken filtering-mode help (most // bindings are disabled while filtering), overflowing the frame. const titles = [_][]const u8{ "Raspberry Pi's", "Nutella", "Bitter melon", "Nice socks", "Eight hours", "Cats", "Plantasia", "Pour over", "VR", "Noguchi Lamps", "Linux", "Business", "Pottery", "Shampoo", "Table tennis", "Milk crates", "Afternoon tea", "Stickers", "20 Weather", "Warm light", "Vernal equinox", "Gaffer's tape", "Terrycloth", }; var test_items: [titles.len]TestItem = undefined; var refs: [titles.len]Item = undefined; for (&test_items, titles, 0..) |*it, title, i| { it.* = .{ .title_text = title, .desc_text = "desc" }; refs[i] = it.item(); }
var list: List = .{}; defer list.deinit(t.allocator); var dd: DefaultDelegate = .{}; list.setItems(&refs); list.setDelegate(dd.delegate()); list.setSize(76, 28);
const q: tea.Msg = .{ .key_press = .@"?" }; const slash: tea.Msg = .{ .key_press = .rune('/') }; const esc: tea.Msg = .{ .key_press = .escape }; const j: tea.Msg = .{ .key_press = .j }; const k: tea.Msg = .{ .key_press = .k };
var buf: [32768]u8 = undefined; var w: Writer = .fixed(&buf); try list.view(&w); try t.expectEqual(@as(u16, 28), matte.measure.lineCount(w.buffered()));
_ = list.update(t.allocator, q); if (list.update(t.allocator, slash)) |cm| if (cm.free_ctx) |f| { if (cm.ctx) |ctx| f(t.allocator, ctx); }; _ = list.update(t.allocator, esc); try t.expect(list.filter_state == .unfiltered); try t.expect(list.help.show_all);
// The frame must still fit `height` after cancelling the filter... var buf2: [32768]u8 = undefined; var w2: Writer = .fixed(&buf2); try list.view(&w2); try t.expectEqual(@as(u16, 28), matte.measure.lineCount(w2.buffered()));
// ...and while paging through items afterwards. _ = list.update(t.allocator, j); var buf3: [32768]u8 = undefined; var w3: Writer = .fixed(&buf3); try list.view(&w3); try t.expectEqual(@as(u16, 28), matte.measure.lineCount(w3.buffered())); _ = list.update(t.allocator, k); var buf4: [32768]u8 = undefined; var w4: Writer = .fixed(&buf4); try list.view(&w4); try t.expectEqual(@as(u16, 28), matte.measure.lineCount(w4.buffered()));}