From 95b4f215f1f0853027bb9ae24bfa4beac3dbae9d Mon Sep 17 00:00:00 2001 From: Filip Rojek Date: Mon, 20 Oct 2025 23:16:34 +0200 Subject: [PATCH] Add optional fuzzy matching --- docs/wmenu.1.scd | 5 +- menu.c | 137 ++++++++++++++++++++++++++++++++++++++++++++--- menu.h | 2 + 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/docs/wmenu.1.scd b/docs/wmenu.1.scd index 4519e8b..9ce21c8 100644 --- a/docs/wmenu.1.scd +++ b/docs/wmenu.1.scd @@ -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. diff --git a/menu.c b/menu.c index 207d71c..dad1de3 100644 --- a/menu.c +++ b/menu.c @@ -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; diff --git a/menu.h b/menu.h index fdd9ad2..64307ef 100644 --- a/menu.h +++ b/menu.h @@ -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