mirror of
https://github.com/mpv-player/mpv.git
synced 2026-10-25 12:42:04 +08:00
The new async completion handling code expects an empty list of completions to be returned if no completions should be shown.
611 lines
19 KiB
Lua
611 lines
19 KiB
Lua
--[[
|
|
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 options = {
|
|
persist_history = false,
|
|
history_path = "~~state/command_history.txt",
|
|
remember_input = true,
|
|
}
|
|
|
|
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 cache = {}
|
|
|
|
|
|
local function get_commands()
|
|
if cache["commands"] then
|
|
return cache["commands"]
|
|
end
|
|
|
|
cache["commands"] = mp.get_property_native("command-list")
|
|
table.sort(cache["commands"], function(c1, c2)
|
|
return c1.name < c2.name
|
|
end)
|
|
|
|
return cache["commands"]
|
|
end
|
|
|
|
local function help_command(param)
|
|
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 [<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)
|
|
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")
|
|
|
|
if options.remember_input then
|
|
last_text = text
|
|
last_cursor_position = cursor_position
|
|
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()
|
|
if cache["property-list"] then
|
|
return cache["property-list"]
|
|
end
|
|
|
|
cache["property-list"] = mp.get_property_native("property-list")
|
|
|
|
for _, sub_property in pairs({"video", "audio", "sub", "sub2"}) do
|
|
table.insert(cache["property-list"], "current-tracks/" .. sub_property)
|
|
end
|
|
|
|
for _, sub_property in pairs({"text", "text-primary"}) do
|
|
table.insert(cache["property-list"], "clipboard/" .. sub_property)
|
|
end
|
|
|
|
return cache["property-list"]
|
|
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 option_info(option, prop, default)
|
|
local key = "option-info/" .. option
|
|
cache[key] = cache[key] or mp.get_property_native(key, false)
|
|
|
|
if not cache[key] then
|
|
return default
|
|
end
|
|
|
|
if prop then
|
|
if cache[key][prop] ~= nil then
|
|
return cache[key][prop]
|
|
end
|
|
return default
|
|
end
|
|
|
|
return cache[key]
|
|
end
|
|
|
|
local function list_option_list()
|
|
if cache["option-list"] then
|
|
return cache["option-list"]
|
|
end
|
|
cache["option-list"] = {}
|
|
|
|
-- 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 option_info(option, "type", ""):find(" list$") then
|
|
table.insert(cache["option-list"], option)
|
|
end
|
|
end
|
|
|
|
mp.set_property("msg-level", msg_level_backup)
|
|
|
|
return cache["option-list"]
|
|
end
|
|
|
|
local function list_option_action_list(option)
|
|
local type = 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)
|
|
local key = "command-file/" .. candidate_command
|
|
if cache[key] ~= nil then
|
|
return cache[key]
|
|
end
|
|
|
|
for _, command in pairs(get_commands()) do
|
|
if command.name == candidate_command then
|
|
cache[key] = command.args[1] and
|
|
(command.args[1].name == "filename" or command.args[1].name == "url")
|
|
return cache[key]
|
|
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 = option_info(option, nil, {})
|
|
|
|
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", "osd", "scaled", "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"] = {"apply", "restore"},
|
|
["frame-step"] = {"play", "seek", "mute"},
|
|
["loadfile"] = {"replace", "append", "insert-next", "insert-at", "play"},
|
|
["screenshot-to-file"] = {"subtitles", "video", "window", "osd", "scaled", "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, response)
|
|
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 response({}, 1)
|
|
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 first_useful_token.text == "run" then
|
|
completions = handle_file_completion(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
|
|
|
|
response(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
|
|
|
|
-- Avoid logging debug messages infinitely.
|
|
if e.prefix == "cplayer" and
|
|
e.text:find('^Run command: script%-message%-to, flags=64, args=%[target="console"') 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)
|