keybinds.lua (14295B)
1 ------------------------------------------------------------------------------------------ 2 ----------------------------------Keybind Implementation---------------------------------- 3 ------------------------------------------------------------------------------------------ 4 ------------------------------------------------------------------------------------------ 5 6 local mp = require 'mp' 7 local msg = require 'mp.msg' 8 local utils = require 'mp.utils' 9 10 local o = require 'modules.options' 11 local g = require 'modules.globals' 12 local fb_utils = require 'modules.utils' 13 local addons = require 'modules.addons' 14 local playlist = require 'modules.playlist' 15 local controls = require 'modules.controls' 16 local movement = require 'modules.navigation.directory-movement' 17 local scanning = require 'modules.navigation.scanning' 18 local cursor = require 'modules.navigation.cursor' 19 20 g.state.keybinds = { 21 {'ENTER', 'play', function() playlist.add_files('replace', false) end}, 22 {'Shift+ENTER', 'play_append', function() playlist.add_files('append-play', false) end}, 23 {'Alt+ENTER', 'play_autoload', function() playlist.add_files('replace', true) end}, 24 {'ESC', 'close', controls.escape}, 25 {'RIGHT', 'down_dir', movement.down_dir}, 26 {'LEFT', 'up_dir', movement.up_dir}, 27 {'Alt+RIGHT', 'history_forward', movement.forwards_history}, 28 {'Alt+LEFT', 'history_back', movement.back_history}, 29 {'DOWN', 'scroll_down', function() cursor.scroll(1, o.wrap) end, {repeatable = true}}, 30 {'UP', 'scroll_up', function() cursor.scroll(-1, o.wrap) end, {repeatable = true}}, 31 {'PGDWN', 'page_down', function() cursor.scroll(o.num_entries) end, {repeatable = true}}, 32 {'PGUP', 'page_up', function() cursor.scroll(-o.num_entries) end, {repeatable = true}}, 33 {'Shift+PGDWN', 'list_bottom', function() cursor.scroll(math.huge) end}, 34 {'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end}, 35 {'HOME', 'goto_current', movement.goto_current_dir}, 36 {'Shift+HOME', 'goto_root', movement.goto_root}, 37 {'Ctrl+r', 'reload', function() scanning.rescan() end}, 38 {'s', 'select_mode', cursor.toggle_select_mode}, 39 {'S', 'select_item', cursor.toggle_selection}, 40 {'Ctrl+a', 'select_all', cursor.select_all} 41 } 42 43 ---a map of key-keybinds - only saves the latest keybind if multiple have the same key code 44 ---@type KeybindList 45 local top_level_keys = {} 46 47 ---Format the item string for either single or multiple items. 48 ---@param base_code_fn Replacer 49 ---@param items Item[] 50 ---@param state State 51 ---@param cmd Keybind 52 ---@param quoted? boolean 53 ---@return string|nil 54 local function create_item_string(base_code_fn, items, state, cmd, quoted) 55 if not items[1] then return end 56 local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn 57 58 local out = {} 59 for _, item in ipairs(items) do 60 table.insert(out, func(item, state)) 61 end 62 63 return table.concat(out, cmd['concat-string'] or ' ') 64 end 65 66 local KEYBIND_CODE_PATTERN = fb_utils.get_code_pattern(fb_utils.code_fns) 67 local item_specific_codes = 'fnij' 68 69 ---Replaces codes in the given string using the replacers. 70 ---@param str string 71 ---@param cmd Keybind 72 ---@param items Item[] 73 ---@param state State 74 ---@return string 75 local function substitute_codes(str, cmd, items, state) 76 ---@type ReplacerTable 77 local overrides = {} 78 79 for code in item_specific_codes:gmatch('.') do 80 overrides[code] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd) end 81 overrides[code:upper()] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd, true) end 82 end 83 84 return fb_utils.substitute_codes(str, overrides, items[1], state) 85 end 86 87 ---Iterates through the command table and substitutes special 88 ---character codes for the correct strings used for custom functions. 89 ---@param cmd Keybind 90 ---@param items Item[] 91 ---@param state State 92 ---@return KeybindCommand 93 local function format_command_table(cmd, items, state) 94 local command = cmd.command 95 if type(command) == 'function' then return command end 96 ---@type string[][] 97 local copy = {} 98 for i = 1, #command do 99 ---@type string[] 100 copy[i] = {} 101 102 for j = 1, #command[i] do 103 copy[i][j] = substitute_codes(cmd.command[i][j], cmd, items, state) 104 end 105 end 106 return copy 107 end 108 109 ---Runs all of the commands in the command table. 110 ---@param cmd Keybind key.command must be an array of command tables compatible with mp.command_native 111 ---@param items Item[] must be an array of multiple items (when multi-type ~= concat the array will be 1 long). 112 ---@param state State 113 local function run_custom_command(cmd, items, state) 114 local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command 115 if type(custom_cmds) == 'function' then 116 error(('attempting to run a function keybind as a command table keybind\n%s'):format(utils.to_string(cmd))) 117 end 118 119 for _, custom_cmd in ipairs(custom_cmds) do 120 msg.debug("running command:", utils.to_string(custom_cmd)) 121 mp.command_native(custom_cmd) 122 end 123 end 124 125 ---returns true if the given code set has item specific codes (%f, %i, etc) 126 ---@param codes Set<string> 127 ---@return boolean 128 local function has_item_codes(codes) 129 for code in pairs(codes) do 130 if item_specific_codes:find(code:lower(), 1, true) then return true end 131 end 132 return false 133 end 134 135 ---Runs one of the custom commands. 136 ---@async 137 ---@param cmd Keybind 138 ---@param state State 139 ---@param co thread 140 ---@return boolean|nil 141 local function run_custom_keybind(cmd, state, co) 142 --evaluates a condition and passes through the correct values 143 local function evaluate_condition(condition, items) 144 local cond = substitute_codes(condition, cmd, items, state) 145 return fb_utils.evaluate_string('return '..cond) == true 146 end 147 148 -- evaluates the string condition to decide if the keybind should be run 149 ---@type boolean 150 local do_item_condition 151 if cmd.condition then 152 if has_item_codes(cmd.condition_codes) then 153 do_item_condition = true 154 elseif not evaluate_condition(cmd.condition, {}) then 155 return false 156 end 157 end 158 159 if cmd.parser then 160 local parser_str = ' '..cmd.parser..' ' 161 if not parser_str:find( '%W'..(state.parser.keybind_name or state.parser.name)..'%W' ) then return false end 162 end 163 164 --these are for the default keybinds, or from addons which use direct functions 165 if type(cmd.command) == 'function' then return cmd.command(cmd, cmd.addon and fb_utils.copy_table(state) or state, co) end 166 167 --the function terminates here if we are running the command on a single item 168 if not (cmd.multiselect and next(state.selection)) then 169 if cmd.filter then 170 if not state.list[state.selected] then return false end 171 if state.list[state.selected].type ~= cmd.filter then return false end 172 end 173 174 if cmd.codes then 175 --if the directory is empty, and this command needs to work on an item, then abort and fallback to the next command 176 if not state.list[state.selected] and has_item_codes(cmd.codes) then return false end 177 end 178 179 if do_item_condition and not evaluate_condition(cmd.condition, { state.list[state.selected] }) then 180 return false 181 end 182 run_custom_command(cmd, { state.list[state.selected] }, state) 183 return true 184 end 185 186 --runs the command on all multi-selected items 187 local selection = fb_utils.sort_keys(state.selection, function(item) 188 if do_item_condition and not evaluate_condition(cmd.condition, { item }) then return false end 189 return not cmd.filter or item.type == cmd.filter 190 end) 191 if not next(selection) then return false end 192 193 if cmd["multi-type"] == "concat" then 194 run_custom_command(cmd, selection, state) 195 196 elseif cmd["multi-type"] == "repeat" or cmd["multi-type"] == nil then 197 for i,_ in ipairs(selection) do 198 run_custom_command(cmd, {selection[i]}, state) 199 200 if cmd.delay then 201 mp.add_timeout(cmd.delay, function() fb_utils.coroutine.resume_err(co) end) 202 coroutine.yield() 203 end 204 end 205 end 206 207 --we passthrough by default if the command is not run on every selected item 208 if cmd.passthrough ~= nil then return end 209 210 local num_selection = 0 211 for _ in pairs(state.selection) do num_selection = num_selection+1 end 212 return #selection == num_selection 213 end 214 215 ---Recursively runs the keybind functions, passing down through the chain 216 ---of keybinds with the same key value. 217 ---@async 218 ---@param keybind Keybind 219 ---@param state State 220 ---@param co thread 221 local function run_keybind_recursive(keybind, state, co) 222 msg.trace("Attempting custom command:", utils.to_string(keybind)) 223 224 if keybind.passthrough ~= nil then 225 run_custom_keybind(keybind, state, co) 226 if keybind.passthrough == true and keybind.prev_key then 227 run_keybind_recursive(keybind.prev_key, state, co) 228 end 229 else 230 if run_custom_keybind(keybind, state, co) == false and keybind.prev_key then 231 run_keybind_recursive(keybind.prev_key, state, co) 232 end 233 end 234 end 235 236 ---A wrapper to run a custom keybind as a lua coroutine. 237 ---@param key Keybind 238 local function run_keybind_coroutine(key) 239 msg.debug("Received custom keybind "..key.key) 240 local co = coroutine.create(run_keybind_recursive) 241 242 local state_copy = { 243 directory = g.state.directory, 244 directory_label = g.state.directory_label, 245 list = g.state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables 246 selected = g.state.selected, 247 selection = fb_utils.copy_table(g.state.selection), 248 parser = g.state.parser, 249 } 250 local success, err = coroutine.resume(co, key, state_copy, co) 251 if not success then 252 msg.error("error running keybind:", utils.to_string(key)) 253 fb_utils.traceback(err, co) 254 end 255 end 256 257 ---Scans the given command table to identify if they contain any custom keybind codes. 258 ---@param command_table KeybindCommand 259 ---@param codes Set<string> 260 ---@return Set<string> 261 local function scan_for_codes(command_table, codes) 262 if type(command_table) ~= "table" then return codes end 263 for _, value in pairs(command_table) do 264 local type = type(value) 265 if type == "table" then 266 scan_for_codes(value, codes) 267 elseif type == "string" then 268 for code in value:gmatch(KEYBIND_CODE_PATTERN) do 269 codes[code] = true 270 end 271 end 272 end 273 return codes 274 end 275 276 ---Inserting the custom keybind into the keybind array for declaration when file-browser is opened. 277 ---Custom keybinds with matching names will overwrite eachother. 278 ---@param keybind Keybind 279 local function insert_custom_keybind(keybind) 280 -- api checking for the keybinds is optional, so set to a valid version if it does not exist 281 keybind.api_version = keybind.api_version or '1.0.0' 282 if not addons.check_api_version(keybind, 'keybind '..keybind.name) then return end 283 284 local command = keybind.command 285 286 --we'll always save the keybinds as either an array of command arrays or a function 287 if type(command) == "table" and type(command[1]) ~= "table" then 288 keybind.command = {command} 289 end 290 291 keybind.codes = scan_for_codes(keybind.command, {}) 292 if not next(keybind.codes) then keybind.codes = nil end 293 keybind.prev_key = top_level_keys[keybind.key] 294 295 if keybind.condition then 296 keybind.condition_codes = {} 297 for code in string.gmatch(keybind.condition, KEYBIND_CODE_PATTERN) do keybind.condition_codes[code] = true end 298 end 299 300 table.insert(g.state.keybinds, {keybind.key, keybind.name, function() run_keybind_coroutine(keybind) end, keybind.flags or {}}) 301 top_level_keys[keybind.key] = keybind 302 end 303 304 ---Loading the custom keybinds. 305 ---Can either load keybinds from the config file, from addons, or from both. 306 local function setup_keybinds() 307 --this is to make the default keybinds compatible with passthrough from custom keybinds 308 for _, keybind in ipairs(g.state.keybinds) do 309 top_level_keys[keybind[1]] = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] } 310 end 311 312 --this loads keybinds from addons 313 for i = #g.parsers, 1, -1 do 314 local parser = g.parsers[i] 315 if parser.keybinds then 316 for i, keybind in ipairs(parser.keybinds) do 317 --if addons use the native array command format, then we need to convert them over to the custom command format 318 if not keybind.key then keybind = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] } 319 else keybind = fb_utils.copy_table(keybind) end 320 321 keybind.name = g.parsers[parser].id.."/"..(keybind.name or tostring(i)) 322 keybind.addon = true 323 insert_custom_keybind(keybind) 324 end 325 end 326 end 327 328 --loads custom keybinds from file-browser-keybinds.json 329 if o.custom_keybinds then 330 local path = mp.command_native({"expand-path", o.custom_keybinds_file}) --[[@as string]] 331 local custom_keybinds, err = io.open( path ) 332 if not custom_keybinds then 333 msg.debug(err) 334 msg.verbose('could not read custom keybind file', path) 335 return 336 end 337 338 local json = custom_keybinds:read("*a") 339 custom_keybinds:close() 340 341 json = utils.parse_json(json) 342 if not json then return error("invalid json syntax for "..path) end 343 344 for i, keybind in ipairs(json --[[@as KeybindList]]) do 345 keybind.name = "custom/"..(keybind.name or tostring(i)) 346 insert_custom_keybind(keybind) 347 end 348 end 349 end 350 351 ---@class keybinds 352 return { 353 setup_keybinds = setup_keybinds, 354 }