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 # SYNOPSIS
*wmenu* [-biPv] \ *wmenu* [-bFiPv] \
[-f _font_] \ [-f _font_] \
[-l _lines_] \ [-l _lines_] \
[-o _output_] \ [-o _output_] \
@ -39,6 +39,9 @@ $PATH and runs the result.
wmenu will not directly display the keyboard input, but instead replace it wmenu will not directly display the keyboard input, but instead replace it
with asterisks. with asterisks.
*-F*
enables fuzzy matching, ranking results by how closely they match the input.
*-v* *-v*
prints version information to stdout, then exits. 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. // Parse menu options from command line arguments.
void menu_getopts(struct menu *menu, int argc, char *argv[]) { void menu_getopts(struct menu *menu, int argc, char *argv[]) {
const char *usage = const char *usage =
"Usage: wmenu [-biPv] [-f font] [-l lines] [-o output] [-p prompt]\n" "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[-N color] [-n color] [-M color] [-m color] [-S color] [-s color]\n"
"\t-F enable fuzzy matching\n";
int opt; 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) { switch (opt) {
case 'b': case 'b':
menu->bottom = true; menu->bottom = true;
@ -100,6 +101,9 @@ void menu_getopts(struct menu *menu, int argc, char *argv[]) {
case 'P': case 'P':
menu->passwd = true; menu->passwd = true;
break; break;
case 'F':
menu->fuzzy = true;
break;
case 'v': case 'v':
puts("wmenu " VERSION); puts("wmenu " VERSION);
exit(EXIT_SUCCESS); exit(EXIT_SUCCESS);
@ -293,6 +297,120 @@ static void append_match(struct item *item, struct item **first, struct item **l
*last = item; *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) { static void match_items(struct menu *menu) {
struct item *lexact = NULL, *exactend = NULL; struct item *lexact = NULL, *exactend = NULL;
struct item *lprefix = NULL, *prefixend = NULL; struct item *lprefix = NULL, *prefixend = NULL;
@ -307,6 +425,14 @@ static void match_items(struct menu *menu) {
menu->sel = NULL; menu->sel = NULL;
size_t input_len = strlen(menu->input); 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 */ /* tokenize input by space for matching the tokens individually */
strcpy(buf, menu->input); strcpy(buf, menu->input);
@ -315,7 +441,7 @@ static void match_items(struct menu *menu) {
tokv = realloc(tokv, (tokc + 1) * sizeof *tokv); tokv = realloc(tokv, (tokc + 1) * sizeof *tokv);
if (!tokv) { if (!tokv) {
fprintf(stderr, "could not realloc %zu bytes", fprintf(stderr, "could not realloc %zu bytes",
(tokc + 1) * sizeof *tokv); (tokc + 1) * sizeof *tokv);
exit(EXIT_FAILURE); exit(EXIT_FAILURE);
} }
tokv[tokc] = tok; tokv[tokc] = tok;
@ -375,7 +501,6 @@ static void match_items(struct menu *menu) {
menu->sel = menu->pages->first; menu->sel = menu->pages->first;
} }
} }
// Marks the menu as needing to be rendered again. // Marks the menu as needing to be rendered again.
void menu_invalidate(struct menu *menu) { void menu_invalidate(struct menu *menu) {
menu->rendered = false; menu->rendered = false;

2
menu.h
View file

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