Skip to content

chunk 的动画效果原理及实现方式

chunk 动画工作原理

chunk 的动画效果自从 2023 年 12 月就已经有想法了,但是当时对 Neovim 提供的异步 API 还不是很了解,而且校内的事情也急着处理,一直拖到了今年五月,现在业已实现,而且稳定运行了一个月没有爆出 issue,这里记录一下实现过程。

TUI 动画怎样形成的

我们实际上看到的是一帧一帧的画面,只要每秒钟运行的频率够高,就可以形成动画的效果,而在 TUI 中实现动画有两种方式,我们首先假设一个 chunk 的距离为 d

  1. 每秒钟固定运行 x 帧,因此每帧动画需要变化 d/x 格,这个计算出来的结果可能是一个分数,而 TUI 中是离散的格子数,不可能出现小数,而如果强行使用向上取整,向下取整,四舍五入等策略,会导致动画出现不连贯和速率不一致的情况,因此不做考虑。
  2. 设定一个固定运行时长 T,每个格子渲染时刻之间间隔 t 秒,以匀速动画为例,这里的 t 就是 T/d,这里不使用 d-1 主要是为了方便程序计算。因为我们可以精确的控制每次渲染间隔的时长,因此选择这种方案。

异步动画?

用户在使用 chunk 的时候,首先需要移动光标到一个位置,然后经过一段时间(delay 选项,为了避免无谓计算),chunk 才会渲染,渲染的同时用户还可能进行其他的操作(比如移动光标),我们不希望动画阻塞用户(没有人愿意为了 200ms 的动画而放弃工作),因此需要考虑一种异步机制来运行动画,并且可以提供中断任务的 API。

动画任务的抽象实现

综上所述,我们希望实现一个类,我们初始化的时候告诉他

  1. 动画中中的每一步需要做的事情
  2. 动画运行总时长
  3. 动画运行总步数
  4. 动画运行策略(匀速动画或是变速动画)

因此伪代码如下

Lua
class LoopTask(fn, strategy, duration, ...args)

我们分别介绍一下里面的参数

  1. fn 表示的是每次循环需要被调用的函数,这个函数通常用来执行渲染的操作,比如在 chunk 模块中我们就会传入

    lua
    function(vt, row, vt_win_col)
        row_opts.virt_text = { { vt, text_hl } }
        row_opts.virt_text_win_col = vt_win_col
        if api.nvim_buf_is_valid(range.bufnr) and api.nvim_buf_line_count(range.bufnr) > row then
            api.nvim_buf_set_extmark(range.bufnr, self.meta.ns_id, row, 0, row_opts)
        end
    end

    在这里我们构造了传入 nvim_buf_set_extmark 的参数,并且判断了渲染的时机(如果某个字符下方有用户的代码,就不进行渲染,以避免覆盖)

  2. strategy 代表动画的策略,比如线性动画,或者缓动动画等。这个是一个新的概念,但如果你学习过 CSS 中的 transition 属性,就不会感觉陌生。以线性动画为例,首先我们有一个固定的动画时长 duration,同时还有运行的步数 x,因此可以生成一个长度为 x 的时间序列数组 time_intervals,其中每个值都是 duration/x。同理,如果我们希望动画先慢后快,那么这个时间序列就是一个递减的序列,但他们的总和还是 duration

  3. duration 就是我们刚才提到的动画持续时间,在 chunk 中我将其默认设置为 200ms(因为 chunk 还承担了提示 textobject 的功能,太慢会让用户困惑)。

  4. args 是一个变长参数,这些变长参数会经过处理后传入上面提到的 fn,我们用一个例子讲解一下

    lua
    self.meta.task = LoopTask(function(vt, row, vt_win_col) end,
        "linear", self.conf.duration, virt_text_list, row_list, virt_text_win_col_list)

    这里的 virt_text_list, row_list, virt_text_win_col_list 就是这次调用中的 args,他们的长度相同,virt_text_list 表示的是即将渲染的字符列表,同理 row_listvirt_text_win_col_list。我们会将这个变长参数转置处理,然后迭代的将参数喂给 fn

    假设我们想渲染两个字符,那么 args 参数为 [['a', 'b'], [1, 2], [0, 0]],表示 a 字符将会渲染在一行零列,b 字符渲染在二行零列,转置后变成 [['a', 1, 0], ['b', 2, 0]],然后在 fn 被调用时分别传入 ['a', 1, 0]['b', 2, 0]。最终实现动画效果。

动画任务的具体实现

我们的类需要如下一些内部字段:

  • timer:用于动画计时
  • progress:用于记录动画的进度,这里使用运行的步数作为进度
lua
local LoopTask = class(function(self, fn, strategy, duration, ...)
    self.data = transpose({ ... })
    self.timer = nil
    self.fn = fn
    self.strategy = strategy
    self.time_intervals = createStrategy(strategy, duration, #self.data)
    self.progress = 1
end)

function LoopTask:start()
    local f
    f = function()
        self.fn(unpack(self.data[self.progress]))
        self.progress = self.progress + 1
        if self.progress > #self.time_intervals then
            self.timer:stop()
            self.timer = nil
            return
        else
            self.timer = setTimeout(f, self.time_intervals[self.progress])
        end
    end
    self.timer = setTimeout(f, self.time_intervals[self.progress])
end

function LoopTask:stop()
    self.timer:stop()
end

这里构造函数在调用的时候有一个 transpose 函数,用来参数转置,实现方法如下:

lua
local function transpose(matrix)
    local res = {}
    for i = 1, #matrix[1] do
        res[i] = {}
        for j = 1, #matrix do
            res[i][j] = matrix[j][i]
        end
    end
    return res
end