diff --git a/DOCS/interface-changes/commands.txt b/DOCS/interface-changes/commands.txt new file mode 100644 index 0000000000..c3d552fc1d --- /dev/null +++ b/DOCS/interface-changes/commands.txt @@ -0,0 +1,4 @@ +commands.lua is split out of console.lua. commands.lua runs and completes commands and adds mpv's log entries to the console's log, while console.lua handles the UI for other scripts +add `--load-commands` option +`script-binding console/enable` becomes `script-binding commands/open`, though the console one is kept as an alias +`script-message-to console type` becomes `script-message-to commands type`, though the console one is kept as an alias. This also now automatically closes the console after running the command. diff --git a/DOCS/man/commands.rst b/DOCS/man/commands.rst new file mode 100644 index 0000000000..d67c367f59 --- /dev/null +++ b/DOCS/man/commands.rst @@ -0,0 +1,52 @@ +COMMANDS +======== + +This script allows running and completing input commands in the console +interactively, and also adds mpv's log to the console's log. + +Keybindings +----------- + +\` + Open the console to enter commands. + +Commands +-------- + +``script-binding commands/open`` + + Open the console to enter commands. + +``script-message-to commands type []`` + Show the console and pre-fill it with the provided text, optionally + specifying the initial cursor position as a positive integer starting from + 1. The console is automatically closed after running the command. + + .. admonition:: Examples for input.conf + + ``% script-message-to commands type "seek absolute-percent" 6`` + Enter a percent position to seek to. + + ``Ctrl+o script-message-to console type "loadfile ''" 11`` + Enter a file or URL to play, with autocompletion of paths in the + filesystem. + +Configuration +------------- + +This script can be customized through a config file ``script-opts/commands.conf`` +placed in mpv's user directory and through the ``--script-opts`` command-line +option. The configuration syntax is described in `mp.options functions`_. + +Configurable Options +~~~~~~~~~~~~~~~~~~~~ + +``persist_history`` + Default: no + + Whether to save the command history to a file and load it. + +``history_path`` + Default: ``~~state/command_history.txt`` + + The file path for ``persist_history`` (see `PATHS`_). diff --git a/DOCS/man/console.rst b/DOCS/man/console.rst index 65a40a9461..01bda743c1 100644 --- a/DOCS/man/console.rst +++ b/DOCS/man/console.rst @@ -2,16 +2,15 @@ CONSOLE ======= This script provides the ability to process the user's textual input to other -scripts through the ``mp.input`` API. It also has a builtin mode of operation to -complete and run mpv input commands and print mpv's log. It can be displayed on -both the video window and the terminal. It can be disabled entirely using the +scripts through the ``mp.input`` API. It can be displayed on both the video +window and the terminal. It can be disabled entirely using the ``--load-console=no`` option. -Keybindings ------------ +Console can either process free-form text or select from a predefined list of +items. -\` - Show the console. +Free-form text mode keybindings +------------------------------- ESC and Ctrl+[ Hide the console. @@ -80,7 +79,7 @@ PGDN Stop navigating the command history. Ctrl+r - Search the command history. + Search the command history. See `SELECT`_ for the key bindings in this mode. INSERT Toggle insert mode. @@ -109,23 +108,6 @@ WHEEL_UP WHEEL_DOWN Move forward in the command history. -Commands --------- - -``script-message-to console type []`` - Show the console and pre-fill it with the provided text, optionally - specifying the initial cursor position as a positive integer starting from - 1. - - .. admonition:: Examples for input.conf - - ``% script-message-to console type "seek absolute-percent; keypress ESC" 6`` - Enter a percent position to seek to and close the console. - - ``Ctrl+o script-message-to console type "loadfile ''; keypress ESC" 11`` - Enter a file or URL to play, with autocompletion of paths in the - filesystem. - Known issues ------------ @@ -139,9 +121,6 @@ This script can be customized through a config file ``script-opts/console.conf`` placed in mpv's user directory and through the ``--script-opts`` command-line option. The configuration syntax is described in `mp.options functions`_. -Key bindings can be changed in a standard way, see for example stats.lua -documentation. - Configurable Options ~~~~~~~~~~~~~~~~~~~~ @@ -232,16 +211,6 @@ Configurable Options Remove duplicate entries in history as to only keep the latest one. -``persist_history`` - Default: no - - Whether to save the command history to a file and load it. - -``history_path`` - Default: ``~~state/command_history.txt`` - - The file path for ``persist_history`` (see `PATHS`_). - ``font_hw_ratio`` Default: auto diff --git a/DOCS/man/lua.rst b/DOCS/man/lua.rst index 13c22e4154..8f719971bb 100644 --- a/DOCS/man/lua.rst +++ b/DOCS/man/lua.rst @@ -984,8 +984,8 @@ REPL. that are used when the console is displayed in the terminal. ``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. + Helper to add a line to the log buffer with the same color as the one used + for commands that error. Useful when the user submits invalid input. ``input.set_log(log)`` Replace the entire log buffer. diff --git a/DOCS/man/mpv.rst b/DOCS/man/mpv.rst index 03f5f8ff0b..230cd39e8d 100644 --- a/DOCS/man/mpv.rst +++ b/DOCS/man/mpv.rst @@ -1544,6 +1544,8 @@ works like in older mpv releases: .. include:: console.rst +.. include:: commands.rst + .. include:: select.rst .. include:: positioning.rst diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst index ec0b7f86ef..b82c76d44a 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -1054,9 +1054,11 @@ Program Behavior the overlay permanent). ``--load-console=`` - Enable the built-in script that shows a console on a key binding and lets - you enter commands (default: yes). The ````` key is used to show the - console by default, and ``ESC`` to hide it again. + Enable the built-in script to handle textual input (default: yes). + +``--load-commands=`` + Enable the built-in script to enter commands in the console (default: yes). + The ````` key is used to activate this by default. ``--load-auto-profiles=`` Enable the builtin script that does auto profiles (default: auto). See diff --git a/etc/input.conf b/etc/input.conf index 5e82574b21..1016dd3d43 100644 --- a/etc/input.conf +++ b/etc/input.conf @@ -104,7 +104,7 @@ #i script-binding stats/display-stats # display information and statistics #I script-binding stats/display-stats-toggle # toggle displaying information and statistics #? script-binding stats/display-page-4-toggle # toggle displaying key bindings -#` script-binding console/enable # open the console +#` script-binding commands/open # open the console #z add sub-delay -0.1 # shift subtitles 100 ms earlier #Z add sub-delay +0.1 # delay subtitles by 100 ms #x add sub-delay +0.1 # delay subtitles by 100 ms diff --git a/options/options.c b/options/options.c index 4d1b8059e2..f6fc928211 100644 --- a/options/options.c +++ b/options/options.c @@ -555,6 +555,7 @@ static const m_option_t mp_opts[] = { .flags = UPDATE_BUILTIN_SCRIPTS}, {"load-select", OPT_BOOL(lua_load_select), .flags = UPDATE_BUILTIN_SCRIPTS}, {"load-positioning", OPT_BOOL(lua_load_positioning), .flags = UPDATE_BUILTIN_SCRIPTS}, + {"load-commands", OPT_BOOL(lua_load_commands), .flags = UPDATE_BUILTIN_SCRIPTS}, #endif // ------------------------- stream options -------------------- @@ -988,6 +989,7 @@ static const struct MPOpts mp_default_opts = { .lua_load_auto_profiles = -1, .lua_load_select = true, .lua_load_positioning = true, + .lua_load_commands = true, #endif .auto_load_scripts = true, .loop_times = 1, diff --git a/options/options.h b/options/options.h index bf8dd4be30..487e7d4a7d 100644 --- a/options/options.h +++ b/options/options.h @@ -175,6 +175,7 @@ typedef struct MPOpts { int lua_load_auto_profiles; bool lua_load_select; bool lua_load_positioning; + bool lua_load_commands; bool auto_load_scripts; diff --git a/player/core.h b/player/core.h index ae2fcd3445..de49806fd0 100644 --- a/player/core.h +++ b/player/core.h @@ -444,7 +444,7 @@ typedef struct MPContext { struct mp_ipc_ctx *ipc_ctx; - int64_t builtin_script_ids[7]; + int64_t builtin_script_ids[8]; mp_mutex abort_lock; diff --git a/player/lua.c b/player/lua.c index 960cde7feb..478641977c 100644 --- a/player/lua.c +++ b/player/lua.c @@ -87,6 +87,9 @@ static const char * const builtin_lua_scripts[][2] = { }, {"@positioning.lua", # include "player/lua/positioning.lua.inc" + }, + {"@commands.lua", +# include "player/lua/commands.lua.inc" }, {0} }; diff --git a/player/lua/commands.lua b/player/lua/commands.lua new file mode 100644 index 0000000000..90ecd6a0b4 --- /dev/null +++ b/player/lua/commands.lua @@ -0,0 +1,566 @@ +--[[ +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 . +]] + +local options = { + persist_history = false, + history_path = "~~state/command_history.txt", +} + +local input = require "mp.input" +local utils = require "mp.utils" + +local styles = { + -- Colors are stolen from base16 Eighties by Chris Kempson + -- and converted to BGR as is required by ASS. + -- 2d2d2d 393939 515151 697374 + -- 939fa0 c8d0d3 dfe6e8 ecf0f2 + -- 7a77f2 5791f9 66ccff 99cc99 + -- cccc66 cc9966 cc99cc 537bd2 + + debug = "{\\1c&Ha09f93&}", + v = "{\\1c&H99cc99&}", + warn = "{\\1c&H66ccff&}", + error = "{\\1c&H7a77f2&}", + fatal = "{\\1c&H5791f9&}", + completion = "{\\1c&Hcc99cc&}", +} +for key, style in pairs(styles) do + styles[key] = style .. "{\\3c&H111111&}" +end + +local terminal_styles = { + debug = "\027[90m", + v = "\027[32m", + warn = "\027[33m", + error = "\027[31m", + fatal = "\027[91m", +} + +local platform = mp.get_property("platform") +local path_separator = platform == "windows" and "\\" or "/" +local completion_pos +local completion_append +local last_text +local last_cursor_position +local commands + +local function get_commands() + if not commands then + commands = mp.get_property_native("command-list") + end + + return commands +end + +local function help_command(param) + table.sort(get_commands(), function(c1, c2) + return c1.name < c2.name + end) + + local output = "" + if param == "" then + output = "Available commands:\n" + for _, cmd in ipairs(get_commands()) do + output = output .. " " .. cmd.name + end + output = output .. "\n" + output = output .. 'Use "help command" to show information about a command.\n' + output = output .. "ESC or Ctrl+d exits the console.\n" + else + local cmd = nil + for _, curcmd in ipairs(get_commands()) do + if curcmd.name:find(param, 1, true) then + cmd = curcmd + if curcmd.name == param then + break -- exact match + end + end + end + + if not cmd then + input.log('No command matches "' .. param .. '"!', styles.error, + terminal_styles.error) + return + end + + output = output .. 'Command "' .. cmd.name .. '"\n' + + for _, arg in ipairs(cmd.args) do + output = output .. " " .. arg.name .. " (" .. arg.type .. ")" + if arg.optional then + output = output .. " (optional)" + end + output = output .. "\n" + end + + if cmd.vararg then + output = output .. "This command supports variable arguments.\n" + end + end + + input.log(output:sub(1, -2)) +end + +local function submit(line) + -- match "help []", return or "", strip all whitespace + local help = line:match("^%s*help%s+(.-)%s*$") or + (line:match("^%s*help$") and "") + + if help then + help_command(help) + elseif line ~= "" then + mp.command(line) + end +end + +local function opened() + mp.enable_messages("terminal-default") +end + +local function closed(text, cursor_position) + mp.enable_messages("silent:terminal-default") + + last_text = text + last_cursor_position = cursor_position +end + +local function command_list() + local cmds = {} + for i, command in ipairs(get_commands()) do + cmds[i] = command.name + end + + return cmds +end + +local function property_list() + local properties = mp.get_property_native("property-list") + + for _, sub_property in pairs({"video", "audio", "sub", "sub2"}) do + properties[#properties + 1] = "current-tracks/" .. sub_property + end + + for _, sub_property in pairs({"text", "text-primary"}) do + properties[#properties + 1] = "clipboard/" .. sub_property + end + + return properties +end + +local function profile_list() + local profiles = {} + + for i, profile in ipairs(mp.get_property_native("profile-list")) do + profiles[i] = profile.name + end + + return profiles +end + +local function list_option_list() + local opts = {} + + -- Don"t log errors for renamed and removed properties. + -- (Just mp.enable_messages("fatal") still logs them to the terminal.) + local msg_level_backup = mp.get_property("msg-level") + mp.set_property("msg-level", msg_level_backup == "" and "cplayer=no" + or msg_level_backup .. ",cplayer=no") + + for _, option in pairs(mp.get_property_native("options")) do + if mp.get_property("option-info/" .. option .. "/type", ""):find(" list$") then + opts[#opts + 1] = option + end + end + + mp.set_property("msg-level", msg_level_backup) + + return opts +end + +local function list_option_action_list(option) + local type = mp.get_property("option-info/" .. option .. "/type") + + if type == "Key/value list" then + return {"add", "append", "set", "remove"} + end + + if type == "String list" or type == "Object settings list" then + return {"add", "append", "clr", "pre", "set", "remove", "toggle"} + end +end + +local function list_option_value_list(option) + local values = mp.get_property_native(option) + + if type(values) ~= "table" then + return + end + + if type(values[1]) ~= "table" then + return values + end + + for i, value in ipairs(values) do + values[i] = value.label and "@" .. value.label or value.name + end + + return values +end + +local function has_file_argument(candidate_command) + for _, command in pairs(get_commands()) do + if command.name == candidate_command then + return command.args[1] and + (command.args[1].name == "filename" or command.args[1].name == "url") + end + end +end + +local function file_list(directory) + if directory == "" then + directory = "." + else + directory = mp.command_native({"expand-path", directory}) + end + + local files = utils.readdir(directory, "files") or {} + + for _, dir in pairs(utils.readdir(directory, "dirs") or {}) do + files[#files + 1] = dir .. path_separator + end + + return files +end + +local function handle_file_completion(before_cur) + local directory, last_component_pos = + before_cur:sub(completion_pos):match("(.-)()[^" .. path_separator .."]*$") + + completion_pos = completion_pos + last_component_pos - 1 + + -- Don"t use completion_append for file completion to not add quotes after + -- directories whose entries you may want to complete afterwards. + completion_append = "" + + return file_list(directory) +end + +local function handle_choice_completion(option, before_cur) + local info = mp.get_property_native("option-info/" .. option, {}) + + if info.type == "Flag" then + return { "no", "yes" }, before_cur + end + + if info["expects-file"] then + return handle_file_completion(before_cur) + end + + -- Fix completing the empty value for --dscale and --cscale. + if info.choices and info.choices[1] == "" and completion_append == "" then + info.choices[1] = '""' + end + + return info.choices +end + +local function command_flags_at_1st_argument_list(command) + local flags = { + ["playlist-next"] = {"weak", "force"}, + ["playlist-play-index"] = {"current", "none"}, + ["playlist-remove"] = {"current"}, + ["rescan-external-files"] = {"reselect", "keep-selection"}, + ["revert-seek"] = {"mark", "mark-permanent"}, + ["screenshot"] = {"subtitles", "video", "window", "each-frame"}, + ["stop"] = {"keep-playlist"}, + } + flags["playlist-prev"] = flags["playlist-next"] + flags["screenshot-raw"] = flags.screenshot + + return flags[command] +end + +local function command_flags_at_2nd_argument_list(command) + local flags = { + ["apply-profile"] = {"default", "restore"}, + ["frame-step"] = {"play", "seek", "mute"}, + ["loadfile"] = {"replace", "append", "append-play", "insert-next", + "insert-next-play", "insert-at", "insert-at-play"}, + ["screenshot-to-file"] = {"subtitles", "video", "window", "each-frame"}, + ["screenshot-raw"] = {"bgr0", "bgra", "rgba", "rgba64"}, + ["seek"] = {"relative", "absolute", "absolute-percent", + "relative-percent", "keyframes", "exact"}, + ["sub-add"] = {"select", "auto", "cached"}, + ["sub-seek"] = {"primary", "secondary"}, + } + flags.loadlist = flags.loadfile + flags["audio-add"] = flags["sub-add"] + flags["video-add"] = flags["sub-add"] + flags["sub-step"] = flags["sub-seek"] + + return flags[command] +end + +local function handle_flags(command, arg_index, flags) + for _, cmd in pairs(get_commands()) do + if cmd.name == command then + if cmd.args[arg_index] and cmd.args[arg_index].type == "Flags" then + break + else + return + end + end + end + + local plus_pos = flags:find("%+[^%+]*$") + + if plus_pos then + completion_pos = completion_pos + plus_pos + end +end + +local function executable_list() + local executable_map = {} + local path = os.getenv("PATH") or "" + local separator = platform == "windows" and ";" or ":" + local exts = {} + + for ext in (os.getenv("PATHEXT") or ""):gmatch("[^;]+") do + exts[ext:lower()] = true + end + + for directory in path:gmatch("[^" .. separator .. "]+") do + for _, executable in pairs(utils.readdir(directory, "files") or {}) do + if not next(exts) or exts[(executable:match("%.%w+$") or ""):lower()] then + executable_map[executable] = true + end + end + end + + local executables = {} + for executable, _ in pairs(executable_map) do + executables[#executables + 1] = executable + end + + return executables +end + +local function filter_label_list(type) + local values = {"all"} + + for _, value in pairs(mp.get_property_native(type)) do + if value.label then + values[#values + 1] = value.label + end + end + + return values +end + +local function complete(before_cur) + local tokens = {} + local first_useful_token_index = 1 + local completions + + local begin_new_token = true + local last_quote + for pos, char in before_cur:gmatch("()(.)") do + if char:find("[%s;]") and not last_quote then + begin_new_token = true + if char == ";" then + first_useful_token_index = #tokens + 1 + end + elseif begin_new_token then + tokens[#tokens + 1] = { text = char, pos = pos } + last_quote = char:match("['\"]") + begin_new_token = false + else + tokens[#tokens].text = tokens[#tokens].text .. char + if char == last_quote then + last_quote = nil + end + end + end + + completion_append = last_quote or "" + + -- Strip quotes from tokens. + for _, token in pairs(tokens) do + if token.text:find('^"') then + token.text = token.text:sub(2):gsub('"$', "") + token.pos = token.pos + 1 + elseif token.text:find("^'") then + token.text = token.text:sub(2):gsub('"$', "") + token.pos = token.pos + 1 + end + end + + -- Skip command prefixes because it is not worth lumping them together with + -- command completions when they are useless for interactive usage. + local command_prefixes = { + ["osd-auto"] = true, ["no-osd"] = true, ["osd-bar"] = true, + ["osd-msg"] = true, ["osd-msg-bar"] = true, ["raw"] = true, + ["expand-properties"] = true, ["repeatable"] = true, + ["nonrepeatable"] = true, ["nonscalable"] = true, + ["async"] = true, ["sync"] = true + } + + -- Add an empty token if the cursor is after whitespace or ; to simplify + -- comparisons. + if before_cur == "" or before_cur:find("[%s;]$") then + tokens[#tokens + 1] = { text = "", pos = #before_cur + 1 } + end + + while tokens[first_useful_token_index] and + command_prefixes[tokens[first_useful_token_index].text] do + if first_useful_token_index == #tokens then + return + end + + first_useful_token_index = first_useful_token_index + 1 + end + + completion_pos = tokens[#tokens].pos + + local add_actions = { + ["add"] = true, ["append"] = true, ["pre"] = true, ["set"] = true + } + + local first_useful_token = tokens[first_useful_token_index] + + local property_pos = before_cur:match("${[=>]?()[%w_/-]*$") + if property_pos then + completion_pos = property_pos + completions = property_list() + completion_append = "}" + elseif #tokens == first_useful_token_index then + completions = command_list() + completions[#completions + 1] = "help" + elseif #tokens == first_useful_token_index + 1 then + if first_useful_token.text == "set" or + first_useful_token.text == "add" or + first_useful_token.text == "cycle" or + first_useful_token.text == "cycle-values" or + first_useful_token.text == "multiply" then + completions = property_list() + elseif first_useful_token.text == "help" then + completions = command_list() + elseif first_useful_token.text == "apply-profile" then + completions = profile_list() + elseif first_useful_token.text == "change-list" then + completions = list_option_list() + elseif first_useful_token.text == "run" then + completions = executable_list() + elseif first_useful_token.text == "vf" or + first_useful_token.text == "af" then + completions = list_option_action_list(first_useful_token.text) + elseif first_useful_token.text == "vf-command" or + first_useful_token.text == "af-command" then + completions = filter_label_list(first_useful_token.text:sub(1,2)) + elseif has_file_argument(first_useful_token.text) then + completions = handle_file_completion(before_cur) + else + completions = command_flags_at_1st_argument_list(first_useful_token.text) + handle_flags(first_useful_token.text, 1, tokens[#tokens].text) + end + elseif first_useful_token.text == "cycle-values" then + completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, + before_cur) + elseif #tokens == first_useful_token_index + 2 then + if first_useful_token.text == "set" then + completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, + before_cur) + elseif first_useful_token.text == "change-list" then + completions = list_option_action_list(tokens[first_useful_token_index + 1].text) + elseif first_useful_token.text == "vf" or + first_useful_token.text == "af" then + if add_actions[tokens[first_useful_token_index + 1].text] then + completions = handle_choice_completion(first_useful_token.text, before_cur) + elseif tokens[first_useful_token_index + 1].text == "remove" then + completions = list_option_value_list(first_useful_token.text) + end + else + completions = command_flags_at_2nd_argument_list(first_useful_token.text) + handle_flags(first_useful_token.text, 2, tokens[#tokens].text) + end + elseif #tokens == first_useful_token_index + 3 then + if first_useful_token.text == "change-list" then + if add_actions[tokens[first_useful_token_index + 2].text] then + completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, + before_cur) + elseif tokens[first_useful_token_index + 2].text == "remove" then + completions = list_option_value_list(tokens[first_useful_token_index + 1].text) + end + elseif first_useful_token.text == "dump-cache" then + completions = handle_file_completion(before_cur) + end + end + + return completions or {}, completion_pos, completion_append +end + +local function open(text, cursor_position, keep_open) + input.get({ + prompt = ">", + submit = submit, + keep_open = keep_open, + opened = opened, + closed = closed, + complete = complete, + autoselect_completion = true, + default_text = text, + cursor_position = cursor_position, + history_path = options.persist_history + and mp.command_native({"expand-path", options.history_path}) or nil, + }) +end + +mp.add_key_binding(nil, "open", function () + open(last_text, last_cursor_position, true) +end) + +-- Open the console with passed text and cursor_position arguments +mp.register_script_message("type", open) + +mp.register_event("log-message", function(e) + -- Ignore log messages from the OSD because of paranoia, since writing them + -- to the OSD could generate more messages in an infinite loop. + if e.prefix:sub(1, 3) == "osd" then return end + + -- Ignore messages output by this script. + if e.prefix == mp.get_script_name() then return end + + -- Ignore buffer overflow warning messages. Overflowed log messages would + -- have been offscreen anyway. + if e.prefix == "overflow" then return end + + -- Filter out trace-level log messages, even if the terminal-default log + -- level includes them. These aren"t too useful for an on-screen display + -- without scrollback and they include messages that are generated from the + -- OSD display itself. + if e.level == "trace" then return end + + -- Use color for debug/v/warn/error/fatal messages. + input.log("[" .. e.prefix .. "] " .. e.text:sub(1, -2), styles[e.level], + terminal_styles[e.level]) +end) + +-- Enable log messages. In silent mode, mpv will queue log messages in a buffer +-- until enable_messages is called again without the silent: prefix. +mp.enable_messages("silent:terminal-default") + +require "mp.options".read_options(options) diff --git a/player/lua/console.lua b/player/lua/console.lua index 1fab1e844b..1269d0e473 100644 --- a/player/lua/console.lua +++ b/player/lua/console.lua @@ -45,24 +45,11 @@ local opts = { match_color = '#0088FF', case_sensitive = platform ~= 'windows' and true or false, history_dedup = true, - persist_history = false, - history_path = '~~state/command_history.txt', font_hw_ratio = 'auto', } local styles = { - -- Colors are stolen from base16 Eighties by Chris Kempson - -- and converted to BGR as is required by ASS. - -- 2d2d2d 393939 515151 697374 - -- 939fa0 c8d0d3 dfe6e8 ecf0f2 - -- 7a77f2 5791f9 66ccff 99cc99 - -- cccc66 cc9966 cc99cc 537bd2 - - debug = '{\\1c&Ha09f93&}', - v = '{\\1c&H99cc99&}', - warn = '{\\1c&H66ccff&}', error = '{\\1c&H7a77f2&}', - fatal = '{\\1c&H5791f9&}', completion = '{\\1c&Hcc99cc&}', } for key, style in pairs(styles) do @@ -70,11 +57,7 @@ for key, style in pairs(styles) do end local terminal_styles = { - debug = '\027[90m', - v = '\027[32m', - warn = '\027[33m', error = '\027[31m', - fatal = '\027[91m', selected_completion = '\027[7m', default_item = '\027[1m', disabled = '\027[38;5;8m', @@ -89,19 +72,17 @@ local pending_update = false local ime_active = mp.get_property_bool('input-ime') local line = '' local cursor = 1 -local default_prompt = '>' -local prompt = default_prompt -local default_id = 'default' -local id = default_id +local prompt +local id -local histories = {[id] = {}} -local history = histories[id] +local histories = {} +local history local history_pos = 1 local searching_history = false local history_paths = {} local histories_to_save = {} -local log_buffers = {[id] = {}} +local log_buffers = {} local key_bindings = {} local dont_bind_up_down = false local overlay = mp.create_osd_overlay('ass-events') @@ -114,11 +95,9 @@ local completion_buffer = {} local selected_completion_index local completion_pos local completion_append -local path_separator = platform == 'windows' and '\\' or '/' local completion_old_line local completion_old_cursor local autoselect_completion -local commands local selectable_items local matches = {} @@ -162,14 +141,6 @@ local function get_margin_y() return opts.margin_y > -1 and opts.margin_y or mp.get_property_native('osd-margin-y') end -local function get_commands() - if not commands then - commands = mp.get_property_native('command-list') - end - - return commands -end - -- Naive helper function to find the next UTF-8 character in 'str' after 'pos' -- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. @@ -960,10 +931,8 @@ local function handle_edit() handle_cursor_move() - if input_caller then - mp.commandv('script-message-to', input_caller, 'input-event', 'edited', - utils.format_json({line})) - end + mp.commandv('script-message-to', input_caller, 'input-event', 'edited', + utils.format_json({line})) end -- Insert a character at the current cursor position (any_unicode) @@ -1029,49 +998,6 @@ local function maybe_exit() end end -local function help_command(param) - table.sort(get_commands(), function(c1, c2) - return c1.name < c2.name - end) - local output = '' - if param == '' then - output = 'Available commands:\n' - for _, cmd in ipairs(get_commands()) do - output = output .. ' ' .. cmd.name - end - output = output .. '\n' - output = output .. 'Use "help command" to show information about a command.\n' - output = output .. "ESC or Ctrl+d exits the console.\n" - else - local cmd = nil - for _, curcmd in ipairs(get_commands()) do - if curcmd.name:find(param, 1, true) then - cmd = curcmd - if curcmd.name == param then - break -- exact match - end - end - end - if not cmd then - log_add('No command matches "' .. param .. '"!', styles.error, - terminal_styles.error) - return - end - output = output .. 'Command "' .. cmd.name .. '"\n' - for _, arg in ipairs(cmd.args) do - output = output .. ' ' .. arg.name .. ' (' .. arg.type .. ')' - if arg.optional then - output = output .. ' (optional)' - end - output = output .. '\n' - end - if cmd.vararg then - output = output .. 'This command supports variable arguments.\n' - end - end - log_add(output:sub(1, -2)) -end - local function unbind_mouse() mp.remove_key_binding('_console_mouse_move') mp.remove_key_binding('_console_mbtn_left') @@ -1089,42 +1015,25 @@ local function submit() return end - if line == '' and input_caller == nil then - return - end - if selectable_items then if #matches > 0 then mp.commandv('script-message-to', input_caller, 'input-event', 'submit', utils.format_json({matches[selected_match].index})) end - elseif input_caller then + else if selected_completion_index == 0 and autoselect_completion then cycle_through_completions() end mp.commandv('script-message-to', input_caller, 'input-event', 'submit', utils.format_json({line})) - else - if selected_completion_index == 0 then - cycle_through_completions() - end - -- match "help []", return 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) + if history[#history] ~= line and line ~= '' then + history_add(line) end end - if history[#history] ~= line and line ~= '' then - history_add(line) - end - - if input_caller and not keep_open then + if not keep_open then set_active(false) elseif not selectable_items then clear() @@ -1428,238 +1337,6 @@ local function text_input(info) end end -local function command_list() - local cmds = {} - for i, command in ipairs(get_commands()) do - cmds[i] = command.name - end - - return cmds -end - -local function property_list() - local properties = mp.get_property_native('property-list') - - for _, sub_property in pairs({'video', 'audio', 'sub', 'sub2'}) do - properties[#properties + 1] = 'current-tracks/' .. sub_property - end - - for _, sub_property in pairs({'text', 'text-primary'}) do - properties[#properties + 1] = 'clipboard/' .. sub_property - end - - return properties -end - -local function profile_list() - local profiles = {} - - for i, profile in ipairs(mp.get_property_native('profile-list')) do - profiles[i] = profile.name - end - - return profiles -end - -local function list_option_list() - local options = {} - - -- Don't log errors for renamed and removed properties. - -- (Just mp.enable_messages('fatal') still logs them to the terminal.) - local msg_level_backup = mp.get_property('msg-level') - mp.set_property('msg-level', msg_level_backup == '' and 'cplayer=no' - or msg_level_backup .. ',cplayer=no') - - for _, option in pairs(mp.get_property_native('options')) do - if mp.get_property('option-info/' .. option .. '/type', ''):find(' list$') then - options[#options + 1] = option - end - end - - mp.set_property('msg-level', msg_level_backup) - - return options -end - -local function list_option_action_list(option) - local type = mp.get_property('option-info/' .. option .. '/type') - - if type == 'Key/value list' then - return {'add', 'append', 'set', 'remove'} - end - - if type == 'String list' or type == 'Object settings list' then - return {'add', 'append', 'clr', 'pre', 'set', 'remove', 'toggle'} - end -end - -local function list_option_value_list(option) - local values = mp.get_property_native(option) - - if type(values) ~= 'table' then - return - end - - if type(values[1]) ~= 'table' then - return values - end - - for i, value in ipairs(values) do - values[i] = value.label and '@' .. value.label or value.name - end - - return values -end - -local function has_file_argument(candidate_command) - for _, command in pairs(get_commands()) do - if command.name == candidate_command then - return command.args[1] and - (command.args[1].name == 'filename' or command.args[1].name == 'url') - end - end -end - -local function file_list(directory) - if directory == '' then - directory = '.' - else - directory = mp.command_native({'expand-path', directory}) - end - - local files = utils.readdir(directory, 'files') or {} - - for _, dir in pairs(utils.readdir(directory, 'dirs') or {}) do - files[#files + 1] = dir .. path_separator - end - - return files -end - -local function handle_file_completion(before_cur) - local directory, last_component_pos = - before_cur:sub(completion_pos):match('(.-)()[^' .. path_separator ..']*$') - - completion_pos = completion_pos + last_component_pos - 1 - - -- Don't use completion_append for file completion to not add quotes after - -- directories whose entries you may want to complete afterwards. - completion_append = '' - - return file_list(directory) -end - -local function handle_choice_completion(option, before_cur) - local info = mp.get_property_native('option-info/' .. option, {}) - - if info.type == 'Flag' then - return { 'no', 'yes' }, before_cur - end - - if info['expects-file'] then - return handle_file_completion(before_cur) - end - - -- Fix completing the empty value for --dscale and --cscale. - if info.choices and info.choices[1] == '' and completion_append == '' then - info.choices[1] = '""' - end - - return info.choices -end - -local function command_flags_at_1st_argument_list(command) - local flags = { - ['playlist-next'] = {'weak', 'force'}, - ['playlist-play-index'] = {'current', 'none'}, - ['playlist-remove'] = {'current'}, - ['rescan-external-files'] = {'reselect', 'keep-selection'}, - ['revert-seek'] = {'mark', 'mark-permanent'}, - ['screenshot'] = {'subtitles', 'video', 'window', 'each-frame'}, - ['stop'] = {'keep-playlist'}, - } - flags['playlist-prev'] = flags['playlist-next'] - flags['screenshot-raw'] = flags.screenshot - - return flags[command] -end - -local function command_flags_at_2nd_argument_list(command) - local flags = { - ['apply-profile'] = {'default', 'restore'}, - ['frame-step'] = {'play', 'seek', 'mute'}, - ['loadfile'] = {'replace', 'append', 'append-play', 'insert-next', - 'insert-next-play', 'insert-at', 'insert-at-play'}, - ['screenshot-to-file'] = {'subtitles', 'video', 'window', 'each-frame'}, - ['screenshot-raw'] = {'bgr0', 'bgra', 'rgba', 'rgba64'}, - ['seek'] = {'relative', 'absolute', 'absolute-percent', - 'relative-percent', 'keyframes', 'exact'}, - ['sub-add'] = {'select', 'auto', 'cached'}, - ['sub-seek'] = {'primary', 'secondary'}, - } - flags.loadlist = flags.loadfile - flags['audio-add'] = flags['sub-add'] - flags['video-add'] = flags['sub-add'] - flags['sub-step'] = flags['sub-seek'] - - return flags[command] -end - -local function handle_flags(command, arg_index, flags) - for _, cmd in pairs(get_commands()) do - if cmd.name == command then - if cmd.args[arg_index] and cmd.args[arg_index].type == 'Flags' then - break - else - return - end - end - end - - local plus_pos = flags:find('%+[^%+]*$') - - if plus_pos then - completion_pos = completion_pos + plus_pos - end -end - -local function list_executables() - local executable_map = {} - local path = os.getenv('PATH') or '' - local separator = platform == 'windows' and ';' or ':' - local exts = {} - - for ext in (os.getenv('PATHEXT') or ''):gmatch('[^;]+') do - exts[ext:lower()] = true - end - - for directory in path:gmatch('[^' .. separator .. ']+') do - for _, executable in pairs(utils.readdir(directory, 'files') or {}) do - if not next(exts) or exts[(executable:match('%.%w+$') or ''):lower()] then - executable_map[executable] = true - end - end - end - - local executables = {} - for executable, _ in pairs(executable_map) do - executables[#executables + 1] = executable - end - - return executables -end - -local function list_filter_labels(type) - local values = {'all'} - - for _, value in pairs(mp.get_property_native(type)) do - if value.label then - values[#values + 1] = value.label - end - end - - return values -end local function common_prefix_length(s1, s2) local common_count = 0 @@ -1723,166 +1400,10 @@ end -- Show autocompletions. complete = function () - if input_caller then - completion_old_line = line - completion_old_cursor = cursor - mp.commandv('script-message-to', input_caller, 'input-event', - 'complete', utils.format_json({line:sub(1, cursor - 1)})) - render() - return - end - - local before_cur = line:sub(1, cursor - 1) - local tokens = {} - local first_useful_token_index = 1 - local completions - - local begin_new_token = true - local last_quote - for pos, char in before_cur:gmatch('()(.)') do - if char:find('[%s;]') and not last_quote then - begin_new_token = true - if char == ';' then - first_useful_token_index = #tokens + 1 - end - elseif begin_new_token then - tokens[#tokens + 1] = { text = char, pos = pos } - last_quote = char:match('["\']') - begin_new_token = false - else - tokens[#tokens].text = tokens[#tokens].text .. char - if char == last_quote then - last_quote = nil - end - end - end - - completion_append = last_quote or '' - - -- Strip quotes from tokens. - for _, token in pairs(tokens) do - if token.text:find('^"') then - token.text = token.text:sub(2):gsub('"$', '') - token.pos = token.pos + 1 - elseif token.text:find("^'") then - token.text = token.text:sub(2):gsub("'$", '') - token.pos = token.pos + 1 - end - end - - -- Skip command prefixes because it is not worth lumping them together with - -- command completions when they are useless for interactive usage. - local command_prefixes = { - ['osd-auto'] = true, ['no-osd'] = true, ['osd-bar'] = true, - ['osd-msg'] = true, ['osd-msg-bar'] = true, ['raw'] = true, - ['expand-properties'] = true, ['repeatable'] = true, - ['nonrepeatable'] = true, ['nonscalable'] = true, - ['async'] = true, ['sync'] = true - } - - -- Add an empty token if the cursor is after whitespace or ; to simplify - -- comparisons. - if before_cur == '' or before_cur:find('[%s;]$') then - tokens[#tokens + 1] = { text = "", pos = cursor } - end - - while tokens[first_useful_token_index] and - command_prefixes[tokens[first_useful_token_index].text] do - if first_useful_token_index == #tokens then - completion_buffer = {} - render() - return - end - - first_useful_token_index = first_useful_token_index + 1 - end - - completion_pos = tokens[#tokens].pos - - local add_actions = { - ['add'] = true, ['append'] = true, ['pre'] = true, ['set'] = true - } - - local first_useful_token = tokens[first_useful_token_index] - - local property_pos = before_cur:match('${[=>]?()[%w_/-]*$') - if property_pos then - completion_pos = property_pos - completions = property_list() - completion_append = '}' - elseif #tokens == first_useful_token_index then - completions = command_list() - completions[#completions + 1] = 'help' - elseif #tokens == first_useful_token_index + 1 then - if first_useful_token.text == 'set' or - first_useful_token.text == 'add' or - first_useful_token.text == 'cycle' or - first_useful_token.text == 'cycle-values' or - first_useful_token.text == 'multiply' then - completions = property_list() - elseif first_useful_token.text == 'help' then - completions = command_list() - elseif first_useful_token.text == 'apply-profile' then - completions = profile_list() - elseif first_useful_token.text == 'change-list' then - completions = list_option_list() - elseif first_useful_token.text == 'run' then - completions = list_executables() - elseif first_useful_token.text == 'vf' or - first_useful_token.text == 'af' then - completions = list_option_action_list(first_useful_token.text) - elseif first_useful_token.text == 'vf-command' or - first_useful_token.text == 'af-command' then - completions = list_filter_labels(first_useful_token.text:sub(1,2)) - elseif has_file_argument(first_useful_token.text) then - completions = handle_file_completion(before_cur) - else - completions = command_flags_at_1st_argument_list(first_useful_token.text) - handle_flags(first_useful_token.text, 1, tokens[#tokens].text) - end - elseif first_useful_token.text == 'cycle-values' then - completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, - before_cur) - elseif #tokens == first_useful_token_index + 2 then - if first_useful_token.text == 'set' then - completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, - before_cur) - elseif first_useful_token.text == 'change-list' then - completions = list_option_action_list(tokens[first_useful_token_index + 1].text) - elseif first_useful_token.text == 'vf' or - first_useful_token.text == 'af' then - if add_actions[tokens[first_useful_token_index + 1].text] then - completions = handle_choice_completion(first_useful_token.text, before_cur) - elseif tokens[first_useful_token_index + 1].text == 'remove' then - completions = list_option_value_list(first_useful_token.text) - end - else - completions = command_flags_at_2nd_argument_list(first_useful_token.text) - handle_flags(first_useful_token.text, 2, tokens[#tokens].text) - end - elseif #tokens == first_useful_token_index + 3 then - if first_useful_token.text == 'change-list' then - if add_actions[tokens[first_useful_token_index + 2].text] then - completions = handle_choice_completion(tokens[first_useful_token_index + 1].text, - before_cur) - elseif tokens[first_useful_token_index + 2].text == 'remove' then - completions = list_option_value_list(tokens[first_useful_token_index + 1].text) - end - elseif first_useful_token.text == 'dump-cache' then - completions = handle_file_completion(before_cur) - end - end - - completion_buffer = {} - selected_completion_index = 0 - completions = completions or {} - table.sort(completions) - completion_pos = completion_pos or 1 - for i, match in ipairs(fuzzy_find(before_cur:sub(completion_pos), - completions, opts.case_sensitive)) do - completion_buffer[i] = completions[match[1]] - end - + completion_old_line = line + completion_old_cursor = cursor + mp.commandv('script-message-to', input_caller, 'input-event', + 'complete', utils.format_json({line:sub(1, cursor - 1)})) render() end @@ -2032,99 +1553,43 @@ set_active = function (active) mp.set_property_bool('user-data/mpv/console/open', true) ime_active = mp.get_property_bool('input-ime') mp.set_property_bool('input-ime', true) - - if not input_caller then - prompt = default_prompt - id = default_id - history = histories[id] - history_paths[id] = opts.persist_history and opts.history_path or nil - histories_to_save[id] = histories_to_save[id] or '' - read_history() - history_pos = #history + 1 - mp.enable_messages('terminal-default') - end elseif searching_history then searching_history = false selectable_items = nil unbind_mouse() else open = false - completion_buffer = {} undefine_key_bindings() - mp.enable_messages('silent:terminal-default') + unbind_mouse() mp.set_property_bool('user-data/mpv/console/open', false) mp.set_property_bool('input-ime', ime_active) - - if input_caller then - mp.commandv('script-message-to', input_caller, 'input-event', - 'closed', utils.format_json({line, cursor})) - input_caller = nil - line = '' - cursor = 1 - selectable_items = nil - default_item = nil - dont_bind_up_down = false - unbind_mouse() - end + mp.commandv('script-message-to', input_caller, 'input-event', + 'closed', utils.format_json({line, cursor})) collectgarbage() end render() end --- Show the console if hidden and replace its contents with 'text' -local function show_and_type(text, cursor_pos) - text = text or '' - cursor_pos = tonumber(cursor_pos) - - -- Save the line currently being edited, just in case - if line ~= text and line ~= '' and history[#history] ~= line then - history_add(line) - end - - line = text - if cursor_pos ~= nil and cursor_pos >= 1 - and cursor_pos <= line:len() + 1 then - cursor = math.floor(cursor_pos) - else - cursor = line:len() + 1 - end - history_pos = #history + 1 - insert_mode = false - if open then - render() - else - set_active(true) - end -end - -mp.add_key_binding(nil, 'enable', function() - autoselect_completion = true - set_active(true) -end) - mp.register_script_message('disable', function() set_active(false) end) -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 open and input_caller and script_name ~= input_caller then + if open and script_name ~= input_caller then mp.commandv('script-message-to', input_caller, 'input-event', 'closed', utils.format_json({line, cursor})) end input_caller = script_name args = utils.parse_json(args) - prompt = args.prompt or default_prompt + prompt = args.prompt line = args.default_text or '' cursor = args.cursor_position or line:len() + 1 id = args.id or script_name .. prompt keep_open = args.keep_open autoselect_completion = args.autoselect_completion dont_bind_up_down = args.dont_bind_up_down + completion_buffer = {} if histories[id] == nil then histories[id] = {} @@ -2163,12 +1628,6 @@ mp.register_script_message('get-input', function (script_name, args) end) mp.register_script_message('log', function (message) - -- input.get 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.text, @@ -2177,10 +1636,6 @@ mp.register_script_message('log', function (message) end) mp.register_script_message('set-log', function (log) - if input_caller == nil then - return - end - log = utils.parse_json(log) log_buffers[id] = {} @@ -2238,33 +1693,6 @@ mp.observe_property("user-data/osc/margins", "native", function(_, val) render() end) --- Enable log messages. In silent mode, mpv will queue log messages in a buffer --- until enable_messages is called again without the silent: prefix. -mp.enable_messages('silent:terminal-default') - -mp.register_event('log-message', function(e) - -- Ignore log messages from the OSD because of paranoia, since writing them - -- to the OSD could generate more messages in an infinite loop. - if e.prefix:sub(1, 3) == 'osd' then return end - - -- Ignore messages output by this script. - if e.prefix == mp.get_script_name() then return end - - -- Ignore buffer overflow warning messages. Overflowed log messages would - -- have been offscreen anyway. - if e.prefix == 'overflow' then return end - - -- Filter out trace-level log messages, even if the terminal-default log - -- level includes them. These aren't too useful for an on-screen display - -- without scrollback and they include messages that are generated from the - -- OSD display itself. - if e.level == 'trace' then return end - - -- Use color for debug/v/warn/error/fatal messages. - log_add('[' .. e.prefix .. '] ' .. e.text:sub(1, -2), styles[e.level], - terminal_styles[e.level]) -end) - mp.register_event('shutdown', function () mp.del_property('user-data/mpv/console') @@ -2281,6 +1709,15 @@ mp.register_event('shutdown', function () end end) +-- These are for backwards compatibility only. +mp.add_key_binding(nil, 'enable', function () + mp.command('script-message-to commands open') +end) + +mp.register_script_message('type', function (text, cursor_pos) + mp.commandv('script-message-to', 'commands', 'type', unpack({text, cursor_pos})) +end) + require 'mp.options'.read_options(opts, nil, render) collectgarbage() diff --git a/player/lua/meson.build b/player/lua/meson.build index a8b8704957..111e40228d 100644 --- a/player/lua/meson.build +++ b/player/lua/meson.build @@ -1,6 +1,7 @@ lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua', 'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua', - 'input.lua', 'fzy.lua', 'select.lua', 'positioning.lua'] + 'input.lua', 'fzy.lua', 'select.lua', 'positioning.lua', + 'commands.lua'] foreach file: lua_files lua_file = custom_target(file, input: file, diff --git a/player/scripting.c b/player/scripting.c index f5473df098..1b3cb1c61f 100644 --- a/player/scripting.c +++ b/player/scripting.c @@ -268,6 +268,7 @@ void mp_load_builtin_scripts(struct MPContext *mpctx) "@auto_profiles.lua"); load_builtin_script(mpctx, 5, mpctx->opts->lua_load_select, "@select.lua"); load_builtin_script(mpctx, 6, mpctx->opts->lua_load_positioning, "@positioning.lua"); + load_builtin_script(mpctx, 7, mpctx->opts->lua_load_commands, "@commands.lua"); } bool mp_load_scripts(struct MPContext *mpctx)