From dde2c438921a250890c81682a3a416a4bd638353 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Wed, 24 Apr 2024 20:14:23 +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 +++- player/javascript/defaults.js | 44 ++-- player/lua.c | 3 + player/lua/console.lua | 143 +++++++++++- player/lua/fzy.lua | 297 ++++++++++++++++++++++++ player/lua/input.lua | 33 ++- player/lua/meson.build | 2 +- 9 files changed, 532 insertions(+), 38 deletions(-) create mode 100644 DOCS/interface-changes/input-select.txt create mode 100644 player/lua/fzy.lua 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/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..bf764028b5cb3 100644 --- a/player/lua.c +++ b/player/lua.c @@ -60,6 +60,9 @@ static const char * const builtin_lua_scripts[][2] = { }, {"mp.assdraw", # include "player/lua/assdraw.lua.inc" + }, + {"mp.fzy", +# include "player/lua/fzy.lua.inc" }, {"mp.input", # include "player/lua/input.lua.inc" 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/fzy.lua b/player/lua/fzy.lua new file mode 100644 index 0000000000000..272bf4b82c98d --- /dev/null +++ b/player/lua/fzy.lua @@ -0,0 +1,297 @@ +--[[ 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. ]] + +-- The lua implementation of the fzy string matching algorithm + +local SCORE_GAP_LEADING = -0.005 +local SCORE_GAP_TRAILING = -0.005 +local SCORE_GAP_INNER = -0.01 +local SCORE_MATCH_CONSECUTIVE = 1.0 +local SCORE_MATCH_SLASH = 0.9 +local SCORE_MATCH_WORD = 0.8 +local SCORE_MATCH_CAPITAL = 0.7 +local SCORE_MATCH_DOT = 0.6 +local SCORE_MAX = math.huge +local SCORE_MIN = -math.huge +local MATCH_MAX_LENGTH = 1024 + +local fzy = {} + +-- Check if `needle` is a subsequence of the `haystack`. +-- +-- Usually called before `score` or `positions`. +-- +-- Args: +-- needle (string) +-- haystack (string) +-- case_sensitive (bool, optional): defaults to false +-- +-- Returns: +-- bool +function fzy.has_match(needle, haystack, case_sensitive) + if not case_sensitive then + needle = string.lower(needle) + haystack = string.lower(haystack) + end + + local j = 1 + for i = 1, string.len(needle) do + j = string.find(haystack, needle:sub(i, i), j, true) + if not j then + return false + else + j = j + 1 + end + end + + return true +end + +local function is_lower(c) + return c:match("%l") +end + +local function is_upper(c) + return c:match("%u") +end + +local function precompute_bonus(haystack) + local match_bonus = {} + + local last_char = "/" + for i = 1, string.len(haystack) do + local this_char = haystack:sub(i, i) + if last_char == "/" or last_char == "\\" then + match_bonus[i] = SCORE_MATCH_SLASH + elseif last_char == "-" or last_char == "_" or last_char == " " then + match_bonus[i] = SCORE_MATCH_WORD + elseif last_char == "." then + match_bonus[i] = SCORE_MATCH_DOT + elseif is_lower(last_char) and is_upper(this_char) then + match_bonus[i] = SCORE_MATCH_CAPITAL + else + match_bonus[i] = 0 + end + + last_char = this_char + end + + return match_bonus +end + +local function compute(needle, haystack, D, M, case_sensitive) + -- Note that the match bonuses must be computed before the arguments are + -- converted to lowercase, since there are bonuses for camelCase. + local match_bonus = precompute_bonus(haystack) + local n = string.len(needle) + local m = string.len(haystack) + + if not case_sensitive then + needle = string.lower(needle) + haystack = string.lower(haystack) + end + + -- Because lua only grants access to chars through substring extraction, + -- get all the characters from the haystack once now, to reuse below. + local haystack_chars = {} + for i = 1, m do + haystack_chars[i] = haystack:sub(i, i) + end + + for i = 1, n do + D[i] = {} + M[i] = {} + + local prev_score = SCORE_MIN + local gap_score = i == n and SCORE_GAP_TRAILING or SCORE_GAP_INNER + local needle_char = needle:sub(i, i) + + for j = 1, m do + if needle_char == haystack_chars[j] then + local score = SCORE_MIN + if i == 1 then + score = ((j - 1) * SCORE_GAP_LEADING) + match_bonus[j] + elseif j > 1 then + local a = M[i - 1][j - 1] + match_bonus[j] + local b = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE + score = math.max(a, b) + end + D[i][j] = score + prev_score = math.max(score, prev_score + gap_score) + M[i][j] = prev_score + else + D[i][j] = SCORE_MIN + prev_score = prev_score + gap_score + M[i][j] = prev_score + end + end + end +end + +-- Compute a matching score. +-- +-- Args: +-- needle (string): must be a subequence of `haystack`, or the result is +-- undefined. +-- haystack (string) +-- case_sensitive (bool, optional): defaults to false +-- +-- Returns: +-- number: higher scores indicate better matches. See also `get_score_min` +-- and `get_score_max`. +function fzy.score(needle, haystack, case_sensitive) + local n = string.len(needle) + local m = string.len(haystack) + + if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then + return SCORE_MIN + elseif n == m then + return SCORE_MAX + else + local D = {} + local M = {} + compute(needle, haystack, D, M, case_sensitive) + return M[n][m] + end +end + +-- Compute the locations where fzy matches a string. +-- +-- Determine where each character of the `needle` is matched to the `haystack` +-- in the optimal match. +-- +-- Args: +-- needle (string): must be a subequence of `haystack`, or the result is +-- undefined. +-- haystack (string) +-- case_sensitive (bool, optional): defaults to false +-- +-- Returns: +-- {int,...}: indices, where `indices[n]` is the location of the `n`th +-- character of `needle` in `haystack`. +-- number: the same matching score returned by `score` +function fzy.positions(needle, haystack, case_sensitive) + local n = string.len(needle) + local m = string.len(haystack) + + if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then + return {}, SCORE_MIN + elseif n == m then + local consecutive = {} + for i = 1, n do + consecutive[i] = i + end + return consecutive, SCORE_MAX + end + + local D = {} + local M = {} + compute(needle, haystack, D, M, case_sensitive) + + local positions = {} + local match_required = false + local j = m + for i = n, 1, -1 do + while j >= 1 do + if D[i][j] ~= SCORE_MIN and (match_required or D[i][j] == M[i][j]) then + match_required = (i ~= 1) and (j ~= 1) and ( + M[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE) + positions[i] = j + j = j - 1 + break + else + j = j - 1 + end + end + end + + return positions, M[n][m] +end + +-- Apply `has_match` and `positions` to an array of haystacks. +-- +-- Args: +-- needle (string) +-- haystack ({string, ...}) +-- case_sensitive (bool, optional): defaults to false +-- +-- Returns: +-- {{idx, positions, score}, ...}: an array with one entry per matching line +-- in `haystacks`, each entry giving the index of the line in `haystacks` +-- as well as the equivalent to the return value of `positions` for that +-- line. +function fzy.filter(needle, haystacks, case_sensitive) + local result = {} + + for i, line in ipairs(haystacks) do + if fzy.has_match(needle, line, case_sensitive) then + local p, s = fzy.positions(needle, line, case_sensitive) + table.insert(result, {i, p, s}) + end + end + + return result +end + +-- The lowest value returned by `score`. +-- +-- In two special cases: +-- - an empty `needle`, or +-- - a `needle` or `haystack` larger than than `get_max_length`, +-- the `score` function will return this exact value, which can be used as a +-- sentinel. This is the lowest possible score. +function fzy.get_score_min() + return SCORE_MIN +end + +-- The score returned for exact matches. This is the highest possible score. +function fzy.get_score_max() + return SCORE_MAX +end + +-- The maximum size for which `fzy` will evaluate scores. +function fzy.get_max_length() + return MATCH_MAX_LENGTH +end + +-- The minimum score returned for normal matches. +-- +-- For matches that don't return `get_score_min`, their score will be greater +-- than than this value. +function fzy.get_score_floor() + return MATCH_MAX_LENGTH * SCORE_GAP_INNER +end + +-- The maximum score for non-exact matches. +-- +-- For matches that don't return `get_score_max`, their score will be less than +-- this value. +function fzy.get_score_ceiling() + return MATCH_MAX_LENGTH * SCORE_MATCH_CONSECUTIVE +end + +-- The name of the currently-running implmenetation, "lua" or "native". +function fzy.get_implementation_name() + return "lua" +end + +return fzy 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() diff --git a/player/lua/meson.build b/player/lua/meson.build index 1d87938f1afac..7df62d219b6c1 100644 --- a/player/lua/meson.build +++ b/player/lua/meson.build @@ -1,6 +1,6 @@ lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua', 'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua', - 'input.lua'] + 'input.lua', 'fzy.lua'] foreach file: lua_files lua_file = custom_target(file, input: join_paths(source_root, 'player', 'lua', file),