scripting: add mp.input

This lets scripts get textual input from the user using console.lua.
This commit is contained in:
Guido Cella 2024-01-02 18:58:32 +01:00 committed by Dudemanguy
parent 2dd3951a9c
commit 871f7a152a
8 changed files with 378 additions and 42 deletions

View File

@ -36,6 +36,7 @@ Interface changes
- `--screenshot-avif-opts` defaults to lossless screenshot
- rename key `MP_KEY_BACK` to `MP_KEY_GO_BACK`
- add `--sub-filter-sdh-enclosures` option
- added the `mp.input` scripting API to query the user for textual input
--- mpv 0.37.0 ---
- `--save-position-on-quit` and its associated commands now store state files
in %LOCALAPPDATA% instead of %APPDATA% directory by default on Windows.

View File

@ -27,16 +27,17 @@ otherwise, the documented Lua options, script directories, loading, etc apply to
JavaScript files too.
Script initialization and lifecycle is the same as with Lua, and most of the Lua
functions at the modules ``mp``, ``mp.utils``, ``mp.msg`` and ``mp.options`` are
available to JavaScript with identical APIs - including running commands,
getting/setting properties, registering events/key-bindings/hooks, etc.
functions in the modules ``mp``, ``mp.utils``, ``mp.msg``, ``mp.options`` and
``mp.input`` are available to JavaScript with identical APIs - including running
commands, getting/setting properties, registering events/key-bindings/hooks,
etc.
Differences from Lua
--------------------
No need to load modules. ``mp``, ``mp.utils``, ``mp.msg`` and ``mp.options``
are preloaded, and you can use e.g. ``var cwd = mp.utils.getcwd();`` without
prior setup.
No need to load modules. ``mp``, ``mp.utils``, ``mp.msg``, ``mp.options`` and
``mp.input`` are preloaded, and you can use e.g. ``var cwd =
mp.utils.getcwd();`` without prior setup.
Errors are slightly different. Where the Lua APIs return ``nil`` for error,
the JavaScript ones return ``undefined``. Where Lua returns ``something, error``
@ -195,6 +196,16 @@ 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.terminate()``
``mp.input.log(message, style)``
``mp.input.log_error(message)``
``mp.input.set_log(log)``
Additional utilities
--------------------

View File

@ -862,6 +862,88 @@ strictly part of the guaranteed API.
Turn the given value into a string. Formats tables and their contents. This
doesn't do anything special; it is only needed because Lua is terrible.
mp.input functions
--------------------
This module lets scripts get textual input from the user using the console
REPL.
``input.get(table)``
Show the console to let the user enter text.
The following entries of ``table`` are read:
``prompt``
The string to be displayed before the input field.
``submit``
A callback invoked when the user presses Enter. The first argument is
the text in the console. You can close the console from within the
callback by calling ``input.terminate()``. If you don't, the console
stays open and the user can input more text.
``opened``
A callback invoked when the console is shown. This can be used to
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.
``complete``
A callback invoked when the user presses TAB. The first argument is the
text before the cursor. The callback should return a table of the string
candidate completion values and the 1-based cursor position from which
the completion starts. console.lua will filter the suggestions beginning
with the the text between this position and the cursor, sort them
alphabetically, insert their longest common prefix, and show them when
there are multiple ones.
``closed``
A callback invoked when the console is hidden, either because
``input.terminate()`` was invoked from the other callbacks, or because
the user closed it with a key binding. The first argument is the text in
the console, and the second argument is the cursor position.
``default_text``
A string to pre-fill the input field with.
``cursor_position``
The initial cursor position, starting from 1.
``id``
An identifier that determines which input history and log buffer to use
among the ones stored for ``input.get()`` calls. The input histories
and logs are stored in memory and do not persist across different mpv
invocations. Defaults to the calling script name with ``prompt``
appended.
``input.terminate()``
Close the console.
``input.log(message, style)``
Add a line to the log buffer. ``style`` can contain additional ASS tags to
apply to ``message``.
``input.log_error(message)``
Helper to add a line to the log buffer with the same color as the one the
console uses for errors. Useful when the user submits invalid input.
``input.set_log(log)``
Replace the entire log buffer.
``log`` is a table of strings, or tables with ``text`` and ``style`` keys.
Example:
::
input.set_log({
"regular text",
{ style = "{\\c&H7a77f2&}", text = "error text" }
})
Events
------

