From 35e25a311f48bf65ebcb18ea3d149bb0a31c23f1 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Wed, 24 Apr 2024 19:02:16 +0200 Subject: [PATCH] scripting: add mp.input.select() This allows scripts to make the user choose from a list of entries by typing part of their text and/or by navigating them with keybindings, like dmenu or fzf. Closes #13964. --- DOCS/interface-changes/input-select.txt | 1 + DOCS/man/javascript.rst | 4 +- DOCS/man/lua.rst | 43 ++- fzy/bonus.h | 156 +++++++++++ fzy/match.c | 332 ++++++++++++++++++++++++ fzy/match.h | 84 ++++++ meson.build | 3 +- player/javascript/defaults.js | 44 ++-- player/lua.c | 81 ++++++ player/lua/console.lua | 143 +++++++++- player/lua/input.lua | 33 ++- 11 files changed, 886 insertions(+), 38 deletions(-) create mode 100644 DOCS/interface-changes/input-select.txt create mode 100644 fzy/bonus.h create mode 100644 fzy/match.c create mode 100644 fzy/match.h diff --git a/DOCS/interface-changes/input-select.txt b/DOCS/interface-changes/input-select.txt new file mode 100644 index 0000000000000..03e421a6c7423 --- /dev/null +++ b/DOCS/interface-changes/input-select.txt @@ -0,0 +1 @@ +`add mp.input.select()` diff --git a/DOCS/man/javascript.rst b/DOCS/man/javascript.rst index 0edb01f674974..6dfae8b601286 100644 --- a/DOCS/man/javascript.rst +++ b/DOCS/man/javascript.rst @@ -196,7 +196,9 @@ meta-paths like ``~~/foo`` (other JS file functions do expand meta paths). ``mp.options.read_options(obj [, identifier [, on_update]])`` (types: string/boolean/number) -``mp.input.get(obj)`` (LE) +``mp.input.get(obj)`` + +``mp.input.select(obj)`` ``mp.input.terminate()`` diff --git a/DOCS/man/lua.rst b/DOCS/man/lua.rst index 73776964d5b8b..6e020b4a4954c 100644 --- a/DOCS/man/lua.rst +++ b/DOCS/man/lua.rst @@ -888,9 +888,8 @@ REPL. present a list of options with ``input.set_log()``. ``edited`` - A callback invoked when the text changes. This can be used to filter a - list of options based on what the user typed with ``input.set_log()``, - like dmenu does. The first argument is the text in the console. + A callback invoked when the text changes. The first argument is the text + in the console. ``complete`` A callback invoked when the user presses TAB. The first argument is the @@ -951,6 +950,44 @@ REPL. } }) +``input.select(table)`` + Specify a list of items that are presented to the user for selection. The + user can type part of the desired option and/or navigate them with + keybindings: `Down` and `Ctrl+n` go down, `Up` and `Ctrl+p` go up, `Page + down` and `Ctrl+f`` scroll down one page, and `Page up` and `Ctrl+b` scroll + up one page. + + The following entries of ``table`` are read: + + ``prompt`` + The string to be displayed before the input field. + + ``items`` + The table of the entries to choose from. + + ``default_item`` + The 1-based integer index of the preselected item. + + ``submit`` + A callback invoked when the user presses Enter. The first argument is + the 1-based integer index of the selected option. You can close the + console from within the callback by calling ``input.terminate()``. + + Example: + + :: + + input.select({ + items = { + "First playlist entry", + "Second playlist entry", + }, + submit = function (id) + mp.commandv("playlist-play-index", id - 1) + input.terminate() + end, + }) + Events ------ diff --git a/fzy/bonus.h b/fzy/bonus.h new file mode 100644 index 0000000000000..14e999c014719 --- /dev/null +++ b/fzy/bonus.h @@ -0,0 +1,156 @@ +/* The MIT License (MIT) + +Copyright (c) 2020 Seth Warn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* bonus.h + * precomputed scoring for the fzy algorithm + * + * original code by John Hawthorn, https://github.com/jhawthorn/fzy + * modifications by + * Rom Grk, https://github.com/romgrk + * Seth Warn, https://github.com/swarn + */ + +#ifndef BONUS_H +#define BONUS_H + +#include "match.h" + + +#define SCORE_GAP_LEADING (-0.005) +#define SCORE_GAP_TRAILING (-0.005) +#define SCORE_GAP_INNER (-0.01) +#define SCORE_MATCH_CONSECUTIVE (1.0) +#define SCORE_MATCH_SLASH (0.9) +#define SCORE_MATCH_WORD (0.8) +#define SCORE_MATCH_CAPITAL (0.7) +#define SCORE_MATCH_DOT (0.6) + +// clang-format off +#define ASSIGN_LOWER(v) \ + ['a'] = (v), \ + ['b'] = (v), \ + ['c'] = (v), \ + ['d'] = (v), \ + ['e'] = (v), \ + ['f'] = (v), \ + ['g'] = (v), \ + ['h'] = (v), \ + ['i'] = (v), \ + ['j'] = (v), \ + ['k'] = (v), \ + ['l'] = (v), \ + ['m'] = (v), \ + ['n'] = (v), \ + ['o'] = (v), \ + ['p'] = (v), \ + ['q'] = (v), \ + ['r'] = (v), \ + ['s'] = (v), \ + ['t'] = (v), \ + ['u'] = (v), \ + ['v'] = (v), \ + ['w'] = (v), \ + ['x'] = (v), \ + ['y'] = (v), \ + ['z'] = (v) + +#define ASSIGN_UPPER(v) \ + ['A'] = (v), \ + ['B'] = (v), \ + ['C'] = (v), \ + ['D'] = (v), \ + ['E'] = (v), \ + ['F'] = (v), \ + ['G'] = (v), \ + ['H'] = (v), \ + ['I'] = (v), \ + ['J'] = (v), \ + ['K'] = (v), \ + ['L'] = (v), \ + ['M'] = (v), \ + ['N'] = (v), \ + ['O'] = (v), \ + ['P'] = (v), \ + ['Q'] = (v), \ + ['R'] = (v), \ + ['S'] = (v), \ + ['T'] = (v), \ + ['U'] = (v), \ + ['V'] = (v), \ + ['W'] = (v), \ + ['X'] = (v), \ + ['Y'] = (v), \ + ['Z'] = (v) + +#define ASSIGN_DIGIT(v) \ + ['0'] = (v), \ + ['1'] = (v), \ + ['2'] = (v), \ + ['3'] = (v), \ + ['4'] = (v), \ + ['5'] = (v), \ + ['6'] = (v), \ + ['7'] = (v), \ + ['8'] = (v), \ + ['9'] = (v) + +static const score_t bonus_states[3][256] = { + { 0 }, + { + ['/'] = SCORE_MATCH_SLASH, + ['\\'] = SCORE_MATCH_SLASH, + ['-'] = SCORE_MATCH_WORD, + ['_'] = SCORE_MATCH_WORD, + [' '] = SCORE_MATCH_WORD, + ['.'] = SCORE_MATCH_DOT, + }, + { + ['/'] = SCORE_MATCH_SLASH, + ['\\'] = SCORE_MATCH_SLASH, + ['-'] = SCORE_MATCH_WORD, + ['_'] = SCORE_MATCH_WORD, + [' '] = SCORE_MATCH_WORD, + ['.'] = SCORE_MATCH_DOT, + + /* ['a' ... 'z'] = SCORE_MATCH_CAPITAL, */ + ASSIGN_LOWER(SCORE_MATCH_CAPITAL) + } +}; + +static const index_t bonus_index[256] = { + /* ['A' ... 'Z'] = 2 */ + ASSIGN_UPPER(2), + + /* ['a' ... 'z'] = 1 */ + ASSIGN_LOWER(1), + + /* ['0' ... '9'] = 1 */ + ASSIGN_DIGIT(1) +}; + +// clang-format on + +#define COMPUTE_BONUS(last_ch, ch) \ + (bonus_states[bonus_index[(unsigned char)(ch)]][(unsigned char)(last_ch)]) + +#endif diff --git a/fzy/match.c b/fzy/match.c new file mode 100644 index 0000000000000..5e606341dc760 --- /dev/null +++ b/fzy/match.c @@ -0,0 +1,332 @@ +/* The MIT License (MIT) + +Copyright (c) 2020 Seth Warn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* match.c + * C implementation of fzy matching + * + * original code by John Hawthorn, https://github.com/jhawthorn/fzy + * modifications by + * Rom Grk, https://github.com/romgrk + * Seth Warn, https://github.com/swarn + */ + +#include "match.h" + +#include +#include +#include + +#include "bonus.h" + + +int has_match(char const * needle, char const * haystack, int case_sensitive) +{ + char needle_lower[MATCH_MAX_LEN + 1]; + char haystack_lower[MATCH_MAX_LEN + 1]; + + if (! case_sensitive) + { + int const n = (int)strlen(needle); + int const m = (int)strlen(haystack); + + for (int i = 0; i < n; i++) + needle_lower[i] = (char)tolower(needle[i]); + for (int i = 0; i < m; i++) + haystack_lower[i] = (char)tolower(haystack[i]); + + needle_lower[n] = 0; + haystack_lower[m] = 0; + + needle = needle_lower; + haystack = haystack_lower; + } + + while (*needle) + { + haystack = strchr(haystack, *needle++); + if (! haystack) + return 0; + + haystack++; + } + return 1; +} + + +#define SWAP(x, y, T) \ + do \ + { \ + T SWAP = x; \ + (x) = y; \ + (y) = SWAP; \ + } while (0) + +#define max(a, b) (((a) > (b)) ? (a) : (b)) + + +struct match_struct +{ + int needle_len; + int haystack_len; + + char const * needle; + char const * haystack; + + char lower_needle[MATCH_MAX_LEN]; + char lower_haystack[MATCH_MAX_LEN]; + + score_t match_bonus[MATCH_MAX_LEN]; +}; + + +static void precompute_bonus(char const * haystack, score_t * match_bonus) +{ + /* Which positions are beginning of words */ + char last_ch = '/'; + for (int i = 0; haystack[i]; i++) + { + char ch = haystack[i]; + match_bonus[i] = COMPUTE_BONUS(last_ch, ch); + last_ch = ch; + } +} + +static void setup_match_struct( + struct match_struct * match, + char const * needle, + char const * haystack, + int is_case_sensitive) +{ + match->needle_len = (int)strlen(needle); + match->haystack_len = (int)strlen(haystack); + + if (match->haystack_len > MATCH_MAX_LEN || match->needle_len > match->haystack_len) + { + return; + } + + if (is_case_sensitive) + { + match->needle = needle; + match->haystack = haystack; + } + else + { + for (int i = 0; i < match->needle_len; i++) + match->lower_needle[i] = (char)tolower(needle[i]); + + for (int i = 0; i < match->haystack_len; i++) + match->lower_haystack[i] = (char)tolower(haystack[i]); + + match->needle = match->lower_needle; + match->haystack = match->lower_haystack; + } + + precompute_bonus(haystack, match->match_bonus); +} + +static inline void match_row( + struct match_struct const * match, + int row, + score_t * curr_D, + score_t * curr_M, + score_t const * last_D, + score_t const * last_M) +{ + unsigned n = match->needle_len; + unsigned m = match->haystack_len; + int i = row; + + char const * needle = match->needle; + char const * haystack = match->haystack; + score_t const * match_bonus = match->match_bonus; + + score_t prev_score = SCORE_MIN; + score_t gap_score = i == n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER; + + for (int j = 0; j < m; j++) + { + if (needle[i] == haystack[j]) + { + score_t score = SCORE_MIN; + if (i == 0) + { + // The match_bonus values are computed out to the length of the + // haystack in precompute_bonus. The index j is less than m, + // the length of the haystack. So the "garbage value" warning + // here is false. + // NOLINTNEXTLINE(clang-analyzer-core.UndefinedBinaryOperatorResult) + score = (j * SCORE_GAP_LEADING) + match_bonus[j]; + } + else if (j) + { + /* i > 0 && j > 0*/ + score = + // NOLINTNEXTLINE(clang-analyzer-core.UndefinedBinaryOperatorResult) + max(last_M[j - 1] + match_bonus[j], + + /* consecutive match, doesn't stack with match_bonus */ + last_D[j - 1] + SCORE_MATCH_CONSECUTIVE); + } + curr_D[j] = score; + curr_M[j] = prev_score = max(score, prev_score + gap_score); + } + else + { + curr_D[j] = SCORE_MIN; + curr_M[j] = prev_score = prev_score + gap_score; + } + } +} + +score_t match(char const * needle, char const * haystack, int case_sensitive) +{ + if (! *needle) + return SCORE_MIN; + + struct match_struct match; + setup_match_struct(&match, needle, haystack, case_sensitive); + + unsigned n = match.needle_len; + unsigned m = match.haystack_len; + + // Unreasonably large candidate; return no score. If it is a valid match, + // it will still be returned, it will just be ranked below any reasonably + // sized candidates. + if (m > MATCH_MAX_LEN || n > m) + return SCORE_MIN; + + // If `needle` is a subsequence of `haystack` and the same length, then + // they are the same string. + if (n == m) + return SCORE_MAX; + + // D[][] Stores the best score for this position ending with a match. + // M[][] Stores the best possible score at this position. + score_t D[2][MATCH_MAX_LEN]; + score_t M[2][MATCH_MAX_LEN]; + + score_t * last_D = D[0]; + score_t * last_M = M[0]; + score_t * curr_D = D[1]; + score_t * curr_M = M[1]; + + for (int i = 0; i < n; i++) + { + match_row(&match, i, curr_D, curr_M, last_D, last_M); + + SWAP(curr_D, last_D, score_t *); + SWAP(curr_M, last_M, score_t *); + } + + return last_M[m - 1]; +} + +score_t match_positions( + char const * needle, + char const * haystack, + index_t * positions, + int is_case_sensitive) +{ + if (! *needle) + return SCORE_MIN; + + struct match_struct match; + setup_match_struct(&match, needle, haystack, is_case_sensitive); + + int n = match.needle_len; + int m = match.haystack_len; + + // Unreasonably large candidate; return no score. If it is a valid match, + // it will still be returned, it will just be ranked below any reasonably + // sized candidates + if (m > MATCH_MAX_LEN || n > m) + return SCORE_MIN; + + // If `needle` is a subsequence of `haystack` and the same length, then + // they are the same string. + if (n == m) + { + if (positions) + for (int i = 0; i < n; i++) + positions[i] = i; + + return SCORE_MAX; + } + + // D[][] Stores the best score for this position ending with a match. + // M[][] Stores the best possible score at this position. + typedef score_t score_row_t[MATCH_MAX_LEN]; + score_row_t * const D = malloc(sizeof(score_row_t) * n); + score_row_t * const M = malloc(sizeof(score_row_t) * n); + + score_t * last_D = NULL; + score_t * last_M = NULL; + score_t * curr_D = NULL; + score_t * curr_M = NULL; + + for (int i = 0; i < n; i++) + { + curr_D = &D[i][0]; + curr_M = &M[i][0]; + + match_row(&match, i, curr_D, curr_M, last_D, last_M); + + last_D = curr_D; + last_M = curr_M; + } + + /* backtrace to find the positions of optimal matching */ + int match_required = 0; + for (int i = n - 1, j = m - 1; i >= 0; i--) + { + for (; j >= 0; j--) + { + // There may be multiple paths which result in the optimal + // weight. + // + // For simplicity, we will pick the first one we encounter, + // the latest in the candidate string. + // NOLINTNEXTLINE(clang-analyzer-core.UndefinedBinaryOperatorResult) + if (D[i][j] != SCORE_MIN && (match_required || D[i][j] == M[i][j])) + { + // If this score was determined using SCORE_MATCH_CONSECUTIVE, + // the previous character MUST be a match + match_required = + i && j && M[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE; + + if (positions) + positions[i] = j--; + + break; + } + } + } + + score_t result = M[n - 1][m - 1]; + + free(M); + free(D); + + return result; +} diff --git a/fzy/match.h b/fzy/match.h new file mode 100644 index 0000000000000..77f687e6135cb --- /dev/null +++ b/fzy/match.h @@ -0,0 +1,84 @@ +/* The MIT License (MIT) + +Copyright (c) 2020 Seth Warn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* match.h + * c interface to fzy matching + * + * original code by John Hawthorn, https://github.com/jhawthorn/fzy + * modifications by + * Rom Grk, https://github.com/romgrk + * Seth Warn, https://github.com/swarn + */ + +#ifndef FZY_NATIVE_H +#define FZY_NATIVE_H + +#include +#include + + +typedef double score_t; +typedef uint32_t index_t; + +#define SCORE_MAX INFINITY +#define SCORE_MIN (-INFINITY) +#define MATCH_MAX_LEN 1024 + + +// Return true if `needle` is a subsequence of `haystack`. +// +// Control case sensitivity of matches with `case_sensitive` +int has_match(char const * needle, char const * haystack, int case_sensitive); + + +// Compute a matching score for two strings. +// +// Note: if `has_match(needle, haystack)` is not true, the return value +// is undefined. +// +// Returns a score measuring the quality of the match. Better matches get +// higher scores. +// +// - returns `SCORE_MIN` where `needle` or `haystack` are longer than +// `MATCH_MAX_LEN`. +// +// - returns `SCORE_MIN` when `needle` or `haystack` are empty strings. +// +// - return `SCORE_MAX` when `strlen(needle) == strlen(haystack)` +score_t match(char const * needle, char const * haystack, int case_sensitive); + + +// Compute a matching score and the indices of matching characters. +// +// - The score is returned as in match() +// +// - `positions` is an array that will be filled in such that `positions[i]` is +// the index of `haystack` where `needle[i]` matches in the optimal match. +score_t match_positions( + char const * needle, + char const * haystack, + index_t * positions, + int is_case_sensitive); + + +#endif diff --git a/meson.build b/meson.build index 117ebeb7b894b..87fe200f72eb4 100644 --- a/meson.build +++ b/meson.build @@ -709,7 +709,8 @@ features += {'lua': lua.found()} lua_version = lua.name() if features['lua'] dependencies += lua - sources += files('player/lua.c') + sources += files('player/lua.c', + 'fzy/match.c') endif if not features['lua'] and lua_opt == 'enabled' error('Lua enabled but no suitable Lua version could be found!') diff --git a/player/javascript/defaults.js b/player/javascript/defaults.js index 9f130c9c925f2..b94a7bfa5ddf0 100644 --- a/player/javascript/defaults.js +++ b/player/javascript/defaults.js @@ -646,6 +646,23 @@ mp.options = { read_options: read_options }; /********************************************************************** * input *********************************************************************/ +function register_event_handler(t) { + mp.register_script_message("input-event", function (type, text, cursor_position) { + if (t[type]) { + var result = t[type](text, cursor_position); + + if (type == "complete" && result) { + mp.commandv("script-message-to", "console", "complete", + JSON.stringify(result[0]), result[1]); + } + } + + if (type == "closed") { + mp.unregister_script_message("input-event"); + } + }) +} + mp.input = { get: function(t) { mp.commandv("script-message-to", "console", "get-input", mp.script_name, @@ -656,23 +673,18 @@ mp.input = { id: t.id, })); - mp.register_script_message("input-event", function (type, text, cursor_position) { - if (t[type]) { - var result = t[type](text, cursor_position); - - if (type == "complete" && result) { - mp.commandv("script-message-to", "console", "complete", - JSON.stringify(result[0]), result[1]); - } - } - - if (type == "closed") { - mp.unregister_script_message("input-event"); - } - }) - - return true; + register_event_handler(t) }, + select: function () { + mp.commandv("script-message-to", "console", "get-input", mp.script_name, + JSON.stringify({ + prompt: t.prompt, + items: t.items, + default_item: t.default_item, + })); + + register_event_handler(t) + } terminate: function () { mp.commandv("script-message-to", "console", "disable"); }, diff --git a/player/lua.c b/player/lua.c index 63547694e2834..674db8dc9db11 100644 --- a/player/lua.c +++ b/player/lua.c @@ -52,6 +52,9 @@ #include "client.h" #include "libmpv/client.h" +#include "fzy/bonus.h" +#include "fzy/match.h" + // List of builtin modules and their contents as strings. // All these are generated from player/lua/*.lua static const char * const builtin_lua_scripts[][2] = { @@ -1205,6 +1208,78 @@ static int script_get_env_list(lua_State *L) return 1; } +// The next 2 functions are taken from https://github.com/swarn/fzy-lua/blob/main/src/fzy_native.c +// Given an array of `count` 0-based indices, push a table on to `L` with +// equivalent 1-based indices. +static void push_indices(lua_State * L, index_t const * const indices, int count) +{ + lua_createtable(L, count, 0); + for (int i = 0; i < count; i++) + { + // Convert from 0-indexing to 1-indexing. + lua_pushinteger(L, indices[i] + 1); + lua_rawseti(L, -2, i + 1); + } +} + +static int script_filter(lua_State *L) +{ + char const * const needle = luaL_checkstring(L, 1); + int const needle_len = (int)strlen(needle); + + int const haystacks_idx = 2; + luaL_checktype(L, haystacks_idx, LUA_TTABLE); + int const haystacks_len = (int)mp_lua_len(L, haystacks_idx); + + bool case_sensitive = false; + if (lua_gettop(L) > 2) + case_sensitive = lua_toboolean(L, 3); + + // Push the result array onto the lua stack. + lua_newtable(L); + int const result_idx = lua_gettop(L); + int result_len = 0; + + // Call `positions` on each haystack string. + for (int i = 1; i <= haystacks_len; i++) + { + lua_rawgeti(L, haystacks_idx, i); + char const * haystack = luaL_checkstring(L, -1); + + if (has_match(needle, haystack, case_sensitive)) + { + result_len++; + + // Make the {idx, positions, score} table. + lua_createtable(L, 3, 0); + + // Set the idx + lua_pushinteger(L, i); + lua_rawseti(L, -2, 1); + + // Generate the positions and the score + index_t result[MATCH_MAX_LEN]; + score_t score = match_positions(needle, haystack, result, case_sensitive); + + // Set the positions + push_indices(L, result, needle_len); + lua_rawseti(L, -2, 2); + + // Set the score + lua_pushnumber(L, score); + lua_rawseti(L, -2, 3); + + // Add this table to the result + lua_rawseti(L, result_idx, result_len); + } + + // Pop the current haystack string off the lua stack. + lua_pop(L, 1); + } + + return 1; +} + #define FN_ENTRY(name) {#name, script_ ## name, 0} #define AF_ENTRY(name) {#name, 0, script_ ## name} struct fn_entry { @@ -1257,6 +1332,11 @@ static const struct fn_entry utils_fns[] = { {0} }; +static const struct fn_entry fzy_fns[] = { + FN_ENTRY(filter), + {0} +}; + typedef struct autofree_data { af_CFunction target; void *ctx; @@ -1335,6 +1415,7 @@ static void add_functions(struct script_ctx *ctx) register_package_fns(L, "mp", main_fns); register_package_fns(L, "mp.utils", utils_fns); + register_package_fns(L, "mp.fzy", fzy_fns); } const struct mp_scripting mp_scripting_lua = { diff --git a/player/lua/console.lua b/player/lua/console.lua index bbfaf478f7417..d372b289274b8 100644 --- a/player/lua/console.lua +++ b/player/lua/console.lua @@ -75,6 +75,7 @@ local styles = { fatal = '{\\1c&H5791f9&\\b1}', suggestion = '{\\1c&Hcc99cc&}', selected_suggestion = '{\\1c&H2fbdfa&\\b1}', + disabled = '{\\1c&Hcccccc&}', } local terminal_styles = { @@ -84,6 +85,7 @@ local terminal_styles = { error = '\027[31m', fatal = '\027[1;31m', selected_suggestion = '\027[7m', + disabled = '\027[38;5;8m', } local repl_active = false @@ -112,6 +114,11 @@ local path_separator = platform == 'windows' and '\\' or '/' local completion_old_line local completion_old_cursor +local selectable_items +local matches = {} +local selected_match = 1 +local first_match_to_print = 1 + local update_timer = nil update_timer = mp.add_periodic_timer(0.05, function() if pending_update then @@ -232,6 +239,20 @@ function ass_escape(str) return mp.command_native({'escape-ass', str}) end +local function calculate_max_log_lines() + if not mp.get_property_native('vo-configured') then + -- Subtract 1 for the input line and for each line in the status line. + -- This does not detect wrapped lines. + return mp.get_property_native('term-size/h', 24) - 2 - + select(2, mp.get_property('term-status-msg'):gsub('\\n', '')) + end + + -- Subtract 1.5 to account for the input line. + return math.floor(mp.get_property_native('osd-height') + * (1 - global_margins.t - global_margins.b) + / opts.font_size - 1.5) +end + -- Takes a list of strings, a max width in characters and -- optionally a max row count. -- The result contains at least one column. @@ -323,6 +344,62 @@ function format_table(list, width_max, rows_max) return table.concat(rows, ass_escape('\n')), row_count end +local function fuzzy_find(needle, haystacks) + local result = require 'mp.fzy'.filter(needle, haystacks) + table.sort(result, function (i, j) + return i[3] > j[3] + end) + for i, value in ipairs(result) do + result[i] = value[1] + end + return result +end + +local function populate_log_with_matches() + if not selectable_items then + return + end + + log_buffers[id] = {} + local log = log_buffers[id] + + -- Subtract 2 for the "(n hidden items)" lines. + local max_log_lines = calculate_max_log_lines() - 2 + + if selected_match < first_match_to_print then + first_match_to_print = selected_match + elseif selected_match > first_match_to_print + max_log_lines - 1 then + first_match_to_print = selected_match - max_log_lines + 1 + end + + if first_match_to_print > 1 then + log[1] = { + text = '↑ (' .. (first_match_to_print - 1) .. ' hidden items)' .. '\n', + style = styles.disabled, + terminal_style = terminal_styles.disabled, + } + end + + local last_match_to_print = math.min(first_match_to_print + max_log_lines - 1, + #matches) + + for i = first_match_to_print, last_match_to_print do + log[#log + 1] = { + text = matches[i].text .. '\n', + style = i == selected_match and styles.selected_suggestion or '', + terminal_style = i == selected_match and terminal_styles.selected_suggestion or '', + } + end + + if last_match_to_print < #matches then + log[#log + 1] = { + text = '↓ (' .. (#matches - last_match_to_print) .. ' hidden items)' .. '\n', + style = styles.disabled, + terminal_style = terminal_styles.disabled, + } + end +end + local function print_to_terminal() -- Clear the log after closing the console. if not repl_active then @@ -330,6 +407,8 @@ local function print_to_terminal() return end + populate_log_with_matches() + local log = '' for _, log_line in ipairs(log_buffers[id]) do log = log .. log_line.terminal_style .. log_line.text .. '\027[0m' @@ -413,16 +492,15 @@ function update() -- Render log messages as ASS. -- This will render at most screeny / font_size - 1 messages. - -- lines above the prompt - -- subtract 1.5 to account for the input line - local screeny_factor = (1 - global_margins.t - global_margins.b) - local lines_max = math.ceil(screeny * screeny_factor / opts.font_size - 1.5) + local lines_max = calculate_max_log_lines() -- Estimate how many characters fit in one line local width_max = math.ceil(screenx / opts.font_size * get_font_hw_ratio()) local suggestions, rows = format_table(suggestion_buffer, width_max, lines_max) local suggestion_ass = style .. styles.suggestion .. suggestions + populate_log_with_matches() + local log_ass = '' local log_buffer = log_buffers[id] local log_messages = #log_buffer @@ -485,6 +563,7 @@ function set_active(active) input_caller = nil line = '' cursor = 1 + selectable_items = nil end collectgarbage() end @@ -548,6 +627,15 @@ function len_utf8(str) end local function handle_edit() + if selectable_items then + matches = {} + selected_match = 1 + + for i, match in ipairs(fuzzy_find(line, selectable_items)) do + matches[i] = { index = match, text = selectable_items[match] } + end + end + suggestion_buffer = {} update() @@ -692,7 +780,7 @@ function handle_enter() if input_caller then mp.commandv('script-message-to', input_caller, 'input-event', 'submit', - line) + selectable_items and matches[selected_match].index or line) else -- match "help []", return or "", strip all whitespace local help = line:match('^%s*help%s+(.-)%s*$') or @@ -745,19 +833,50 @@ end -- Go to the specified relative position in the command history (Up, Down) function move_history(amount) + if selectable_items then + selected_match = selected_match + amount + if selected_match > #matches then + selected_match = 1 + elseif selected_match < 1 then + selected_match = #matches + end + update() + return + end + go_history(history_pos + amount) end -- Go to the first command in the command history (PgUp) function handle_pgup() + if selectable_items then + selected_match = math.max(selected_match - calculate_max_log_lines() + 1, 1) + update() + return + end + go_history(1) end -- Stop browsing history and start editing a blank line (PgDown) function handle_pgdown() + if selectable_items then + selected_match = math.min(selected_match + calculate_max_log_lines() - 1, #matches) + update() + return + end + go_history(#history + 1) end +local function page_up_or_prev_char() + return selectable_items and handle_pgup() or prev_char() +end + +local function page_down_or_next_char() + return selectable_items and handle_pgdown() or next_char() +end + -- Move to the start of the current word, or if already at the start, the start -- of the previous word. (Ctrl+Left) function prev_word() @@ -1285,9 +1404,9 @@ function get_bindings() { 'shift+ins', function() paste(false) end }, { 'mbtn_mid', function() paste(false) end }, { 'left', function() prev_char() end }, - { 'ctrl+b', function() prev_char() end }, + { 'ctrl+b', function() page_up_or_prev_char() end }, { 'right', function() next_char() end }, - { 'ctrl+f', function() next_char() end }, + { 'ctrl+f', function() page_down_or_next_char() end}, { 'up', function() move_history(-1) end }, { 'ctrl+p', function() move_history(-1) end }, { 'wheel_up', function() move_history(-1) end }, @@ -1394,6 +1513,16 @@ mp.register_script_message('get-input', function (script_name, args) history = histories[id] history_pos = #history + 1 + selectable_items = args.items + if selectable_items then + matches = {} + selected_match = args.default_item or 1 + first_match_to_print = 1 + for i, option in ipairs(selectable_items) do + matches[#matches + 1] = { index = i, text = option } + end + end + set_active(true) mp.commandv('script-message-to', input_caller, 'input-event', 'opened') end) diff --git a/player/lua/input.lua b/player/lua/input.lua index 24283e4086b71..a29289a163a9e 100644 --- a/player/lua/input.lua +++ b/player/lua/input.lua @@ -18,15 +18,7 @@ License along with mpv. If not, see . local utils = require "mp.utils" local input = {} -function input.get(t) - mp.commandv("script-message-to", "console", "get-input", - mp.get_script_name(), utils.format_json({ - prompt = t.prompt, - default_text = t.default_text, - cursor_position = t.cursor_position, - id = t.id, - })) - +local function register_event_handler(t) mp.register_script_message("input-event", function (type, text, cursor_position) if t[type] then local suggestions, completion_start_position = t[type](text, cursor_position) @@ -41,8 +33,29 @@ function input.get(t) mp.unregister_script_message("input-event") end end) +end + +function input.get(t) + mp.commandv("script-message-to", "console", "get-input", + mp.get_script_name(), utils.format_json({ + prompt = t.prompt, + default_text = t.default_text, + cursor_position = t.cursor_position, + id = t.id, + })) + + register_event_handler(t) +end + +function input.select(t) + mp.commandv("script-message-to", "console", "get-input", + mp.get_script_name(), utils.format_json({ + prompt = t.prompt, + items = t.items, + default_item = t.default_item, + })) - return true + register_event_handler(t) end function input.terminate()