--[[ MIT License Copyright (c) 2021 Oscar Manglaras 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. ]] local mp = require 'mp' local msg = require 'mp.msg' local utils = require 'mp.utils' local options = require 'mp.options' -- Default options local opts = { -- All drawing is scaled by this value, including the text borders and the -- cursor. Change it if you have a high-DPI display. scale = 1, -- Set the font used for the REPL and the console. This probably doesn't -- have to be a monospaced font. font = "", -- Set the font size used for the REPL and the console. This will be -- multiplied by "scale." font_size = 16, } options.read_options(opts, "user_input") local API_VERSION = "0.1.0" local API_MAJOR_MINOR = API_VERSION:match("%d+%.%d+") local co = nil local queue = {} local active_ids = {} local histories = {} local request = nil local line = '' --[[ The below code is a modified implementation of text input from mpv's console.lua: https://github.com/mpv-player/mpv/blob/7ca14d646c7e405f3fb1e44600e2a67fc4607238/player/lua/console.lua Modifications: removed support for log messages, sending commands, tab complete, help commands removed update timer Changed esc key to call handle_esc function handle_esc and handle_enter now resume the main coroutine with a response table made history specific to request ids localised all functions - reordered some to fit keybindings use new names ]]-- ------------------------------START ORIGINAL MPV CODE----------------------------------- ---------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------- -- Copyright (C) 2019 the mpv developers -- -- Permission to use, copy, modify, and/or distribute this software for any -- purpose with or without fee is hereby granted, provided that the above -- copyright notice and this permission notice appear in all copies. -- -- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. local assdraw = require 'mp.assdraw' local function detect_platform() local o = {} -- Kind of a dumb way of detecting the platform but whatever if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then return 'windows' elseif mp.get_property_native('options/macos-force-dedicated-gpu', o) ~= o then return 'macos' elseif os.getenv('WAYLAND_DISPLAY') then return 'wayland' end return 'x11' end -- Pick a better default font for Windows and macOS local platform = detect_platform() if platform == 'windows' then opts.font = 'Consolas' elseif platform == 'macos' then opts.font = 'Menlo' else opts.font = 'monospace' end local repl_active = false local insert_mode = false local cursor = 1 local key_bindings = {} local global_margin_y = 0 -- Escape a string for verbatim display on the OSD local function ass_escape(str) -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if -- it isn't followed by a recognised character, so add a zero-width -- non-breaking space str = str:gsub('\\', '\\\239\187\191') str = str:gsub('{', '\\{') str = str:gsub('}', '\\}') -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of -- consecutive newlines str = str:gsub('\n', '\239\187\191\\N') -- Turn leading spaces into hard spaces to prevent ASS from stripping them str = str:gsub('\\N ', '\\N\\h') str = str:gsub('^ ', '\\h') return str end -- Render the REPL and console as an ASS OSD local function update() local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) dpi_scale = dpi_scale * opts.scale local screenx, screeny, aspect = mp.get_osd_size() screenx = screenx / dpi_scale screeny = screeny / dpi_scale -- Clear the OSD if the REPL is not active if not repl_active then mp.set_osd_ass(screenx, screeny, '') return end local ass = assdraw.ass_new() local style = '{\\r' .. '\\1a&H00&\\3a&H00&\\4a&H99&' .. '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' local queue_style = '{\\r' .. '\\1a&H00&\\3a&H00&\\4a&H99&' .. '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. '\\c&H66ccff&' .. '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor -- inline with the surrounding text, but it sets the advance to the width -- of the drawing. So the cursor doesn't affect layout too much, make it as -- thin as possible and make it appear to be 1px wide by giving it 0.5px -- horizontal borders. local cheight = opts.font_size * 8 local cglyph = '{\\r' .. '\\1a&H44&\\3a&H44&\\4a&H99&' .. '\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&' .. '\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}' .. 'm 0 0 l 1 0 l 1 ' .. cheight .. ' l 0 ' .. cheight .. '{\\p0}' local before_cur = ass_escape(line:sub(1, cursor - 1)) local after_cur = ass_escape(line:sub(cursor)) ass:new_event() ass:an(1) ass:pos(2, screeny - 2 - global_margin_y * screeny) if (#queue == 2) then ass:append(queue_style .. string.format("There is 1 more request queued\\N")) elseif (#queue > 2) then ass:append(queue_style .. string.format("There are %d more requests queued\\N", #queue-1)) end ass:append(style .. request.text .. '\\N') ass:append('> ' .. before_cur) ass:append(cglyph) ass:append(style .. after_cur) -- Redraw the cursor with the REPL text invisible. This will make the -- cursor appear in front of the text. ass:new_event() ass:an(1) ass:pos(2, screeny - 2) ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur) ass:append(cglyph) ass:append(style .. '{\\alpha&HFF&}' .. after_cur) mp.set_osd_ass(screenx, screeny, ass.text) 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. local function next_utf8(str, pos) if pos > str:len() then return pos end repeat pos = pos + 1 until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf return pos end -- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' local function prev_utf8(str, pos) if pos <= 1 then return pos end repeat pos = pos - 1 until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf return pos end -- Insert a character at the current cursor position (any_unicode) local function handle_char_input(c) if insert_mode then line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) else line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) end cursor = cursor + #c update() end -- Remove the character behind the cursor (Backspace) local function handle_backspace() if cursor <= 1 then return end local prev = prev_utf8(line, cursor) line = line:sub(1, prev - 1) .. line:sub(cursor) cursor = prev update() end -- Remove the character in front of the cursor (Del) local function handle_del() if cursor > line:len() then return end line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) update() end -- Toggle insert mode (Ins) local function handle_ins() insert_mode = not insert_mode end -- Move the cursor to the next character (Right) local function next_char(amount) cursor = next_utf8(line, cursor) update() end -- Move the cursor to the previous character (Left) local function prev_char(amount) cursor = prev_utf8(line, cursor) update() end -- Clear the current line (Ctrl+C) local function clear() line = '' cursor = 1 insert_mode = false request.history.pos = #request.history.list + 1 update() end -- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D) local function maybe_exit() if line == '' then else handle_del() end end local function handle_esc() coroutine.resume(co, { line = nil, err = "exited" }) end -- Run the current command and clear the line (Enter) local function handle_enter() if request.history.list[#request.history.list] ~= line and line ~= "" then request.history.list[#request.history.list + 1] = line end coroutine.resume(co, { line = line }) end -- Go to the specified position in the command history local function go_history(new_pos) local old_pos = request.history.pos request.history.pos = new_pos -- Restrict the position to a legal value if request.history.pos > #request.history.list + 1 then request.history.pos = #request.history.list + 1 elseif request.history.pos < 1 then request.history.pos = 1 end -- Do nothing if the history position didn't actually change if request.history.pos == old_pos then return end -- If the user was editing a non-history line, save it as the last history -- entry. This makes it much less frustrating to accidentally hit Up/Down -- while editing a line. if old_pos == #request.history.list + 1 and line ~= '' and request.history.list[#request.history.list] ~= line then request.history.list[#request.history.list + 1] = line end -- Now show the history line (or a blank line for #history + 1) if request.history.pos <= #request.history.list then line = request.history.list[request.history.pos] else line = '' end cursor = line:len() + 1 insert_mode = false update() end -- Go to the specified relative position in the command history (Up, Down) local function move_history(amount) go_history(request.history.pos + amount) end -- Go to the first command in the command history (PgUp) local function handle_pgup() go_history(1) end -- Stop browsing history and start editing a blank line (PgDown) local function handle_pgdown() go_history(#request.history.list + 1) end -- Move to the start of the current word, or if already at the start, the start -- of the previous word. (Ctrl+Left) local function prev_word() -- This is basically the same as next_word() but backwards, so reverse the -- string in order to do a "backwards" find. This wouldn't be as annoying -- to do if Lua didn't insist on 1-based indexing. cursor = line:len() - select(2, line:reverse():find('%s*[^%s]*', line:len() - cursor + 2)) + 1 update() end -- Move to the end of the current word, or if already at the end, the end of -- the next word. (Ctrl+Right) local function next_word() cursor = select(2, line:find('%s*[^%s]*', cursor)) + 1 update() end -- Move the cursor to the beginning of the line (HOME) local function go_home() cursor = 1 update() end -- Move the cursor to the end of the line (END) local function go_end() cursor = line:len() + 1 update() end -- Delete from the cursor to the beginning of the word (Ctrl+Backspace) local function del_word() local before_cur = line:sub(1, cursor - 1) local after_cur = line:sub(cursor) before_cur = before_cur:gsub('[^%s]+%s*$', '', 1) line = before_cur .. after_cur cursor = before_cur:len() + 1 update() end -- Delete from the cursor to the end of the word (Ctrl+Del) local function del_next_word() if cursor > line:len() then return end local before_cur = line:sub(1, cursor - 1) local after_cur = line:sub(cursor) after_cur = after_cur:gsub('^%s*[^%s]+', '', 1) line = before_cur .. after_cur update() end -- Delete from the cursor to the end of the line (Ctrl+K) local function del_to_eol() line = line:sub(1, cursor - 1) update() end -- Delete from the cursor back to the start of the line (Ctrl+U) local function del_to_start() line = line:sub(cursor) cursor = 1 update() end -- Returns a string of UTF-8 text from the clipboard (or the primary selection) local function get_clipboard(clip) if platform == 'x11' then local res = utils.subprocess({ args = { 'xclip', '-selection', clip and 'clipboard' or 'primary', '-out' }, playback_only = false, }) if not res.error then return res.stdout end elseif platform == 'wayland' then local res = utils.subprocess({ args = { 'wl-paste', clip and '-n' or '-np' }, playback_only = false, }) if not res.error then return res.stdout end elseif platform == 'windows' then local res = utils.subprocess({ args = { 'powershell', '-NoProfile', '-Command', [[& { Trap { Write-Error -ErrorRecord $_ Exit 1 } $clip = "" if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText } else { Add-Type -AssemblyName PresentationCore $clip = [Windows.Clipboard]::GetText() } $clip = $clip -Replace "`r","" $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) }]] }, playback_only = false, }) if not res.error then return res.stdout end elseif platform == 'macos' then local res = utils.subprocess({ args = { 'pbpaste' }, playback_only = false, }) if not res.error then return res.stdout end end return '' end -- Paste text from the window-system's clipboard. 'clip' determines whether the -- clipboard or the primary selection buffer is used (on X11 and Wayland only.) local function paste(clip) local text = get_clipboard(clip) local before_cur = line:sub(1, cursor - 1) local after_cur = line:sub(cursor) line = before_cur .. text .. after_cur cursor = cursor + text:len() update() end -- List of input bindings. This is a weird mashup between common GUI text-input -- bindings and readline bindings. local function get_bindings() local bindings = { { 'esc', handle_esc }, { 'enter', handle_enter }, { 'kp_enter', handle_enter }, { 'shift+enter', function() handle_char_input('\n') end }, { 'ctrl+j', handle_enter }, { 'ctrl+m', handle_enter }, { 'bs', handle_backspace }, { 'shift+bs', handle_backspace }, { 'ctrl+h', handle_backspace }, { 'del', handle_del }, { 'shift+del', handle_del }, { 'ins', handle_ins }, { 'shift+ins', function() paste(false) end }, { 'mbtn_mid', function() paste(false) end }, { 'left', function() prev_char() end }, { 'ctrl+b', function() prev_char() end }, { 'right', function() next_char() end }, { 'ctrl+f', function() next_char() end }, { 'up', function() move_history(-1) end }, { 'ctrl+p', function() move_history(-1) end }, { 'wheel_up', function() move_history(-1) end }, { 'down', function() move_history(1) end }, { 'ctrl+n', function() move_history(1) end }, { 'wheel_down', function() move_history(1) end }, { 'wheel_left', function() end }, { 'wheel_right', function() end }, { 'ctrl+left', prev_word }, { 'alt+b', prev_word }, { 'ctrl+right', next_word }, { 'alt+f', next_word }, { 'ctrl+a', go_home }, { 'home', go_home }, { 'ctrl+e', go_end }, { 'end', go_end }, { 'pgup', handle_pgup }, { 'pgdwn', handle_pgdown }, { 'ctrl+c', clear }, { 'ctrl+d', maybe_exit }, { 'ctrl+k', del_to_eol }, { 'ctrl+u', del_to_start }, { 'ctrl+v', function() paste(true) end }, { 'meta+v', function() paste(true) end }, { 'ctrl+bs', del_word }, { 'ctrl+w', del_word }, { 'ctrl+del', del_next_word }, { 'alt+d', del_next_word }, { 'kp_dec', function() handle_char_input('.') end }, } for i = 0, 9 do bindings[#bindings + 1] = {'kp' .. i, function() handle_char_input('' .. i) end} end return bindings end local function text_input(info) if info.key_text and (info.event == "press" or info.event == "down" or info.event == "repeat") then handle_char_input(info.key_text) end end local function define_key_bindings() if #key_bindings > 0 then return end for _, bind in ipairs(get_bindings()) do -- Generate arbitrary name for removing the bindings later. local name = "_userinput_" .. bind[1] key_bindings[#key_bindings + 1] = name mp.add_forced_key_binding(bind[1], name, bind[2], {repeatable = true}) end mp.add_forced_key_binding("any_unicode", "_userinput_text", text_input, {repeatable = true, complex = true}) key_bindings[#key_bindings + 1] = "_userinput_text" end local function undefine_key_bindings() for _, name in ipairs(key_bindings) do mp.remove_key_binding(name) end key_bindings = {} end -- Set the REPL visibility ("enable", Esc) local function set_active(active) if active == repl_active then return end if active then repl_active = true insert_mode = false define_key_bindings() else clear() repl_active = false undefine_key_bindings() collectgarbage() end update() end mp.observe_property("user-data/osc/margins", "native", function(_, val) if val then global_margins = val else global_margins = { t = 0, b = 0 } 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) mp.observe_property('osd-height', 'native', update) mp.observe_property('display-hidpi-scale', 'native', update) ---------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------- -------------------------------END ORIGINAL MPV CODE------------------------------------ --[[ sends a response to the original script in the form of a json string it is expected that all requests get a response, if the input is nil then err should say why current error codes are: exited the user closed the input instead of pressing Enter already_queued a request with the specified id was already in the queue cancelled a script cancelled the request replace replaced by another request ]] local function send_response(res) if res.source then mp.commandv("script-message-to", res.source, res.response, (utils.format_json(res))) else mp.commandv("script-message", res.response, (utils.format_json(res))) end end -- push new request onto the queue -- if a request with the same id already exists and the queueable flag is not enabled then -- a nil result will be returned to the function function push_request(req) if active_ids[req.id] then if req.replace then for i, q_req in ipairs(queue) do if q_req.id == req.id then send_response{ err = "replaced", response = q_req.response, source = q_req.source } queue[i] = req if i == 1 then request = req end end end update() return end if not req.queueable then send_response{ err = "already_queued", response = req.response, source = req.source } return end end table.insert(queue, req) active_ids[req.id] = (active_ids[req.id] or 0) + 1 if #queue == 1 then coroutine.resume(co) end update() end -- safely removes an item from the queue and updates the set of active requests function remove_request(index) local req = table.remove(queue, index) active_ids[req.id] = active_ids[req.id] - 1 if active_ids[req.id] == 0 then active_ids[req.id] = nil end return req end --an infinite loop that moves through the request queue --uses a coroutine to handle asynchronous operations local function driver() while (true) do while queue[1] do request = queue[1] line = request.default_input cursor = request.cursor_pos if repl_active then update() else set_active(true) end res = coroutine.yield() if res then res.source, res.response = request.source, request.response send_response(res) remove_request(1) end end set_active(false) coroutine.yield() end end co = coroutine.create(driver) --cancels any input request that returns true for the given predicate function local function cancel_input_request(pred) for i = #queue, 1, -1 do if pred(i) then req = remove_request(i) send_response{ err = "cancelled", response = req.response, source = req.source } --if we're removing the first item then that means the coroutine is waiting for a response --we will need to tell the coroutine to resume, upon which it will move to the next request --if there is something in the buffer then save it to the history before erasing it if i == 1 then local old_line = line if old_line ~= "" then table.insert(histories[req.id].list, old_line) end clear() coroutine.resume(co) end end end end mp.register_script_message("cancel-user-input/uid", function(uid) cancel_input_request(function(i) return queue[i].response == uid end) end) -- removes all requests with the specified id from the queue mp.register_script_message("cancel-user-input/id", function(id) cancel_input_request(function(i) return queue[i].id == id end) end) -- ensures a request has the correct fields and is correctly formatted local function format_request_fields(req) assert(req.version, "input requests require an API version string") if not string.find(req.version, API_MAJOR_MINOR, 1, true) then error(("input request has invalid version: expected %s.x, got %s"):format(API_MAJOR_MINOR, req.version)) end assert(req.response, "input requests require a response string") assert(req.id, "input requests require an id string") req.text = ass_escape(req.request_text or "") req.default_input = req.default_input or "" req.cursor_pos = tonumber(req.cursor_pos) or 1 req.id = req.id or "mpv" if req.cursor_pos ~= 1 then if req.cursor_pos < 1 then req.cursor_pos = 1 elseif req.cursor_pos > #req.default_input then req.cursor_pos = #req.default_input + 1 end end if not histories[req.id] then histories[req.id] = {pos = 1, list = {}} end req.history = histories[req.id] return req end -- updates the fields of a specific request mp.register_script_message("update-user-input/uid", function(uid, req_opts) req_opts = utils.parse_json(req_opts) req_opts.response = uid for i, req in ipairs(queue) do if req.response == uid then local success, result = pcall(format_request_fields, req_opts) if not success then return msg.error(result) end queue[i] = result if i == 1 then request = queue[1] end update() return end end end) --the function that parses the input requests local function input_request(req) req = format_request_fields(req) push_request(req) end -- script message to recieve input requests, get-user-input.lua acts as an interface to call this script message mp.register_script_message("request-user-input", function(req) msg.debug(req) req = utils.parse_json(req) local success, err = pcall(input_request, req) if not success then send_response{ err = err, response = req.response, source = req.source} msg.error(err) end end)