898 lines
29 KiB
Lua
898 lines
29 KiB
Lua
|
local mp = require 'mp'
|
||
|
local utils = require 'mp.utils'
|
||
|
local assdraw = require 'mp.assdraw'
|
||
|
|
||
|
-- create namespace with default values
|
||
|
local em = {
|
||
|
|
||
|
-- customisable values ------------------------------------------------------
|
||
|
|
||
|
lines_to_show = 17, -- NOT including search line
|
||
|
pause_on_open = true,
|
||
|
resume_on_exit = "only-if-was-paused", -- another possible value is true
|
||
|
|
||
|
-- styles (earlyer it was a table, but required many more steps to pass def-s
|
||
|
-- here from .conf file)
|
||
|
font_size = 21,
|
||
|
-- cursor 'width', useful to change if you have hidpi monitor
|
||
|
cursor_x_border = 0.3,
|
||
|
line_bottom_margin = 1, -- basically space between lines
|
||
|
text_color = {
|
||
|
default = 'ffffff',
|
||
|
accent = 'd8a07b',
|
||
|
current = 'aaaaaa',
|
||
|
comment = '636363',
|
||
|
},
|
||
|
menu_x_padding = 5, -- this padding for now applies only to 'left', not x
|
||
|
menu_y_padding = 2, -- but this one applies to both - top & bottom
|
||
|
|
||
|
|
||
|
-- values that should be passed from main script ----------------------------
|
||
|
|
||
|
search_heading = 'Default search heading',
|
||
|
-- 'full' is required from main script, 'current_i' is optional
|
||
|
-- others are 'private'
|
||
|
list = {
|
||
|
full = {}, filtered = {}, current_i = nil, pointer_i = 1, show_from_to = {}
|
||
|
},
|
||
|
-- field to compare with when searching for 'current value' by 'current_i'
|
||
|
index_field = 'index',
|
||
|
-- fields to use when searching for string match / any other custom searching
|
||
|
-- if value has 0 length, then search list item itself
|
||
|
filter_by_fields = {},
|
||
|
|
||
|
|
||
|
-- 'private' values that are not supposed to be changed from the outside ----
|
||
|
|
||
|
is_active = false,
|
||
|
-- https://mpv.io/manual/master/#lua-scripting-mp-create-osd-overlay(format)
|
||
|
ass = mp.create_osd_overlay("ass-events"),
|
||
|
was_paused = false, -- flag that indicates that vid was paused by this script
|
||
|
|
||
|
line = '',
|
||
|
-- if there was no cursor it wouldn't have been needed, but for now we need
|
||
|
-- variable below only to compare it with 'line' and see if we need to filter
|
||
|
prev_line = '',
|
||
|
cursor = 1,
|
||
|
history = {},
|
||
|
history_pos = 1,
|
||
|
key_bindings = {},
|
||
|
insert_mode = false,
|
||
|
|
||
|
-- used only in 'update' func to get error text msgs
|
||
|
error_codes = {
|
||
|
no_match = 'Match required',
|
||
|
no_submit_provided = 'No submit function provided'
|
||
|
}
|
||
|
}
|
||
|
|
||
|
-- PRIVATE METHODS ------------------------------------------------------------
|
||
|
|
||
|
-- declare constructor function
|
||
|
function em:new(o)
|
||
|
o = o or {}
|
||
|
setmetatable(o, self)
|
||
|
self.__index = self
|
||
|
|
||
|
-- some options might be customised by user in .conf file and read as strings
|
||
|
-- in that case parse those
|
||
|
if type(o.filter_by_fields) == 'string' then
|
||
|
o.filter_by_fields = utils.parse_json(o.filter_by_fields)
|
||
|
end
|
||
|
|
||
|
if type(o.text_color) == 'string' then
|
||
|
o.text_color = utils.parse_json(o.text_color)
|
||
|
end
|
||
|
|
||
|
return o
|
||
|
end
|
||
|
|
||
|
-- this func is just a getter of a current list depending on search line
|
||
|
function em:current()
|
||
|
return self.line == '' and self.list.full or self.list.filtered
|
||
|
end
|
||
|
|
||
|
-- REVIEW: how to get rid of this wrapper and handle filter func sideeffects
|
||
|
-- in a more elegant way?
|
||
|
function em:filter_wrapper()
|
||
|
-- handles sideeffect that are needed to be run on filtering list
|
||
|
-- cuz the filter func may be redefined in main script and therefore needs
|
||
|
-- to be straight forward - only doing filtering and returning the table
|
||
|
|
||
|
-- passing current query just in case, so ppl can use it in their custom funcs
|
||
|
self.list.filtered = self:filter(self.line)
|
||
|
|
||
|
self.prev_line = self.line
|
||
|
self.list.pointer_i = 1
|
||
|
self:set_from_to(true)
|
||
|
end
|
||
|
|
||
|
function em:set_from_to(reset_flag)
|
||
|
-- additional variables just for shorter var name
|
||
|
local i = self.list.pointer_i
|
||
|
local to_show = self.lines_to_show
|
||
|
local total = #self:current()
|
||
|
|
||
|
if reset_flag or to_show >= total then
|
||
|
self.list.show_from_to = { 1, math.min(to_show, total) }
|
||
|
return
|
||
|
end
|
||
|
|
||
|
-- If menu is opened with something already selected we want this 'selected'
|
||
|
-- to be displayed close to the middle of the menu. That's why 'show_from_to'
|
||
|
-- is not initially set, so we can know - if show_from_to length is 0 - it is
|
||
|
-- first call of this func in cur. init
|
||
|
if #self.list.show_from_to == 0 then
|
||
|
-- set show_from_to so chosen item will be displayed close to middle
|
||
|
local half_list = math.ceil(to_show / 2)
|
||
|
if i < half_list then
|
||
|
self.list.show_from_to = { 1, to_show }
|
||
|
elseif total - i < half_list then
|
||
|
self.list.show_from_to = { total - to_show + 1, total }
|
||
|
else
|
||
|
self.list.show_from_to = { i - half_list + 1, i - half_list + to_show }
|
||
|
end
|
||
|
else
|
||
|
local first, last = table.unpack(self.list.show_from_to)
|
||
|
|
||
|
-- handle cursor moving towards start / end bondary
|
||
|
if first ~= 1 and i - first < 2 then
|
||
|
self.list.show_from_to = { first - 1, last - 1 }
|
||
|
end
|
||
|
if last ~= total and last - i < 2 then
|
||
|
self.list.show_from_to = { first + 1, last + 1 }
|
||
|
end
|
||
|
|
||
|
-- handle index jumps from beginning to end and backwards
|
||
|
if i > last then
|
||
|
self.list.show_from_to = { i - to_show + 1, i }
|
||
|
end
|
||
|
if i < first then self.list.show_from_to = { 1, to_show } end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function em:change_selected_index(num)
|
||
|
self.list.pointer_i = self.list.pointer_i + num
|
||
|
if self.list.pointer_i < 1 then
|
||
|
self.list.pointer_i = #self:current()
|
||
|
elseif self.list.pointer_i > #self:current() then
|
||
|
self.list.pointer_i = 1
|
||
|
end
|
||
|
self:set_from_to()
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Render the REPL and console as an ASS OSD
|
||
|
function em:update(err_code)
|
||
|
-- ASS tags documentation here - https://aegi.vmoe.info/docs/3.0/ASS_Tags/
|
||
|
|
||
|
-- do not bother if function was called to close the menu..
|
||
|
if not self.is_active then
|
||
|
em.ass:remove()
|
||
|
return
|
||
|
end
|
||
|
|
||
|
local line_height = self.font_size + self.line_bottom_margin
|
||
|
local ww, wh = mp.get_osd_size() -- window width & height
|
||
|
-- '+ 1' below is a search string
|
||
|
local menu_y_pos =
|
||
|
wh - (line_height * (self.lines_to_show + 1) + self.menu_y_padding * 2)
|
||
|
|
||
|
-- didn't find better place to handle filtered list update
|
||
|
if self.line ~= self.prev_line then self:filter_wrapper() end
|
||
|
|
||
|
local function get_background()
|
||
|
local a = self:ass_new_wrapper()
|
||
|
a:append('{\\1c&H1c1c1c\\1a&H19}') -- background color & opacity
|
||
|
a:pos(0, 0)
|
||
|
a:draw_start()
|
||
|
a:rect_cw(0, menu_y_pos, ww, wh)
|
||
|
a:draw_stop()
|
||
|
return a.text
|
||
|
end
|
||
|
|
||
|
local function get_search_header()
|
||
|
local a = self:ass_new_wrapper()
|
||
|
|
||
|
a:pos(self.menu_x_padding, menu_y_pos + self.menu_y_padding)
|
||
|
|
||
|
local search_prefix = table.concat({
|
||
|
self:get_font_color('accent'),
|
||
|
(#self:current() ~= 0 and self.list.pointer_i or '!'),
|
||
|
'/', #self:current(), '\\h\\h', self.search_heading, ':\\h'
|
||
|
});
|
||
|
|
||
|
a:append(search_prefix)
|
||
|
-- reset font color after search prefix
|
||
|
a:append(self:get_font_color 'default')
|
||
|
|
||
|
-- 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 = self.font_size * 8
|
||
|
-- TODO: maybe do it using draw_rect from ass?
|
||
|
local cglyph = '{\\r' .. -- styles reset
|
||
|
'\\1c&Hffffff&\\3c&Hffffff' .. -- font color and border color
|
||
|
'\\xbord' .. self.cursor_x_border .. '\\p4\\pbo24}' .. -- xborder, scale x8 and baseline offset
|
||
|
'm 0 0 l 0 ' .. cheight .. -- drawing just a line
|
||
|
'{\\p0\\r}' -- finish drawing and reset styles
|
||
|
local before_cur = self:ass_escape(self.line:sub(1, self.cursor - 1))
|
||
|
local after_cur = self:ass_escape(self.line:sub(self.cursor))
|
||
|
|
||
|
a:append(table.concat({
|
||
|
before_cur, cglyph, self:reset_styles(),
|
||
|
self:get_font_color('default'), after_cur,
|
||
|
(err_code and '\\h' .. self.error_codes[err_code] or "")
|
||
|
}))
|
||
|
|
||
|
return a.text
|
||
|
|
||
|
-- NOTE: perhaps this commented code will some day help me in coding cursor
|
||
|
-- like in M-x emacs menu:
|
||
|
-- Redraw the cursor with the REPL text invisible. This will make the
|
||
|
-- cursor appear in front of the text.
|
||
|
-- ass:new_event()
|
||
|
-- ass:an(1)
|
||
|
-- ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur)
|
||
|
-- ass:append(cglyph)
|
||
|
-- ass:append(style .. '{\\alpha&HFF&}' .. after_cur)
|
||
|
end
|
||
|
|
||
|
local function get_list()
|
||
|
local a = assdraw.ass_new()
|
||
|
|
||
|
local function apply_highlighting(y)
|
||
|
a:new_event()
|
||
|
a:append(self:reset_styles())
|
||
|
a:append('{\\1c&Hffffff\\1a&HE6}') -- background color & opacity
|
||
|
a:pos(0, 0)
|
||
|
a:draw_start()
|
||
|
a:rect_cw(0, y, ww, y + self.font_size)
|
||
|
a:draw_stop()
|
||
|
end
|
||
|
|
||
|
-- REVIEW: maybe make another function 'get_line_str' and move there
|
||
|
-- everything from this for loop?
|
||
|
-- REVIEW: how to use something like table.unpack below?
|
||
|
for i = self.list.show_from_to[1], self.list.show_from_to[2] do
|
||
|
local value = assert(self:current()[i], 'no value with index ' .. i)
|
||
|
local y_offset = menu_y_pos + self.menu_y_padding +
|
||
|
(line_height * (i - self.list.show_from_to[1] + 1))
|
||
|
|
||
|
if i == self.list.pointer_i then apply_highlighting(y_offset) end
|
||
|
|
||
|
a:new_event()
|
||
|
a:append(self:reset_styles())
|
||
|
a:pos(self.menu_x_padding, y_offset)
|
||
|
a:append(self:get_line(i, value))
|
||
|
end
|
||
|
|
||
|
return a.text
|
||
|
end
|
||
|
|
||
|
em.ass.res_x = ww
|
||
|
em.ass.res_y = wh
|
||
|
em.ass.data = table.concat({
|
||
|
get_background(),
|
||
|
get_search_header(),
|
||
|
get_list()
|
||
|
}, "\n")
|
||
|
|
||
|
em.ass:update()
|
||
|
|
||
|
end
|
||
|
|
||
|
-- params:
|
||
|
-- - data : {list: {}, [current_i] : num}
|
||
|
function em:init(data)
|
||
|
self.list.full = data.list or {}
|
||
|
self.list.current_i = data.current_i or nil
|
||
|
self.list.pointer_i = data.current_i or 1
|
||
|
self:set_active(true)
|
||
|
end
|
||
|
|
||
|
function em:exit()
|
||
|
self:undefine_key_bindings()
|
||
|
collectgarbage()
|
||
|
end
|
||
|
|
||
|
-- TODO: write some idle func like this
|
||
|
-- function idle()
|
||
|
-- if pending_selection then
|
||
|
-- gallery:set_selection(pending_selection)
|
||
|
-- pending_selection = nil
|
||
|
-- end
|
||
|
-- if ass_changed or geometry_changed then
|
||
|
-- local ww, wh = mp.get_osd_size()
|
||
|
-- if geometry_changed then
|
||
|
-- geometry_changed = false
|
||
|
-- compute_geometry(ww, wh)
|
||
|
-- end
|
||
|
-- if ass_changed then
|
||
|
-- ass_changed = false
|
||
|
-- mp.set_osd_ass(ww, wh, ass)
|
||
|
-- end
|
||
|
-- end
|
||
|
-- end
|
||
|
-- ...
|
||
|
-- and handle it as follows
|
||
|
-- init():
|
||
|
-- mp.register_idle(idle)
|
||
|
-- idle()
|
||
|
-- exit():
|
||
|
-- mp.unregister_idle(idle)
|
||
|
-- idle()
|
||
|
-- And in these observers he is setting a flag, that's being checked in func above
|
||
|
-- mp.observe_property("osd-width", "native", mark_geometry_stale)
|
||
|
-- mp.observe_property("osd-height", "native", mark_geometry_stale)
|
||
|
|
||
|
-- PRIVATE METHODS END --------------------------------------------------------
|
||
|
|
||
|
-- PUBLIC METHODS -------------------------------------------------------------
|
||
|
|
||
|
function em:filter()
|
||
|
-- default filter func, might be redefined in main script
|
||
|
local result = {}
|
||
|
|
||
|
local function get_full_search_str(v)
|
||
|
local str = ''
|
||
|
for _, key in ipairs(self.filter_by_fields) do str = str .. (v[key] or '') end
|
||
|
return str
|
||
|
end
|
||
|
|
||
|
for _, v in ipairs(self.list.full) do
|
||
|
-- if filter_by_fields has 0 length, then search list item itself
|
||
|
if #self.filter_by_fields == 0 then
|
||
|
if self:search_method(v) then table.insert(result, v) end
|
||
|
else
|
||
|
-- NOTE: we might use search_method on fiels separately like this:
|
||
|
-- for _,key in ipairs(self.filter_by_fields) do
|
||
|
-- if self:search_method(v[key]) then table.insert(result, v) end
|
||
|
-- end
|
||
|
-- But since im planning to implement fuzzy search in future i need full
|
||
|
-- search string here
|
||
|
if self:search_method(get_full_search_str(v)) then
|
||
|
table.insert(result, v)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- TODO: implement fuzzy search and maybe match highlights
|
||
|
function em:search_method(str)
|
||
|
-- also might be redefined by main script
|
||
|
|
||
|
-- convert to string just to make sure..
|
||
|
return tostring(str):lower():find(self.line:lower(), 1, true)
|
||
|
end
|
||
|
|
||
|
-- this module requires submit function to be defined in main script
|
||
|
function em:submit() self:update('no_submit_provided') end
|
||
|
|
||
|
function em:update_list(list)
|
||
|
-- for now this func doesn't handle cases when we have 'current_i' to update
|
||
|
-- it
|
||
|
self.list.full = list
|
||
|
if self.line ~= self.prev_line then self:filter_wrapper() end
|
||
|
end
|
||
|
|
||
|
-- PUBLIC METHODS END ---------------------------------------------------------
|
||
|
|
||
|
-- HELPER METHODS -------------------------------------------------------------
|
||
|
|
||
|
function em:get_line(_, v) -- [i]ndex, [v]alue
|
||
|
-- this func might be redefined in main script to get a custom-formatted line
|
||
|
-- default implementation of this func supposes that value.content field is a
|
||
|
-- String
|
||
|
local a = assdraw.ass_new()
|
||
|
local style = (self.list.current_i == v[self.index_field])
|
||
|
and 'current' or 'default'
|
||
|
|
||
|
a:append(self:reset_styles())
|
||
|
a:append(self:get_font_color(style))
|
||
|
-- content as default field, which is holding string
|
||
|
-- no point in moving it to main object since content itself is being
|
||
|
-- composed in THIS function, that might (and most likely, should) be
|
||
|
-- redefined in main script
|
||
|
a:append(v.content or 'Something is off in `get_line` func')
|
||
|
return a.text
|
||
|
end
|
||
|
|
||
|
-- REVIEW: for now i don't see normal way of mergin this func with below one
|
||
|
-- but it's being used only once
|
||
|
function em:reset_styles()
|
||
|
local a = assdraw.ass_new()
|
||
|
-- alignment top left, no word wrapping, border 0, shadow 0
|
||
|
a:append('{\\an7\\q2\\bord0\\shad0}')
|
||
|
a:append('{\\fs' .. self.font_size .. '}')
|
||
|
return a.text
|
||
|
end
|
||
|
|
||
|
-- function to get rid of some copypaste
|
||
|
function em:ass_new_wrapper()
|
||
|
local a = assdraw.ass_new()
|
||
|
a:new_event()
|
||
|
a:append(self:reset_styles())
|
||
|
return a
|
||
|
end
|
||
|
|
||
|
function em:get_font_color(style)
|
||
|
return '{\\1c&H' .. self.text_color[style] .. '}'
|
||
|
end
|
||
|
|
||
|
-- HELPER METHODS END ---------------------------------------------------------
|
||
|
|
||
|
|
||
|
--[[
|
||
|
The below code is a modified implementation of text input from mpv's console.lua:
|
||
|
https://github.com/mpv-player/mpv/blob/87c9eefb2928252497f6141e847b74ad1158bc61/player/lua/console.lua
|
||
|
|
||
|
I was too lazy to list all modifications i've done to the script, but if u
|
||
|
rly need to see those - do diff with the original code
|
||
|
]] --
|
||
|
|
||
|
-------------------------------------------------------------------------------
|
||
|
-- START ORIGINAL MPV CODE --
|
||
|
-------------------------------------------------------------------------------
|
||
|
|
||
|
-- Copyright (C) 2019 the mpv developers
|
||
|
--
|
||
|
-- Permission to use, copy, modify, and/or distribute this software for any
|
||
|
-- purpose with or without fee is hereby granted, provided that the above
|
||
|
-- copyright notice and this permission notice appear in all copies.
|
||
|
--
|
||
|
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||
|
-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||
|
-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||
|
-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||
|
-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||
|
-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||
|
-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||
|
|
||
|
function em:detect_platform()
|
||
|
local o = {}
|
||
|
-- Kind of a dumb way of detecting the platform but whatever
|
||
|
if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then
|
||
|
return 'windows'
|
||
|
elseif mp.get_property_native('options/macos-force-dedicated-gpu', o) ~= o then
|
||
|
return 'macos'
|
||
|
elseif os.getenv('WAYLAND_DISPLAY') then
|
||
|
return 'wayland'
|
||
|
end
|
||
|
return 'x11'
|
||
|
end
|
||
|
|
||
|
-- Escape a string for verbatim display on the OSD
|
||
|
function em:ass_escape(str)
|
||
|
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
||
|
-- it isn't followed by a recognised character, so add a zero-width
|
||
|
-- non-breaking space
|
||
|
str = str:gsub('\\', '\\\239\187\191')
|
||
|
str = str:gsub('{', '\\{')
|
||
|
str = str:gsub('}', '\\}')
|
||
|
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
||
|
-- consecutive newlines
|
||
|
str = str:gsub('\n', '\239\187\191\\N')
|
||
|
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
|
||
|
str = str:gsub('\\N ', '\\N\\h')
|
||
|
str = str:gsub('^ ', '\\h')
|
||
|
return str
|
||
|
end
|
||
|
|
||
|
-- Set the REPL visibility ("enable", Esc)
|
||
|
function em:set_active(active)
|
||
|
if active == self.is_active then return end
|
||
|
if active then
|
||
|
self.is_active = true
|
||
|
self.insert_mode = false
|
||
|
mp.enable_messages('terminal-default')
|
||
|
self:define_key_bindings()
|
||
|
|
||
|
-- set flag 'was_paused' only if vid wasn't paused before EM init
|
||
|
if self.pause_on_open and not mp.get_property_bool("pause", false) then
|
||
|
mp.set_property_bool("pause", true)
|
||
|
self.was_paused = true
|
||
|
end
|
||
|
|
||
|
self:set_from_to()
|
||
|
self:update()
|
||
|
else
|
||
|
-- no need to call 'update' in this block cuz 'clear' method is calling it
|
||
|
self.is_active = false
|
||
|
self:undefine_key_bindings()
|
||
|
|
||
|
if self.resume_on_exit == true or
|
||
|
(self.resume_on_exit == "only-if-was-paused" and self.was_paused) then
|
||
|
mp.set_property_bool("pause", false)
|
||
|
end
|
||
|
|
||
|
self:clear()
|
||
|
collectgarbage()
|
||
|
end
|
||
|
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.
|
||
|
function em:next_utf8(str, pos)
|
||
|
if pos > str:len() then return pos end
|
||
|
repeat
|
||
|
pos = pos + 1
|
||
|
until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
|
||
|
return pos
|
||
|
end
|
||
|
|
||
|
-- As above, but finds the previous UTF-8 charcter in 'str' before 'pos'
|
||
|
function em:prev_utf8(str, pos)
|
||
|
if pos <= 1 then return pos end
|
||
|
repeat
|
||
|
pos = pos - 1
|
||
|
until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
|
||
|
return pos
|
||
|
end
|
||
|
|
||
|
-- Insert a character at the current cursor position (any_unicode)
|
||
|
function em:handle_char_input(c)
|
||
|
if self.insert_mode then
|
||
|
self.line = self.line:sub(1, self.cursor - 1) .. c .. self.line:sub(self:next_utf8(self.line, self.cursor))
|
||
|
else
|
||
|
self.line = self.line:sub(1, self.cursor - 1) .. c .. self.line:sub(self.cursor)
|
||
|
end
|
||
|
self.cursor = self.cursor + #c
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Remove the character behind the cursor (Backspace)
|
||
|
function em:handle_backspace()
|
||
|
if self.cursor <= 1 then return end
|
||
|
local prev = self:prev_utf8(self.line, self.cursor)
|
||
|
self.line = self.line:sub(1, prev - 1) .. self.line:sub(self.cursor)
|
||
|
self.cursor = prev
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Remove the character in front of the cursor (Del)
|
||
|
function em:handle_del()
|
||
|
if self.cursor > self.line:len() then return end
|
||
|
self.line = self.line:sub(1, self.cursor - 1) .. self.line:sub(self:next_utf8(self.line, self.cursor))
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Toggle insert mode (Ins)
|
||
|
function em:handle_ins()
|
||
|
self.insert_mode = not self.insert_mode
|
||
|
end
|
||
|
|
||
|
-- Move the cursor to the next character (Right)
|
||
|
function em:next_char()
|
||
|
self.cursor = self:next_utf8(self.line, self.cursor)
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Move the cursor to the previous character (Left)
|
||
|
function em:prev_char()
|
||
|
self.cursor = self:prev_utf8(self.line, self.cursor)
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Clear the current line (Ctrl+C)
|
||
|
function em:clear()
|
||
|
self.line = ''
|
||
|
self.prev_line = ''
|
||
|
|
||
|
self.list.current_i = nil
|
||
|
self.list.pointer_i = 1
|
||
|
self.list.filtered = {}
|
||
|
self.list.show_from_to = {}
|
||
|
|
||
|
self.was_paused = false
|
||
|
|
||
|
self.cursor = 1
|
||
|
self.insert_mode = false
|
||
|
self.history_pos = #self.history + 1
|
||
|
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Run the current command and clear the line (Enter)
|
||
|
function em:handle_enter()
|
||
|
if #self:current() == 0 then
|
||
|
self:update('no_match')
|
||
|
return
|
||
|
end
|
||
|
|
||
|
if self.history[#self.history] ~= self.line then
|
||
|
self.history[#self.history + 1] = self.line
|
||
|
end
|
||
|
|
||
|
self:submit(self:current()[self.list.pointer_i])
|
||
|
self:set_active(false)
|
||
|
end
|
||
|
|
||
|
-- Go to the specified position in the command history
|
||
|
function em:go_history(new_pos)
|
||
|
local old_pos = self.history_pos
|
||
|
self.history_pos = new_pos
|
||
|
|
||
|
-- Restrict the position to a legal value
|
||
|
if self.history_pos > #self.history + 1 then
|
||
|
self.history_pos = #self.history + 1
|
||
|
elseif self.history_pos < 1 then
|
||
|
self.history_pos = 1
|
||
|
end
|
||
|
|
||
|
-- Do nothing if the history position didn't actually change
|
||
|
if self.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 == #self.history + 1 and self.line ~= '' and self.history[#self.history] ~= self.line then
|
||
|
self.history[#self.history + 1] = self.line
|
||
|
end
|
||
|
|
||
|
-- Now show the history line (or a blank line for #history + 1)
|
||
|
if self.history_pos <= #self.history then
|
||
|
self.line = self.history[self.history_pos]
|
||
|
else
|
||
|
self.line = ''
|
||
|
end
|
||
|
self.cursor = self.line:len() + 1
|
||
|
self.insert_mode = false
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Go to the specified relative position in the command history (Up, Down)
|
||
|
function em:move_history(amount)
|
||
|
self:go_history(self.history_pos + amount)
|
||
|
end
|
||
|
|
||
|
-- Go to the first command in the command history (PgUp)
|
||
|
function em:handle_pgup()
|
||
|
self:go_history(1)
|
||
|
end
|
||
|
|
||
|
-- Stop browsing history and start editing a blank line (PgDown)
|
||
|
function em:handle_pgdown()
|
||
|
self:go_history(#self.history + 1)
|
||
|
end
|
||
|
|
||
|
-- Move to the start of the current word, or if already at the start, the start
|
||
|
-- of the previous word. (Ctrl+Left)
|
||
|
function em: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.
|
||
|
self.cursor = self.line:len() - select(2, self.line:reverse():find('%s*[^%s]*', self.line:len() - self.cursor + 2)) + 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Move to the end of the current word, or if already at the end, the end of
|
||
|
-- the next word. (Ctrl+Right)
|
||
|
function em:next_word()
|
||
|
self.cursor = select(2, self.line:find('%s*[^%s]*', self.cursor)) + 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Move the cursor to the beginning of the line (HOME)
|
||
|
function em:go_home()
|
||
|
self.cursor = 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Move the cursor to the end of the line (END)
|
||
|
function em:go_end()
|
||
|
self.cursor = self.line:len() + 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Delete from the cursor to the beginning of the word (Ctrl+Backspace)
|
||
|
function em:del_word()
|
||
|
local before_cur = self.line:sub(1, self.cursor - 1)
|
||
|
local after_cur = self.line:sub(self.cursor)
|
||
|
|
||
|
before_cur = before_cur:gsub('[^%s]+%s*$', '', 1)
|
||
|
self.line = before_cur .. after_cur
|
||
|
self.cursor = before_cur:len() + 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Delete from the cursor to the end of the word (Ctrl+Del)
|
||
|
function em:del_next_word()
|
||
|
if self.cursor > self.line:len() then return end
|
||
|
|
||
|
local before_cur = self.line:sub(1, self.cursor - 1)
|
||
|
local after_cur = self.line:sub(self.cursor)
|
||
|
|
||
|
after_cur = after_cur:gsub('^%s*[^%s]+', '', 1)
|
||
|
self.line = before_cur .. after_cur
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Delete from the cursor to the end of the line (Ctrl+K)
|
||
|
function em:del_to_eol()
|
||
|
self.line = self.line:sub(1, self.cursor - 1)
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Delete from the cursor back to the start of the line (Ctrl+U)
|
||
|
function em:del_to_start()
|
||
|
self.line = self.line:sub(self.cursor)
|
||
|
self.cursor = 1
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- Returns a string of UTF-8 text from the clipboard (or the primary selection)
|
||
|
function em:get_clipboard(clip)
|
||
|
-- Pick a better default font for Windows and macOS
|
||
|
local platform = self:detect_platform()
|
||
|
|
||
|
if platform == 'x11' then
|
||
|
local res = utils.subprocess({
|
||
|
args = { 'xclip', '-selection', clip and 'clipboard' or 'primary', '-out' },
|
||
|
playback_only = false,
|
||
|
})
|
||
|
if not res.error then
|
||
|
return res.stdout
|
||
|
end
|
||
|
elseif platform == 'wayland' then
|
||
|
local res = utils.subprocess({
|
||
|
args = { 'wl-paste', clip and '-n' or '-np' },
|
||
|
playback_only = false,
|
||
|
})
|
||
|
if not res.error then
|
||
|
return res.stdout
|
||
|
end
|
||
|
elseif platform == 'windows' then
|
||
|
local res = utils.subprocess({
|
||
|
args = { 'powershell', '-NoProfile', '-Command', [[& {
|
||
|
Trap {
|
||
|
Write-Error -ErrorRecord $_
|
||
|
Exit 1
|
||
|
}
|
||
|
|
||
|
$clip = ""
|
||
|
if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) {
|
||
|
$clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText
|
||
|
} else {
|
||
|
Add-Type -AssemblyName PresentationCore
|
||
|
$clip = [Windows.Clipboard]::GetText()
|
||
|
}
|
||
|
|
||
|
$clip = $clip -Replace "`r",""
|
||
|
$u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip)
|
||
|
[Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length)
|
||
|
}]] },
|
||
|
playback_only = false,
|
||
|
})
|
||
|
if not res.error then
|
||
|
return res.stdout
|
||
|
end
|
||
|
elseif platform == 'macos' then
|
||
|
local res = utils.subprocess({
|
||
|
args = { 'pbpaste' },
|
||
|
playback_only = false,
|
||
|
})
|
||
|
if not res.error then
|
||
|
return res.stdout
|
||
|
end
|
||
|
end
|
||
|
return ''
|
||
|
end
|
||
|
|
||
|
-- Paste text from the window-system's clipboard. 'clip' determines whether the
|
||
|
-- clipboard or the primary selection buffer is used (on X11 and Wayland only.)
|
||
|
function em:paste(clip)
|
||
|
local text = self:get_clipboard(clip)
|
||
|
local before_cur = self.line:sub(1, self.cursor - 1)
|
||
|
local after_cur = self.line:sub(self.cursor)
|
||
|
self.line = before_cur .. text .. after_cur
|
||
|
self.cursor = self.cursor + text:len()
|
||
|
self:update()
|
||
|
end
|
||
|
|
||
|
-- List of input bindings. This is a weird mashup between common GUI text-input
|
||
|
-- bindings and readline bindings.
|
||
|
function em:get_bindings()
|
||
|
local bindings = {
|
||
|
{ 'ctrl+[', function() self:set_active(false) end },
|
||
|
{ 'ctrl+g', function() self:set_active(false) end },
|
||
|
{ 'esc', function() self:set_active(false) end },
|
||
|
{ 'enter', function() self:handle_enter() end },
|
||
|
{ 'kp_enter', function() self:handle_enter() end },
|
||
|
{ 'ctrl+m', function() self:handle_enter() end },
|
||
|
{ 'bs', function() self:handle_backspace() end },
|
||
|
{ 'shift+bs', function() self:handle_backspace() end },
|
||
|
{ 'ctrl+h', function() self:handle_backspace() end },
|
||
|
{ 'del', function() self:handle_del() end },
|
||
|
{ 'shift+del', function() self:handle_del() end },
|
||
|
{ 'ins', function() self:handle_ins() end },
|
||
|
{ 'shift+ins', function() self:paste(false) end },
|
||
|
{ 'mbtn_mid', function() self:paste(false) end },
|
||
|
{ 'left', function() self:prev_char() end },
|
||
|
{ 'ctrl+b', function() self:prev_char() end },
|
||
|
{ 'right', function() self:next_char() end },
|
||
|
{ 'ctrl+f', function() self:next_char() end },
|
||
|
{ 'ctrl+k', function() self:change_selected_index(-1) end },
|
||
|
{ 'ctrl+p', function() self:change_selected_index(-1) end },
|
||
|
{ 'ctrl+j', function() self:change_selected_index(1) end },
|
||
|
{ 'ctrl+n', function() self:change_selected_index(1) end },
|
||
|
{ 'up', function() self:move_history(-1) end },
|
||
|
{ 'alt+p', function() self:move_history(-1) end },
|
||
|
{ 'wheel_up', function() self:move_history(-1) end },
|
||
|
{ 'down', function() self:move_history(1) end },
|
||
|
{ 'alt+n', function() self:move_history(1) end },
|
||
|
{ 'wheel_down', function() self:move_history(1) end },
|
||
|
{ 'wheel_left', function() end },
|
||
|
{ 'wheel_right', function() end },
|
||
|
{ 'ctrl+left', function() self:prev_word() end },
|
||
|
{ 'alt+b', function() self:prev_word() end },
|
||
|
{ 'ctrl+right', function() self:next_word() end },
|
||
|
{ 'alt+f', function() self:next_word() end },
|
||
|
{ 'ctrl+a', function() self:go_home() end },
|
||
|
{ 'home', function() self:go_home() end },
|
||
|
{ 'ctrl+e', function() self:go_end() end },
|
||
|
{ 'end', function() self:go_end() end },
|
||
|
{ 'pgup', function() self:handle_pgup() end },
|
||
|
{ 'pgdwn', function() self:handle_pgdown() end },
|
||
|
{ 'ctrl+c', function() self:clear() end },
|
||
|
{ 'ctrl+d', function() self:handle_del() end },
|
||
|
{ 'ctrl+u', function() self:del_to_start() end },
|
||
|
{ 'ctrl+v', function() self:paste(true) end },
|
||
|
{ 'meta+v', function() self:paste(true) end },
|
||
|
{ 'ctrl+bs', function() self:del_word() end },
|
||
|
{ 'ctrl+w', function() self:del_word() end },
|
||
|
{ 'ctrl+del', function() self:del_next_word() end },
|
||
|
{ 'alt+d', function() self:del_next_word() end },
|
||
|
{ 'kp_dec', function() self:handle_char_input('.') end },
|
||
|
}
|
||
|
|
||
|
for i = 0, 9 do
|
||
|
bindings[#bindings + 1] =
|
||
|
{ 'kp' .. i, function() self:handle_char_input('' .. i) end }
|
||
|
end
|
||
|
|
||
|
return bindings
|
||
|
end
|
||
|
|
||
|
function em:text_input(info)
|
||
|
if info.key_text and (info.event == "press" or info.event == "down"
|
||
|
or info.event == "repeat")
|
||
|
then
|
||
|
self:handle_char_input(info.key_text)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function em:define_key_bindings()
|
||
|
if #self.key_bindings > 0 then
|
||
|
return
|
||
|
end
|
||
|
for _, bind in ipairs(self:get_bindings()) do
|
||
|
-- Generate arbitrary name for removing the bindings later.
|
||
|
local name = "search_" .. (#self.key_bindings + 1)
|
||
|
self.key_bindings[#self.key_bindings + 1] = name
|
||
|
mp.add_forced_key_binding(bind[1], name, bind[2], { repeatable = true })
|
||
|
end
|
||
|
mp.add_forced_key_binding("any_unicode", "search_input", function(...)
|
||
|
self:text_input(...)
|
||
|
end, { repeatable = true, complex = true })
|
||
|
self.key_bindings[#self.key_bindings + 1] = "search_input"
|
||
|
end
|
||
|
|
||
|
function em:undefine_key_bindings()
|
||
|
for _, name in ipairs(self.key_bindings) do
|
||
|
mp.remove_key_binding(name)
|
||
|
end
|
||
|
self.key_bindings = {}
|
||
|
end
|
||
|
|
||
|
-------------------------------------------------------------------------------
|
||
|
-- END ORIGINAL MPV CODE --
|
||
|
-------------------------------------------------------------------------------
|
||
|
|
||
|
return em
|