Add optional fuzzy matching

This commit is contained in:
Filip Rojek 2025-10-20 23:16:34 +02:00 committed by GitHub
parent 0a38d45abb
commit 95b4f215f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 137 additions and 7 deletions

View file

@ -6,7 +6,7 @@ wmenu - dynamic menu for Wayland
# SYNOPSIS
*wmenu* [-biPv] \
*wmenu* [-bFiPv] \
[-f _font_] \
[-l _lines_] \
[-o _output_] \
@ -39,6 +39,9 @@ $PATH and runs the result.
wmenu will not directly display the keyboard input, but instead replace it
with asterisks.
*-F*
enables fuzzy matching, ranking results by how closely they match the input.
*-v*
prints version information to stdout, then exits.

137
menu.c
View file

@ -84,12 +84,13 @@ static bool parse_color(const char *color, uint32_t *result) {
// Parse menu options from command line arguments.
void menu_getopts(struct menu *menu, int argc, char *argv[]) {
const char *usage =
"Usage: wmenu [-biPv] [-f font] [-l lines] [-o output] [-p prompt]\n"
"\t[-N color] [-n color] [-M color] [-m color] [-S color] [-s color]\n";
const char *usage =
"Usage: wmenu [-bFiPv] [-f font] [-l lines] [-o output] [-p prompt]\n"
"\t[-N color] [-n color] [-M color] [-m color] [-S color] [-s color]\n"
"\t-F enable fuzzy matching\n";
int opt;
while ((opt = getopt(argc, argv, "bhiPvf:l:o:p:N:n:M:m:S:s:")) != -1) {
while ((opt = getopt(argc, argv, "bhiPFvf:l:o:p:N:n:M:m:S:s:")) != -1) {
switch (opt) {
case 'b':
menu->bottom = true;
@ -100,6 +101,9 @@ void menu_getopts(struct menu *menu, int argc, char *argv[]) {
case 'P':
menu->passwd = true;
break;
case 'F':
menu->fuzzy = true;
break;
case 'v':
puts("wmenu " VERSION);
exit(EXIT_SUCCESS);
@ -293,6 +297,120 @@ static void append_match(struct item *item, struct item **first, struct item **l
*last = item;
}
struct fuzzy_match {
struct item *item;
int score;
size_t index;
};
static char normalize_char(struct menu *menu, char c) {
if (menu->strncmp == strncasecmp) {
return (char)tolower((unsigned char)c);
}
return c;
}
static bool fuzzy_match(struct menu *menu, const char *text, const char *pattern, int *score) {
if (!pattern[0]) {
*score = 0;
return true;
}
int start = -1;
int last = -1;
int total_gap = 0;
int idx = 0;
for (size_t pi = 0; pattern[pi]; pi++) {
char pc = normalize_char(menu, pattern[pi]);
bool found = false;
for (; text[idx]; idx++) {
char tc = normalize_char(menu, text[idx]);
if (tc == pc) {
if (start == -1) {
start = idx;
} else {
total_gap += idx - last - 1;
}
last = idx;
idx++;
found = true;
break;
}
}
if (!found) {
return false;
}
}
if (start == -1) {
return false;
}
int span = last - start;
*score = start * 2048 + total_gap * 128 + span;
return true;
}
static int compare_fuzzy(const void *a, const void *b) {
const struct fuzzy_match *ma = a;
const struct fuzzy_match *mb = b;
if (ma->score != mb->score) {
return (ma->score > mb->score) - (ma->score < mb->score);
}
if (ma->index < mb->index) {
return -1;
}
if (ma->index > mb->index) {
return 1;
}
return 0;
}
static void fuzzy_match_items(struct menu *menu) {
struct fuzzy_match *matches = NULL;
size_t match_count = 0;
size_t match_capacity = 0;
for (size_t k = 0; k < menu->item_count; k++) {
struct item *item = &menu->items[k];
int score;
if (!fuzzy_match(menu, item->text, menu->input, &score)) {
continue;
}
if (match_count == match_capacity) {
size_t new_capacity = match_capacity ? match_capacity * 2 : 16;
void *new_matches = realloc(matches, new_capacity * sizeof(*matches));
if (!new_matches) {
fprintf(stderr, "could not realloc %zu bytes", new_capacity * sizeof(*matches));
exit(EXIT_FAILURE);
}
match_capacity = new_capacity;
matches = new_matches;
}
matches[match_count].item = item;
matches[match_count].score = score;
matches[match_count].index = k;
match_count++;
}
if (match_count == 0) {
free(matches);
menu->matches = NULL;
menu->matches_end = NULL;
return;
}
qsort(matches, match_count, sizeof(*matches), compare_fuzzy);
menu->matches = matches[0].item;
menu->matches->prev_match = NULL;
menu->matches->next_match = NULL;
struct item *prev = menu->matches;
for (size_t i = 1; i < match_count; i++) {
struct item *item = matches[i].item;
item->prev_match = prev;
item->next_match = NULL;
prev->next_match = item;
prev = item;
}
prev->next_match = NULL;
menu->matches_end = prev;
free(matches);
}
static void match_items(struct menu *menu) {
struct item *lexact = NULL, *exactend = NULL;
struct item *lprefix = NULL, *prefixend = NULL;
@ -307,6 +425,14 @@ static void match_items(struct menu *menu) {
menu->sel = NULL;
size_t input_len = strlen(menu->input);
if (menu->fuzzy && input_len > 0) {
fuzzy_match_items(menu);
page_items(menu);
if (menu->pages) {
menu->sel = menu->pages->first;
}
return;
}
/* tokenize input by space for matching the tokens individually */
strcpy(buf, menu->input);
@ -315,7 +441,7 @@ static void match_items(struct menu *menu) {
tokv = realloc(tokv, (tokc + 1) * sizeof *tokv);
if (!tokv) {
fprintf(stderr, "could not realloc %zu bytes",
(tokc + 1) * sizeof *tokv);
(tokc + 1) * sizeof *tokv);
exit(EXIT_FAILURE);
}
tokv[tokc] = tok;
@ -375,7 +501,6 @@ static void match_items(struct menu *menu) {
menu->sel = menu->pages->first;
}
}
// Marks the menu as needing to be rendered again.
void menu_invalidate(struct menu *menu) {
menu->rendered = false;

2
menu.h
View file

@ -35,6 +35,8 @@ struct menu {
int (*strncmp)(const char *, const char *, size_t);
// Whether the input is a password
bool passwd;
// Enable fuzzy matching
bool fuzzy;
// The font used to display the menu
char *font;
// The number of lines to list items vertically