View File

@ -642,6 +642,53 @@ function read_options(opts, id, on_update, conf_override) {
mp.options = { read_options: read_options };
/**********************************************************************
* input
*********************************************************************/
mp.input = {
get: function(t) {
mp.commandv("script-message-to", "console", "get-input", mp.script_name,
JSON.stringify({
prompt: t.prompt,
default_text: t.default_text,
cursor_position: t.cursor_position,
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;
},
terminate: function () {
mp.commandv("script-message-to", "console", "disable");
},
log: function (message, style) {
mp.commandv("script-message-to", "console", "log",
JSON.stringify({ text: message, style: style }));
},
log_error: function (message) {
mp.commandv("script-message-to", "console", "log",
JSON.stringify({ text: message, error: true }));
},
set_log: function (log) {
mp.commandv("script-message-to", "console", "set-log",
JSON.stringify(log));
}
}
/**********************************************************************
* various
*********************************************************************/

View File

@ -60,6 +60,9 @@ static const char * const builtin_lua_scripts[][2] = {
},
{"mp.assdraw",
# include "player/lua/assdraw.lua.inc"
},
{"mp.input",
# include "player/lua/input.lua.inc"
},
{"mp.options",
# include "player/lua/options.lua.inc"

View File

@ -82,11 +82,17 @@ local insert_mode = false
local pending_update = false
local line = ''
local cursor = 1
local history = {}
local default_prompt = '>'
local prompt = default_prompt
local default_id = 'default'
local id = default_id
local histories = {[id] = {}}
local history = histories[id]
local history_pos = 1
local log_buffer = {}
local log_buffers = {[id] = {}}
local key_bindings = {}
local global_margins = { t = 0, b = 0 }
local input_caller
local suggestion_buffer = {}
local selected_suggestion_index
@ -94,6 +100,8 @@ local completion_start_position
local completion_append
local file_commands = {}
local path_separator = platform == 'windows' and '\\' or '/'
local completion_old_line
local completion_old_cursor
local update_timer = nil
update_timer = mp.add_periodic_timer(0.05, function()
@ -190,6 +198,7 @@ end
-- Add a line to the log buffer (which is limited to 100 lines)
function log_add(style, text)
local log_buffer = log_buffers[id]
log_buffer[#log_buffer + 1] = { style = style, text = text }
if #log_buffer > 100 then
table.remove(log_buffer, 1)
@ -321,7 +330,7 @@ local function print_to_terminal()
end
local log = ''
for _, log_line in ipairs(log_buffer) do
for _, log_line in ipairs(log_buffers[id]) do
log = log .. log_line.text
end
@ -337,8 +346,9 @@ local function print_to_terminal()
after_cur = ' '
end
mp.osd_message(log .. suggestions .. '> ' .. before_cur .. '\027[7m' ..
after_cur:sub(1, 1) .. '\027[0m' .. after_cur:sub(2), 999)
mp.osd_message(log .. suggestions .. prompt .. ' ' .. before_cur ..
'\027[7m' .. after_cur:sub(1, 1) .. '\027[0m' ..
after_cur:sub(2), 999)
end
-- Render the REPL and console as an ASS OSD
@ -407,6 +417,7 @@ function update()
local suggestion_ass = style .. styles.suggestion .. suggestions
local log_ass = ''
local log_buffer = log_buffers[id]
local log_messages = #log_buffer
local log_max_lines = math.max(0, lines_max - rows)
if log_max_lines < log_messages then
@ -423,7 +434,7 @@ function update()
if #suggestions > 0 then
ass:append(suggestion_ass .. '\\N')
end
ass:append(style .. '> ' .. before_cur)
ass:append(style .. ass_escape(prompt) .. ' ' .. before_cur)
ass:append(cglyph)
ass:append(style .. after_cur)
@ -432,7 +443,7 @@ function update()
ass:new_event()
ass:an(1)
ass:pos(2, screeny - 2 - global_margins.b * screeny)
ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur)
ass:append(style .. '{\\alpha&HFF&}' .. ass_escape(prompt) .. ' ' .. before_cur)
ass:append(cglyph)
ass:append(style .. '{\\alpha&HFF&}' .. after_cur)
@ -446,12 +457,28 @@ function set_active(active)
repl_active = true
insert_mode = false
mp.enable_key_bindings('console-input', 'allow-hide-cursor+allow-vo-dragging')
mp.enable_messages('terminal-default')
define_key_bindings()
if not input_caller then
prompt = default_prompt
id = default_id
history = histories[id]
history_pos = #history + 1
mp.enable_messages('terminal-default')
end
else
repl_active = false
suggestion_buffer = {}
undefine_key_bindings()
mp.enable_messages('silent:terminal-default')
if input_caller then
mp.commandv('script-message-to', input_caller, 'input-event',
'closed', line, cursor)
input_caller = nil
line = ''
cursor = 1
end
collectgarbage()
end
update()
@ -513,6 +540,16 @@ function len_utf8(str)
return len
end
local function handle_edit()
suggestion_buffer = {}
update()
if input_caller then
mp.commandv('script-message-to', input_caller, 'input-event', 'edited',
line)
end
end
-- Insert a character at the current cursor position (any_unicode)
function handle_char_input(c)
if insert_mode then
@ -521,8 +558,7 @@ function handle_char_input(c)
line = line:sub(1, cursor - 1) .. c .. line:sub(cursor)
end
cursor = cursor + #c
suggestion_buffer = {}
update()
handle_edit()
end
-- Remove the character behind the cursor (Backspace)
@ -531,16 +567,14 @@ function handle_backspace()
local prev = prev_utf8(line, cursor)
line = line:sub(1, prev - 1) .. line:sub(cursor)
cursor = prev
suggestion_buffer = {}
update()
handle_edit()
end
-- Remove the character in front of the cursor (Del)
function handle_del()
if cursor > line:len() then return end
line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor))
suggestion_buffer = {}
update()
handle_edit()
end
-- Toggle insert mode (Ins)
@ -568,8 +602,7 @@ function clear()
cursor = 1
insert_mode = false
history_pos = #history + 1
suggestion_buffer = {}
update()
handle_edit()
end
-- Close the REPL if the current line is empty, otherwise delete the next
@ -642,20 +675,25 @@ end
-- Run the current command and clear the line (Enter)
function handle_enter()
if line == '' then
if line == '' and input_caller == nil then
return
end
if history[#history] ~= line then
if history[#history] ~= line and line ~= '' then
history_add(line)
end
-- match "help [<text>]", return <text> or "", strip all whitespace
local help = line:match('^%s*help%s+(.-)%s*$') or
(line:match('^%s*help$') and '')
if help then
help_command(help)
if input_caller then
mp.commandv('script-message-to', input_caller, 'input-event', 'submit',
line)
else
mp.command(line)
-- match "help [<text>]", return <text> or "", strip all whitespace
local help = line:match('^%s*help%s+(.-)%s*$') or
(line:match('^%s*help$') and '')
if help then
help_command(help)
else
mp.command(line)
end
end
clear()
@ -1025,6 +1063,14 @@ function complete(backwards)
return
end
if input_caller then
completion_old_line = line
completion_old_cursor = cursor
mp.commandv('script-message-to', input_caller, 'input-event',
'complete', line:sub(1, cursor - 1))
return
end
local before_cur = line:sub(1, cursor - 1)
local after_cur = line:sub(cursor)
@ -1111,8 +1157,7 @@ function del_word()
before_cur = before_cur:gsub('[^%s]+%s*$', '', 1)
line = before_cur .. after_cur
cursor = before_cur:len() + 1
suggestion_buffer = {}
update()
handle_edit()
end
-- Delete from the cursor to the end of the word (Ctrl+Del)
@ -1124,28 +1169,25 @@ function del_next_word()
after_cur = after_cur:gsub('^%s*[^%s]+', '', 1)
line = before_cur .. after_cur
suggestion_buffer = {}
update()
handle_edit()
end
-- Delete from the cursor to the end of the line (Ctrl+K)
function del_to_eol()
line = line:sub(1, cursor - 1)
suggestion_buffer = {}
update()
handle_edit()
end
-- Delete from the cursor back to the start of the line (Ctrl+U)
function del_to_start()
line = line:sub(cursor)
cursor = 1
suggestion_buffer = {}
update()
handle_edit()
end
-- Empty the log buffer of all messages (Ctrl+L)
function clear_log_buffer()
log_buffer = {}
log_buffers[id] = {}
update()
end
@ -1212,8 +1254,7 @@ function paste(clip)
local after_cur = line:sub(cursor)
line = before_cur .. text .. after_cur
cursor = cursor + text:len()
suggestion_buffer = {}
update()
handle_edit()
end
-- List of input bindings. This is a weird mashup between common GUI text-input
@ -1318,11 +1359,95 @@ mp.add_key_binding(nil, 'enable', function()
set_active(true)
end)
mp.register_script_message('disable', function()
set_active(false)
end)
-- Add a script-message to show the REPL and fill it with the provided text
mp.register_script_message('type', function(text, cursor_pos)
show_and_type(text, cursor_pos)
end)
mp.register_script_message('get-input', function (script_name, args)
if repl_active then
return
end
input_caller = script_name
args = utils.parse_json(args)
prompt = args.prompt or default_prompt
line = args.default_text or ''
cursor = tonumber(args.cursor_position) or line:len() + 1
id = args.id or script_name .. prompt
if histories[id] == nil then
histories[id] = {}
log_buffers[id] = {}
end
history = histories[id]
history_pos = #history + 1
set_active(true)
mp.commandv('script-message-to', input_caller, 'input-event', 'opened')
end)
mp.register_script_message('log', function (message)
-- input.get's edited handler is invoked after submit, so avoid modifying
-- the default log.
if input_caller == nil then
return
end
message = utils.parse_json(message)
log_add(message.error and styles.error or message.style or '',
message.text .. '\n')
end)
mp.register_script_message('set-log', function (log)
if input_caller == nil then
return
end
log = utils.parse_json(log)
log_buffers[id] = {}
for i = 1, #log do
if type(log[i]) == 'table' then
log[i].text = log[i].text .. '\n'
log_buffers[id][i] = log[i]
else
log_buffers[id][i] = {
style = '',
text = log[i] .. '\n',
}
end
end
update()
end)
mp.register_script_message('complete', function(list, start_pos)
if line ~= completion_old_line or cursor ~= completion_old_cursor then
return
end
local completions, prefix = complete_match(line:sub(start_pos, cursor),
utils.parse_json(list))
local before_cur = line:sub(1, start_pos - 1) .. prefix
local after_cur = line:sub(cursor)
cursor = before_cur:len() + 1
line = before_cur .. after_cur
if #completions > 1 then
suggestion_buffer = completions
selected_suggestion_index = 0
completion_start_position = start_pos
completion_append = ''
end
update()
end)
-- Redraw the REPL when the OSD size changes. This is needed because the
-- PlayRes of the OSD will need to be adjusted.
mp.observe_property('osd-width', 'native', update)

66
player/lua/input.lua Normal file
View File

@ -0,0 +1,66 @@
--[[
This file is part of mpv.
mpv is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
mpv is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with mpv. If not, see <http://www.gnu.org/licenses/>.
]]
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,
}))
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)
if type == "complete" and suggestions then
mp.commandv("script-message-to", "console", "complete",
utils.format_json(suggestions), completion_start_position)
end
end
if type == "closed" then
mp.unregister_script_message("input-event")
end
end)
return true
end
function input.terminate()
mp.commandv("script-message-to", "console", "disable")
end
function input.log(message, style)
mp.commandv("script-message-to", "console", "log",
utils.format_json({ text = message, style = style }))
end
function input.log_error(message)
mp.commandv("script-message-to", "console", "log",
utils.format_json({ text = message, error = true }))
end
function input.set_log(log)
mp.commandv("script-message-to", "console", "set-log", utils.format_json(log))
end
return input

View File

@ -1,5 +1,6 @@
lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua',
'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua']
'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua',
'input.lua']
foreach file: lua_files
lua_file = custom_target(file,
input: join_paths(source_root, 'player', 'lua', file),