├── LICENSE ├── README.md ├── examples └── open-file.lua ├── user-input-module.lua └── user-input.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Oscar Manglaras 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-user-input 2 | 3 | This script aims to create a common API that other scripts can use to request text input from the user via the OSD. 4 | This script was built from [mpv's console.lua](https://github.com/mpv-player/mpv/blob/7ca14d646c7e405f3fb1e44600e2a67fc4607238/player/lua/console.lua). 5 | The logging, commands, and tab completion have been removed, leaving just the text input and history code. 6 | As a result this script's text input has almost identical behaviour to console.lua. 7 | 8 | Around the original code is a system to recieve input requests via script messages, and respond with the users input, and an error message if the request was somehow terminated. 9 | The script utilises a queue system to handle multiple requests at once, and there are various option flags to control how to handle multiple requests from the same source. 10 | 11 | Usage of this API requires that standard interface functions be used to request and cancel input requests, these functions are packaged into [user-input-module.lua](/user-input-module.lua), which can be loaded as a module, or simply pasted into another script. 12 | If a script does choose to load the module, then I recommend it be loaded from `~~/script-modules` rather than `~~/scripts`. 13 | 14 | The aim of this script is that it be seamless enough that it could be added to mpv player officially. 15 | 16 | For versions of mpv ≤ v0.36 see the [mpv-v0.36 branch](https://github.com/CogentRedTester/mpv-user-input/tree/mpv-v0.36). 17 | 18 | ## Installation 19 | 20 | **If you've been directed here by another script that requires this API follow these instructions unless told otherwise.** 21 | 22 | Place [user-input.lua](user-input.lua) inside the `~~/scripts/` directory, and place [user-input-module.lua](user-input-module.lua) inside the `~~/script-modules/` directory. 23 | Create these directories if they do not exist. `~~/` represents the mpv config directory. 24 | 25 | ### Advanced 26 | 27 | What is important is that `user-input.lua` is loaded as a script my mpv, which can be done from anywhere using the `--script` option. 28 | Meanwhile, `user-input-module.lua` needs to be in one of the lua package paths; scripts that use this API are recommended to use `~~/script-modules/`, but you can set any directory using the `LUA_PATH` environment variable. 29 | 30 | ### Developers 31 | 32 | If you use the recommended `~~/script-modules/` directory then load this addon with the following code: 33 | 34 | ```lua 35 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"})..package.path 36 | local input = require "user-input-module" 37 | ``` 38 | 39 | ## Interface Functions - v0.1.0 40 | 41 | Note: this API is still in its early stages, so these functions may change. 42 | 43 | ### `get_user_input(fn [, options [, ...]])` 44 | 45 | Requests input from the user and returns a request table. 46 | 47 | ```lua 48 | input.get_user_input(print) -- prints the user input plus the error code 49 | ``` 50 | 51 | `fn` is called when user-input sends a response, the first argument will be the input string the user entered, 52 | the second argument will be an error string if the input is `nil`. 53 | Any additional arguments sent after the options table will be sent to fn as additional arguments after the error string. 54 | 55 | The following error codes currently exist: 56 | 57 | ```properties 58 | exited the user closed the input instead of pressing Enter 59 | already_queued a request with the specified id was already in the queue 60 | cancelled the request was cancelled 61 | replaced request was replaced 62 | ``` 63 | 64 | If the request throws an error for whatever reason then that Lua error message will be returned instead. 65 | Those error messages are undefined and could change at any time. 66 | 67 | #### options 68 | 69 | Options is an optional table of values and flags which can be used to control the behaviour of user-input. The function will preset some options if they are left blank. 70 | The following options are currently available: 71 | 72 | | name | type | default | description | 73 | |---------------|---------|---------------------------|-------------------------------------------------------------------------------------------------------------------| 74 | | id | string | mp.get_script_name()..`/` | used for storing input history and detecting duplicate requests | 75 | | source | string | mp.get_script_name() | used to show the source of the request in square brackets | 76 | | request_text | string | `requesting user input:` | printed above the input box - use it to describe the input request | 77 | | default_input | string | | text to pre-enter into the input | 78 | | cursor_pos | number | 1 | the numerical position to place the cursor - for use with the default_input field | 79 | | queueable | boolean | false | allows requests to be queued even if there is already one queued with the same id | 80 | | replace | boolean | false | replace the queued request with the same id with the new request | 81 | 82 | The function prepends the script name to any id to avoid conflicts between 83 | different scripts. 84 | Do not use both the `queuable` and `replace` flags for input requests with the same ID, the behaviour is undefined and may change at any time. 85 | 86 | Here is an example for printing only a sucessful input: 87 | 88 | ```lua 89 | input.get_user_input(function(line, err) 90 | if line then print(line) end 91 | end, { request_text = "print text:" }) 92 | ``` 93 | 94 | #### request table 95 | 96 | The request table returned by `get_user_input` can be used to modify the behaviour of an existing request. 97 | The defined fields are: 98 | 99 | | name | type | description | 100 | |---------------|---------|-------------------------------------------------------------------------------------------------------------------| 101 | | callback | function| the callback function - same as `fn` passed to `get_user_input()` - can be set to a different function to modify the callback | 102 | | passthrough_args | table| an array of extra arguments to pass to the callback - cannot be `nil` | 103 | | pending | boolean | true if the request is still pending, false if the request is completed | 104 | | cancel | method | cancels the request - unlike `cancel_user_input()` this does not cancel all requests with a matching id | 105 | | update | method | takes an options table and updates the request - maintains the original request unlike the `replace` flag - not all options can be changed | 106 | 107 | A method is referring to a function that is called with Lua's method syntax: 108 | 109 | ```lua 110 | local request = input.get_user_input(print) 111 | request:update{ 112 | request_text = "hello world:" 113 | } 114 | request:cancel() 115 | ``` 116 | 117 | ### `cancel_user_input([id])` 118 | 119 | Removes all input requests with a matching string id. 120 | If no id is provided, then the default id for `get_user_input()` will be used. 121 | 122 | The cancellation happens asynchronously. 123 | 124 | ### `get_user_input_co([options [, co_resume]])` 125 | 126 | This is a wrapper function around `get_user_input()` that uses [coroutines](https://www.lua.org/manual/5.1/manual.html#2.11) 127 | to make the input request behave synchronously. It returns `line, err`, as would 128 | normally be passed to the callback function. 129 | 130 | This function will yield the current coroutine and resume once the input 131 | response has been received. If the coroutine is forcibly resumed by the user then 132 | it will send a cancellation request to `user-input` and will return `nil, 'cancelled'`. 133 | The request object is passed to the yield function. 134 | 135 | ```lua 136 | local function main() 137 | local line, err = input.get_user_input_co({ request_text = 'test input:' }) 138 | if line then print(line) end 139 | end 140 | 141 | local co = coroutine.create(main) 142 | local success, request = coroutine.resume(co) 143 | ``` 144 | 145 | If a function is passed as `co_resume` then custom resume behaviour can be setup instead 146 | of the default `coroutine.resume`. This can be useful if you want to define what happens when an error is thrown. 147 | This function is the equivalent of the usual callback function, except it's purpose is to resume 148 | the yielded coroutine and that it has three agruments: 149 | `uid, line, err` where `uid` is a unique variable that needs to be 150 | passed to `coroutine.resume()`. The functions created by `coroutine.wrap()` can be passed into here. 151 | The following examples show how this can be used to propogate errors using `coroutine.wrap()`, 152 | and how to safely catch and print errors using a custom error handler. 153 | 154 | ```lua 155 | -- if an error is thrown the script will crash instead of the error being caught 156 | local driver 157 | driver = coroutine.wrap(function() 158 | local line, err = input.get_user_input_co({ request_text = 'test input:' }, driver) 159 | if line then print(line) end 160 | end) 161 | 162 | local request = driver() 163 | ``` 164 | 165 | ```lua 166 | -- if the coroutine throws an error it is caught and a stack trace is printed 167 | -- note that the error handler shown here is actually what is used by default when `co_resume` is nil 168 | function coroutine_resume_err(uid, line, err) 169 | local co = coroutine.running() 170 | local success, err = coroutine.resume(co, uid, line, err) 171 | if not success then 172 | msg.warn( debug.traceback(co) ) 173 | msg.error(err) 174 | end 175 | end 176 | 177 | local function main() 178 | local line, err = input.get_user_input_co({ request_text = 'test input:' }, coroutine_resume_err) 179 | if line then print(line) end 180 | end 181 | 182 | local co = coroutine.create(main) 183 | local success, request = coroutine.resume(co) 184 | ``` 185 | 186 | ## Examples 187 | 188 | The [examples](/examples) folder contains some scripts that make user of the API. 189 | 190 | You can find more examples in the [wiki page](https://github.com/CogentRedTester/mpv-user-input/wiki/Scripts-that-use-mpv%E2%80%90user%E2%80%90input). 191 | -------------------------------------------------------------------------------- /examples/open-file.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a demonstration script for mpv-user-input: 3 | https://github.com/CogentRedTester/mpv-user-input 4 | 5 | It allows users to enter file paths for mpv to open 6 | Ctrl+o replaces the current file with the entered link 7 | Ctrl+O appends the file to the playlist 8 | ]] 9 | 10 | local mp = require "mp" 11 | 12 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"})..package.path 13 | local uin = require "user-input-module" 14 | 15 | local function loadfile(path, err, flag) 16 | if not path then return end 17 | mp.commandv("loadfile", path, flag) 18 | end 19 | 20 | mp.add_key_binding("Ctrl+o", "open-file-input", function() 21 | uin.get_user_input(loadfile, { 22 | request_text = "Enter path:", 23 | replace = true 24 | }, "replace") 25 | end) 26 | 27 | mp.add_key_binding("Ctrl+O", "append-file-input", function() 28 | uin.get_user_input(loadfile, { 29 | request_text = "Enter path to append to playlist:", 30 | replace = true 31 | }, "append-play") 32 | end) -------------------------------------------------------------------------------- /user-input-module.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a module designed to interface with mpv-user-input 3 | https://github.com/CogentRedTester/mpv-user-input 4 | 5 | Loading this script as a module will return a table with two functions to format 6 | requests to get and cancel user-input requests. See the README for details. 7 | 8 | Alternatively, developers can just paste these functions directly into their script, 9 | however this is not recommended as there is no guarantee that the formatting of 10 | these requests will remain the same for future versions of user-input. 11 | ]] 12 | 13 | local API_VERSION = "0.1.0" 14 | 15 | local mp = require 'mp' 16 | local msg = require "mp.msg" 17 | local utils = require 'mp.utils' 18 | local mod = {} 19 | 20 | local name = mp.get_script_name() 21 | local counter = 1 22 | 23 | local function pack(...) 24 | local t = {...} 25 | t.n = select("#", ...) 26 | return t 27 | end 28 | 29 | local request_mt = {} 30 | 31 | -- ensures the option tables are correctly formatted based on the input 32 | local function format_options(options, response_string) 33 | return { 34 | response = response_string, 35 | version = API_VERSION, 36 | id = name..'/'..(options.id or ""), 37 | source = name, 38 | request_text = ("[%s] %s"):format(options.source or name, options.request_text or options.text or "requesting user input:"), 39 | default_input = options.default_input, 40 | cursor_pos = tonumber(options.cursor_pos), 41 | queueable = options.queueable and true, 42 | replace = options.replace and true 43 | } 44 | end 45 | 46 | -- cancels the request 47 | function request_mt:cancel() 48 | assert(self.uid, "request object missing UID") 49 | mp.commandv("script-message-to", "user_input", "cancel-user-input/uid", self.uid) 50 | end 51 | 52 | -- updates the options for the request 53 | function request_mt:update(options) 54 | assert(self.uid, "request object missing UID") 55 | options = utils.format_json( format_options(options) ) 56 | mp.commandv("script-message-to", "user_input", "update-user-input/uid", self.uid, options) 57 | end 58 | 59 | -- sends a request to ask the user for input using formatted options provided 60 | -- creates a script message to recieve the response and call fn 61 | function mod.get_user_input(fn, options, ...) 62 | options = options or {} 63 | local response_string = name.."/__user_input_request/"..counter 64 | counter = counter + 1 65 | 66 | local request = { 67 | uid = response_string, 68 | passthrough_args = pack(...), 69 | callback = fn, 70 | pending = true 71 | } 72 | 73 | -- create a callback for user-input to respond to 74 | mp.register_script_message(response_string, function(response) 75 | mp.unregister_script_message(response_string) 76 | request.pending = false 77 | 78 | response = utils.parse_json(response) 79 | request.callback(response.line, response.err, unpack(request.passthrough_args, 1, request.passthrough_args.n)) 80 | end) 81 | 82 | -- send the input command 83 | options = utils.format_json( format_options(options, response_string) ) 84 | mp.commandv("script-message-to", "user_input", "request-user-input", options) 85 | 86 | return setmetatable(request, { __index = request_mt }) 87 | end 88 | 89 | -- runs the request synchronously using coroutines 90 | -- takes the option table and an optional coroutine resume function 91 | function mod.get_user_input_co(options, co_resume) 92 | local co, main = coroutine.running() 93 | assert(not main and co, "get_user_input_co must be run from within a coroutine") 94 | 95 | local uid = {} 96 | local request = mod.get_user_input(function(line, err) 97 | if co_resume then 98 | co_resume(uid, line, err) 99 | else 100 | local success, er = coroutine.resume(co, uid, line, err) 101 | if not success then 102 | msg.warn(debug.traceback(co)) 103 | msg.error(er) 104 | end 105 | end 106 | end, options) 107 | 108 | -- if the uid was not sent then the coroutine was resumed by the user. 109 | -- we will treat this as a cancellation request 110 | local success, line, err = coroutine.yield(request) 111 | if success ~= uid then 112 | request:cancel() 113 | request.callback = function() end 114 | return nil, "cancelled" 115 | end 116 | 117 | return line, err 118 | end 119 | 120 | -- sends a request to cancel all input requests with the given id 121 | function mod.cancel_user_input(id) 122 | id = name .. '/' .. (id or "") 123 | mp.commandv("script-message-to", "user_input", "cancel-user-input/id", id) 124 | end 125 | 126 | return mod -------------------------------------------------------------------------------- /user-input.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | local utils = require 'mp.utils' 4 | local options = require 'mp.options' 5 | 6 | -- Default options 7 | local opts = { 8 | -- All drawing is scaled by this value, including the text borders and the 9 | -- cursor. Change it if you have a high-DPI display. 10 | scale = 1, 11 | -- Set the font used for the REPL and the console. This probably doesn't 12 | -- have to be a monospaced font. 13 | font = "", 14 | -- Set the font size used for the REPL and the console. This will be 15 | -- multiplied by "scale." 16 | font_size = 16, 17 | } 18 | 19 | options.read_options(opts, "user_input") 20 | 21 | local API_VERSION = "0.1.0" 22 | local API_MAJOR_MINOR = API_VERSION:match("%d+%.%d+") 23 | 24 | local co = nil 25 | local queue = {} 26 | local active_ids = {} 27 | local histories = {} 28 | local request = nil 29 | 30 | local line = '' 31 | 32 | 33 | --[[ 34 | The below code is a modified implementation of text input from mpv's console.lua: 35 | https://github.com/mpv-player/mpv/blob/7ca14d646c7e405f3fb1e44600e2a67fc4607238/player/lua/console.lua 36 | 37 | Modifications: 38 | removed support for log messages, sending commands, tab complete, help commands 39 | removed update timer 40 | Changed esc key to call handle_esc function 41 | handle_esc and handle_enter now resume the main coroutine with a response table 42 | made history specific to request ids 43 | localised all functions - reordered some to fit 44 | keybindings use new names 45 | ]]-- 46 | 47 | ------------------------------START ORIGINAL MPV CODE----------------------------------- 48 | ---------------------------------------------------------------------------------------- 49 | ---------------------------------------------------------------------------------------- 50 | ---------------------------------------------------------------------------------------- 51 | ---------------------------------------------------------------------------------------- 52 | 53 | -- Copyright (C) 2019 the mpv developers 54 | -- 55 | -- Permission to use, copy, modify, and/or distribute this software for any 56 | -- purpose with or without fee is hereby granted, provided that the above 57 | -- copyright notice and this permission notice appear in all copies. 58 | -- 59 | -- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 60 | -- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 61 | -- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 62 | -- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 63 | -- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 64 | -- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 65 | -- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 66 | 67 | local assdraw = require 'mp.assdraw' 68 | 69 | local function detect_platform() 70 | local o = {} 71 | -- Kind of a dumb way of detecting the platform but whatever 72 | if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then 73 | return 'windows' 74 | elseif mp.get_property_native('options/macos-force-dedicated-gpu', o) ~= o then 75 | return 'macos' 76 | elseif os.getenv('WAYLAND_DISPLAY') then 77 | return 'wayland' 78 | end 79 | return 'x11' 80 | end 81 | 82 | -- Pick a better default font for Windows and macOS 83 | local platform = detect_platform() 84 | if platform == 'windows' then 85 | opts.font = 'Consolas' 86 | elseif platform == 'macos' then 87 | opts.font = 'Menlo' 88 | else 89 | opts.font = 'monospace' 90 | end 91 | 92 | local repl_active = false 93 | local insert_mode = false 94 | local cursor = 1 95 | local key_bindings = {} 96 | local global_margin_y = 0 97 | 98 | -- Escape a string for verbatim display on the OSD 99 | local function ass_escape(str) 100 | -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if 101 | -- it isn't followed by a recognised character, so add a zero-width 102 | -- non-breaking space 103 | str = str:gsub('\\', '\\\239\187\191') 104 | str = str:gsub('{', '\\{') 105 | str = str:gsub('}', '\\}') 106 | -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of 107 | -- consecutive newlines 108 | str = str:gsub('\n', '\239\187\191\\N') 109 | -- Turn leading spaces into hard spaces to prevent ASS from stripping them 110 | str = str:gsub('\\N ', '\\N\\h') 111 | str = str:gsub('^ ', '\\h') 112 | return str 113 | end 114 | 115 | -- Render the REPL and console as an ASS OSD 116 | local function update() 117 | local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) 118 | 119 | dpi_scale = dpi_scale * opts.scale 120 | 121 | local screenx, screeny, aspect = mp.get_osd_size() 122 | screenx = screenx / dpi_scale 123 | screeny = screeny / dpi_scale 124 | 125 | -- Clear the OSD if the REPL is not active 126 | if not repl_active then 127 | mp.set_osd_ass(screenx, screeny, '') 128 | return 129 | end 130 | 131 | local ass = assdraw.ass_new() 132 | local style = '{\\r' .. 133 | '\\1a&H00&\\3a&H00&\\4a&H99&' .. 134 | '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. 135 | '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. 136 | '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' 137 | 138 | local queue_style = '{\\r' .. 139 | '\\1a&H00&\\3a&H00&\\4a&H99&' .. 140 | '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. 141 | '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. '\\c&H66ccff&' .. 142 | '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' 143 | 144 | -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor 145 | -- inline with the surrounding text, but it sets the advance to the width 146 | -- of the drawing. So the cursor doesn't affect layout too much, make it as 147 | -- thin as possible and make it appear to be 1px wide by giving it 0.5px 148 | -- horizontal borders. 149 | local cheight = opts.font_size * 8 150 | local cglyph = '{\\r' .. 151 | '\\1a&H44&\\3a&H44&\\4a&H99&' .. 152 | '\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&' .. 153 | '\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}' .. 154 | 'm 0 0 l 1 0 l 1 ' .. cheight .. ' l 0 ' .. cheight .. 155 | '{\\p0}' 156 | local before_cur = ass_escape(line:sub(1, cursor - 1)) 157 | local after_cur = ass_escape(line:sub(cursor)) 158 | 159 | ass:new_event() 160 | ass:an(1) 161 | ass:pos(2, screeny - 2 - global_margin_y * screeny) 162 | 163 | if (#queue == 2) then ass:append(queue_style .. string.format("There is 1 more request queued\\N")) 164 | elseif (#queue > 2) then ass:append(queue_style .. string.format("There are %d more requests queued\\N", #queue-1)) end 165 | ass:append(style .. request.text .. '\\N') 166 | ass:append('> ' .. before_cur) 167 | ass:append(cglyph) 168 | ass:append(style .. after_cur) 169 | 170 | -- Redraw the cursor with the REPL text invisible. This will make the 171 | -- cursor appear in front of the text. 172 | ass:new_event() 173 | ass:an(1) 174 | ass:pos(2, screeny - 2) 175 | ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur) 176 | ass:append(cglyph) 177 | ass:append(style .. '{\\alpha&HFF&}' .. after_cur) 178 | 179 | mp.set_osd_ass(screenx, screeny, ass.text) 180 | end 181 | 182 | -- Naive helper function to find the next UTF-8 character in 'str' after 'pos' 183 | -- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. 184 | local function next_utf8(str, pos) 185 | if pos > str:len() then return pos end 186 | repeat 187 | pos = pos + 1 188 | until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf 189 | return pos 190 | end 191 | 192 | -- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' 193 | local function prev_utf8(str, pos) 194 | if pos <= 1 then return pos end 195 | repeat 196 | pos = pos - 1 197 | until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf 198 | return pos 199 | end 200 | 201 | -- Insert a character at the current cursor position (any_unicode) 202 | local function handle_char_input(c) 203 | if insert_mode then 204 | line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) 205 | else 206 | line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) 207 | end 208 | cursor = cursor + #c 209 | update() 210 | end 211 | 212 | -- Remove the character behind the cursor (Backspace) 213 | local function handle_backspace() 214 | if cursor <= 1 then return end 215 | local prev = prev_utf8(line, cursor) 216 | line = line:sub(1, prev - 1) .. line:sub(cursor) 217 | cursor = prev 218 | update() 219 | end 220 | 221 | -- Remove the character in front of the cursor (Del) 222 | local function handle_del() 223 | if cursor > line:len() then return end 224 | line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) 225 | update() 226 | end 227 | 228 | -- Toggle insert mode (Ins) 229 | local function handle_ins() 230 | insert_mode = not insert_mode 231 | end 232 | 233 | -- Move the cursor to the next character (Right) 234 | local function next_char(amount) 235 | cursor = next_utf8(line, cursor) 236 | update() 237 | end 238 | 239 | -- Move the cursor to the previous character (Left) 240 | local function prev_char(amount) 241 | cursor = prev_utf8(line, cursor) 242 | update() 243 | end 244 | 245 | -- Clear the current line (Ctrl+C) 246 | local function clear() 247 | line = '' 248 | cursor = 1 249 | insert_mode = false 250 | request.history.pos = #request.history.list + 1 251 | update() 252 | end 253 | 254 | -- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D) 255 | local function maybe_exit() 256 | if line == '' then 257 | else 258 | handle_del() 259 | end 260 | end 261 | 262 | local function handle_esc() 263 | coroutine.resume(co, { 264 | line = nil, 265 | err = "exited" 266 | }) 267 | end 268 | 269 | -- Run the current command and clear the line (Enter) 270 | local function handle_enter() 271 | if request.history.list[#request.history.list] ~= line and line ~= "" then 272 | request.history.list[#request.history.list + 1] = line 273 | end 274 | coroutine.resume(co, { 275 | line = line 276 | }) 277 | end 278 | 279 | -- Go to the specified position in the command history 280 | local function go_history(new_pos) 281 | local old_pos = request.history.pos 282 | request.history.pos = new_pos 283 | 284 | -- Restrict the position to a legal value 285 | if request.history.pos > #request.history.list + 1 then 286 | request.history.pos = #request.history.list + 1 287 | elseif request.history.pos < 1 then 288 | request.history.pos = 1 289 | end 290 | 291 | -- Do nothing if the history position didn't actually change 292 | if request.history.pos == old_pos then 293 | return 294 | end 295 | 296 | -- If the user was editing a non-history line, save it as the last history 297 | -- entry. This makes it much less frustrating to accidentally hit Up/Down 298 | -- while editing a line. 299 | if old_pos == #request.history.list + 1 and line ~= '' and request.history.list[#request.history.list] ~= line then 300 | request.history.list[#request.history.list + 1] = line 301 | end 302 | 303 | -- Now show the history line (or a blank line for #history + 1) 304 | if request.history.pos <= #request.history.list then 305 | line = request.history.list[request.history.pos] 306 | else 307 | line = '' 308 | end 309 | cursor = line:len() + 1 310 | insert_mode = false 311 | update() 312 | end 313 | 314 | -- Go to the specified relative position in the command history (Up, Down) 315 | local function move_history(amount) 316 | go_history(request.history.pos + amount) 317 | end 318 | 319 | -- Go to the first command in the command history (PgUp) 320 | local function handle_pgup() 321 | go_history(1) 322 | end 323 | 324 | -- Stop browsing history and start editing a blank line (PgDown) 325 | local function handle_pgdown() 326 | go_history(#request.history.list + 1) 327 | end 328 | 329 | -- Move to the start of the current word, or if already at the start, the start 330 | -- of the previous word. (Ctrl+Left) 331 | local function prev_word() 332 | -- This is basically the same as next_word() but backwards, so reverse the 333 | -- string in order to do a "backwards" find. This wouldn't be as annoying 334 | -- to do if Lua didn't insist on 1-based indexing. 335 | cursor = line:len() - select(2, line:reverse():find('%s*[^%s]*', line:len() - cursor + 2)) + 1 336 | update() 337 | end 338 | 339 | -- Move to the end of the current word, or if already at the end, the end of 340 | -- the next word. (Ctrl+Right) 341 | local function next_word() 342 | cursor = select(2, line:find('%s*[^%s]*', cursor)) + 1 343 | update() 344 | end 345 | 346 | -- Move the cursor to the beginning of the line (HOME) 347 | local function go_home() 348 | cursor = 1 349 | update() 350 | end 351 | 352 | -- Move the cursor to the end of the line (END) 353 | local function go_end() 354 | cursor = line:len() + 1 355 | update() 356 | end 357 | 358 | -- Delete from the cursor to the beginning of the word (Ctrl+Backspace) 359 | local function del_word() 360 | local before_cur = line:sub(1, cursor - 1) 361 | local after_cur = line:sub(cursor) 362 | 363 | before_cur = before_cur:gsub('[^%s]+%s*$', '', 1) 364 | line = before_cur .. after_cur 365 | cursor = before_cur:len() + 1 366 | update() 367 | end 368 | 369 | -- Delete from the cursor to the end of the word (Ctrl+Del) 370 | local function del_next_word() 371 | if cursor > line:len() then return end 372 | 373 | local before_cur = line:sub(1, cursor - 1) 374 | local after_cur = line:sub(cursor) 375 | 376 | after_cur = after_cur:gsub('^%s*[^%s]+', '', 1) 377 | line = before_cur .. after_cur 378 | update() 379 | end 380 | 381 | -- Delete from the cursor to the end of the line (Ctrl+K) 382 | local function del_to_eol() 383 | line = line:sub(1, cursor - 1) 384 | update() 385 | end 386 | 387 | -- Delete from the cursor back to the start of the line (Ctrl+U) 388 | local function del_to_start() 389 | line = line:sub(cursor) 390 | cursor = 1 391 | update() 392 | end 393 | 394 | -- Returns a string of UTF-8 text from the clipboard (or the primary selection) 395 | local function get_clipboard(clip) 396 | if platform == 'x11' then 397 | local res = utils.subprocess({ 398 | args = { 'xclip', '-selection', clip and 'clipboard' or 'primary', '-out' }, 399 | playback_only = false, 400 | }) 401 | if not res.error then 402 | return res.stdout 403 | end 404 | elseif platform == 'wayland' then 405 | local res = utils.subprocess({ 406 | args = { 'wl-paste', clip and '-n' or '-np' }, 407 | playback_only = false, 408 | }) 409 | if not res.error then 410 | return res.stdout 411 | end 412 | elseif platform == 'windows' then 413 | local res = utils.subprocess({ 414 | args = { 'powershell', '-NoProfile', '-Command', [[& { 415 | Trap { 416 | Write-Error -ErrorRecord $_ 417 | Exit 1 418 | } 419 | 420 | $clip = "" 421 | if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { 422 | $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText 423 | } else { 424 | Add-Type -AssemblyName PresentationCore 425 | $clip = [Windows.Clipboard]::GetText() 426 | } 427 | 428 | $clip = $clip -Replace "`r","" 429 | $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) 430 | [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) 431 | }]] }, 432 | playback_only = false, 433 | }) 434 | if not res.error then 435 | return res.stdout 436 | end 437 | elseif platform == 'macos' then 438 | local res = utils.subprocess({ 439 | args = { 'pbpaste' }, 440 | playback_only = false, 441 | }) 442 | if not res.error then 443 | return res.stdout 444 | end 445 | end 446 | return '' 447 | end 448 | 449 | -- Paste text from the window-system's clipboard. 'clip' determines whether the 450 | -- clipboard or the primary selection buffer is used (on X11 and Wayland only.) 451 | local function paste(clip) 452 | local text = get_clipboard(clip) 453 | local before_cur = line:sub(1, cursor - 1) 454 | local after_cur = line:sub(cursor) 455 | line = before_cur .. text .. after_cur 456 | cursor = cursor + text:len() 457 | update() 458 | end 459 | 460 | -- List of input bindings. This is a weird mashup between common GUI text-input 461 | -- bindings and readline bindings. 462 | local function get_bindings() 463 | local bindings = { 464 | { 'esc', handle_esc }, 465 | { 'enter', handle_enter }, 466 | { 'kp_enter', handle_enter }, 467 | { 'shift+enter', function() handle_char_input('\n') end }, 468 | { 'ctrl+j', handle_enter }, 469 | { 'ctrl+m', handle_enter }, 470 | { 'bs', handle_backspace }, 471 | { 'shift+bs', handle_backspace }, 472 | { 'ctrl+h', handle_backspace }, 473 | { 'del', handle_del }, 474 | { 'shift+del', handle_del }, 475 | { 'ins', handle_ins }, 476 | { 'shift+ins', function() paste(false) end }, 477 | { 'mbtn_mid', function() paste(false) end }, 478 | { 'left', function() prev_char() end }, 479 | { 'ctrl+b', function() prev_char() end }, 480 | { 'right', function() next_char() end }, 481 | { 'ctrl+f', function() next_char() end }, 482 | { 'up', function() move_history(-1) end }, 483 | { 'ctrl+p', function() move_history(-1) end }, 484 | { 'wheel_up', function() move_history(-1) end }, 485 | { 'down', function() move_history(1) end }, 486 | { 'ctrl+n', function() move_history(1) end }, 487 | { 'wheel_down', function() move_history(1) end }, 488 | { 'wheel_left', function() end }, 489 | { 'wheel_right', function() end }, 490 | { 'ctrl+left', prev_word }, 491 | { 'alt+b', prev_word }, 492 | { 'ctrl+right', next_word }, 493 | { 'alt+f', next_word }, 494 | { 'ctrl+a', go_home }, 495 | { 'home', go_home }, 496 | { 'ctrl+e', go_end }, 497 | { 'end', go_end }, 498 | { 'pgup', handle_pgup }, 499 | { 'pgdwn', handle_pgdown }, 500 | { 'ctrl+c', clear }, 501 | { 'ctrl+d', maybe_exit }, 502 | { 'ctrl+k', del_to_eol }, 503 | { 'ctrl+u', del_to_start }, 504 | { 'ctrl+v', function() paste(true) end }, 505 | { 'meta+v', function() paste(true) end }, 506 | { 'ctrl+bs', del_word }, 507 | { 'ctrl+w', del_word }, 508 | { 'ctrl+del', del_next_word }, 509 | { 'alt+d', del_next_word }, 510 | { 'kp_dec', function() handle_char_input('.') end }, 511 | } 512 | 513 | for i = 0, 9 do 514 | bindings[#bindings + 1] = 515 | {'kp' .. i, function() handle_char_input('' .. i) end} 516 | end 517 | 518 | return bindings 519 | end 520 | 521 | local function text_input(info) 522 | if info.key_text and (info.event == "press" or info.event == "down" 523 | or info.event == "repeat") 524 | then 525 | handle_char_input(info.key_text) 526 | end 527 | end 528 | 529 | local function define_key_bindings() 530 | if #key_bindings > 0 then 531 | return 532 | end 533 | for _, bind in ipairs(get_bindings()) do 534 | -- Generate arbitrary name for removing the bindings later. 535 | local name = "_userinput_" .. bind[1] 536 | key_bindings[#key_bindings + 1] = name 537 | mp.add_forced_key_binding(bind[1], name, bind[2], {repeatable = true}) 538 | end 539 | mp.add_forced_key_binding("any_unicode", "_userinput_text", text_input, 540 | {repeatable = true, complex = true}) 541 | key_bindings[#key_bindings + 1] = "_userinput_text" 542 | end 543 | 544 | local function undefine_key_bindings() 545 | for _, name in ipairs(key_bindings) do 546 | mp.remove_key_binding(name) 547 | end 548 | key_bindings = {} 549 | end 550 | 551 | -- Set the REPL visibility ("enable", Esc) 552 | local function set_active(active) 553 | if active == repl_active then return end 554 | if active then 555 | repl_active = true 556 | insert_mode = false 557 | define_key_bindings() 558 | else 559 | clear() 560 | repl_active = false 561 | undefine_key_bindings() 562 | collectgarbage() 563 | end 564 | update() 565 | end 566 | 567 | 568 | mp.observe_property("user-data/osc/margins", "native", function(_, val) 569 | if val then 570 | global_margins = val 571 | else 572 | global_margins = { t = 0, b = 0 } 573 | end 574 | update() 575 | end) 576 | 577 | -- Redraw the REPL when the OSD size changes. This is needed because the 578 | -- PlayRes of the OSD will need to be adjusted. 579 | mp.observe_property('osd-width', 'native', update) 580 | mp.observe_property('osd-height', 'native', update) 581 | mp.observe_property('display-hidpi-scale', 'native', update) 582 | 583 | ---------------------------------------------------------------------------------------- 584 | ---------------------------------------------------------------------------------------- 585 | ---------------------------------------------------------------------------------------- 586 | -------------------------------END ORIGINAL MPV CODE------------------------------------ 587 | 588 | --[[ 589 | sends a response to the original script in the form of a json string 590 | it is expected that all requests get a response, if the input is nil then err should say why 591 | current error codes are: 592 | exited the user closed the input instead of pressing Enter 593 | already_queued a request with the specified id was already in the queue 594 | cancelled a script cancelled the request 595 | replace replaced by another request 596 | ]] 597 | local function send_response(res) 598 | if res.source then 599 | mp.commandv("script-message-to", res.source, res.response, (utils.format_json(res))) 600 | else 601 | mp.commandv("script-message", res.response, (utils.format_json(res))) 602 | end 603 | end 604 | 605 | -- push new request onto the queue 606 | -- if a request with the same id already exists and the queueable flag is not enabled then 607 | -- a nil result will be returned to the function 608 | function push_request(req) 609 | if active_ids[req.id] then 610 | if req.replace then 611 | for i, q_req in ipairs(queue) do 612 | if q_req.id == req.id then 613 | send_response{ err = "replaced", response = q_req.response, source = q_req.source } 614 | queue[i] = req 615 | if i == 1 then request = req end 616 | end 617 | end 618 | update() 619 | return 620 | end 621 | 622 | if not req.queueable then 623 | send_response{ err = "already_queued", response = req.response, source = req.source } 624 | return 625 | end 626 | end 627 | 628 | table.insert(queue, req) 629 | active_ids[req.id] = (active_ids[req.id] or 0) + 1 630 | if #queue == 1 then coroutine.resume(co) end 631 | update() 632 | end 633 | 634 | -- safely removes an item from the queue and updates the set of active requests 635 | function remove_request(index) 636 | local req = table.remove(queue, index) 637 | active_ids[req.id] = active_ids[req.id] - 1 638 | 639 | if active_ids[req.id] == 0 then active_ids[req.id] = nil end 640 | return req 641 | end 642 | 643 | --an infinite loop that moves through the request queue 644 | --uses a coroutine to handle asynchronous operations 645 | local function driver() 646 | while (true) do 647 | while queue[1] do 648 | request = queue[1] 649 | line = request.default_input 650 | cursor = request.cursor_pos 651 | 652 | if repl_active then update() 653 | else set_active(true) end 654 | 655 | res = coroutine.yield() 656 | if res then 657 | res.source, res.response = request.source, request.response 658 | send_response(res) 659 | remove_request(1) 660 | end 661 | end 662 | 663 | set_active(false) 664 | coroutine.yield() 665 | end 666 | end 667 | 668 | co = coroutine.create(driver) 669 | 670 | --cancels any input request that returns true for the given predicate function 671 | local function cancel_input_request(pred) 672 | for i = #queue, 1, -1 do 673 | if pred(i) then 674 | req = remove_request(i) 675 | send_response{ err = "cancelled", response = req.response, source = req.source } 676 | 677 | --if we're removing the first item then that means the coroutine is waiting for a response 678 | --we will need to tell the coroutine to resume, upon which it will move to the next request 679 | --if there is something in the buffer then save it to the history before erasing it 680 | if i == 1 then 681 | local old_line = line 682 | if old_line ~= "" then table.insert(histories[req.id].list, old_line) end 683 | clear() 684 | coroutine.resume(co) 685 | end 686 | end 687 | end 688 | end 689 | 690 | mp.register_script_message("cancel-user-input/uid", function(uid) 691 | cancel_input_request(function(i) return queue[i].response == uid end) 692 | end) 693 | 694 | -- removes all requests with the specified id from the queue 695 | mp.register_script_message("cancel-user-input/id", function(id) 696 | cancel_input_request(function(i) return queue[i].id == id end) 697 | end) 698 | 699 | -- ensures a request has the correct fields and is correctly formatted 700 | local function format_request_fields(req) 701 | assert(req.version, "input requests require an API version string") 702 | if not string.find(req.version, API_MAJOR_MINOR, 1, true) then 703 | error(("input request has invalid version: expected %s.x, got %s"):format(API_MAJOR_MINOR, req.version)) 704 | end 705 | 706 | assert(req.response, "input requests require a response string") 707 | assert(req.id, "input requests require an id string") 708 | 709 | req.text = ass_escape(req.request_text or "") 710 | req.default_input = req.default_input or "" 711 | req.cursor_pos = tonumber(req.cursor_pos) or 1 712 | req.id = req.id or "mpv" 713 | 714 | if req.cursor_pos ~= 1 then 715 | if req.cursor_pos < 1 then req.cursor_pos = 1 716 | elseif req.cursor_pos > #req.default_input then req.cursor_pos = #req.default_input + 1 end 717 | end 718 | 719 | if not histories[req.id] then histories[req.id] = {pos = 1, list = {}} end 720 | req.history = histories[req.id] 721 | return req 722 | end 723 | 724 | -- updates the fields of a specific request 725 | mp.register_script_message("update-user-input/uid", function(uid, req_opts) 726 | req_opts = utils.parse_json(req_opts) 727 | req_opts.response = uid 728 | for i, req in ipairs(queue) do 729 | if req.response == uid then 730 | local success, result = pcall(format_request_fields, req_opts) 731 | if not success then return msg.error(result) end 732 | 733 | queue[i] = result 734 | if i == 1 then request = queue[1] end 735 | update() 736 | return 737 | end 738 | end 739 | end) 740 | 741 | --the function that parses the input requests 742 | local function input_request(req) 743 | req = format_request_fields(req) 744 | push_request(req) 745 | end 746 | 747 | -- script message to recieve input requests, get-user-input.lua acts as an interface to call this script message 748 | mp.register_script_message("request-user-input", function(req) 749 | msg.debug(req) 750 | req = utils.parse_json(req) 751 | local success, err = pcall(input_request, req) 752 | if not success then 753 | send_response{ err = err, response = req.response, source = req.source} 754 | msg.error(err) 755 | end 756 | end) 757 | 758 | --------------------------------------------------------------------------------