mpv/portable_config/script-modules/scroll-list.lua
local mp = require 'mp'
local scroll_list = {
    global_style = [[]],
    header_style = [[{\q2\fs30\c&H00ccff&}]],
    list_style = [[{\q2\fs25\c&Hffffff&}]],
    wrapper_style = [[{\c&H00ccff&\fs16}]],
    cursor_style = [[{\c&H0000ff&}]],
    selected_style = [[{\c&H0000ff&}]],

    cursor = [[➤\h]],
    indent = [[\h\h\h\h]],

    num_entries = 16,
    wrap = false,
    empty_text = "无"
}

--formats strings for ass handling
--this function is based on a similar function from https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110
function scroll_list.ass_escape(str, replace_newline)
    if replace_newline == true then replace_newline = "\\\239\187\191n" end

    --escape the invalid single characters
    str = str:gsub('[\\{}\n]', {
        -- 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
        ['\\'] = '\\\239\187\191',
        ['{'] = '\\{',
        ['}'] = '\\}',
        -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
        -- consecutive newlines
        ['\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')

    if replace_newline then
        str = str:gsub("\\N", replace_newline)
    end
    return str
end

--format and return the header string
function scroll_list:format_header_string(str)
    return str
end

--appends the entered text to the overlay
function scroll_list:append(text)
        if text == nil then return end
        self.ass.data = self.ass.data .. text
    end

--appends a newline character to the osd
function scroll_list:newline()
    self.ass.data = self.ass.data .. '\\N'
end

--re-parses the list into an ass string
--if the list is closed then it flags an update on the next open
function scroll_list:update()
    if self.hidden then self.flag_update = true
    else self:update_ass() end
end

--prints the header to the overlay
function scroll_list:format_header()
    self:append(self.header_style)
    self:append(self:format_header_string(self.header))
    self:newline()
end

--formats each line of the list and prints it to the overlay
function scroll_list:format_line(index, item)
    self:append(self.list_style)

    if index == self.selected then self:append(self.cursor_style..self.cursor..self.selected_style)
    else self:append(self.indent) end

    self:append(item.style)
    self:append(item.ass)
    self:newline()
end

--refreshes the ass text using the contents of the list
function scroll_list:update_ass()
    self.ass.data = self.global_style
    self:format_header()

    if #self.list < 1 then
        self:append(self.empty_text)
        self.ass:update()
        return
    end

    local start = 1
    local finish = start+self.num_entries-1

    --handling cursor positioning
    local mid = math.ceil(self.num_entries/2)+1
    if self.selected+mid > finish then
        local offset = self.selected - finish + mid

        --if we've overshot the end of the list then undo some of the offset
        if finish + offset > #self.list then
            offset = offset - ((finish+offset) - #self.list)
        end

        start = start + offset
        finish = finish + offset
    end

    --making sure that we don't overstep the boundaries
    if start < 1 then start = 1 end
    local overflow = finish < #self.list
    --this is necessary when the number of items in the dir is less than the max
    if not overflow then finish = #self.list end

    --adding a header to show there are items above in the list
    if start > 1 then self:append(self.wrapper_style..(start-1)..' 个文件⬆\\N\\N') end

    for i=start, finish do
        self:format_line(i, self.list[i])
    end

    if overflow then self:append('\\N'..self.wrapper_style..#self.list-finish..' 个文件⬇') end
    self.ass:update()
end

--moves the selector down the list
function scroll_list:scroll_down()
    if self.selected < #self.list then
        self.selected = self.selected + 1
        self:update_ass()
    elseif self.wrap then
        self.selected = 1
        self:update_ass()
    end
end

--moves the selector up the list
function scroll_list:scroll_up()
    if self.selected > 1 then
        self.selected = self.selected - 1
        self:update_ass()
    elseif self.wrap then
        self.selected = #self.list
        self:update_ass()
    end
end

--moves the selector to the list next page
function scroll_list:move_pagedown()
    if #self.list > self.num_entries then
        self.selected = self.selected + self.num_entries
        if self.selected > #self.list then self.selected = #self.list end
        self:update_ass()
    end
end

--moves the selector to the list previous page
function scroll_list:move_pageup()
    if #self.list > self.num_entries then
        self.selected = self.selected - self.num_entries
        if self.selected < 1 then self.selected = 1 end
        self:update_ass()
    end
end

--moves the selector to the list begin
function scroll_list:move_begin()
    if #self.list > 1 then
        self.selected = 1
        self:update_ass()
    end
end

--moves the selector to the list end
function scroll_list:move_end()
    if #self.list > 1 then
        self.selected = #self.list
        self:update_ass()
    end
end

--adds the forced keybinds
function scroll_list:add_keybinds()
    for _,v in ipairs(self.keybinds) do
        mp.add_forced_key_binding(v[1], 'dynamic/'..self.ass.id..'/'..v[2], v[3], v[4])
    end
end

--removes the forced keybinds
function scroll_list:remove_keybinds()
    for _,v in ipairs(self.keybinds) do
        mp.remove_key_binding('dynamic/'..self.ass.id..'/'..v[2])
    end
end

--opens the list and sets the hidden flag
function scroll_list:open_list()
    self.hidden = false
    if not self.flag_update then self.ass:update()
    else self.flag_update = false ; self:update_ass() end
end

--closes the list and sets the hidden flag
function scroll_list:close_list()
    self.hidden = true
    self.ass:remove()
end

--modifiable function that opens the list
function scroll_list:open()
    if self.hidden then self:add_keybinds() end
    self:open_list()
end

--modifiable function that closes the list
function scroll_list:close()
    self:remove_keybinds()
    self:close_list()
end

--toggles the list
function scroll_list:toggle()
    if self.hidden then self:open()
    else self:close() end
end

--clears the list in-place
function scroll_list:clear()
    local i = 1
    while self.list[i] do
        self.list[i] = nil
        i = i + 1
    end
end

--added alias for ipairs(list.list) for lua 5.1
function scroll_list:ipairs()
    return ipairs(self.list)
end

--append item to the end of the list
function scroll_list:insert(item)
    self.list[#self.list + 1] = item
end

local metatable = {
    __index = function(t, key)
        if scroll_list[key] ~= nil then return scroll_list[key]
        elseif key == "__current" then return t.list[t.selected]
        elseif type(key) == "number" then return t.list[key] end
    end,
    __newindex = function(t, key, value)
        if type(key) == "number" then rawset(t.list, key, value)
        else rawset(t, key, value) end
    end,
    __scroll_list = scroll_list,
    __len = function(t) return #t.list end,
    __ipairs = function(t) return ipairs(t.list) end
}

--creates a new list object
function scroll_list:new()
    local vars
    vars = {
        ass = mp.create_osd_overlay('ass-events'),
        hidden = true,
        flag_update = true,

        header = "header \\N ----------------------------------------------",
        list = {},
        selected = 1,

        keybinds = {
            {'DOWN', 'scroll_down', function() vars:scroll_down() end, {repeatable = true}},
            {'UP', 'scroll_up', function() vars:scroll_up() end, {repeatable = true}},
            {'PGDWN', 'move_pagedown', function() vars:move_pagedown() end, {}},
            {'PGUP', 'move_pageup', function() vars:move_pageup() end, {}},
            {'HOME', 'move_begin', function() vars:move_begin() end, {}},
            {'END', 'move_end', function() vars:move_end() end, {}},
            {'ESC', 'close_browser', function() vars:close() end, {}}
        }
    }
    return setmetatable(vars, metatable)
end

return scroll_list:new()
