chunk 动画工作原理
chunk 的动画效果自从 2023 年 12 月就已经有想法了,但是当时对 Neovim 提供的异步 API 还不是很了解,而且校内的事情也急着处理,一直拖到了今年五月,现在业已实现,而且稳定运行了一个月没有爆出 issue,这里记录一下实现过程。
TUI 动画怎样形成的
我们实际上看到的是一帧一帧的画面,只要每秒钟运行的频率够高,就可以形成动画的效果,而在 TUI 中实现动画有两种方式,我们首先假设一个 chunk 的距离为 d
格
- 每秒钟固定运行
x
帧,因此每帧动画需要变化d/x
格,这个计算出来的结果可能是一个分数,而 TUI 中是离散的格子数,不可能出现小数,而如果强行使用向上取整,向下取整,四舍五入等策略,会导致动画出现不连贯和速率不一致的情况,因此不做考虑。 - 设定一个固定运行时长
T
,每个格子渲染时刻之间间隔t
秒,以匀速动画为例,这里的t
就是T/d
,这里不使用d-1
主要是为了方便程序计算。因为我们可以精确的控制每次渲染间隔的时长,因此选择这种方案。
异步动画?
用户在使用 chunk 的时候,首先需要移动光标到一个位置,然后经过一段时间(delay
选项,为了避免无谓计算),chunk 才会渲染,渲染的同时用户还可能进行其他的操作(比如移动光标),我们不希望动画阻塞用户(没有人愿意为了 200ms
的动画而放弃工作),因此需要考虑一种异步机制来运行动画,并且可以提供中断任务的 API。
动画任务的抽象实现
综上所述,我们希望实现一个类,我们初始化的时候告诉他
- 动画中中的每一步需要做的事情
- 动画运行总时长
- 动画运行总步数
- 动画运行策略(匀速动画或是变速动画)
因此伪代码如下
Lua
class LoopTask(fn, strategy, duration, ...args)
我们分别介绍一下里面的参数
fn
表示的是每次循环需要被调用的函数,这个函数通常用来执行渲染的操作,比如在chunk
模块中我们就会传入luafunction(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
的参数,并且判断了渲染的时机(如果某个字符下方有用户的代码,就不进行渲染,以避免覆盖)strategy
代表动画的策略,比如线性动画,或者缓动动画等。这个是一个新的概念,但如果你学习过 CSS 中的transition
属性,就不会感觉陌生。以线性动画为例,首先我们有一个固定的动画时长duration
,同时还有运行的步数x
,因此可以生成一个长度为x
的时间序列数组time_intervals
,其中每个值都是duration/x
。同理,如果我们希望动画先慢后快,那么这个时间序列就是一个递减的序列,但他们的总和还是duration
。duration
就是我们刚才提到的动画持续时间,在chunk
中我将其默认设置为200ms
(因为chunk
还承担了提示textobject
的功能,太慢会让用户困惑)。args
是一个变长参数,这些变长参数会经过处理后传入上面提到的fn
,我们用一个例子讲解一下luaself.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_list
和virt_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