nirish

config files for niri window manager
Log | Files | Refs | README | LICENSE

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 }