馃 a tiny plugin for smoother movement keybinds
1if vim.g.loaded_smoothie then
2 return
3end
4
5vim.g.loaded_smoothie = true
6
7local cfg = vim.g.smoothie_config or {}
8local defaults = {
9 interval = 20,
10 maxtime = 200,
11}
12
13local opt = function(default, ...)
14 return vim.F.if_nil(vim.tbl_get(cfg, ...), default)
15end
16local opts = vim.defaulttable(function(key)
17 return opt(defaults[key], key)
18end)
19
20local M, H = {}, {}
21Smoothie = vim.defaulttable(function(k)
22 return M[k] or H[k]
23end)
24
25local active = false
26
27---@param win integer
28---@param step fun(win, ...): true? returns true if repeater should be stopped
29---@param init fun(): any[]
30function M.smooth(win, step, init)
31 if active then
32 vim.wait(1000, function()
33 return not active
34 end)
35 end
36 active = true
37
38 local args = init()
39
40 H.throttle(function(t)
41 if not active then
42 return t.cancel()
43 end
44
45 if step(t.total_ms / opts.maxtime, unpack(args)) then
46 active = false
47 t.cancel()
48 end
49
50 vim.api.nvim__redraw({ win = win, valid = true, cursor = true })
51 end, opts.interval)
52end
53
54function M.scroll(vcount)
55 local win = vim.api.nvim_get_current_win()
56 -- distance to scroll
57 local d = vim.api.nvim_get_option_value("scroll", { scope = "local", win = win })
58
59 local m = vcount / math.abs(vcount)
60
61 local function step(delta, start, dst)
62 local diff = dst.row - start.row
63
64 -- reached dst or overshot
65 -- - diff == 0: no more distance to travel
66 -- - diff * m: both need to be oriented in the same direction (positive product)
67 if diff == 0 or diff * m < 0 then
68 return true
69 end
70
71 -- travel t of the current diff (ceiled), at least 1
72 local s = m * math.max(1, math.ceil(H.coillerp(math.abs(diff), delta)))
73 start.row = start.row + s
74
75 vim._with({ win = win }, function()
76 vim.fn.setpos(".", { start.buf, start.row + 1, start.col, 0 })
77 end)
78 end
79
80 M.smooth(win, step, function()
81 local buf, lnum, col = unpack(vim.fn.getpos("."))
82 local start = vim.pos.cursor(buf, { lnum, col })
83 local dst = vim.pos.cursor(buf, { math.max(0, math.ceil(lnum + (vcount * d))), col })
84 return { start, dst }
85 end)
86end
87
88function M.align()
89 local win = vim.api.nvim_get_current_win()
90
91 local function step(delta, m, start, dst)
92 local diff = dst.row - start.row
93
94 -- reached dst or overshot
95 -- - diff == 0: no more distance to travel
96 -- - diff * m: both need to be oriented in the same direction (positive product)
97 if diff == 0 or diff * m < 0 then
98 return true
99 end
100
101 -- travel t of the current diff (ceiled), at least 1
102 local s = math.max(1, math.ceil(H.coillerp(math.abs(diff), delta)))
103 start.row = start.row + (m * s)
104
105 vim.schedule(function()
106 vim._with({ win = win }, function()
107 for _ = 1, s do
108 local key = vim.api.nvim_replace_termcodes(m > 0 and "<c-y>" or "<c-e>", true, false, true)
109 vim.api.nvim_feedkeys(key, "n", false)
110 end
111 end)
112 end)
113 end
114
115 M.smooth(win, step, function()
116 local buf, lnum, col = unpack(vim.fn.getpos("."))
117 local start = vim.pos.cursor(buf, { lnum, col })
118
119 local m
120 local wfirst = vim.fn.getpos("w0")[2]
121 local wlast = vim.fn.getpos("w$")[2]
122 -- no lines are visible
123 if wlast < wfirst then
124 m = 0
125 end
126 local wheight = vim.api.nvim_win_get_height(win)
127 local wdst = math.min(wlast, math.floor(wfirst + wheight / 2))
128 local dst = vim.pos.cursor(buf, { wdst, col })
129 local diff = dst.row - start.row
130 m = m or diff / math.abs(diff)
131
132 return { m, start, dst }
133 end)
134end
135
136vim.keymap.set("n", "<plug>(smoothie-ctrl-d)", function()
137 vim._with({ win = 0 }, function()
138 M.scroll(vim.v.count1)
139 end)
140end)
141vim.keymap.set("n", "<plug>(smoothie-ctrl-u)", function()
142 vim._with({ win = 0 }, function()
143 M.scroll(-vim.v.count1)
144 end)
145end)
146vim.keymap.set("n", "<plug>(smoothie-zz)", function()
147 vim._with({ win = 0 }, function()
148 M.align()
149 end)
150end)
151
152-- helpers ====================================================================
153
154---@param min number
155---@param n number
156---@param max number
157---@return number
158function H.clamp(min, n, max)
159 return math.max(min, math.min(n, max))
160end
161
162---@param n number
163---@param t number
164---@return number
165function H.coillerp(n, t)
166 t = H.clamp(0, t, 1)
167 return (math.log(1 + t) * 1 + math.log(1 + t)) * n
168end
169
170---@param f fun(ref): true?
171---@param interval integer
172function H.throttle(f, interval)
173 vim.validate("f", f, "function")
174 vim.validate("interval", interval, "number")
175
176 local repeater = vim.uv.new_timer()
177 if not repeater then
178 return
179 end
180
181 local now = vim.uv.now()
182 local timer = {
183 now = now,
184 --- last tick
185 tick = now,
186 ntick = nil,
187 total_ms = 0,
188 cancel = function()
189 if repeater ~= nil and not repeater:is_closing() then
190 repeater:stop()
191 repeater:close()
192 return
193 end
194 end,
195 }
196
197 local ready = true
198 repeater:start(
199 0,
200 interval,
201 vim.schedule_wrap(function()
202 vim.uv.update_time()
203
204 local ntick = vim.uv.now()
205 vim.wait(interval, function()
206 return ready
207 end, 5, true)
208 local wait_time = vim.uv.now() - ntick
209
210 --- next tick
211 timer.ntick = ntick
212 -- total time since timer start
213 timer.total_ms = timer.ntick - timer.now
214 -- delta since last tick
215 timer.delta = timer.ntick - timer.tick
216
217 ready = false
218 local timeout = math.max(0, interval - wait_time)
219 vim.defer_fn(function()
220 if f(timer) then
221 timer.cancel()
222 end
223
224 ready = true
225 end, timeout)
226
227 timer.tick = timer.ntick
228 end)
229 )
230end