-- 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 msg = require "mp.msg" local utils = require "mp.utils" local assdraw = require "mp.assdraw" local function detect_platform() local platform = mp.get_property_native("platform") if platform == "darwin" or platform == "windows" then return platform elseif os.getenv("WAYLAND_DISPLAY") or os.getenv("WAYLAND_SOCKET") then return "wayland" end return "x11" end local platform = detect_platform() -- Default options local script_opts = { monospace_font = "", font_size = 24, border_size = 1.65, background_alpha = 80, gap = 0.2, padding = 10, menu_outline_size = 0, menu_outline_color = "#FFFFFF", corner_radius = 8, margin_x = -1, margin_y = -1, scale_with_window = "auto", focused_color = "#222222", focused_back_color = "#FFFFFF", match_color = "#0088FF", exact_match = false, case_sensitive = false, history_dedup = true, font_hw_ratio = "auto", } local opts = script_opts local styles = { error = "{\\1c&H7a77f2&}", completion = "{\\1c&Hcc99cc&}", inactive = "{\\alpha&H66&}", } for key, style in pairs(styles) do styles[key] = style .. "{\\3c&H111111&}" end local terminal_styles = { error = "\027[31m", selected_completion = "\027[7m", default_item = "\027[1m", disabled = "\027[38;5;8m", matched_position = "\027[34m", match_end = "\027[39m", } local open = false local osd_msg_active = false local insert_mode = false local pending_update = false local ime_active = false local line = "" local cursor = 1 local prompt local id local overlay = mp.create_osd_overlay("ass-events") local width_overlay = mp.create_osd_overlay("ass-events") width_overlay.compute_bounds = true width_overlay.hidden = true local histories = {} local history = {} local history_pos = 1 local searching_history = false local history_paths = {} local histories_to_save = {} local MAX_LOG_LINES = 10000 local log_buffers = {} local log_offset = 0 local key_bindings = {} local global_margins = { t = 0, b = 0 } local input_caller local input_caller_handler local keep_open = false local completion_buffer = {} local dim_completions = false local selected_completion_index local completion_pos local completion_append local autoselect_completion local has_completions local selectable_items local matches = {} local focused_match = 1 local first_match_to_print = 1 local default_item local item_positions = {} local max_item_width = 0 local horizontal_offset = 0 local complete local cycle_through_completions local set_active local render local property_cache = {} local completion_timer = mp.add_timeout(0.1, function() dim_completions = true render() end, true) local function get_property_cached(name, def) if property_cache[name] ~= nil then return property_cache[name] end return def end local function get_font() if not has_completions then return end if opts.monospace_font ~= "" then return opts.monospace_font end -- Pick a better default font for Windows and macOS if platform == "windows" then return "Consolas" end if platform == "darwin" then return "Menlo" end return "monospace" end local function get_margin_x() return opts.margin_x > -1 and opts.margin_x or mp.get_property_native("osd-margin-x") end local function get_margin_y() return opts.margin_y > -1 and opts.margin_y or mp.get_property_native("osd-margin-y") 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 character 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 local 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 utf8_positions(str) local pos = 1 local positions = {true} while pos <= str:len() do pos = next_utf8(str, pos) positions[pos] = true end return positions end -- Converts values to strings, using JSON formatting for tables. -- JSON is used instead of utils.to_string as that is the format the -- values are received in from script-messages. local function to_string(v) if type(v) == "table" then return utils.format_json(v) else return tostring(v) end end -- Functions to calculate the font width. local width_length_ratio = 0.5 local osd_width, osd_height = 0, 0 local text_osd = mp.create_osd_overlay("ass-events") text_osd.compute_bounds, text_osd.hidden = true, true local function measure_bounds(ass_text) 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" size = size / 0.8 local width -- Limit to 5 iterations local repetitions_left = 5 for i = 1, repetitions_left do size = size * 0.8 local ass = assdraw.ass_new() ass.text = template:format(align, size, get_font(), rotation, text) local _, _, x1, y1 = measure_bounds(ass.text) -- Check if nothing got clipped if x1 and x1 < osd_width and y1 < osd_height then width = horizontal and x1 or y1 break end if i == repetitions_left then width = 0 end end 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 local 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) 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 -- Escape a string for verbatim display on the OSD local function ass_escape(str) return mp.command_native({"escape-ass", str}) end local function should_scale() return opts.scale_with_window == "yes" or (opts.scale_with_window == "auto" and mp.get_property_native("osd-scale-by-window")) end local function scale_factor() if should_scale() and osd_height > 0 then return osd_height / 720 end return get_property_cached("display-hidpi-scale", 1) end local function terminal_output() -- Unlike vo-configured, current-vo doesn't become falsy while switching VO, -- which would print the log to the OSD. return not mp.get_property("current-vo") or not mp.get_property_native("video-osd") end local function get_scaled_osd_dimensions() local scale = scale_factor() return osd_width / scale, osd_height / scale end local function get_line_height() if selectable_items then return opts.font_size * (1 + opts.gap) end return opts.font_size end local function calculate_max_lines() if terminal_output() then -- Subtract 1 for the input line and for each line in the status line. -- This does not detect wrapped lines. return mp.get_property_native("term-size/h", 24) - 2 - select(2, mp.get_property("term-status-msg"):gsub("\\n", "")) end return math.floor((select(2, get_scaled_osd_dimensions()) * (1 - global_margins.t - global_margins.b) - get_margin_y() - (selectable_items and opts.padding * 2 or 0)) / get_line_height() -- Subtract 1 for the input line and 0.5 for the empty -- line between the log and the input line. - 1.5) end local function calculate_max_item_width() if not selectable_items or terminal_output() then return end local longest_item = prompt .. ("a"):rep(9) for _, item in pairs(selectable_items) do if #item > #longest_item then longest_item = item end end local osd_w, osd_h = get_scaled_osd_dimensions() local font = get_font() width_overlay.res_x = osd_w width_overlay.res_y = osd_h width_overlay.data = "{\\fs" .. opts.font_size .. (font and "\\fn" .. font or "") .. "\\q2}" .. ass_escape(longest_item) local result = width_overlay:update() if result.x0 then max_item_width = math.min(result.x1 - result.x0, osd_w - get_margin_x() * 2 - opts.padding * 2) end end local function should_highlight_completion(i) return i == selected_completion_index or (i == 1 and selected_completion_index == 0 and autoselect_completion) end local function mpv_color_to_ass(color) return color:sub(8,9) .. color:sub(6,7) .. color:sub(4,5), string.format("%x", 255 - tonumber("0x" .. color:sub(2,3))) end local function color_option_to_ass(color) return color:sub(6,7) .. color:sub(4,5) .. color:sub(2,3) end local function get_selected_ass() local color, alpha = mpv_color_to_ass(mp.get_property("osd-selected-color")) local outline_color, outline_alpha = mpv_color_to_ass(mp.get_property("osd-selected-outline-color")) return "{\\b1\\1c&H" .. color .. "&\\1a&H" .. alpha .. "&\\3c&H" .. outline_color .. "&\\3a&H" .. outline_alpha .. "&}" 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 local function format_grid(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 local 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 should_highlight_completion(i) then local style_override = dim_completions and styles.inactive or "" columns[column] = get_selected_ass() .. style_override .. columns[column] .. "{\\b\\1a&\\3a&}" .. styles.completion .. style_override 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 fuzzy_find(needle, haystacks) local result = require "mp.fzy".filter(needle, haystacks) table.sort(result, function (i, j) if i[3] ~= j[3] then return i[3] > j[3] end return i[1] < j[1] end) return result end local function find_matches(needle, haystacks) if not opts.exact_match and needle:sub(1, 1) ~= "'" then return fuzzy_find(needle, haystacks) end if not opts.exact_match then needle = needle:sub(2) end if not opts.case_sensitive then needle = needle:lower() end local result = {} local needle_words = {} for word in needle:gmatch("%S+") do needle_words[#needle_words + 1] = word end for i, haystack in ipairs(haystacks) do if not opts.case_sensitive then haystack = haystack:lower() end local matching_positions = {} for _, word in pairs(needle_words) do local start, e = haystack:find(word, 1, true) if start then for j = start, e do matching_positions[#matching_positions + 1] = j end else matching_positions = nil break end end if matching_positions then table.sort(matching_positions) result[#result + 1] = { i, matching_positions } end end return result end local function get_matches_to_print(terminal) if not selectable_items or focused_match == 0 then return {} end local items = {} local max_lines = calculate_max_lines() local escape = terminal and function (str) return str end or ass_escape local highlight = terminal and terminal_styles.matched_position or "{\\1c&H" .. color_option_to_ass(opts.match_color) .. "}" if focused_match < first_match_to_print then first_match_to_print = focused_match elseif focused_match > first_match_to_print + max_lines - 1 then first_match_to_print = focused_match - max_lines + 1 end local last_match_to_print = math.min(first_match_to_print + max_lines - 1, #matches) if last_match_to_print - first_match_to_print + 1 < math.min(max_lines, #matches) and last_match_to_print >= math.min(max_lines, #matches) then first_match_to_print = last_match_to_print - math.min(max_lines, #matches) + 1 end for i = first_match_to_print, last_match_to_print do local item = "" local end_highlight = terminal and terminal_styles.match_end or "{\\1c}" if terminal then if matches[i].index == default_item then item = terminal_styles.default_item end if i == focused_match then item = item .. terminal_styles.selected_completion end else if i == focused_match then if searching_history and mp.get_property("osd-border-style") == "outline-and-shadow" then item = get_selected_ass() else item = "{\\1c&H" .. color_option_to_ass(opts.focused_color) .. "&}" end end_highlight = item end end local char_positions = utf8_positions(matches[i].text) local start_of_last_match = matches[i].positions[1] if not start_of_last_match then item = item .. escape(matches[i].text) elseif start_of_last_match > 1 then item = item .. escape(matches[i].text:sub(1, start_of_last_match - 1)) end for j, pos in ipairs(matches[i].positions) do local last_pos = matches[i].positions[j - 1] if last_pos and pos > last_pos + 1 then if char_positions[start_of_last_match] and char_positions[last_pos + 1] then item = item .. highlight .. escape(matches[i].text:sub(start_of_last_match, last_pos)) .. end_highlight .. escape(matches[i].text:sub(last_pos + 1, pos - 1)) else item = item .. escape(matches[i].text:sub(start_of_last_match, pos - 1)) end start_of_last_match = pos end end if start_of_last_match then local last_pos = matches[i].positions[#matches[i].positions] if char_positions[start_of_last_match] and char_positions[last_pos + 1] then item = item .. highlight .. escape(matches[i].text:sub(start_of_last_match, last_pos)) .. end_highlight .. escape(matches[i].text:sub(last_pos + 1)) else item = item .. escape(matches[i].text:sub(start_of_last_match)) end end items[#items + 1] = item end return items end local function update_overlay(data, res_x, res_y, z) if overlay.data == data and overlay.res_x == res_x and overlay.res_y == res_y and overlay.z == z then return end overlay.data = data overlay.res_x = res_x overlay.res_y = res_y overlay.z = z overlay:update() end local function print_to_terminal() -- Clear the log after closing the console. if not open then if osd_msg_active then mp.osd_message("") end osd_msg_active = false return end local log = "" local counter = "" if selectable_items then if #selectable_items > calculate_max_lines() then local digits = math.ceil(math.log(#selectable_items, 10)) counter = terminal_styles.disabled .. "[" .. string.format("%0" .. digits .. "d", focused_match) .. "/" .. string.format("%0" .. digits .. "d", #matches) .. "]\027[0m " end local clip = mp.get_property("term-clip-cc") for _, item in ipairs(get_matches_to_print(true)) do log = log .. clip .. item .. "\027[0m\n" end else for _, log_line in ipairs(log_buffers[id]) do log = log .. log_line.terminal_style .. log_line.text .. "\027[0m\n" end if not dim_completions then for i, completion in ipairs(completion_buffer) do if should_highlight_completion(i) then log = log .. terminal_styles.selected_completion .. completion .. "\027[0m" else log = log .. completion end log = log .. (i < #completion_buffer and "\t" or "\n") end end 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 .. counter .. prompt .. " " .. before_cur .. "\027[7m" .. after_cur:sub(1, 1) .. "\027[0m" .. after_cur:sub(2), 999) osd_msg_active = true end render = function() pending_update = false if terminal_output() then print_to_terminal() return end -- Clear the OSD if the console was being printed to the terminal if osd_msg_active then mp.osd_message("") osd_msg_active = false end -- Clear the OSD after closing the console if not open then update_overlay("", 0, 0, 0) return end local ass = assdraw.ass_new() local osd_w, osd_h = get_scaled_osd_dimensions() local line_height = get_line_height() local max_lines = calculate_max_lines() local x, y, alignment, clipping_coordinates if selectable_items and not searching_history then x = (osd_w - max_item_width) / 2 y = osd_h * (global_margins.t + (1 - global_margins.t - global_margins.b) / 2) - (math.min(#selectable_items, max_lines) + 1.5) * line_height / 2 alignment = 7 clipping_coordinates = x .. ",0," .. x + max_item_width .. "," .. osd_h else x = get_margin_x() y = osd_h * (1 - global_margins.b) - get_margin_y() alignment = 1 -- Avoid drawing below topbar OSC when there are wrapped lines. local coordinate_top = math.floor(global_margins.t * osd_h + 0.5) clipping_coordinates = "0," .. coordinate_top .. "," .. osd_w .. "," .. osd_h end local font = get_font() -- Use the same blur value as the rest of the OSD. 288 is the OSD's -- PlayResY. local blur = mp.get_property_native("osd-blur") * osd_h / 288 local border_style = mp.get_property("osd-border-style") local style = "{\\r" .. (font and "\\fn" .. font or "") .. "\\fs" .. opts.font_size .. "\\bord" .. opts.border_size .. "\\fsp0" .. "\\blur" .. blur .. (selectable_items and "\\q2" or "\\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 color, alpha = mpv_color_to_ass(mp.get_property("osd-color")) local cheight = opts.font_size * 8 local cglyph = "{\\r\\blur0" .. (get_property_cached("focused") == false and "\\alpha&HFF&" or "\\3a&H" .. alpha .. "&") .. "\\3c&H" .. color .. "&" .. "\\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)) local log_ass = "" local log_buffer = log_buffers[id] or {} log_offset = math.max(math.min(log_offset, #log_buffer - 1), 0) local last = #log_buffer - log_offset local first = math.max(last - math.min(max_lines, #log_buffer) + 1, 1) for i = first, last do log_ass = log_ass .. style .. log_buffer[i].style .. ass_escape(log_buffer[i].text) .. "\\N" end local completion_ass = "" if completion_buffer[1] and not selectable_items then -- Estimate how many characters fit in one line -- Even with bottom-left anchoring, -- libass/ass_render.c:ass_render_event() subtracts --osd-margin-x from -- the maximum text width twice. -- TODO: --osd-margin-x should scale with osd-width and PlayResX to make -- the calculation accurate. local width_max = math.floor( (osd_w - x - mp.get_property_native("osd-margin-x") * 2) / opts.font_size * get_font_hw_ratio()) local completions, rows = format_grid(completion_buffer, width_max, max_lines) max_lines = max_lines - rows completion_ass = style .. styles.completion .. (dim_completions and styles.inactive or "") .. completions .. "\\N" end -- Background if selectable_items and (not searching_history or border_style == "background-box") then style = style .. "{\\bord0\\blur0\\4a&Hff&}" local back_color, back_alpha = mpv_color_to_ass(mp.get_property( border_style == "background-box" and "osd-back-color" or "osd-outline-color")) if not searching_history then back_alpha = string.format("%x", opts.background_alpha) end ass:new_event() ass:an(alignment) ass:pos(x, y) ass:append("{\\1c&H" .. back_color .. "&\\1a&H" .. back_alpha .. "&\\bord" .. opts.menu_outline_size .. "\\3c&H" .. color_option_to_ass(opts.menu_outline_color) .. "\\blur0&}") if border_style == "background-box" then ass:append("{\\4a&Hff&}") end ass:draw_start() ass:round_rect_cw(-opts.padding, opts.padding * (alignment == 7 and -1 or 1), max_item_width + opts.padding, (1.5 + math.min(#matches, max_lines)) * line_height + opts.padding * (alignment == 7 and 1 or 2), opts.corner_radius, opts.corner_radius) ass:draw_stop() end local items = get_matches_to_print() item_positions = {} for i, item in ipairs(items) do local item_y = alignment == 7 and y + (1 + i) * line_height or y - (1.5 + #items - i) * line_height if (first_match_to_print - 1 + i == focused_match or matches[first_match_to_print - 1 + i].index == default_item) and (not searching_history or border_style == "background-box") then ass:new_event() ass:an(4) ass:pos(x, item_y) ass:append("{\\blur0\\bord0\\4aH&ff&\\1c&H" .. color_option_to_ass(opts.focused_back_color) .. "&}") if first_match_to_print - 1 + i ~= focused_match then ass:append("{\\1aH&cc&}") end ass:draw_start() ass:rect_cw(-opts.padding, 0, max_item_width + opts.padding, line_height) ass:draw_stop() end ass:new_event() ass:an(4) ass:pos(x - horizontal_offset, item_y) ass:append(style .. item) item_positions[#item_positions + 1] = { item_y - line_height / 2, item_y + line_height / 2 } end -- Scrollbar if selectable_items and #matches > max_lines then ass:new_event() ass:an(alignment + 2) ass:pos(x + max_item_width, y) ass:append(style) if not searching_history or border_style == "background-box" then ass:append("{\\bord0\\4a&Hff&\\blur0}") end ass:append(focused_match .. "/" .. #matches) local start_percentage = (first_match_to_print - 1) / #matches local end_percentage = (first_match_to_print - 1 + max_lines) / #matches if end_percentage - start_percentage < 0.04 then local diff = 0.04 - (end_percentage - start_percentage) start_percentage = start_percentage * (1 - diff) end_percentage = end_percentage + diff * (1 - end_percentage) end local max_height = max_lines * line_height local bar_y = alignment == 7 and y + 1.5 * line_height + start_percentage * max_height or y - 1.5 * line_height - max_height * (1 - end_percentage) local height = max_height * (end_percentage - start_percentage) ass:new_event() ass:an(alignment) ass:append("{\\blur0\\4a&Hff&\\bord1}") ass:pos(x + max_item_width + opts.padding - 1, bar_y) ass:draw_start() ass:rect_cw(0, 0, -opts.padding / 2, height) ass:draw_stop() end ass:new_event() ass:an(alignment) ass:pos(x, y) if not selectable_items then ass:append(log_ass .. "\\N" .. completion_ass) end ass:append(style .. ass_escape(prompt) .. " " .. before_cur) ass:append(cglyph) ass:append(style .. after_cur) -- Redraw the cursor with the input text invisible. This will make the -- cursor appear in front of the text. ass:new_event() ass:an(alignment) ass:pos(x, y) ass:append(style .. "{\\alpha&HFF&}" .. ass_escape(prompt) .. " " .. before_cur) ass:append(cglyph) ass:append(style .. "{\\alpha&HFF&}" .. after_cur) -- z with selectable_items needs to be greater than the OSC's. update_overlay(ass.text, osd_w, osd_h, selectable_items and 2000 or 0) end local update_timer = nil update_timer = mp.add_periodic_timer(0.05, function() if pending_update then render() else update_timer:kill() end end) update_timer:kill() -- Add a line to the history and deduplicate local function history_add(text) if history[#history] == text or text == "" then return end 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 if history_paths[id] then histories_to_save[id] = histories_to_save[id] .. text .. "\n" end end local function handle_cursor_move() -- Don't show completions after a command is entered because they move its -- output up, and allow clearing completions by emptying the line. if line == "" then completion_buffer = {} render() else complete() end end local function handle_edit() if not selectable_items then handle_cursor_move() mp.commandv("script-message-to", input_caller, input_caller_handler, "edited", utils.format_json({line})) return end matches = {} for i, match in ipairs(find_matches(line, selectable_items)) do matches[i] = { index = match[1], text = selectable_items[match[1]], positions = match[2], } end if line == "" and default_item then focused_match = default_item local max_lines = calculate_max_lines() first_match_to_print = math.max(1, focused_match + 1 - math.ceil(max_lines / 2)) if first_match_to_print > #selectable_items - max_lines + 1 then first_match_to_print = math.max(1, #selectable_items - max_lines + 1) end else focused_match = 1 end render() 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 handle_edit() 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 handle_edit() 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)) handle_edit() 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() cursor = next_utf8(line, cursor) handle_cursor_move() end -- Move the cursor to the previous character (Left) local function prev_char() cursor = prev_utf8(line, cursor) handle_cursor_move() end -- Clear the current line (Ctrl+C) local function clear() line = "" cursor = 1 insert_mode = false history_pos = #history + 1 handle_edit() end -- Close the console if the current line is empty, otherwise delete the next -- character (Ctrl+D) local function maybe_exit() if line == "" then set_active(false) else handle_del() end end local function unbind_mouse() mp.remove_key_binding("_console_mouse_move") mp.remove_key_binding("_console_mbtn_left") end local function after_cur_matches_completion() local token = line:sub(completion_pos) for _, completion in pairs(completion_buffer) do if completion == token:sub(1, #completion) then return true end end end -- Run the current command or select the current item local function submit() if searching_history then searching_history = false selectable_items = nil line = #matches > 0 and matches[focused_match].text or "" cursor = #line + 1 handle_edit() unbind_mouse() return end if selectable_items then if #matches > 0 then mp.commandv("script-message-to", input_caller, input_caller_handler, "submit", utils.format_json({matches[focused_match].index})) end else if selected_completion_index == 0 and autoselect_completion and not after_cur_matches_completion() then cycle_through_completions() end mp.commandv("script-message-to", input_caller, input_caller_handler, "submit", utils.format_json({line})) history_add(line) end if not keep_open then set_active(false) elseif not selectable_items then clear() end end local function determine_hovered_item() local osd_w, _ = get_scaled_osd_dimensions() local scale = scale_factor() local mouse_pos = mp.get_property_native("mouse-pos") local mouse_x = mouse_pos.x / scale local mouse_y = mouse_pos.y / scale local item_x0 = (searching_history and get_margin_x() or (osd_w - max_item_width) / 2) - opts.padding if mouse_x < item_x0 or mouse_x > item_x0 + max_item_width + opts.padding * 2 then return end for i, positions in ipairs(item_positions) do if mouse_y >= positions[1] and mouse_y <= positions[2] then return first_match_to_print - 1 + i end end end local function bind_mouse() mp.add_forced_key_binding("MOUSE_MOVE", "_console_mouse_move", function() local item = determine_hovered_item() if item and item ~= focused_match then focused_match = item render() end end) mp.add_forced_key_binding("MBTN_LEFT", "_console_mbtn_left", function() local item = determine_hovered_item() if item then focused_match = item submit() else set_active(false) end end) end -- Go to the specified position in the command history local function go_history(new_pos) local old_pos = history_pos history_pos = new_pos -- Restrict the position to a legal value if history_pos > #history + 1 then history_pos = #history + 1 elseif history_pos < 1 then history_pos = 1 end -- Do nothing if the history position didn't actually change if 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 == #history + 1 then history_add(line) end -- Now show the history line (or a blank line for #history + 1) if history_pos <= #history then line = history[history_pos] else line = "" end cursor = line:len() + 1 insert_mode = false handle_edit() end -- Go to the specified relative position in the command history (Up, Down) local function move_history(amount, is_wheel) if not selectable_items then go_history(history_pos + amount) return end if is_wheel then local max_lines = calculate_max_lines() -- Update focused_match only if it's the first or last printed item and -- there are hidden items. if (amount > 0 and focused_match == first_match_to_print and first_match_to_print - 1 + max_lines < #matches) or (amount < 0 and focused_match == first_match_to_print - 1 + max_lines and first_match_to_print > 1) then focused_match = focused_match + amount end if amount > 0 and first_match_to_print < #matches - max_lines + 1 or amount < 0 and first_match_to_print > 1 then -- math.min and math.max would only be needed with amounts other than -- 1 and -1. first_match_to_print = math.min( math.max(first_match_to_print + amount, 1), #matches - max_lines + 1) end local item = determine_hovered_item() if item then focused_match = item end render() return end focused_match = focused_match + amount if focused_match > #matches then focused_match = 1 elseif focused_match < 1 then focused_match = #matches end render() end local function horizontal_scroll(amount) horizontal_offset = math.max(horizontal_offset + amount, 0) render() end -- Go to the first command in the command history (PgUp) local function handle_pgup() if selectable_items then focused_match = math.max(focused_match - calculate_max_lines() + 1, 1) render() return end go_history(1) end -- Stop browsing history and start editing a blank line (PgDown) local function handle_pgdown() if selectable_items then focused_match = math.min(focused_match + calculate_max_lines() - 1, #matches) render() return end go_history(#history + 1) end local function search_history() if selectable_items or #history == 0 then return end searching_history = true selectable_items = {} horizontal_offset = 0 for i = 1, #history do selectable_items[i] = history[#history + 1 - i] end calculate_max_item_width() handle_edit() bind_mouse() end local function page_up_or_prev_char() if selectable_items then handle_pgup() else prev_char() end end local function page_down_or_next_char() if selectable_items then handle_pgdown() else next_char() end 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 handle_cursor_move() 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 handle_cursor_move() end -- Move the cursor to the beginning of the line (HOME) local function go_home() cursor = 1 handle_cursor_move() end -- Move the cursor to the end of the line (END) local function go_end() cursor = line:len() + 1 handle_cursor_move() 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 handle_edit() 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 handle_edit() end -- Delete from the cursor to the end of the line (Ctrl+K) local function del_to_eol() line = line:sub(1, cursor - 1) handle_edit() 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 handle_edit() end -- Empty the log buffer of all messages (Ctrl+L) local function clear_log_buffer() if not selectable_items then log_buffers[id] = {} end render() end local function scroll_log(amount) if selectable_items then return end log_offset = log_offset + amount render() end -- Returns a string of UTF-8 text from the clipboard (or the primary selection) local function get_clipboard(clip) mp.commandv("update-clipboard", clip and "text" or "text-primary") if platform == "x11" then local property = clip and "clipboard/text" or "clipboard/text-primary" return mp.get_property(property, "") elseif platform == "wayland" then if mp.get_property("current-clipboard-backend") == "wayland" then local property = clip and "clipboard/text" or "clipboard/text-primary" return mp.get_property(property, "") end -- Wayland VO clipboard is only updated on window focus if clip and get_property_cached("focused") then return mp.get_property("clipboard/text", "") end 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" or platform == "darwin" then return mp.get_property("clipboard/text", "") 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() handle_edit() end local function copy() if not selectable_items then mp.set_property("clipboard/text", line) mp.msg.info("Input line copied") elseif matches[1] then mp.set_property("clipboard/text", matches[focused_match].text) if terminal_output() then mp.msg.info("Item copied") else mp.osd_message("Item copied") end end 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 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 local 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 -- 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 cycle_through_completions = function (backwards) -- Disable tab completions while waiting for completion responses if completion_timer:is_enabled() or dim_completions then return end if #completion_buffer == 0 then -- Allow Tab completion of commands before typing anything. if line == "" then complete() end return end selected_completion_index = selected_completion_index + (backwards and -1 or 1) if selected_completion_index > #completion_buffer then selected_completion_index = 1 elseif selected_completion_index < 1 then selected_completion_index = #completion_buffer end local before_cur = line:sub(1, completion_pos - 1) .. completion_buffer[selected_completion_index] .. completion_append line = before_cur .. strip_common_characters(line:sub(cursor), completion_buffer[selected_completion_index] .. completion_append) cursor = before_cur:len() + 1 render() end local function enable_completions() dim_completions = false completion_timer:kill() end -- Show autocompletions. complete = function () completion_timer:resume() mp.commandv("script-message-to", input_caller, input_caller_handler, "complete", utils.format_json({line:sub(1, cursor - 1)})) render() 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", function() set_active(false) end }, { "ctrl+[", function() set_active(false) end }, { "enter", submit }, { "kp_enter", submit }, { "shift+enter", function() handle_char_input("\n") end }, { "ctrl+j", submit }, { "ctrl+m", submit }, { "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() page_up_or_prev_char() end }, { "right", function() next_char() end }, { "ctrl+f", function() page_down_or_next_char() end}, { "up", function() move_history(-1) end }, { "ctrl+p", function() move_history(-1) end }, { "wheel_up", function() move_history(-1, true) end }, { "down", function() move_history(1) end }, { "ctrl+n", function() move_history(1) end }, { "wheel_down", function() move_history(1, true) end }, { "shift+up", function() scroll_log(1) end }, { "shift+down", function() scroll_log(-1) end }, { "wheel_left", function() end }, { "wheel_right", function() end }, { "shift+left", function() horizontal_scroll(-25) end }, { "shift+right", function() horizontal_scroll( 25) end }, { "wheel_left", function() horizontal_scroll(-25) end }, { "wheel_right", function() horizontal_scroll( 25) end }, { "shift+wheel_up", function() horizontal_scroll(-25) end }, { "shift+wheel_down", function() horizontal_scroll( 25) end }, { "ctrl+left", prev_word }, { "alt+b", prev_word }, { "ctrl+right", next_word }, { "alt+f", next_word }, { "tab", cycle_through_completions }, { "ctrl+i", cycle_through_completions }, { "shift+tab", function() cycle_through_completions(true) end }, { "ctrl+a", go_home }, { "home", go_home }, { "ctrl+e", go_end }, { "end", go_end }, { "pgup", handle_pgup }, { "pgdwn", handle_pgdown }, { "ctrl+r", search_history }, { "ctrl+c", clear }, { "ctrl+d", maybe_exit }, { "ctrl+k", del_to_eol }, { "ctrl+l", clear_log_buffer }, { "ctrl+u", del_to_start }, { "ctrl+y", copy, }, { "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 }, { "kp_add", function() handle_char_input("+") end }, { "kp_subtract", function() handle_char_input("-") end }, { "kp_multiply", function() handle_char_input("*") end }, { "kp_divide", 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 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 = "_console_" .. (#key_bindings + 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", "_console_text", text_input, {repeatable = true, complex = true}) key_bindings[#key_bindings + 1] = "_console_text" end local function undefine_key_bindings() for _, name in ipairs(key_bindings) do mp.remove_key_binding(name) end key_bindings = {} end local function read_history() if not history_paths[id] or history[1] then return end local history_file = io.open(mp.command_native({"expand-path", history_paths[id]})) if history_file == nil then return end if opts.history_dedup then local unfiltered_history = {} for command in history_file:lines() do unfiltered_history[#unfiltered_history + 1] = command end local history_map = {} for i = #unfiltered_history, 1, -1 do local command = unfiltered_history[i] if not history_map[command] then history[#history + 1] = command history_map[command] = true end end for i = 1, #history / 2, 1 do history[i], history[#history - i + 1] = history[#history - i + 1], history[i] end else for command in history_file:lines() do history[#history + 1] = command end end history_file:close() end -- Open or close the console set_active = function (active) if active == open then return end if active then open = true insert_mode = false define_key_bindings() mp.set_property_bool("user-data/mpv/console/open", true) ime_active = mp.get_property_bool("input-ime") mp.set_property_bool("input-ime", true) elseif searching_history then searching_history = false selectable_items = nil unbind_mouse() else open = false undefine_key_bindings() unbind_mouse() completion_timer:kill() mp.set_property_bool("user-data/mpv/console/open", false) mp.set_property_bool("input-ime", ime_active) mp.commandv("script-message-to", input_caller, input_caller_handler, "closed", utils.format_json({line, cursor})) collectgarbage() end render() end mp.register_script_message("disable", function(message) message = utils.parse_json(message or "") if not message or message.client_name == input_caller then set_active(false) end end) mp.register_script_message("get-input", function (args) args = utils.parse_json(args) if type(args) ~= "table" then return msg.error("Input request aborted - " .. "get-input must be passed a JSON formatted table.") end if not args.client_name or not args.handler_id then return msg.error("Input request aborted - " .. "get-input must be passed a 'client_name' and 'handler_id'") end if open then mp.commandv("script-message-to", input_caller, input_caller_handler, "closed", utils.format_json({line, cursor})) end input_caller = args.client_name input_caller_handler = args.handler_id prompt = to_string(args.prompt or "") line = to_string(args.default_text or "") cursor = math.floor(tonumber(args.cursor_position) or line:len() + 1) keep_open = args.keep_open default_item = args.default_item has_completions = args.has_completions searching_history = false if cursor < 1 then cursor = 1 elseif cursor > line:len() + 1 then cursor = line:len() + 1 end -- Allows clients to override script-opts, but first ensures the overrides are the same type. if type(args.console_opt_overrides) == "table" then for k, v in pairs(args.console_opt_overrides) do if type(v) ~= type(script_opts[k]) then args.console_opt_overrides[k] = nil msg.warn(("mp.input (%s): ignoring console_opt_overrides.%s - must be of type %s" ):format(input_caller, k, type(script_opts[k]))) end end opts = setmetatable(args.console_opt_overrides, {__index = script_opts}) else opts = script_opts end enable_completions() if args.items then if type(args.items) ~= "table" then msg.warn(("input.select (%s): 'items' must be a table, instead received a %s" ):format(input_caller, type(args.items))) -- set as empty to avoid errors args.items = {} end selectable_items = {} horizontal_offset = 0 -- Limit the number of characters to prevent libass from freezing mpv. -- Not important for terminal output. local limit if osd_width == 0 or terminal_output() then limit = 5000 else limit = 5 * osd_width / opts.font_size end for i, item in ipairs(args.items) do item = to_string(item) local last = next_utf8(item, limit) - 1 selectable_items[i] = item:gsub("[\r\n].*", "…"):sub(1, last) .. (last < #item and "…" or "") end calculate_max_item_width() handle_edit() bind_mouse() else selectable_items = nil unbind_mouse() id = args.id or input_caller .. args.prompt log_offset = 0 completion_buffer = {} autoselect_completion = args.autoselect_completion if histories[id] == nil then histories[id] = {} log_buffers[id] = {} histories_to_save[id] = "" end history = histories[id] -- We probably do not want to be converting accidental non-string values to filepaths. history_paths[id] = type(args.history_path) == "string" and args.history_path or nil read_history() history_pos = #history + 1 handle_cursor_move() end set_active(true) -- We want to ensure the keybindings have been set before sending the "opened" event -- in case scripts want to override our bindings. mp.flush_keybindings() mp.commandv("script-message-to", input_caller, input_caller_handler, "opened") end) -- Add a line to the log buffer mp.register_script_message("log", function (message) message = utils.parse_json(message or "") if type(message) ~= "table" or not message.log_id then return msg.error("Line not appended to log buffer - " .. "log must be passed a JSON formatted table which contains a 'log_id'.") end local log_buffer = log_buffers[message.log_id] if not log_buffer then return end log_buffer[#log_buffer + 1] = { text = to_string(message.text or ""), style = message.error and styles.error or to_string(message.style or ""), terminal_style = message.error and terminal_styles.error or to_string(message.terminal_style or ""), } if #log_buffer > MAX_LOG_LINES then table.remove(log_buffer, 1) end if not open or message.log_id ~= id then return end if log_offset > 0 then log_offset = log_offset + 1 end if not update_timer:is_enabled() then render() update_timer:resume() else pending_update = true end end) mp.register_script_message("set-log", function (log_id, log) log = utils.parse_json(log) if not log_id then return msg.error("Log buffer not overwritten - " .. "set-log must be passed a 'log_id' as the first argument.") end -- This error message provides extra details as mp.input passes the `log` table to -- console.lua unchanged, meaning that this guard may be triggered by Lua and Js clients. if type(log) ~= "table" then return msg.error("Log buffer not overwritten - " .. "set-log must be passed a table as second argument, " .. ("instead received a %s."):format(type(log))) end log_buffers[log_id] = {} for i = 1, #log do if type(log[i]) == "table" then log[i].text = to_string(log[i].text or "") log[i].style = to_string(log[i].style or "") log[i].terminal_style = to_string(log[i].terminal_style or "") log_buffers[id][i] = log[i] else log_buffers[id][i] = { text = to_string(log[i] or ""), style = "", terminal_style = "", } end end if log_id == id then render() end end) mp.register_script_message("complete", function (message) message = utils.parse_json(message) if type(message) ~= "table" then return msg.error("Completions not received - " .. "complete must be passed a JSON formatted table.") end if message.client_name ~= input_caller or message.handler_id ~= input_caller_handler then return end completion_buffer = {} selected_completion_index = 0 local completions = message.list if type(completions) ~= "table" then msg.warn(("Completions invalid (%s) - " .. "completions must be a table, instead received a %s" ):format(input_caller, type(completions))) completions = {} else for i = 1, #completions do completions[i] = to_string(completions[i]) end end table.sort(completions) completion_pos = math.floor(tonumber(message.start_pos) or 1) completion_append = to_string(message.append or "") if completion_pos < 1 then completion_pos = 1 end for i, match in ipairs(fuzzy_find(line:sub(completion_pos, cursor - 1), completions)) do completion_buffer[i] = completions[match[1]] end -- Rendering outdated completions makes the completions appear more -- responsive, but we only want allow selecting up-to-date completions. if line:sub(1, cursor - 1) == message.original_line then enable_completions() end render() end) local function resize() calculate_max_item_width() render() end mp.observe_property("osd-dimensions", "native", function (_, value) if value.w == osd_width and value.h == osd_height then return end osd_width, osd_height = value.w, value.h resize() end) mp.observe_property("display-hidpi-scale", "native", function (name, value) property_cache[name] = value resize() end) mp.observe_property("focused", "native", function (name, value) property_cache[name] = value render() end) mp.observe_property("user-data/osc/margins", "native", function(_, val) if type(val) == "table" and type(val.t) == "number" and type(val.b) == "number" then global_margins = val else global_margins = { t = 0, b = 0 } end render() end) mp.register_event("shutdown", function () mp.del_property("user-data/mpv/console") for history_id, history_path in pairs(history_paths) do history_path = mp.command_native({"expand-path", history_path}) local history_file, error_message = io.open(history_path, "ab") if history_file then history_file:write(histories_to_save[history_id]) history_file:close() else mp.msg.error("Failed to write history: " .. error_message) end end end) -- These are for backwards compatibility only. mp.add_key_binding(nil, "enable", function () mp.command("script-message-to commands open") end) mp.register_script_message("type", function (...) mp.commandv("script-message-to", "commands", "type", ...) end) require "mp.options".read_options(script_opts, nil, render) collectgarbage()