From 0a31f394478ff651a374bc04e3635f8e5b39673f Mon Sep 17 00:00:00 2001 From: traxys Date: Sun, 15 Oct 2023 17:31:03 +0200 Subject: [PATCH] plugins/flash: init + tests (#625) --- lib/helpers.nix | 4 + plugins/default.nix | 1 + plugins/utils/flash.nix | 465 +++++++++++++++++++++ tests/test-sources/plugins/utils/flash.nix | 184 ++++++++ 4 files changed, 654 insertions(+) create mode 100644 plugins/utils/flash.nix create mode 100644 tests/test-sources/plugins/utils/flash.nix diff --git a/lib/helpers.nix b/lib/helpers.nix index e93f5714..6c307761 100644 --- a/lib/helpers.nix +++ b/lib/helpers.nix @@ -51,6 +51,10 @@ with lib; rec { then "nil" else ""; + listToUnkeyedAttrs = list: + builtins.listToAttrs + (lib.lists.imap0 (idx: lib.nameValuePair "__unkeyed-${toString idx}") list); + emptyTable = {"__empty" = null;}; highlightType = with lib.types; diff --git a/plugins/default.nix b/plugins/default.nix index 43f5f593..8196021d 100644 --- a/plugins/default.nix +++ b/plugins/default.nix @@ -99,6 +99,7 @@ ./utils/cursorline.nix ./utils/easyescape.nix ./utils/endwise.nix + ./utils/flash.nix ./utils/floaterm.nix ./utils/goyo.nix ./utils/hardtime.nix diff --git a/plugins/utils/flash.nix b/plugins/utils/flash.nix new file mode 100644 index 00000000..2e2f47ec --- /dev/null +++ b/plugins/utils/flash.nix @@ -0,0 +1,465 @@ +{ + pkgs, + config, + lib, + ... +}: +with lib; let + cfg = config.plugins.flash; + helpers = import ../helpers.nix {inherit lib;}; +in { + options.plugins.flash = let + configOpts = { + labels = helpers.defaultNullOpts.mkStr "asdfghjklqwertyuiopzxcvbnm" '' + Labels appear next to the matches, allowing you to quickly jump to any location. Labels are + guaranteed not to exist as a continuation of the search pattern. + ''; + + search = { + automatic = mkOption { + type = types.bool; + default = false; + description = '' + Automatically set the values according to context. Same as passing `search = {}` in lua + ''; + }; + + multiWindow = helpers.defaultNullOpts.mkBool true "search/jump in all windows"; + + forward = helpers.defaultNullOpts.mkBool true "search direction"; + + wrap = helpers.defaultNullOpts.mkBool true '' + when `false`, find only matches in the given direction + ''; + + mode = + helpers.defaultNullOpts.mkNullable + (with types; either (enum ["exact" "search" "fuzzy"]) helpers.rawType) ''"exact"'' '' + - exact: exact match + - search: regular search + - fuzzy: fuzzy search + - fun(str): custom search function that returns a pattern + For example, to only match at the begining of a word: + function(str) + return "\\<" .. str + end + ''; + + incremental = helpers.defaultNullOpts.mkBool false "behave like `incsearch`"; + + exclude = + helpers.defaultNullOpts.mkNullable (with types; listOf (either str helpers.rawType)) '' + [ + "notify" + "cmp_menu" + "noice" + "flash_prompt" + ( + helpers.mkRaw + ''' + function(win) + return not vim.api.nvim_win_get_config(win).focusable + end + ''' + ) + ] + '' '' + Excluded filetypes and custom window filters + ''; + + trigger = helpers.defaultNullOpts.mkStr "" '' + Optional trigger character that needs to be typed before a jump label can be used. + It's NOT recommended to set this, unless you know what you're doing + ''; + + maxLength = + helpers.defaultNullOpts.mkNullable + (with types; either (enum [false]) types.int) "false" '' + max pattern length. If the pattern length is equal to this labels will no longer be + skipped. When it exceeds this length it will either end in a jump or terminate the search + ''; + }; + + jump = { + jumplist = helpers.defaultNullOpts.mkBool true "save location in the jumplist"; + + pos = helpers.defaultNullOpts.mkEnumFirstDefault ["start" "end" "range"] "jump position"; + + history = helpers.defaultNullOpts.mkBool false "add pattern to search history"; + + register = helpers.defaultNullOpts.mkBool false "add pattern to search register"; + + nohlsearch = helpers.defaultNullOpts.mkBool false "clear highlight after jump"; + + autojump = helpers.defaultNullOpts.mkBool false '' + automatically jump when there is only one match + ''; + + inclusive = helpers.mkNullOrOption types.bool '' + You can force inclusive/exclusive jumps by setting the `inclusive` option. By default it + will be automatically set based on the mode. + ''; + + offset = helpers.mkNullOrOption types.int '' + jump position offset. Not used for range jumps. + 0: default + 1: when pos == "end" and pos < current position + ''; + }; + + label = { + uppercase = helpers.defaultNullOpts.mkBool true "allow upercase labels"; + + exclude = helpers.defaultNullOpts.mkStr "" '' + add any labels with the correct case here, that you want to exclude + ''; + + current = helpers.defaultNullOpts.mkBool true '' + add a label for the first match in the current window. + you can always jump to the first match with `` + ''; + + after = helpers.defaultNullOpts.mkNullable (with types; either bool (listOf int)) "true" '' + show the label after the match + ''; + + before = helpers.defaultNullOpts.mkNullable (with types; either bool (listOf int)) "false" '' + show the label before the match + ''; + + style = helpers.defaultNullOpts.mkEnum ["eol" "overlay" "right_align" "inline"] "overlay" '' + position of the label extmark + ''; + + reuse = helpers.defaultNullOpts.mkEnumFirstDefault ["lowercase" "all" "none"] '' + flash tries to re-use labels that were already assigned to a position, + when typing more characters. By default only lower-case labels are re-used. + ''; + + distance = helpers.defaultNullOpts.mkBool true '' + for the current window, label targets closer to the cursor first + ''; + + minPatternLength = helpers.defaultNullOpts.mkInt 0 '' + minimum pattern length to show labels + Ignored for custom labelers. + ''; + + rainbow = { + enabled = helpers.defaultNullOpts.mkBool false '' + Enable this to use rainbow colors to highlight labels + Can be useful for visualizing Treesitter ranges. + ''; + + shade = helpers.defaultNullOpts.mkNullable (types.ints.between 1 9) "5" ""; + }; + + format = + helpers.defaultNullOpts.mkStr '' + format = function(opts) + return { { opts.match.label, opts.hl_group } } + end + '' '' + With `format`, you can change how the label is rendered. + Should return a list of `[text, highlight]` tuples. + @class Flash.Format + @field state Flash.State + @field match Flash.Match + @field hl_group string + @field after boolean + @type fun(opts:Flash.Format): string[][] + ''; + }; + + highlight = { + backdrop = helpers.defaultNullOpts.mkBool true "show a backdrop with hl FlashBackdrop"; + + matches = helpers.defaultNullOpts.mkBool true "Highlight the search matches"; + + priority = helpers.defaultNullOpts.mkPositiveInt 5000 "extmark priority"; + + groups = builtins.mapAttrs (_: default: helpers.defaultNullOpts.mkStr default "") { + match = "FlashMatch"; + current = "FlashCurrent"; + backdrop = "FlashBackdrop"; + label = "FlashLabel"; + }; + }; + + action = helpers.mkNullOrOption types.str '' + action to perform when picking a label. + defaults to the jumping logic depending on the mode. + @type fun(match:Flash.Match, state:Flash.State) + ''; + + pattern = helpers.defaultNullOpts.mkStr "" "initial pattern to use when opening flash"; + + continue = helpers.defaultNullOpts.mkBool false '' + When `true`, flash will try to continue the last search + ''; + + config = helpers.mkNullOrOption types.str '' + Set config to a function to dynamically change the config + @type fun(opts:Flash.Config) + ''; + + prompt = { + enabled = helpers.defaultNullOpts.mkBool true '' + options for the floating window that shows the prompt, for regular jumps + ''; + + # Not sure what is the type... + prefix = helpers.defaultNullOpts.mkNullable (types.listOf types.anything) '' + [ ["⚡" "FlashPromptIcon"] ] + '' ""; + winConfig = helpers.defaultNullOpts.mkNullable (types.attrsOf types.anything) '' + { + relative = "editor"; + width = 1; + height = 1; + row = -1; + col = 0; + zindex = 1000; + } + '' "See nvim_open_win for more details"; + }; + + remoteOp = { + restore = helpers.defaultNullOpts.mkBool false '' + restore window views and cursor position after doing a remote operation + ''; + + motion = helpers.defaultNullOpts.mkBool false '' + For `jump.pos = "range"`, this setting is ignored. + - `true`: always enter a new motion when doing a remote operation + - `false`: use the window's cursor position and jump target + - `nil`: act as `true` for remote windows, `false` for the current window + ''; + }; + }; + in + helpers.extraOptionsOptions + // { + enable = mkEnableOption "flash.nvim"; + + package = helpers.mkPackageOption "flash.nvim" pkgs.vimPlugins.flash-nvim; + + modes = let + mkModeConfig = extra: default: desc: + helpers.defaultNullOpts.mkNullable (types.submodule { + options = configOpts // extra; + }) + default + desc; + in { + search = + mkModeConfig { + enabled = helpers.defaultNullOpts.mkBool true '' + when `true`, flash will be activated during regular search by default. + You can always toggle when searching with `require("flash").toggle()` + ''; + } '' + { + enabled = true; + highlight = { backdrop = false; }; + jump = { history = true; register = true; nohlsearch = true; }; + /* + forward will be automatically set to the search direction + mode is always set to 'search' + incremental is set to 'true' when 'incsearch' is enabled + */ + search.automatic = true; + } + '' '' + options used when flash is activated through a regular search with `/` or `?` + ''; + char = + mkModeConfig { + enabled = helpers.defaultNullOpts.mkBool true ""; + + autohide = helpers.defaultNullOpts.mkBool false '' + hide after jump when not using jump labels + ''; + + jumpLabels = helpers.defaultNullOpts.mkBool false "show jump labels"; + + multiLine = helpers.defaultNullOpts.mkBool true '' + set to `false` to use the current line only + ''; + + keys = + helpers.defaultNullOpts.mkNullable (types.attrsOf types.str) '' + helpers.listToUnkeyedAttrs [ "f" "F" "t" "T" ";" "," ] + '' '' + by default all keymaps are enabled, but you can disable some of them, + by removing them from the list. + If you rather use another key, you can map them + to something else, e.g., `{ ";" = "L"; "," = "H"; }` + ''; + + charActions = + helpers.defaultNullOpts.mkStr '' + function(motion) + return { + [";"] = "next", -- set to right to always go right + [","] = "prev", -- set to left to always go left + -- clever-f style + [motion:lower()] = "next", + [motion:upper()] = "prev", + -- jump2d style: same case goes next, opposite case goes prev + -- [motion] = "next", + -- [motion:match("%l") and motion:upper() or motion:lower()] = "prev", + } + end + '' '' + The direction for `prev` and `next` is determined by the motion. + `left` and `right` are always left and right. + ''; + } '' + { + enabled = true; + /* dynamic configuration for ftFT motions */ + config = ''' + function(opts) + -- autohide flash when in operator-pending mode + opts.autohide = vim.fn.mode(true):find("no") and vim.v.operator == "y" + + -- disable jump labels when not enabled, when using a count, + -- or when recording/executing registers + opts.jump_labels = opts.jump_labels + and vim.v.count == 0 + and vim.fn.reg_executing() == "" + and vim.fn.reg_recording() == "" + + -- Show jump labels only in operator-pending mode + -- opts.jump_labels = vim.v.count == 0 and vim.fn.mode(true):find("o") + end + '''; + autohide = false; + jumpLabels = false; + multiLine = false; + label = { exclude = "hjkliardc"; }; + keys = helpers.listToUnkeyedAttrs [ "f" "F" "t" "T" ";" "," ]; + charActions = ''' + function(motion) + return { + [";"] = "next", -- set to right to always go right + [","] = "prev", -- set to left to always go left + -- clever-f style + [motion:lower()] = "next", + [motion:upper()] = "prev", + -- jump2d style: same case goes next, opposite case goes prev + -- [motion] = "next", + -- [motion:match("%l") and motion:upper() or motion:lower()] = "prev", + } + end + '''; + search = { wrap = false; }; + highlight = { backdrop = true; }; + jump = { register = false; }; + } + '' "options used when flash is activated through a regular search with `/` or `?`"; + treesitter = + mkModeConfig {} '' + { + labels = "abcdefghijklmnopqrstuvwxyz"; + jump = { pos = "range"; }; + search = { incremental = false; }; + label = { before = true; after = true; style = "inline"; }; + highlight = { backdrop = false; matches = false; }; + }helpers.ifNonNull' + '' '' + options used for treesitter selections `require("flash").treesitter()` + ''; + treesitterSearch = mkModeConfig {} '' + { + jump = { pos = "range"; }; + search = { multiWindow = true; wrap = true; incremental = false; }; + remoteOp = { restore = true }; + label = { before = true; after = true; style = "inline"; }; + } + '' ""; + remote = mkModeConfig {} '' + { remoteOp = { restore = true; motion = true; }; } + '' "options used for remote flash"; + }; + } + // configOpts; + + config = let + mkRawIfNonNull = v: helpers.ifNonNull' v (helpers.mkRaw v); + + mkGlobalConfig = c: { + inherit (c) labels; + search = + if c.search.automatic + then helpers.emptyTable + else { + multi_window = c.search.multiWindow; + inherit (c.search) forward wrap mode incremental exclude trigger; + max_length = c.search.maxLength; + }; + jump = { + inherit (c.jump) jumplist pos history register nohlsearch autojump inclusive offset; + }; + label = { + inherit (c.label) uppercase exclude current after before style reuse distance; + min_pattern_length = c.label.minPatternLength; + rainbow = { + inherit (c.label.rainbow) enabled shade; + }; + format = mkRawIfNonNull c.label.format; + }; + highlight = { + inherit (c.highlight) backdrop matches priority groups; + }; + action = mkRawIfNonNull c.action; + inherit (c) pattern continue; + config = mkRawIfNonNull c.config; + prompt = { + inherit (c.prompt) enabled prefix; + win_config = c.prompt.winConfig; + }; + remote_op = { + inherit (c.remoteOp) restore motion; + }; + }; + + options = + (mkGlobalConfig cfg) + // { + modes = let + mkModeConfig = c: extra: + helpers.ifNonNull' c ( + (mkGlobalConfig c) // (extra c) + ); + in { + search = + mkModeConfig cfg.modes.search + (c: { + inherit (c) enabled; + }); + char = + mkModeConfig cfg.modes.char + (c: { + inherit (c) enabled autohide; + jump_labels = c.jumpLabels; + multi_line = c.multiLine; + inherit (c) keys; + char_actions = mkRawIfNonNull c.charActions; + }); + treesitter = mkModeConfig cfg.modes.treesitter (c: {}); + treesitter_search = mkModeConfig cfg.modes.treesitterSearch (c: {}); + remote = mkModeConfig cfg.modes.remote (c: {}); + }; + } + // cfg.extraOptions; + in + mkIf cfg.enable { + extraPlugins = [cfg.package]; + + extraConfigLua = '' + require('flash').setup(${helpers.toLuaObject options}) + ''; + }; +} diff --git a/tests/test-sources/plugins/utils/flash.nix b/tests/test-sources/plugins/utils/flash.nix new file mode 100644 index 00000000..72468c9f --- /dev/null +++ b/tests/test-sources/plugins/utils/flash.nix @@ -0,0 +1,184 @@ +{helpers, ...}: { + empty = { + plugins.flash.enable = true; + }; + + defaults = { + plugins.flash = { + enable = true; + + labels = "asdfghjklqwertyuiopzxcvbnm"; + search = { + multiWindow = true; + forward = true; + wrap = true; + mode = "exact"; + incremental = false; + exclude = [ + "notify" + "cmp_menu" + "noice" + "flash_prompt" + { + __raw = '' + function(win) + -- exclude non-focusable windows + return not vim.api.nvim_win_get_config(win).focusable + end + ''; + } + ]; + trigger = ""; + maxLength = false; + }; + jump = { + jumplist = true; + pos = "start"; + history = false; + register = false; + nohlsearch = false; + autojump = false; + inclusive = null; + offset = null; + }; + label = { + uppercase = true; + exclude = ""; + current = true; + after = true; + before = false; + style = "overlay"; + reuse = "lowercase"; + distance = true; + minPatternLength = 0; + rainbow = { + enabled = false; + shade = 5; + }; + format = '' + function(opts) + return { { opts.match.label, opts.hl_group } } + end + ''; + }; + highlight = { + backdrop = true; + matches = true; + priority = 5000; + groups = { + match = "FlashMatch"; + current = "FlashCurrent"; + backdrop = "FlashBackdrop"; + label = "FlashLabel"; + }; + }; + action = null; + pattern = ""; + continue = false; + config = null; + prompt = { + enabled = true; + prefix = [["⚡" "FlashPromptIcon"]]; + winConfig = { + relative = "editor"; + width = 1; + height = 1; + row = -1; + col = 0; + zindex = 1000; + }; + }; + remoteOp = { + restore = false; + motion = false; + }; + modes = { + search = { + enabled = true; + highlight = {backdrop = false;}; + jump = { + history = true; + register = true; + nohlsearch = true; + }; + search.automatic = true; + }; + char = { + enabled = true; + config = '' + function(opts) + -- autohide flash when in operator-pending mode + opts.autohide = vim.fn.mode(true):find("no") and vim.v.operator == "y" + + -- disable jump labels when not enabled, when using a count, + -- or when recording/executing registers + opts.jump_labels = opts.jump_labels + and vim.v.count == 0 + and vim.fn.reg_executing() == "" + and vim.fn.reg_recording() == "" + + -- Show jump labels only in operator-pending mode + -- opts.jump_labels = vim.v.count == 0 and vim.fn.mode(true):find("o") + end + ''; + autohide = false; + jumpLabels = false; + multiLine = true; + label = {exclude = "hjkliardc";}; + keys = helpers.listToUnkeyedAttrs ["f" "F" "t" "T" ";" ","]; + charActions = '' + function(motion) + return { + [";"] = "next", -- set to `right` to always go right + [","] = "prev", -- set to `left` to always go left + -- clever-f style + [motion:lower()] = "next", + [motion:upper()] = "prev", + -- jump2d style: same case goes next, opposite case goes prev + -- [motion] = "next", + -- [motion:match("%l") and motion:upper() or motion:lower()] = "prev", + } + end + ''; + search = {wrap = false;}; + highlight = {backdrop = true;}; + jump = {register = false;}; + }; + treesitter = { + labels = "abcdefghijklmnopqrstuvwxyz"; + jump = {pos = "range";}; + search = {incremental = false;}; + label = { + before = true; + after = true; + style = "inline"; + }; + highlight = { + backdrop = false; + matches = false; + }; + }; + treesitterSearch = { + jump = {pos = "range";}; + search = { + multiWindow = true; + wrap = true; + incremental = false; + }; + remoteOp = {restore = true;}; + label = { + before = true; + after = true; + style = "inline"; + }; + }; + remote = { + remoteOp = { + restore = true; + motion = true; + }; + }; + }; + }; + }; +}