diff options
Diffstat (limited to 'player/lua/console.lua')
-rw-r--r-- | player/lua/console.lua | 1029 |
1 files changed, 865 insertions, 164 deletions
diff --git a/player/lua/console.lua b/player/lua/console.lua index a483bbe1f4..bbfaf478f7 100644 --- a/player/lua/console.lua +++ b/player/lua/console.lua @@ -13,7 +13,6 @@ -- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. local utils = require 'mp.utils' -local options = require 'mp.options' local assdraw = require 'mp.assdraw' -- Default options @@ -21,21 +20,28 @@ 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. + -- Set the font used for the REPL and the console. + -- This has to be a monospaced font. font = "", -- Set the font size used for the REPL and the console. This will be - -- multiplied by "scale." + -- multiplied by "scale". font_size = 16, + border_size = 1, + case_sensitive = true, + -- Remove duplicate entries in history as to only keep the latest one. + history_dedup = true, + -- The ratio of font height to font width. + -- Adjusts table width of completion suggestions. + -- Values in the range 1.8..2.5 make sense for common monospace fonts. + font_hw_ratio = 'auto', } 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' + local platform = mp.get_property_native('platform') + if platform == 'darwin' or platform == 'windows' then + return platform + elseif os.getenv('WAYLAND_DISPLAY') then + return 'wayland' end return 'x11' end @@ -44,25 +50,67 @@ end local platform = detect_platform() if platform == 'windows' then opts.font = 'Consolas' -elseif platform == 'macos' then + opts.case_sensitive = false +elseif platform == 'darwin' then opts.font = 'Menlo' else opts.font = 'monospace' end -- Apply user-set options -options.read_options(opts) +require 'mp.options'.read_options(opts) + +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&\\b1}', + suggestion = '{\\1c&Hcc99cc&}', + selected_suggestion = '{\\1c&H2fbdfa&\\b1}', +} + +local terminal_styles = { + debug = '\027[1;30m', + v = '\027[32m', + warn = '\027[33m', + error = '\027[31m', + fatal = '\027[1;31m', + selected_suggestion = '\027[7m', +} local repl_active = false local insert_mode = false local pending_update = false local line = '' local cursor = 1 -local history = {} +local default_prompt = '>' +local prompt = default_prompt +local default_id = 'default' +local id = default_id +local histories = {[id] = {}} +local history = histories[id] local history_pos = 1 -local log_buffer = {} +local log_buffers = {[id] = {}} local key_bindings = {} -local global_margin_y = 0 +local global_margins = { t = 0, b = 0 } +local input_caller + +local suggestion_buffer = {} +local selected_suggestion_index +local completion_start_position +local completion_append +local file_commands = {} +local path_separator = platform == 'windows' and '\\' or '/' +local completion_old_line +local completion_old_cursor local update_timer = nil update_timer = mp.add_periodic_timer(0.05, function() @@ -74,24 +122,97 @@ update_timer = mp.add_periodic_timer(0.05, function() end) update_timer:kill() -utils.shared_script_property_observe("osc-margins", function(_, val) +mp.observe_property("user-data/osc/margins", "native", function(_, val) if val then - -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each - -- value being the border size as ratio of the window size (0.0-1.0) - local vals = {} - for v in string.gmatch(val, "[^,]+") do - vals[#vals + 1] = tonumber(v) - end - global_margin_y = vals[4] -- bottom + global_margins = val else - global_margin_y = 0 + global_margins = { t = 0, b = 0 } end update() end) +do + local width_length_ratio = 0.5 + local osd_width, osd_height = 100, 100 + + ---Update osd resolution if valid + local function update_osd_resolution() + local dim = mp.get_property_native('osd-dimensions') + if not dim or dim.w == 0 or dim.h == 0 then + return + end + osd_width = dim.w + osd_height = dim.h + end + + local text_osd = mp.create_osd_overlay('ass-events') + text_osd.compute_bounds, text_osd.hidden = true, true + + local function measure_bounds(ass_text) + update_osd_resolution() + text_osd.res_x, text_osd.res_y = osd_width, osd_height + text_osd.data = ass_text + local res = text_osd:update() + return res.x0, res.y0, res.x1, res.y1 + end + + ---Measure text width and normalize to a font size of 1 + ---text has to be ass safe + local function normalized_text_width(text, size, horizontal) + local align, rotation = horizontal and 7 or 1, horizontal and 0 or -90 + local template = '{\\pos(0,0)\\rDefault\\blur0\\bord0\\shad0\\q2\\an%s\\fs%s\\fn%s\\frz%s}%s' + local x1, y1 = nil, nil + size = size / 0.8 + -- prevent endless loop + local repetitions_left = 5 + repeat + size = size * 0.8 + local ass = assdraw.ass_new() + ass.text = template:format(align, size, opts.font, rotation, text) + _, _, x1, y1 = measure_bounds(ass.text) + repetitions_left = repetitions_left - 1 + -- make sure nothing got clipped + until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0 + local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1) + return width / size, horizontal and osd_width or osd_height + end + + local function fit_on_osd(text) + local estimated_width = #text * width_length_ratio + if osd_width >= osd_height then + -- Fill the osd as much as possible, bigger is more accurate. + return math.min(osd_width / estimated_width, osd_height), true + else + return math.min(osd_height / estimated_width, osd_width), false + end + end + + local measured_font_hw_ratio = nil + function get_font_hw_ratio() + local font_hw_ratio = tonumber(opts.font_hw_ratio) + if font_hw_ratio then + return font_hw_ratio + end + if not measured_font_hw_ratio then + local alphabet = 'abcdefghijklmnopqrstuvwxyz' + local text = alphabet:rep(3) + update_osd_resolution() + local size, horizontal = fit_on_osd(text) + local normalized_width = normalized_text_width(text, size * 0.9, horizontal) + measured_font_hw_ratio = #text / normalized_width * 0.95 + end + return measured_font_hw_ratio + end +end + -- Add a line to the log buffer (which is limited to 100 lines) -function log_add(style, text) - log_buffer[#log_buffer + 1] = { style = style, text = text } +function log_add(text, style, terminal_style) + local log_buffer = log_buffers[id] + log_buffer[#log_buffer + 1] = { + text = text, + style = style or '', + terminal_style = terminal_style or '', + } if #log_buffer > 100 then table.remove(log_buffer, 1) end @@ -108,25 +229,146 @@ end -- Escape a string for verbatim display on the OSD 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 + return mp.command_native({'escape-ass', str}) +end + +-- Takes a list of strings, a max width in characters and +-- optionally a max row count. +-- The result contains at least one column. +-- Rows are cut off from the top if rows_max is specified. +-- returns a string containing the formatted table and the row count +function format_table(list, width_max, rows_max) + if #list == 0 then + return '', 0 + end + + local spaces_min = 2 + local spaces_max = 8 + local list_size = #list + local column_count = 1 + local row_count = list_size + local column_widths + -- total width without spacing + local width_total = 0 + + local list_widths = {} + for i, item in ipairs(list) do + list_widths[i] = len_utf8(item) + end + + -- use as many columns as possible + for columns = 2, list_size do + local rows_lower_bound = math.min(rows_max, math.ceil(list_size / columns)) + local rows_upper_bound = math.min(rows_max, list_size, math.ceil(list_size / (columns - 1) - 1)) + for rows = rows_upper_bound, rows_lower_bound, -1 do + cw = {} + width_total = 0 + + -- find out width of each column + for column = 1, columns do + local width = 0 + for row = 1, rows do + local i = row + (column - 1) * rows + local item_width = list_widths[i] + if not item_width then break end + if width < item_width then + width = item_width + end + end + cw[column] = width + width_total = width_total + width + if width_total + (columns - 1) * spaces_min > width_max then + break + end + end + + if width_total + (columns - 1) * spaces_min <= width_max then + row_count = rows + column_count = columns + column_widths = cw + else + break + end + end + if width_total + (columns - 1) * spaces_min > width_max then + break + end + end + + local spaces = math.floor((width_max - width_total) / (column_count - 1)) + spaces = math.max(spaces_min, math.min(spaces_max, spaces)) + local spacing = column_count > 1 + and ass_escape(string.format('%' .. spaces .. 's', ' ')) + or '' + + local rows = {} + for row = 1, row_count do + local columns = {} + for column = 1, column_count do + local i = row + (column - 1) * row_count + if i > #list then break end + -- more then 99 leads to 'invalid format (width or precision too long)' + local format_string = column == column_count and '%s' + or '%-' .. math.min(column_widths[column], 99) .. 's' + columns[column] = ass_escape(string.format(format_string, list[i])) + + if i == selected_suggestion_index then + columns[column] = styles.selected_suggestion .. columns[column] + .. '{\\b0}'.. styles.suggestion + end + end + -- first row is at the bottom + rows[row_count - row + 1] = table.concat(columns, spacing) + end + return table.concat(rows, ass_escape('\n')), row_count +end + +local function print_to_terminal() + -- Clear the log after closing the console. + if not repl_active then + mp.osd_message('') + return + end + + local log = '' + for _, log_line in ipairs(log_buffers[id]) do + log = log .. log_line.terminal_style .. log_line.text .. '\027[0m' + end + + local suggestions = '' + for i, suggestion in ipairs(suggestion_buffer) do + if i == selected_suggestion_index then + suggestions = suggestions .. terminal_styles.selected_suggestion .. + suggestion .. '\027[0m' + else + suggestions = suggestions .. suggestion + end + suggestions = suggestions .. (i < #suggestion_buffer and '\t' or '\n') + end + + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + -- Ensure there is a character with inverted colors to print. + if after_cur == '' then + after_cur = ' ' + end + + mp.osd_message(log .. suggestions .. prompt .. ' ' .. before_cur .. + '\027[7m' .. after_cur:sub(1, 1) .. '\027[0m' .. + after_cur:sub(2), 999) end -- Render the REPL and console as an ASS OSD function update() pending_update = false + -- Unlike vo-configured, current-vo doesn't become falsy while switching VO, + -- which would print the log to the OSD. + if not mp.get_property('current-vo') then + print_to_terminal() + return + end + local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) dpi_scale = dpi_scale * opts.scale @@ -141,20 +383,26 @@ function update() return end + local coordinate_top = math.floor(global_margins.t * screeny + 0.5) + local clipping_coordinates = '0,' .. coordinate_top .. ',' .. + screenx .. ',' .. screeny local ass = assdraw.ass_new() + local has_shadow = mp.get_property('osd-back-color'):sub(2, 3) == '00' local style = '{\\r' .. - '\\1a&H00&\\3a&H00&\\4a&H99&' .. - '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. + '\\1a&H00&\\3a&H00&\\1c&Heeeeee&\\3c&H111111&' .. + (has_shadow and '\\4a&H99&\\4c&H000000&' or '') .. '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. - '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' + '\\bord' .. opts.border_size .. '\\xshad0\\yshad1\\fsp0\\q1' .. + '\\clip(' .. clipping_coordinates .. ')}' -- 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&' .. + local cglyph = '{\\rDefault' .. + (mp.get_property_native('focused') == false + and '\\alpha&HFF&' or '\\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 .. @@ -162,11 +410,23 @@ function update() local before_cur = ass_escape(line:sub(1, cursor - 1)) local after_cur = ass_escape(line:sub(cursor)) - -- Render log messages as ASS. This will render at most screeny / font_size - -- messages. + -- Render log messages as ASS. + -- This will render at most screeny / font_size - 1 messages. + + -- lines above the prompt + -- subtract 1.5 to account for the input line + local screeny_factor = (1 - global_margins.t - global_margins.b) + local lines_max = math.ceil(screeny * screeny_factor / opts.font_size - 1.5) + -- Estimate how many characters fit in one line + local width_max = math.ceil(screenx / opts.font_size * get_font_hw_ratio()) + + local suggestions, rows = format_table(suggestion_buffer, width_max, lines_max) + local suggestion_ass = style .. styles.suggestion .. suggestions + local log_ass = '' + local log_buffer = log_buffers[id] local log_messages = #log_buffer - local log_max_lines = math.ceil(screeny / opts.font_size) + local log_max_lines = math.max(0, lines_max - rows) if log_max_lines < log_messages then log_messages = log_max_lines end @@ -176,9 +436,12 @@ function update() ass:new_event() ass:an(1) - ass:pos(2, screeny - 2 - global_margin_y * screeny) + ass:pos(2, screeny - 2 - global_margins.b * screeny) ass:append(log_ass .. '\\N') - ass:append(style .. '> ' .. before_cur) + if #suggestions > 0 then + ass:append(suggestion_ass .. '\\N') + end + ass:append(style .. ass_escape(prompt) .. ' ' .. before_cur) ass:append(cglyph) ass:append(style .. after_cur) @@ -186,8 +449,8 @@ function update() -- 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:pos(2, screeny - 2 - global_margins.b * screeny) + ass:append(style .. '{\\alpha&HFF&}' .. ass_escape(prompt) .. ' ' .. before_cur) ass:append(cglyph) ass:append(style .. '{\\alpha&HFF&}' .. after_cur) @@ -201,12 +464,28 @@ function set_active(active) repl_active = true insert_mode = false mp.enable_key_bindings('console-input', 'allow-hide-cursor+allow-vo-dragging') - mp.enable_messages('terminal-default') define_key_bindings() + + if not input_caller then + prompt = default_prompt + id = default_id + history = histories[id] + history_pos = #history + 1 + mp.enable_messages('terminal-default') + end else repl_active = false + suggestion_buffer = {} undefine_key_bindings() mp.enable_messages('silent:terminal-default') + + if input_caller then + mp.commandv('script-message-to', input_caller, 'input-event', + 'closed', line, cursor) + input_caller = nil + line = '' + cursor = 1 + end collectgarbage() end update() @@ -220,7 +499,7 @@ function show_and_type(text, cursor_pos) -- Save the line currently being edited, just in case if line ~= text and line ~= '' and history[#history] ~= line then - history[#history + 1] = line + history_add(line) end line = text @@ -249,7 +528,7 @@ function next_utf8(str, pos) return pos end --- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' +-- As above, but finds the previous UTF-8 character in 'str' before 'pos' function prev_utf8(str, pos) if pos <= 1 then return pos end repeat @@ -258,7 +537,27 @@ function prev_utf8(str, pos) return pos end --- Insert a character at the current cursor position (any_unicode, Shift+Enter) +function len_utf8(str) + local len = 0 + local pos = 1 + while pos <= str:len() do + pos = next_utf8(str, pos) + len = len + 1 + end + return len +end + +local function handle_edit() + suggestion_buffer = {} + update() + + if input_caller then + mp.commandv('script-message-to', input_caller, 'input-event', 'edited', + line) + end +end + +-- Insert a character at the current cursor position (any_unicode) function handle_char_input(c) if insert_mode then line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) @@ -266,7 +565,7 @@ function handle_char_input(c) line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) end cursor = cursor + #c - update() + handle_edit() end -- Remove the character behind the cursor (Backspace) @@ -275,14 +574,14 @@ function handle_backspace() local prev = prev_utf8(line, cursor) line = line:sub(1, prev - 1) .. line:sub(cursor) cursor = prev - update() + handle_edit() end -- Remove the character in front of the cursor (Del) function handle_del() if cursor > line:len() then return end line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) - update() + handle_edit() end -- Toggle insert mode (Ins) @@ -293,12 +592,14 @@ end -- Move the cursor to the next character (Right) function next_char(amount) cursor = next_utf8(line, cursor) + suggestion_buffer = {} update() end -- Move the cursor to the previous character (Left) function prev_char(amount) cursor = prev_utf8(line, cursor) + suggestion_buffer = {} update() end @@ -308,19 +609,24 @@ function clear() cursor = 1 insert_mode = false history_pos = #history + 1 - update() + handle_edit() end --- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D) +-- Close the REPL if the current line is empty, otherwise delete the next +-- character (Ctrl+D) function maybe_exit() if line == '' then set_active(false) + else + handle_del() end end function help_command(param) local cmdlist = mp.get_property_native('command-list') - local error_style = '{\\1c&H7a77f2&}' + table.sort(cmdlist, function(c1, c2) + return c1.name < c2.name + end) local output = '' if param == '' then output = 'Available commands:\n' @@ -341,7 +647,8 @@ function help_command(param) end end if not cmd then - log_add(error_style, 'No command matches "' .. param .. '"!') + log_add('No command matches "' .. param .. '"!\n', styles.error, + terminal_styles.error) return end output = output .. 'Command "' .. cmd.name .. '"\n' @@ -356,25 +663,45 @@ function help_command(param) output = output .. 'This command supports variable arguments.\n' end end - log_add('', output) + log_add(output) +end + +-- Add a line to the history and deduplicate +function history_add(text) + if opts.history_dedup then + -- More recent entries are more likely to be repeated + for i = #history, 1, -1 do + if history[i] == text then + table.remove(history, i) + break + end + end + end + + history[#history + 1] = text end -- Run the current command and clear the line (Enter) function handle_enter() - if line == '' then + if line == '' and input_caller == nil then return end - if history[#history] ~= line then - history[#history + 1] = line + if history[#history] ~= line and line ~= '' then + history_add(line) end - -- match "help [<text>]", return <text> or "", strip all whitespace - local help = line:match('^%s*help%s+(.-)%s*$') or - (line:match('^%s*help$') and '') - if help then - help_command(help) + if input_caller then + mp.commandv('script-message-to', input_caller, 'input-event', 'submit', + line) else - mp.command(line) + -- match "help [<text>]", return <text> or "", strip all whitespace + local help = line:match('^%s*help%s+(.-)%s*$') or + (line:match('^%s*help$') and '') + if help then + help_command(help) + else + mp.command(line) + end end clear() @@ -401,7 +728,7 @@ function go_history(new_pos) -- entry. This makes it much less frustrating to accidentally hit Up/Down -- while editing a line. if old_pos == #history + 1 and line ~= '' and history[#history] ~= line then - history[#history + 1] = line + history_add(line) end -- Now show the history line (or a blank line for #history + 1) @@ -412,6 +739,7 @@ function go_history(new_pos) end cursor = line:len() + 1 insert_mode = false + suggestion_buffer = {} update() end @@ -437,6 +765,7 @@ function prev_word() -- 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 + suggestion_buffer = {} update() end @@ -444,110 +773,367 @@ end -- the next word. (Ctrl+Right) function next_word() cursor = select(2, line:find('%s*[^%s]*', cursor)) + 1 + suggestion_buffer = {} update() end +local function command_list() + local commands = {} + for i, command in ipairs(mp.get_property_native('command-list')) do + commands[i] = command.name + end + + return commands +end + +local function command_list_and_help() + local commands = command_list() + commands[#commands + 1] = 'help' + + return commands +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 _, option in ipairs(mp.get_property_native('options')) do + properties[#properties + 1] = 'options/' .. option + properties[#properties + 1] = 'file-local-options/' .. option + properties[#properties + 1] = 'option-info/' .. option + + for _, sub_property in pairs({ + 'name', 'type', 'set-from-commandline', 'set-locally', + 'default-value', 'min', 'max', 'choices', + }) do + properties[#properties + 1] = 'option-info/' .. option .. '/' .. + sub_property + end + 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_verb_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 + + return {} +end + +local function choice_list(option) + local info = mp.get_property_native('option-info/' .. option, {}) + + if info.type == 'Flag' then + return { 'no', 'yes' } + end + + return info.choices or {} +end + +local function find_commands_with_file_argument() + if #file_commands > 0 then + return file_commands + end + + for _, command in pairs(mp.get_property_native('command-list')) do + if command.args[1] and + (command.args[1].name == 'filename' or command.args[1].name == 'url') then + file_commands[#file_commands + 1] = command.name + end + end + + return file_commands +end + +local function file_list(directory) + if directory == '' then + 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 + -- List of tab-completions: --- pattern: A Lua pattern used in string:find. Should return the start and --- end positions of the word to be completed in the first and second --- capture groups (using the empty parenthesis notation "()") --- list: A list of candidate completion values. +-- pattern: A Lua pattern used in string:match. It should return the start +-- position of the word to be completed in the first capture (using +-- the empty parenthesis notation "()"). In patterns with 2 +-- captures, the first determines the completions, and the second is +-- the start of the word to be completed. +-- list: A function that returns a list of candidate completion values. -- append: An extra string to be appended to the end of a successful -- completion. It is only appended if 'list' contains exactly one -- match. function build_completers() - -- Build a list of commands, properties and options for tab-completion - local option_info = { - 'name', 'type', 'set-from-commandline', 'set-locally', 'default-value', - 'min', 'max', 'choices', - } - local cmd_list = {} - for i, cmd in ipairs(mp.get_property_native('command-list')) do - cmd_list[i] = cmd.name - end - local prop_list = mp.get_property_native('property-list') - for _, opt in ipairs(mp.get_property_native('options')) do - prop_list[#prop_list + 1] = 'options/' .. opt - prop_list[#prop_list + 1] = 'file-local-options/' .. opt - prop_list[#prop_list + 1] = 'option-info/' .. opt - for _, p in ipairs(option_info) do - prop_list[#prop_list + 1] = 'option-info/' .. opt .. '/' .. p - end - end - - return { - { pattern = '^%s*()[%w_-]+()$', list = cmd_list, append = ' ' }, - { pattern = '^%s*set%s+()[%w_/-]+()$', list = prop_list, append = ' ' }, - { pattern = '^%s*set%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, - { pattern = '^%s*add%s+()[%w_/-]+()$', list = prop_list, append = ' ' }, - { pattern = '^%s*add%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, - { pattern = '^%s*cycle%s+()[%w_/-]+()$', list = prop_list, append = ' ' }, - { pattern = '^%s*cycle%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, - { pattern = '^%s*multiply%s+()[%w_/-]+()$', list = prop_list, append = ' ' }, - { pattern = '^%s*multiply%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, - { pattern = '${()[%w_/-]+()$', list = prop_list, append = '}' }, + local completers = { + { pattern = '^%s*()[%w_-]*$', list = command_list_and_help, append = ' ' }, + { pattern = '^%s*help%s+()[%w_-]*$', list = command_list }, + { pattern = '^%s*set%s+"?([%w_-]+)"?%s+()%S*$', list = choice_list }, + { pattern = '^%s*set%s+"?([%w_-]+)"?%s+"()%S*$', list = choice_list, append = '"' }, + { pattern = '^%s*cycle[-_]values%s+"?([%w_-]+)"?.-%s+()%S*$', list = choice_list, append = " " }, + { pattern = '^%s*cycle[-_]values%s+"?([%w_-]+)"?.-%s+"()%S*$', list = choice_list, append = '" ' }, + { pattern = '^%s*apply[-_]profile%s+"()%S*$', list = profile_list, append = '"' }, + { pattern = '^%s*apply[-_]profile%s+()%S*$', list = profile_list }, + { pattern = '^%s*change[-_]list%s+()[%w_-]*$', list = list_option_list, append = ' ' }, + { pattern = '^%s*change[-_]list%s+()"[%w_-]*$', list = list_option_list, append = '" ' }, + { pattern = '^%s*change[-_]list%s+"?([%w_-]+)"?%s+()%a*$', list = list_option_verb_list, append = ' ' }, + { pattern = '^%s*change[-_]list%s+"?([%w_-]+)"?%s+"()%a*$', list = list_option_verb_list, append = '" ' }, + { pattern = '^%s*([av]f)%s+()%a*$', list = list_option_verb_list, append = ' ' }, + { pattern = '^%s*([av]f)%s+"()%a*$', list = list_option_verb_list, append = '" ' }, + { pattern = '${[=>]?()[%w_/-]*$', list = property_list, append = '}' }, } + + for _, command in pairs({'set', 'add', 'cycle', 'cycle[-_]values', 'multiply'}) do + completers[#completers + 1] = { + pattern = '^%s*' .. command .. '%s+()[%w_/-]*$', + list = property_list, + append = ' ', + } + completers[#completers + 1] = { + pattern = '^%s*' .. command .. '%s+"()[%w_/-]*$', + list = property_list, + append = '" ', + } + end + + + for _, command in pairs(find_commands_with_file_argument()) do + completers[#completers + 1] = { + pattern = '^%s*' .. command:gsub('-', '[-_]') .. + '%s+["\']?(.-)()[^' .. path_separator ..']*$', + list = file_list, + -- Unfortunately appending " here would append it everytime a + -- directory is fully completed, even if you intend to browse it + -- afterwards. + } + end + + return completers end --- Use 'list' to find possible tab-completions for 'part.' Returns the longest --- common prefix of all the matching list items and a flag that indicates --- whether the match was unique or not. -function complete_match(part, list) - local completion = nil - local full_match = false +function common_prefix_length(s1, s2) + local common_count = 0 + for i = 1, #s1 do + if s1:byte(i) ~= s2:byte(i) then + break + end + common_count = common_count + 1 + end + return common_count +end - for _, candidate in ipairs(list) do - if candidate:sub(1, part:len()) == part then - if completion and completion ~= candidate then - local prefix_len = part:len() - while completion:sub(1, prefix_len + 1) - == candidate:sub(1, prefix_len + 1) do - prefix_len = prefix_len + 1 - end - completion = candidate:sub(1, prefix_len) - full_match = false - else - completion = candidate - full_match = true +function max_overlap_length(s1, s2) + for s1_offset = 0, #s1 - 1 do + local match = true + for i = 1, #s1 - s1_offset do + if s1:byte(s1_offset + i) ~= s2:byte(i) then + match = false + break end end + if match then + return #s1 - s1_offset + end end + return 0 +end - return completion, full_match +-- If str starts with the first or last characters of prefix, strip them. +local function strip_common_characters(str, prefix) + return str:sub(1 + math.max( + common_prefix_length(prefix, str), + max_overlap_length(prefix, str))) +end + +-- Find the longest common case-sensitive prefix of the entries in "list". +local function find_common_prefix(list) + local prefix = list[1] + + for i = 2, #list do + prefix = prefix:sub(1, common_prefix_length(prefix, list[i])) + end + + return prefix +end + +-- Return the entries of "list" beginning with "part" and the longest common +-- prefix of the matches. +local function complete_match(part, list) + local completions = {} + + for _, candidate in pairs(list) do + if candidate:sub(1, part:len()) == part then + completions[#completions + 1] = candidate + end + end + + local prefix = find_common_prefix(completions) + + if opts.case_sensitive then |