diff options
Diffstat (limited to 'mpv/scripts/file-browser/modules/navigation')
3 files changed, 553 insertions, 0 deletions
diff --git a/mpv/scripts/file-browser/modules/navigation/cursor.lua b/mpv/scripts/file-browser/modules/navigation/cursor.lua new file mode 100644 index 0000000..84e553e --- /dev/null +++ b/mpv/scripts/file-browser/modules/navigation/cursor.lua @@ -0,0 +1,134 @@ +-------------------------------------------------------------------------------------------------------- +--------------------------------Scroll/Select Implementation-------------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +local g = require 'modules.globals' +local fb_utils = require 'modules.utils' +local ass = require 'modules.ass' + +---@class cursor +local cursor = {} + +--disables multiselect +function cursor.disable_select_mode() + g.state.multiselect_start = nil + g.state.initial_selection = nil +end + +--enables multiselect +function cursor.enable_select_mode() + g.state.multiselect_start = g.state.selected + g.state.initial_selection = fb_utils.copy_table(g.state.selection) +end + +--calculates what drag behaviour is required for that specific movement +local function drag_select(original_pos, new_pos) + if original_pos == new_pos then return end + + local setting = g.state.selection[g.state.multiselect_start or -1] + for i = original_pos, new_pos, (new_pos > original_pos and 1 or -1) do + --if we're moving the cursor away from the starting point then set the selection + --otherwise restore the original selection + if i > g.state.multiselect_start then + if new_pos > original_pos then + g.state.selection[i] = setting + elseif i ~= new_pos then + g.state.selection[i] = g.state.initial_selection[i] + end + elseif i < g.state.multiselect_start then + if new_pos < original_pos then + g.state.selection[i] = setting + elseif i ~= new_pos then + g.state.selection[i] = g.state.initial_selection[i] + end + end + end +end + +--moves the selector up and down the list by the entered amount +function cursor.scroll(n, wrap) + local num_items = #g.state.list + if num_items == 0 then return end + + local original_pos = g.state.selected + + if original_pos + n > num_items then + g.state.selected = wrap and 1 or num_items + elseif original_pos + n < 1 then + g.state.selected = wrap and num_items or 1 + else + g.state.selected = original_pos + n + end + + if g.state.multiselect_start then drag_select(original_pos, g.state.selected) end + ass.update_ass() +end + +--selects the first item in the list which is highlighted as playing +function cursor.select_playing_item() + for i,item in ipairs(g.state.list) do + if ass.highlight_entry(item) then + g.state.selected = i + return + end + end +end + +--scans the list for which item to select by default +--chooses the folder that the script just moved out of +--or, otherwise, the item highlighted as currently playing +function cursor.select_prev_directory() + -- makes use of the directory stack to more exactly select the prev directory + local down_stack = g.directory_stack.stack[g.directory_stack.position + 1] + if down_stack then + for i, item in ipairs(g.state.list) do + if fb_utils.get_new_directory(item, g.state.directory) == down_stack then + g.state.selected = i + return + end + end + end + + if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then + for i, item in ipairs(g.state.list) do + if + g.state.prev_directory:find(fb_utils.get_full_path(item), 1, true) or + g.state.prev_directory:find(fb_utils.get_new_directory(item, g.state.directory), 1, true) + then + g.state.selected = i + return + end + end + end + + cursor.select_playing_item() +end + +--toggles the selection +function cursor.toggle_selection() + if not g.state.list[g.state.selected] then return end + g.state.selection[g.state.selected] = not g.state.selection[g.state.selected] or nil + ass.update_ass() +end + +--select all items in the list +function cursor.select_all() + for i,_ in ipairs(g.state.list) do + g.state.selection[i] = true + end + ass.update_ass() +end + +--toggles select mode +function cursor.toggle_select_mode() + if g.state.multiselect_start == nil then + cursor.enable_select_mode() + cursor.toggle_selection() + else + cursor.disable_select_mode() + ass.update_ass() + end +end + +return cursor diff --git a/mpv/scripts/file-browser/modules/navigation/directory-movement.lua b/mpv/scripts/file-browser/modules/navigation/directory-movement.lua new file mode 100644 index 0000000..37e85c8 --- /dev/null +++ b/mpv/scripts/file-browser/modules/navigation/directory-movement.lua @@ -0,0 +1,209 @@ + +local mp = require 'mp' +local msg = require 'mp.msg' +local utils = require 'mp.utils' + +local o = require 'modules.options' +local g = require 'modules.globals' +local ass = require 'modules.ass' +local scanning = require 'modules.navigation.scanning' +local fb_utils = require 'modules.utils' +local cursor = require 'modules.navigation.cursor' + +---@class directory_movement +local directory_movement = {} +local NavType = scanning.NavType + +---Appends an item to the directory stack, wiping any +---directories further ahead than the current position. +---@param dir string +local function directory_stack_append(dir) + -- don't clear the stack if we're re-entering the same directory + if g.directory_stack.stack[g.directory_stack.position + 1] == dir then + g.directory_stack.position = g.directory_stack.position + 1 + return + end + + local j = #g.directory_stack.stack + while g.directory_stack.position < j do + g.directory_stack.stack[j] = nil + j = j - 1 + end + table.insert(g.directory_stack.stack, dir) + g.directory_stack.position = g.directory_stack.position + 1 +end + +---@param dir string +local function directory_stack_prepend(dir) + table.insert(g.directory_stack.stack, 1, dir) + g.directory_stack.position = 1 +end + +---Clears directories from the history +---@param from? number All entries >= this index are cleared. +---@return string[] +function directory_movement.clear_history(from) + ---@type string[] + local cleared = {} + + from = from or 1 + for i = g.history.size, from, -1 do + table.insert(cleared, g.history.list[i]) + g.history.list[i] = nil + g.history.size = g.history.size - 1 + + if g.history.position >= i then + g.history.position = g.history.position - 1 + end + end + + return cleared +end + +---Append a directory to the history +---If we have navigated backward in the history, +---then clear any history beyond the current point. +---@param directory string +function directory_movement.append_history(directory) + if g.history.list[g.history.position] == directory then + msg.debug('reloading same directory - history unchanged:', directory) + return + end + + msg.debug('appending to history:', directory) + if g.history.position < g.history.size then + directory_movement.clear_history(g.history.position + 1) + end + + table.insert(g.history.list, directory) + g.history.size = g.history.size + 1 + g.history.position = g.history.position + 1 + + if g.history.size > o.history_size then + table.remove(g.history.list, 1) + g.history.size = g.history.size - 1 + end +end + +---@param filepath string +function directory_movement.set_current_file(filepath) + --if we're in idle mode then we want to open the working directory + if filepath == nil then + g.current_file.directory = fb_utils.fix_path( mp.get_property("working-directory", ""), true) + g.current_file.name = nil + g.current_file.path = nil + g.current_file.original_path = nil + return + end + + local absolute_path = fb_utils.absolute_path(filepath) + local resolved_path = fb_utils.resolve_directory_mapping(absolute_path) + + g.current_file.directory, g.current_file.name = utils.split_path(resolved_path) + g.current_file.original_path = absolute_path + g.current_file.path = resolved_path + + if o.cursor_follows_playing_item then cursor.select_playing_item() end + ass.update_ass() +end + +--the base function for moving to a directory +---@param directory string +---@param nav_type? NavigationType +---@param store_history? boolean default `true` +---@param parse_properties? ParseProperties +---@return thread +function directory_movement.goto_directory(directory, nav_type, store_history, parse_properties) + local current = g.state.list[g.state.selected] + g.state.directory = directory + + if g.state.directory_label then + if nav_type == NavType.DOWN then + g.state.directory_label = g.state.directory_label..(current.label or current.name) + elseif nav_type == NavType.UP then + g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$") + end + end + + if o.history_size > 0 and store_history == nil or store_history then + directory_movement.append_history(directory) + end + + return scanning.rescan(nav_type or NavType.GOTO, nil, parse_properties) +end + +---Move the browser to a particular point in the browser history. +---The history is a linear list of visited directories from oldest to newest. +---If the user changes directories while the current history position is not the head of the list, +---any later directories get cleared and the new directory becomes the new head. +---@param pos number The history index to move to. Clamped to [1,history_length] +---@return number|false # The index actually moved to after clamping. Returns -1 if the index was invalid (can occur if history is empty or disabled) +function directory_movement.goto_history(pos) + if type(pos) ~= "number" then return false end + + if pos < 1 then pos = 1 + elseif pos > g.history.size then pos = g.history.size end + if not g.history.list[pos] then return false end + + g.history.position = pos + directory_movement.goto_directory(g.history.list[pos]) + return pos +end + +--loads the root list +function directory_movement.goto_root() + msg.verbose('jumping to root') + return directory_movement.goto_directory("") +end + +--switches to the directory of the currently playing file +function directory_movement.goto_current_dir() + msg.verbose('jumping to current directory') + return directory_movement.goto_directory(g.current_file.directory) +end + +--moves up a directory +function directory_movement.up_dir() + if g.state.directory == '' then return end + + local cached_parent_dir = g.directory_stack.stack[g.directory_stack.position - 1] + if cached_parent_dir then + g.directory_stack.position = g.directory_stack.position - 1 + return directory_movement.goto_directory(cached_parent_dir, NavType.UP) + end + + local parent_dir = g.state.directory:match("^(.-/+)[^/]+/*$") or "" + + if o.skip_protocol_schemes and parent_dir:find("^(%a[%w+-.]*)://$") then + return directory_movement.goto_root() + end + + directory_stack_prepend(parent_dir) + return directory_movement.goto_directory(parent_dir, NavType.UP) +end + +--moves down a directory +function directory_movement.down_dir() + local current = g.state.list[g.state.selected] + if not current or not fb_utils.parseable_item(current) then return end + + local directory, redirected = fb_utils.get_new_directory(current, g.state.directory) + directory_stack_append(directory) + return directory_movement.goto_directory(directory, redirected and NavType.REDIRECT or NavType.DOWN) +end + +--moves backwards through the directory history +function directory_movement.back_history() + msg.debug('moving backwards in history to', g.history.list[g.history.position-1]) + if g.history.position == 1 then return end + directory_movement.goto_history(g.history.position - 1) +end + +--moves forward through the history +function directory_movement.forwards_history() + msg.debug('moving forwards in history to', g.history.list[g.history.position+1]) + if g.history.position == g.history.size then return end + directory_movement.goto_history(g.history.position + 1) +end + +return directory_movement diff --git a/mpv/scripts/file-browser/modules/navigation/scanning.lua b/mpv/scripts/file-browser/modules/navigation/scanning.lua new file mode 100644 index 0000000..0923ba9 --- /dev/null +++ b/mpv/scripts/file-browser/modules/navigation/scanning.lua @@ -0,0 +1,210 @@ +local mp = require 'mp' +local msg = require 'mp.msg' +local utils = require 'mp.utils' + +local g = require 'modules.globals' +local fb_utils = require 'modules.utils' +local cursor = require 'modules.navigation.cursor' +local ass = require 'modules.ass' + +local parse_state_API = require 'modules.apis.parse-state' + +---@class scanning +local scanning = {} + +---@enum NavigationType +local NavType = { + DOWN = 1, + UP = -1, + REDIRECT = 2, + GOTO = 3, + RESCAN = 4, +} + +scanning.NavType = NavType + +---@param directory_stack? boolean +local function clear_non_adjacent_state(directory_stack) + g.state.directory_label = nil + if directory_stack then + g.directory_stack.stack = {g.state.directory} + g.directory_stack.position = 1 + end +end + +---parses the given directory or defers to the next parser if nil is returned +---@async +---@param directory string +---@param index number +---@return List? +---@return Opts? +function scanning.choose_and_parse(directory, index) + msg.debug(("finding parser for %q"):format(directory)) + ---@type Parser, List?, Opts? + local parser, list, opts + local parse_state = g.parse_states[coroutine.running() or ""] + while list == nil and not parse_state.already_deferred and index <= #g.parsers do + parser = g.parsers[index] + if parser:can_parse(directory, parse_state) then + msg.debug("attempting parser:", parser:get_id()) + list, opts = parser:parse(directory, parse_state) + end + index = index + 1 + end + if not list then return nil, {} end + + msg.debug("list returned from:", parser:get_id()) + opts = opts or {} + if list then opts.id = opts.id or parser:get_id() end + return list, opts +end + +---Sets up the parse_state table and runs the parse operation. +---@async +---@param directory string +---@param parse_state_template ParseStateTemplate +---@return List|nil +---@return Opts +local function run_parse(directory, parse_state_template) + msg.verbose(("scanning files in %q"):format(directory)) + + ---@type ParseStateFields + local parse_state = { + source = parse_state_template.source, + directory = directory, + properties = parse_state_template.properties or {} + } + + local co = coroutine.running() + g.parse_states[co] = fb_utils.set_prototype(parse_state, parse_state_API) --[[@as ParseState]] + + local list, opts = scanning.choose_and_parse(directory, 1) + + if list == nil then return msg.debug("no successful parsers found"), {} end + opts = opts or {} + opts.parser = g.parsers[opts.id] + + if not opts.filtered then fb_utils.filter(list) end + if not opts.sorted then fb_utils.sort(list) end + return list, opts +end + +---Returns the contents of the given directory using the given parse state. +---If a coroutine has already been used for a parse then create a new coroutine so that +---the every parse operation has a unique thread ID. +---@async +---@param directory string +---@param parse_state ParseStateTemplate +---@return List|nil +---@return Opts +function scanning.scan_directory(directory, parse_state) + local co = fb_utils.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state)) + if not g.parse_states[co] then return run_parse(directory, parse_state) end + + --if this coroutine is already is use by another parse operation then we create a new + --one and hand execution over to that + ---@async + local new_co = coroutine.create(function() + fb_utils.coroutine.resume_err(co, run_parse(directory, parse_state)) + end) + + --queue the new coroutine on the mpv event queue + mp.add_timeout(0, function() + local success, err = coroutine.resume(new_co) + if not success then + fb_utils.traceback(err, new_co) + fb_utils.coroutine.resume_err(co) + end + end) + return g.parse_states[co]:yield() +end + +---Sends update requests to the different parsers. +---@async +---@param moving_adjacent? number|boolean +---@param parse_properties? ParseProperties +local function update_list(moving_adjacent, parse_properties) + msg.verbose('opening directory: ' .. g.state.directory) + + g.state.selected = 1 + g.state.selection = {} + + local directory = g.state.directory + local list, opts = scanning.scan_directory(g.state.directory, { source = "browser", properties = parse_properties }) + + --if the running coroutine isn't the one stored in the state variable, then the user + --changed directories while the coroutine was paused, and this operation should be aborted + if coroutine.running() ~= g.state.co then + msg.verbose(g.ABORT_ERROR.msg) + msg.debug("expected:", g.state.directory, "received:", directory) + return + end + + --apply fallbacks if the scan failed + if not list then + msg.warn("could not read directory", g.state.directory) + list, opts = {}, {} + opts.empty_text = g.style.warning..'Error: could not parse directory' + end + + g.state.list = list + g.state.parser = opts.parser + + --setting custom options from parsers + g.state.directory_label = opts.directory_label + g.state.empty_text = opts.empty_text or g.state.empty_text + + --we assume that directory is only changed when redirecting to a different location + --therefore we need to change the `moving_adjacent` flag and clear some state values + if opts.directory then + g.state.directory = opts.directory + moving_adjacent = false + clear_non_adjacent_state(true) + end + + if opts.selected_index then + g.state.selected = opts.selected_index or g.state.selected + if g.state.selected > #g.state.list then g.state.selected = #g.state.list + elseif g.state.selected < 1 then g.state.selected = 1 end + end + + if moving_adjacent then cursor.select_prev_directory() + else cursor.select_playing_item() end + g.state.prev_directory = g.state.directory +end + +---rescans the folder and updates the list. +---@param nav_type? NavigationType +---@param cb? function +---@param parse_properties? ParseProperties +---@return thread # The coroutine for the triggered parse operation. May be aborted early if directory is in the cache. +function scanning.rescan(nav_type, cb, parse_properties) + if nav_type == nil then nav_type = NavType.RESCAN end + + --we can only make assumptions about the directory label when moving from adjacent directories + if nav_type == NavType.GOTO or nav_type == NavType.REDIRECT then + clear_non_adjacent_state(nav_type == NavType.GOTO) + end + + g.state.empty_text = "~" + g.state.list = {} + cursor.disable_select_mode() + ass.update_ass() + + --the directory is always handled within a coroutine to allow addons to + --pause execution for asynchronous operations + ---@async + local co = fb_utils.coroutine.queue(function() + update_list(nav_type, parse_properties) + if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end + + ass.update_ass() + if cb then fb_utils.coroutine.run(cb) end + end) + + g.state.co = co + return co +end + + +return scanning |
