--- *mini.indentscope* Visualize and work with indent scope --- *MiniIndentscope* --- --- MIT License Copyright (c) 2022 Evgeni Chasnovski --- --- ============================================================================== --- --- Indent scope (or just "scope") is a maximum set of consecutive lines which --- contains certain reference line (cursor line by default) and every member --- has indent not less than certain reference indent ("indent at cursor" by --- default: minimum between cursor column and indent of cursor line). --- --- Features: --- - Visualize scope with animated vertical line. It is very fast and done --- automatically in a non-blocking way (other operations can be performed, --- like moving cursor). You can customize debounce delay and animation rule. --- --- - Customization of scope computation options can be done on global level --- (in |MiniIndentscope.config|), for a certain buffer (using --- `vim.b.miniindentscope_config` buffer variable), or within a call (using --- `opts` variable in |MiniIndentscope.get_scope|). --- --- - Customizable notion of a border: which adjacent lines with strictly lower --- indent are recognized as such. This is useful for a certain filetypes --- (for example, Python or plain text). --- --- - Customizable way of line to be considered "border first". This is useful --- if you want to place cursor on function header and get scope of its body. --- --- - There are textobjects and motions to operate on scope. Support |count| --- and dot-repeat (in operator pending mode). --- --- # Setup~ --- --- This module needs a setup with `require('mini.indentscope').setup({})` --- (replace `{}` with your `config` table). It will create global Lua table --- `MiniIndentscope` which you can use for scripting or manually (with `:lua --- MiniIndentscope.*`). --- --- See |MiniIndentscope.config| for available config settings. --- --- You can override runtime config settings locally to buffer inside --- `vim.b.miniindentscope_config` which should have same structure as --- `MiniIndentscope.config`. See |mini.nvim-buffer-local-config| for more details. --- --- # Comparisons~ --- --- - 'lukas-reineke/indent-blankline.nvim': --- - Its main functionality is about showing static guides of indent levels. --- - Implementation of 'mini.indentscope' is similar to --- 'indent-blankline.nvim' (using |extmarks| on first column to be shown --- even on blank lines). They can be used simultaneously, but it will --- lead to one of the visualizations being on top (hiding) of another. --- --- # Highlight groups~ --- --- * `MiniIndentscopeSymbol` - symbol showing on every line of scope if its --- indent is multiple of 'shiftwidth'. --- * `MiniIndentscopeSymbolOff` - symbol showing on every line of scope if its --- indent is not multiple of 'shiftwidth'. --- Default: links to `MiniIndentscopeSymbol`. --- --- To change any highlight group, modify it directly with |:highlight|. --- --- # Disabling~ --- --- To disable autodrawing, set `vim.g.miniindentscope_disable` (globally) or --- `vim.b.miniindentscope_disable` (for a buffer) to `true`. Considering high --- number of different scenarios and customization intentions, writing exact --- rules for disabling module's functionality is left to user. See --- |mini.nvim-disabling-recipes| for common recipes. --- Drawing of scope indicator --- --- Draw of scope indicator is done as iterative animation. It has the --- following design: --- - Draw indicator on origin line (where cursor is at) immediately. Indicator --- is visualized as `MiniIndentscope.config.symbol` placed to the right of --- scope's border indent. This creates a line from top to bottom scope edges. --- - Draw upward and downward concurrently per one line. Progression by one --- line in both direction is considered to be one step of animation. --- - Before each step wait certain amount of time, which is decided by --- "animation function". It takes next and total step numbers (both are one --- or bigger) and returns number of milliseconds to wait before drawing next --- step. Comparing to a more popular "easing functions" in animation (input: --- duration since animation start; output: percent of animation done), it is --- a discrete inverse version of its derivative. Such interface proved to be --- more appropriate for kind of task at hand. --- --- Special cases~ --- --- - When scope to be drawn intersects (same indent, ranges overlap) currently --- visible one (at process or finished drawing), drawing is done immediately --- without animation. With most common example being typing new text, this --- feels more natural. --- - Scope for the whole buffer is not drawn as it is isually redundant. --- Technically, it can be thought as drawn at column 0 (because border --- indent is -1) which is not visible. ---@tag MiniIndentscope-drawing -- Module definition ========================================================== local MiniIndentscope = {} local H = {} --- Module setup --- ---@param config table|nil Module config table. See |MiniIndentscope.config|. --- ---@usage `require('mini.indentscope').setup({})` (replace `{}` with your `config` table) MiniIndentscope.setup = function(config) -- Export module _G.MiniIndentscope = MiniIndentscope -- Setup config config = H.setup_config(config) -- Apply config H.apply_config(config) -- Define behavior H.create_autocommands() -- Create default highlighting H.create_default_hl() end --- Module config --- --- Default values: ---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) ---@text # Options ~ --- --- - Options can be supplied globally (from this `config`), locally to buffer --- (via `options` field of `vim.b.miniindentscope_config` buffer variable), --- or locally to call (as argument to |MiniIndentscope.get_scope()|). --- --- - Option `border` controls which line(s) with smaller indent to categorize --- as border. This matters for textobjects and motions. --- It also controls how empty lines are treated: they are included in scope --- only if followed by a border. Another way of looking at it is that indent --- of blank line is computed based on value of `border` option. --- Here is an illustration of how `border` works in presence of empty lines: --- > --- |both|bottom|top|none| --- 1|function foo() | 0 | 0 | 0 | 0 | --- 2| | 4 | 0 | 4 | 0 | --- 3| print('Hello world') | 4 | 4 | 4 | 4 | --- 4| | 4 | 4 | 2 | 2 | --- 5| end | 2 | 2 | 2 | 2 | --- < --- Numbers inside a table are indent values of a line computed with certain --- value of `border`. So, for example, a scope with reference line 3 and --- right-most column has body range depending on value of `border` option: --- - `border` is "both": range is 2-4, border is 1 and 5 with indent 2. --- - `border` is "top": range is 2-3, border is 1 with indent 0. --- - `border` is "bottom": range is 3-4, border is 5 with indent 0. --- - `border` is "none": range is 3-3, border is empty with indent `nil`. --- --- - Option `indent_at_cursor` controls if cursor position should affect --- computation of scope. If `true`, reference indent is a minimum of --- reference line's indent and cursor column. In main example, here how --- scope's body range differs depending on cursor column and `indent_at_cursor` --- value (assuming cursor is on line 3 and it is whole buffer): --- > --- Column\Option true|false --- 1 and 2 2-5 | 2-4 --- 3 and more 2-4 | 2-4 --- < --- - Option `try_as_border` controls how to act when input line can be --- recognized as a border of some neighbor indent scope. In main example, --- when input line is 1 and can be recognized as border for inner scope, --- value `try_as_border = true` means that inner scope will be returned. --- Similar, for input line 5 inner scope will be returned if it is --- recognized as border. MiniIndentscope.config = { -- Draw options draw = { -- Delay (in ms) between event and start of drawing scope indicator delay = 100, -- Animation rule for scope's first drawing. A function which, given -- next and total step numbers, returns wait time (in ms). See -- |MiniIndentscope.gen_animation| for builtin options. To disable -- animation, use `require('mini.indentscope').gen_animation.none()`. --minidoc_replace_start animation = --, animation = function(s, n) return 20 end, --minidoc_replace_end -- Symbol priority. Increase to display on top of more symbols. priority = 2, }, -- Module mappings. Use `''` (empty string) to disable one. mappings = { -- Textobjects object_scope = 'ii', object_scope_with_border = 'ai', -- Motions (jump to respective border line; if not present - body line) goto_top = '[i', goto_bottom = ']i', }, -- Options which control scope computation options = { -- Type of scope's border: which line(s) with smaller indent to -- categorize as border. Can be one of: 'both', 'top', 'bottom', 'none'. border = 'both', -- Whether to use cursor column when computing reference indent. -- Useful to see incremental scopes with horizontal cursor movements. indent_at_cursor = true, -- Whether to first check input line to be a border of adjacent scope. -- Use it if you want to place cursor on function header to get scope of -- its body. try_as_border = false, }, -- Which character to use for drawing scope indicator symbol = '╎', } --minidoc_afterlines_end -- Module functionality ======================================================= --- Compute indent scope --- --- Indent scope (or just "scope") is a maximum set of consecutive lines which --- contains certain reference line (cursor line by default) and every member --- has indent not less than certain reference indent ("indent at column" by --- default). Here "indent at column" means minimum between input column value --- and indent of reference line. When using cursor column, this allows for a --- useful interactive view of nested indent scopes by making horizontal --- movements within line. --- --- Options controlling actual computation is taken from these places in order: --- - Argument `opts`. Use it to ensure independence from other sources. --- - Buffer local variable `vim.b.miniindentscope_config` (`options` field). --- Useful to define local behavior (for example, for a certain filetype). --- - Global options from |MiniIndentscope.config|. --- --- Algorithm overview~ --- --- - Compute reference "indent at column". Reference line is an input `line` --- which might be modified to one of its neighbors if `try_as_border` option --- is `true`: if it can be viewed as border of some neighbor scope, it will. --- - Process upwards and downwards from reference line to search for line with --- indent strictly less than reference one. This is like casting rays up and --- down from reference line and reference indent until meeting "a wall" --- (character to the right of indent or buffer edge). Latest line before --- meeting is a respective end of scope body. It always exists because --- reference line is a such one. --- - Based on top and bottom lines with strictly lower indent, construct --- scopes's border. The way it is computed is decided based on `border` --- option (see |MiniIndentscope.config| for more information). --- - Compute border indent as maximum indent of border lines (or reference --- indent minus one in case of no border). This is used during drawing --- visual indicator. --- --- Indent computation~ --- --- For every line indent is intended to be computed unambiguously: --- - For "normal" lines indent is an output of |indent()|. --- - Indent is `-1` for imaginary lines 0 and past last line. --- - For blank and empty lines indent is computed based on previous --- (|prevnonblank()|) and next (|nextnonblank()|) non-blank lines. The way --- it is computed is decided based on `border` in order to not include blank --- lines at edge of scope's body if there is no border there. See --- |MiniIndentscope.config| for a details example. --- ---@param line number|nil Input line number (starts from 1). Can be modified to a --- neighbor if `try_as_border` is `true`. Default: cursor line. ---@param col number|nil Column number (starts from 1). Default: if --- `indent_at_cursor` option is `true` - cursor column from `curswant` of --- |getcurpos()| (allows for more natural behavior on empty lines); --- `math.huge` otherwise in order to not incorporate cursor in computation. ---@param opts table|nil Options to override global or buffer local ones (see --- |MiniIndentscope.config|). --- ---@return table Table with scope information: --- - - table with (top line of scope, inclusive), --- (bottom line of scope, inclusive), and (minimum indent within --- scope) keys. Line numbers start at 1. --- - - table with (line of top border, might be `nil`), --- (line of bottom border, might be `nil`), and (indent --- of border) keys. Line numbers start at 1. --- - - identifier of current buffer. --- - - table with (reference line), (reference --- column), and ("indent at column") keys. MiniIndentscope.get_scope = function(line, col, opts) opts = H.get_config({ options = opts }).options -- Compute default `line` and\or `col` if not (line and col) then local curpos = vim.fn.getcurpos() line = line or curpos[2] line = opts.try_as_border and H.border_correctors[opts.border](line, opts) or line -- Use `curpos[5]` (`curswant`, see `:h getcurpos()`) to account for blank -- and empty lines. col = col or (opts.indent_at_cursor and curpos[5] or math.huge) end -- Compute "indent at column" local line_indent = H.get_line_indent(line, opts) local indent = math.min(col, line_indent) -- Make early return local body = { indent = indent } if indent <= 0 then body.top, body.bottom, body.indent = 1, vim.fn.line('$'), line_indent else local up_min_indent, down_min_indent body.top, up_min_indent = H.cast_ray(line, indent, 'up', opts) body.bottom, down_min_indent = H.cast_ray(line, indent, 'down', opts) body.indent = math.min(line_indent, up_min_indent, down_min_indent) end return { body = body, border = H.border_from_body[opts.border](body, opts), buf_id = vim.api.nvim_get_current_buf(), reference = { line = line, column = col, indent = indent }, } end --- Draw scope manually --- --- Scope is visualized as a vertical line within scope's body range at column --- equal to border indent plus one (or body indent if border is absent). --- Numbering starts from one. --- ---@param scope table|nil Scope. Default: output of |MiniIndentscope.get_scope| --- with default arguments. ---@param opts table|nil Options. Currently supported: --- - - animation function for drawing. See --- |MiniIndentscope-drawing| and |MiniIndentscope.gen_animation|. --- - - priority number for visualization. See `priority` option --- for |nvim_buf_set_extmark()|. MiniIndentscope.draw = function(scope, opts) scope = scope or MiniIndentscope.get_scope() local config = H.get_config() local draw_opts = vim.tbl_deep_extend('force', { animation_fun = config.draw.animation, priority = config.draw.priority }, opts or {}) H.undraw_scope() H.current.scope = scope H.draw_scope(scope, draw_opts) end --- Undraw currently visible scope manually MiniIndentscope.undraw = function() H.undraw_scope() end --- Generate builtin animation function --- --- This is a builtin source to generate animation function for usage in --- `MiniIndentscope.config.draw.animation`. Most of them are variations of --- common easing functions, which provide certain type of progression for --- revealing scope visual indicator. --- --- Each field corresponds to one family of progression which can be customized --- further by supplying appropriate arguments. --- --- Examples ~ --- - Don't use animation: `MiniIndentscope.gen_animation.none()` --- - Use quadratic "out" easing with total duration of 1000 ms: --- `gen_animation.quadratic({ easing = 'out', duration = 1000, unit = 'total' })` --- ---@seealso |MiniIndentscope-drawing| for more information about how drawing is done. MiniIndentscope.gen_animation = {} ---@alias __indentscope_animation_opts table|nil Options that control progression. Possible keys: --- - `(string)` - a subtype of progression. One of "in" --- (accelerating from zero speed), "out" (decelerating to zero speed), --- "in-out" (default; accelerating halfway, decelerating after). --- - `(number)` - duration (in ms) of a unit. Default: 20. --- - `(string)` - which unit's duration `opts.duration` controls. One --- of "step" (default; ensures average duration of step to be `opts.duration`) --- or "total" (ensures fixed total duration regardless of scope's range). ---@alias __indentscope_animation_return function Animation function (see |MiniIndentscope-drawing|). --- Generate no animation --- --- Show indicator immediately. Same as animation function always returning 0. MiniIndentscope.gen_animation.none = function() return function() return 0 end end --- Generate linear progression --- ---@param opts __indentscope_animation_opts --- ---@return __indentscope_animation_return MiniIndentscope.gen_animation.linear = function(opts) return H.animation_arithmetic_powers(0, H.normalize_animation_opts(opts)) end --- Generate quadratic progression --- ---@param opts __indentscope_animation_opts --- ---@return __indentscope_animation_return MiniIndentscope.gen_animation.quadratic = function(opts) return H.animation_arithmetic_powers(1, H.normalize_animation_opts(opts)) end --- Generate cubic progression --- ---@param opts __indentscope_animation_opts --- ---@return __indentscope_animation_return MiniIndentscope.gen_animation.cubic = function(opts) return H.animation_arithmetic_powers(2, H.normalize_animation_opts(opts)) end --- Generate quartic progression --- ---@param opts __indentscope_animation_opts --- ---@return __indentscope_animation_return MiniIndentscope.gen_animation.quartic = function(opts) return H.animation_arithmetic_powers(3, H.normalize_animation_opts(opts)) end --- Generate exponential progression --- ---@param opts __indentscope_animation_opts --- ---@return __indentscope_animation_return MiniIndentscope.gen_animation.exponential = function(opts) return H.animation_geometrical_powers(H.normalize_animation_opts(opts)) end --- Move cursor within scope --- --- Cursor is placed on a first non-blank character of target line. --- ---@param side string One of "top" or "bottom". ---@param use_border boolean|nil Whether to move to border or within scope's body. --- If particular border is absent, body is used. ---@param scope table|nil Scope to use. Default: output of |MiniIndentscope.get_scope()|. MiniIndentscope.move_cursor = function(side, use_border, scope) scope = scope or MiniIndentscope.get_scope() -- This defaults to body's side if it is not present in border local target_line = use_border and scope.border[side] or scope.body[side] target_line = math.min(math.max(target_line, 1), vim.fn.line('$')) vim.api.nvim_win_set_cursor(0, { target_line, 0 }) -- Move to first non-blank character to allow chaining scopes vim.cmd('normal! ^') end --- Function for motion mappings --- --- Move to a certain side of border. Respects |count| and dot-repeat (in --- operator-pending mode). Doesn't move cursor for scope that is not shown --- (drawing indent less that zero). --- ---@param side string One of "top" or "bottom". ---@param add_to_jumplist boolean|nil Whether to add movement to jump list. It is --- `true` only for Normal mode mappings. MiniIndentscope.operator = function(side, add_to_jumplist) local scope = MiniIndentscope.get_scope() -- Don't support scope that can't be shown if H.scope_get_draw_indent(scope) < 0 then return end -- Add movement to jump list. Needs remembering `count1` before that because -- it seems to reset it to 1. local count = vim.v.count1 if add_to_jumplist then vim.cmd('normal! m`') end -- Make sequence of jumps for _ = 1, count do MiniIndentscope.move_cursor(side, true, scope) -- Use `try_as_border = false` to enable chaining scope = MiniIndentscope.get_scope(nil, nil, { try_as_border = false }) -- Don't support scope that can't be shown if H.scope_get_draw_indent(scope) < 0 then return end end end --- Function for textobject mappings --- --- Respects |count| and dot-repeat (in operator-pending mode). Doesn't work --- for scope that is not shown (drawing indent less that zero). --- ---@param use_border boolean|nil Whether to include border in textobject. When --- `true` and `try_as_border` option is `false`, allows "chaining" calls for --- incremental selection. MiniIndentscope.textobject = function(use_border) local scope = MiniIndentscope.get_scope() -- Don't support scope that can't be shown if H.scope_get_draw_indent(scope) < 0 then return end -- Allow chaining only if using border local count = use_border and vim.v.count1 or 1 -- Make sequence of incremental selections for _ = 1, count do -- Try finish cursor on border local start, finish = 'top', 'bottom' if use_border and scope.border.bottom == nil then start, finish = 'bottom', 'top' end H.exit_visual_mode() MiniIndentscope.move_cursor(start, use_border, scope) vim.cmd('normal! V') MiniIndentscope.move_cursor(finish, use_border, scope) -- Use `try_as_border = false` to enable chaining scope = MiniIndentscope.get_scope(nil, nil, { try_as_border = false }) -- Don't support scope that can't be shown if H.scope_get_draw_indent(scope) < 0 then return end end end -- Helper data ================================================================ -- Module default config H.default_config = vim.deepcopy(MiniIndentscope.config) -- Namespace for drawing vertical line H.ns_id = vim.api.nvim_create_namespace('MiniIndentscope') -- Timer for doing animation H.timer = vim.loop.new_timer() -- Table with current relevalnt data: -- - `event_id` - counter for events. -- - `scope` - latest drawn scope. -- - `draw_status` - status of current drawing. H.current = { event_id = 0, scope = {}, draw_status = 'none' } -- Functions to compute indent in ambiguous cases H.indent_funs = { ['min'] = function(top_indent, bottom_indent) return math.min(top_indent, bottom_indent) end, ['max'] = function(top_indent, bottom_indent) return math.max(top_indent, bottom_indent) end, ['top'] = function(top_indent, bottom_indent) return top_indent end, ['bottom'] = function(top_indent, bottom_indent) return bottom_indent end, } -- Functions to compute indent of blank line to satisfy `config.options.border` H.blank_indent_funs = { ['none'] = H.indent_funs.min, ['top'] = H.indent_funs.bottom, ['bottom'] = H.indent_funs.top, ['both'] = H.indent_funs.max, } -- Functions to compute border from body H.border_from_body = { ['none'] = function(body, opts) return {} end, ['top'] = function(body, opts) return { top = body.top - 1, indent = H.get_line_indent(body.top - 1, opts) } end, ['bottom'] = function(body, opts) return { bottom = body.bottom + 1, indent = H.get_line_indent(body.bottom + 1, opts) } end, ['both'] = function(body, opts) return { top = body.top - 1, bottom = body.bottom + 1, indent = math.max(H.get_line_indent(body.top - 1, opts), H.get_line_indent(body.bottom + 1, opts)), } end, } -- Functions to correct line in case it is a border H.border_correctors = { ['none'] = function(line, opts) return line end, ['top'] = function(line, opts) local cur_indent, next_indent = H.get_line_indent(line, opts), H.get_line_indent(line + 1, opts) return (cur_indent < next_indent) and (line + 1) or line end, ['bottom'] = function(line, opts) local prev_indent, cur_indent = H.get_line_indent(line - 1, opts), H.get_line_indent(line, opts) return (cur_indent < prev_indent) and (line - 1) or line end, ['both'] = function(line, opts) local prev_indent, cur_indent, next_indent = H.get_line_indent(line - 1, opts), H.get_line_indent(line, opts), H.get_line_indent(line + 1, opts) if prev_indent <= cur_indent and next_indent <= cur_indent then return line end -- If prev and next indents are equal and bigger than current, prefer next if prev_indent <= next_indent then return line + 1 end return line - 1 end, } -- Helper functionality ======================================================= -- Settings ------------------------------------------------------------------- H.setup_config = function(config) -- General idea: if some table elements are not present in user-supplied -- `config`, take them from default config vim.validate({ config = { config, 'table', true } }) config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {}) -- Validate per nesting level to produce correct error message vim.validate({ draw = { config.draw, 'table' }, mappings = { config.mappings, 'table' }, options = { config.options, 'table' }, symbol = { config.symbol, 'string' }, }) vim.validate({ ['draw.delay'] = { config.draw.delay, 'number' }, ['draw.animation'] = { config.draw.animation, 'function' }, ['draw.priority'] = { config.draw.priority, 'number' }, ['mappings.object_scope'] = { config.mappings.object_scope, 'string' }, ['mappings.object_scope_with_border'] = { config.mappings.object_scope_with_border, 'string' }, ['mappings.goto_top'] = { config.mappings.goto_top, 'string' }, ['mappings.goto_bottom'] = { config.mappings.goto_bottom, 'string' }, ['options.border'] = { config.options.border, 'string' }, ['options.indent_at_cursor'] = { config.options.indent_at_cursor, 'boolean' }, ['options.try_as_border'] = { config.options.try_as_border, 'boolean' }, }) return config end H.apply_config = function(config) MiniIndentscope.config = config local maps = config.mappings --stylua: ignore start H.map('n', maps.goto_top, [[lua MiniIndentscope.operator('top', true)]], { desc = 'Go to indent scope top' }) H.map('n', maps.goto_bottom, [[lua MiniIndentscope.operator('bottom', true)]], { desc = 'Go to indent scope bottom' }) H.map('x', maps.goto_top, [[lua MiniIndentscope.operator('top')]], { desc = 'Go to indent scope top' }) H.map('x', maps.goto_bottom, [[lua MiniIndentscope.operator('bottom')]], { desc = 'Go to indent scope bottom' }) H.map('x', maps.object_scope, 'lua MiniIndentscope.textobject(false)', { desc = 'Object scope' }) H.map('x', maps.object_scope_with_border, 'lua MiniIndentscope.textobject(true)', { desc = 'Object scope with border' }) -- Use `...` to have proper dot-repeat -- See https://github.com/neovim/neovim/issues/23406 -- TODO: use local functions if/when that issue is resolved H.map('o', maps.goto_top, [[lua MiniIndentscope.operator('top')]], { desc = 'Go to indent scope top' }) H.map('o', maps.goto_bottom, [[lua MiniIndentscope.operator('bottom')]], { desc = 'Go to indent scope bottom' }) H.map('o', maps.object_scope, 'lua MiniIndentscope.textobject(false)', { desc = 'Object scope' }) H.map('o', maps.object_scope_with_border, 'lua MiniIndentscope.textobject(true)', { desc = 'Object scope with border' }) --stylua: ignore start end H.create_autocommands = function() local augroup = vim.api.nvim_create_augroup('MiniIndentscope', {}) local au = function(event, pattern, callback, desc) vim.api.nvim_create_autocmd(event, { group = augroup, pattern = pattern, callback = callback, desc = desc }) end au( { 'CursorMoved', 'CursorMovedI', 'ModeChanged' }, '*', function() H.auto_draw({ lazy = true }) end, 'Auto draw indentscope lazily' ) au( { 'TextChanged', 'TextChangedI', 'TextChangedP', 'WinScrolled' }, '*', function() H.auto_draw() end, 'Auto draw indentscope' ) end --stylua: ignore H.create_default_hl = function() vim.api.nvim_set_hl(0, 'MiniIndentscopeSymbol', { default = true, link = 'Delimiter' }) vim.api.nvim_set_hl(0, 'MiniIndentscopeSymbolOff', { default = true, link = 'MiniIndentscopeSymbol' }) end H.is_disabled = function() return vim.g.miniindentscope_disable == true or vim.b.miniindentscope_disable == true end H.get_config = function(config) return vim.tbl_deep_extend('force', MiniIndentscope.config, vim.b.miniindentscope_config or {}, config or {}) end -- Autocommands --------------------------------------------------------------- H.auto_draw = function(opts) if H.is_disabled() then H.undraw_scope() return end opts = opts or {} local scope = MiniIndentscope.get_scope() -- Make early return if nothing has to be done. Doing this before updating -- event id allows to not interrupt ongoing animation. if opts.lazy and H.current.draw_status ~= 'none' and H.scope_is_equal(scope, H.current.scope) then return end -- Account for current event local local_event_id = H.current.event_id + 1 H.current.event_id = local_event_id -- Compute drawing options for current event local draw_opts = H.make_autodraw_opts(scope) -- Allow delay if draw_opts.delay > 0 then H.undraw_scope(draw_opts) end -- Use `defer_fn()` even if `delay` is 0 to draw indicator only after all -- events are processed (stops flickering) vim.defer_fn(function() if H.current.event_id ~= local_event_id then return end H.undraw_scope(draw_opts) H.current.scope = scope H.draw_scope(scope, draw_opts) end, draw_opts.delay) end -- Scope ---------------------------------------------------------------------- -- Line indent: -- - Equals output of `vim.fn.indent()` in case of non-blank line. -- - Depends on `MiniIndentscope.config.options.border` in such way so as to -- ignore blank lines before line not recognized as border. H.get_line_indent = function(line, opts) local prev_nonblank = vim.fn.prevnonblank(line) local res = vim.fn.indent(prev_nonblank) -- Compute indent of blank line depending on `options.border` values if line ~= prev_nonblank then local next_indent = vim.fn.indent(vim.fn.nextnonblank(line)) local blank_rule = H.blank_indent_funs[opts.border] res = blank_rule(res, next_indent) end return res end H.cast_ray = function(line, indent, direction, opts) local final_line, increment = 1, -1 if direction == 'down' then final_line, increment = vim.fn.line('$'), 1 end local min_indent = math.huge for l = line, final_line, increment do local new_indent = H.get_line_indent(l + increment, opts) if new_indent < indent then return l, min_indent end if new_indent < min_indent then min_indent = new_indent end end return final_line, min_indent end H.scope_get_draw_indent = function(scope) return scope.border.indent or (scope.body.indent - 1) end H.scope_is_equal = function(scope_1, scope_2) if type(scope_1) ~= 'table' or type(scope_2) ~= 'table' then return false end return scope_1.buf_id == scope_2.buf_id and H.scope_get_draw_indent(scope_1) == H.scope_get_draw_indent(scope_2) and scope_1.body.top == scope_2.body.top and scope_1.body.bottom == scope_2.body.bottom end H.scope_has_intersect = function(scope_1, scope_2) if type(scope_1) ~= 'table' or type(scope_2) ~= 'table' then return false end if (scope_1.buf_id ~= scope_2.buf_id) or (H.scope_get_draw_indent(scope_1) ~= H.scope_get_draw_indent(scope_2)) then return false end local body_1, body_2 = scope_1.body, scope_2.body return (body_2.top <= body_1.top and body_1.top <= body_2.bottom) or (body_1.top <= body_2.top and body_2.top <= body_1.bottom) end -- Indicator ------------------------------------------------------------------ --- Compute indicator of scope to be displayed --- --- Indicator is visual representation of scope in current window view using --- extmarks. Currently only needed because Neovim can't correctly process --- horizontal window scroll (Neovim issue: --- https://github.com/neovim/neovim/issues/14050) --- ---@return table|nil Table with indicator info or empty one in case indicator --- shouldn't be drawn. ---@private H.indicator_compute = function(scope) scope = scope or H.current.scope local indent = H.scope_get_draw_indent(scope) -- Don't draw indicator that should be outside of screen. This condition is -- (perpusfully) "responsible" for not drawing indicator spanning whole file. if indent < 0 then return {} end -- Text indentation should depend on current window view because it will use -- `virt_text_win_col` attribute of extmark options (the only way to reliably -- put it anywhere on screen; important to show properly on empty lines). local col = indent - vim.fn.winsaveview().leftcol if col < 0 then return {} end -- Pick highlight group based on if indent is a multiple of shiftwidth. -- This adds visual indicator of whether indent is "correct". local hl_group = (indent % vim.fn.shiftwidth() == 0) and 'MiniIndentscopeSymbol' or 'MiniIndentscopeSymbolOff' local virt_text = { { H.get_config().symbol, hl_group } } return { buf_id = vim.api.nvim_get_current_buf(), virt_text = virt_text, virt_text_win_col = col, top = scope.body.top, bottom = scope.body.bottom, } end -- Drawing -------------------------------------------------------------------- H.draw_scope = function(scope, opts) scope = scope or {} opts = opts or {} local indicator = H.indicator_compute(scope) -- Don't draw anything if nothing to be displayed if indicator.virt_text == nil or #indicator.virt_text == 0 then H.current.draw_status = 'finished' return end -- Make drawing function local draw_fun = H.make_draw_function(indicator, opts) -- Perform drawing H.current.draw_status = 'drawing' H.draw_indicator_animation(indicator, draw_fun, opts.animation_fun) end H.draw_indicator_animation = function(indicator, draw_fun, animation_fun) -- Draw from origin (cursor line but within indicator range) local top, bottom = indicator.top, indicator.bottom local origin = math.min(math.max(vim.fn.line('.'), top), bottom) local step = 0 local n_steps = math.max(origin - top, bottom - origin) local wait_time = 0 local draw_step draw_step = vim.schedule_wrap(function() -- Check for not drawing outside of interval is done inside `draw_fun` local success = draw_fun(origin - step) if step > 0 then success = success and draw_fun(origin + step) end if not success or step == n_steps then H.current.draw_status = step == n_steps and 'finished' or H.current.draw_status H.timer:stop() return end step = step + 1 wait_time = wait_time + animation_fun(step, n_steps) -- Repeat value of `timer` seems to be rounded down to milliseconds. This -- means that values less than 1 will lead to timer stop repeating. Instead -- call next step function directly. if wait_time < 1 then H.timer:set_repeat(0) -- Use `return` to make this proper "tail call" return draw_step() else H.timer:set_repeat(wait_time) -- Restart `wait_time` only if it is actually used. Do this accounting -- actually set repeat time. wait_time = wait_time - H.timer:get_repeat() -- Usage of `again()` is needed to overcome the fact that it is called -- inside callback and to restart initial timer. Mainly this is needed -- only in case of transition from 'non-repeating' timer to 'repeating' -- one in case of complex animation functions. See -- https://docs.libuv.org/en/v1.x/timer.html#api H.timer:again() end end) -- Start non-repeating timer without callback execution. This shouldn't be -- `timer:start(0, 0, draw_step)` because it will execute `draw_step` on the -- next redraw (flickers on window scroll). H.timer:start(10000000, 0, draw_step) -- Draw step zero (at origin) immediately draw_step() end H.undraw_scope = function(opts) opts = opts or {} -- Don't operate outside of current event if able to verify if opts.event_id and opts.event_id ~= H.current.event_id then return end pcall(vim.api.nvim_buf_clear_namespace, H.current.scope.buf_id or 0, H.ns_id, 0, -1) H.current.draw_status = 'none' H.current.scope = {} end H.make_autodraw_opts = function(scope) local config = H.get_config() local res = { event_id = H.current.event_id, type = 'animation', delay = config.draw.delay, animation_fun = config.draw.animation, priority = config.draw.priority, } if H.current.draw_status == 'none' then return res end -- Draw immediately scope which intersects (same indent, overlapping ranges) -- currently drawn or finished. This is more natural when typing text. if H.scope_has_intersect(scope, H.current.scope) then res.type = 'immediate' res.delay = 0 res.animation_fun = MiniIndentscope.gen_animation.none() return res end return res end H.make_draw_function = function(indicator, opts) local extmark_opts = { hl_mode = 'combine', priority = opts.priority, right_gravity = false, virt_text = indicator.virt_text, virt_text_win_col = indicator.virt_text_win_col, virt_text_pos = 'overlay', } local current_event_id = opts.event_id return function(l) -- Don't draw if outdated if H.current.event_id ~= current_event_id and current_event_id ~= nil then return false end -- Don't draw if disabled if H.is_disabled() then return false end -- Don't put extmark outside of indicator range if not (indicator.top <= l and l <= indicator.bottom) then return true end return pcall(vim.api.nvim_buf_set_extmark, indicator.buf_id, H.ns_id, l - 1, 0, extmark_opts) end end -- Animations ----------------------------------------------------------------- --- Imitate common power easing function --- --- Every step is preceded by waiting time decreasing/increasing in power --- series fashion (`d` is "delta", ensures total duration time): --- - "in": d*n^p; d*(n-1)^p; ... ; d*2^p; d*1^p --- - "out": d*1^p; d*2^p; ... ; d*(n-1)^p; d*n^p --- - "in-out": "in" until 0.5*n, "out" afterwards --- --- This way it imitates `power + 1` common easing function because animation --- progression behaves as sum of `power` elements. --- ---@param power number Power of series. ---@param opts table Options from `MiniIndentscope.gen_animation` entry. ---@private H.animation_arithmetic_powers = function(power, opts) -- Sum of first `n_steps` natural numbers raised to `power` local arith_power_sum = ({ [0] = function(n_steps) return n_steps end, [1] = function(n_steps) return n_steps * (n_steps + 1) / 2 end, [2] = function(n_steps) return n_steps * (n_steps + 1) * (2 * n_steps + 1) / 6 end, [3] = function(n_steps) return n_steps ^ 2 * (n_steps + 1) ^ 2 / 4 end, })[power] -- Function which computes common delta so that overall duration will have -- desired value (based on supplied `opts`) local duration_unit, duration_value = opts.unit, opts.duration local make_delta = function(n_steps, is_in_out) local total_time = duration_unit == 'total' and duration_value or (duration_value * n_steps) local total_parts if is_in_out then -- Examples: -- - n_steps=5: 3^d, 2^d, 1^d, 2^d, 3^d -- - n_steps=6: 3^d, 2^d, 1^d, 1^d, 2^d, 3^d total_parts = 2 * arith_power_sum(math.ceil(0.5 * n_steps)) - (n_steps % 2 == 1 and 1 or 0) else total_parts = arith_power_sum(n_steps) end return total_time / total_parts end return ({ ['in'] = function(s, n) return make_delta(n) * (n - s + 1) ^ power end, ['out'] = function(s, n) return make_delta(n) * s ^ power end, ['in-out'] = function(s, n) local n_half = math.ceil(0.5 * n) local s_halved if n % 2 == 0 then s_halved = s <= n_half and (n_half - s + 1) or (s - n_half) else s_halved = s < n_half and (n_half - s + 1) or (s - n_half + 1) end return make_delta(n, true) * s_halved ^ power end, })[opts.easing] end --- Imitate common exponential easing function --- --- Every step is preceded by waiting time decreasing/increasing in geometric --- progression fashion (`d` is 'delta', ensures total duration time): --- - 'in': (d-1)*d^(n-1); (d-1)*d^(n-2); ...; (d-1)*d^1; (d-1)*d^0 --- - 'out': (d-1)*d^0; (d-1)*d^1; ...; (d-1)*d^(n-2); (d-1)*d^(n-1) --- - 'in-out': 'in' until 0.5*n, 'out' afterwards --- ---@param opts table Options from `MiniIndentscope.gen_animation` entry. ---@private H.animation_geometrical_powers = function(opts) -- Function which computes common delta so that overall duration will have -- desired value (based on supplied `opts`) local duration_unit, duration_value = opts.unit, opts.duration local make_delta = function(n_steps, is_in_out) local total_time = duration_unit == 'step' and (duration_value * n_steps) or duration_value -- Exact solution to avoid possible (bad) approximation if n_steps == 1 then return total_time + 1 end if is_in_out then local n_half = math.ceil(0.5 * n_steps) -- Example for n_steps=6: -- Steps: (d-1)*d^2, (d-1)*d^1, (d-1)*d^0, (d-1)*d^0, (d-1)*d^1, (d-1)*d^2 -- Sum: 2 * (d - 1) * (d^0 + d^1 + d^2) = 2 * (d^3 - 1) -- Solution: 2 * (d^3 - 1) = total_time => -- d = math.pow(0.5 * total_time + 1, 1 / 3) -- -- Example for n_steps=5: -- Steps: (d-1)*d^2, (d-1)*d^1, (d-1)*d^0, (d-1)*d^1, (d-1)*d^2 -- Sum: 2 * (d - 1) * (d^0 + d^1 + d^2) - (d - 1) = 2 * (d^3 - 1) - (d - 1) -- Solution: 2 * (d^3 - 1) - (d - 1) = total_time => -- As there is no general explicit solution, use approximation => -- (Exact solution without `- (d-1)`): -- d_0 = math.pow(0.5 * total_time + 1, 1 / 3); -- (Correction by solving exactly withtou `- (d-1)` for -- `total_time_corr = total_time + (d_0 - 1)`): -- d_1 = math.pow(0.5 * total_time_corr + 1, 1 / 3) if n_steps % 2 == 1 then total_time = total_time + math.pow(0.5 * total_time + 1, 1 / n_half) - 1 end return math.pow(0.5 * total_time + 1, 1 / n_half) end return math.pow(total_time + 1, 1 / n_steps) end return ({ ['in'] = function(s, n) local delta = make_delta(n) return (delta - 1) * delta ^ (n - s) end, ['out'] = function(s, n) local delta = make_delta(n) return (delta - 1) * delta ^ (s - 1) end, ['in-out'] = function(s, n) local n_half, delta = math.ceil(0.5 * n), make_delta(n, true) local s_halved if n % 2 == 0 then s_halved = s <= n_half and (n_half - s) or (s - n_half - 1) else s_halved = s < n_half and (n_half - s) or (s - n_half) end return (delta - 1) * delta ^ s_halved end, })[opts.easing] end H.normalize_animation_opts = function(x) x = vim.tbl_deep_extend('force', { easing = 'in-out', duration = 20, unit = 'step' }, x or {}) if not vim.tbl_contains({ 'in', 'out', 'in-out' }, x.easing) then H.error([[In `gen_animation` option `easing` should be one of 'in', 'out', or 'in-out'.]]) end if type(x.duration) ~= 'number' or x.duration < 0 then H.error([[In `gen_animation` option `duration` should be a positive number.]]) end if not vim.tbl_contains({ 'total', 'step' }, x.unit) then H.error([[In `gen_animation` option `unit` should be one of 'step' or 'total'.]]) end return x end -- Utilities ------------------------------------------------------------------ H.error = function(msg) error(('(mini.indentscope) %s'):format(msg)) end H.map = function(mode, lhs, rhs, opts) if lhs == '' then return end opts = vim.tbl_deep_extend('force', { silent = true }, opts or {}) vim.keymap.set(mode, lhs, rhs, opts) end H.exit_visual_mode = function() local ctrl_v = vim.api.nvim_replace_termcodes('', true, true, true) local cur_mode = vim.fn.mode() if cur_mode == 'v' or cur_mode == 'V' or cur_mode == ctrl_v then vim.cmd('normal! ' .. cur_mode) end end return MiniIndentscope