A smooth scrolling plugin for Kakoune

Update

kakoune-smooth-scroll is now updated to support many more keys by default, including for searching and selection rotation, and movements through goto and object modes.
demo

If you were using it before and you use default movement keys, you can remove the old configuration and enable it per window with smooth-scroll-enable. You can check out the README for automatically enabling it for all windows. Also if you remap default keys (e.g. if you adapted your mappings for Dvorak/Colemak etc.) or if you are not happy with the default behavior, configuration options are described in the README to support customizing keybindings.

Any feedback is welcome regarding improving the configuration or any issues you might have.

Original post

Hi folks, even though it is a bit frivolous, something I have been missing since switching from Vim to Kakoune has been a smooth scrolling implementation, specifically something like vim-smooth-scroll. It didn’t look trivial to implement with Kakoune’s approach to plugins. I have been working on it on the side, tried a few different ways it could be done and it has been a decent learning experience.

I very naively started with printing scroll keys in a execute-keys block, which obviously didn’t work since it waits the shell expansion in its argument to finish before executing the keys. Then I discovered sending keys to the server through kak -p inside a %sh block, which also led me to discover how to not block Kakoune by using the >/dev/null 2>&1 </dev/null & trick, like in the make and grep implementations. This was a workable solution however it turned out a bit too slow for smooth scrolling, since the 3 process calls I made in the main loop took about 10-15ms per scroll tick.

After asking in IRC if there is a kak -p-like pipe that I can send commands to so I don’t have to invoke it every time (apparently there isn’t), mawww pointed me to Kakoune’s remote API which has a pretty simple protocol using Unix sockets. So I wrote a Python script that uses this API to send the scrolling events, which resolved the performance issue.

Then for fun, I also added a physics-based scrolling implementation with constant friction which is present in some Vim plugins like comfortable-motion.vim. I initially thought it was a gimmick but it kind of grew on me as I used it.

Today I packaged my implementation and put it up here, if anyone else’s interested: https://github.com/caksoylar/kakoune-smooth-scroll. Below is the screencast from the README showing the smooth scrolling behavior:

I also have a pure sh implementation in the plugin using kak -p that is used as a fallback in case Python 3.6+ isn’t available. Since the Python implementation uses an internal API it is not guaranteed to keep the same interface or even exist in the future. For that reason I am not super comfortable using it in a public plugin, but let me know if you have any comments.

If in the future we get timer hooks in Kakoune it should be possible to implement this plugin in a performant way without resorting to the remote API.-

4 Likes

I hadn’t thought about smooth-scrolling before, but your demonstration is very appealing. However, I personally don’t (think I) use <c-u> or <c-d> very much, and <c-b> and <c-f> only when I really want to get somewhere quickly, so none of those keybindings are particularly useful to me. On the other hand, I’d really like smooth-scrolling to show me unpredictable leaps in position, like m or n or ).

It seems to me that most of the ingredients are already in-place - your plugin could store %val{window_range} in a NormalIdle hook, and if that value ever changed, you could scroll back to the original position with {count}vk (or whatever) then smoothly animate back to the “new” position. However, currently when vj or vk move the main selection off-screen, it collapses to a single cell which would be quite frustrating for commands like m where maintaining the selection is the whole point.

mawww has hinted that future versions of Kakoune might not have that restriction; it’d be interesting to see what smooth-scrolling would be like in such an editor, even though it’s not here yet.

1 Like

That is a very good point, I also think it would be very useful for that use case. There is a plugin for Vim that supports all movements sexy-scroller.vim. It essentially hooks into all CursorMoved events and does what you describe (keep track of original position, save new position and move back to the original, then smoothly scroll to the new). I can’t see much of a visible flicker for the initial jump and the rollback, but it is in theory possible. I think for Kakoune we can similarly hook to NormalKey etc. and check window_range like you mentioned.

I didn’t know about that, but that is indeed a big problem for the above approach. Even ignoring that issue though, I can’t emulate even <c-d> etc. completely faithfully right now, since only vj/vk cannot scroll through wrapped lines (I think it is related to this issue). So I have to use vjj/vkk which always moves the cursor unlike built-in <c-d>/<c-u>, so a theoretical move that preserves selections isn’t possible with that either.

Maybe an implementation that saves the selection, does all the moving and at the end restores it would work around these issues? I’ll take a look.

It was easier than I thought to implement @Screwtapello’s suggestion in the v2 branch, although this is likely the 80% part that is 20% of the work. Right now it tries to smooth-scroll whenever the window moves more than 10 lines, checking it at NormalIdle and NormalKey. Overall it is a bit janky, major issues are:

  • The initial movement is very visible as a flicker before I can undo it and re-do it smoothly
    • Maybe due to NormalIdle delay? Probably exacerbated by Python’s startup
  • I have to hide the selection while scrolling then restore it when I am done
    • This shouldn’t be an issue without the vj/vk restrictions in the future
  • Opening a buffer always does a couple jumps which are now animated (see below, until it goes to the top of the buffer)

asciicast

I tried poking at your v2 branch, but I’m afraid I just broke things and made a mess, so I don’t have concrete fixes to propose. However, I have some thoughts:

  • The initial jank is almost certainly due to waiting for the Python interpreter to start up. For this kind of effort, do what you can in Kakoune script before resorting to shell, and do what you can in shell before invoking an external tool.

  • Opening a buffer probably does some jumps because the “last” scroll offset always defaults to the invalid “-100 0 0 0”. To set a sensible value before any of your other hooks run, you can hook WinCreate:

    hook -group scroll global WinCreate .* %{
        set-option window scroll_window %val{window_range}
    }
    
  • To run commands when scrolling is complete (unhiding the cursor, restoring selections, etc.) I would probably have the Python script just set scroll_running to false, and then have a WinSetOption scroll_running=false hook that does those adjustments. That way, all the option-setting stuff can be in the .kak file, and all the inertial-scrolling and socket-communication code can be in Python.

Thanks a lot, your comments were very helpful! I pushed an update to v2 branch now: https://github.com/caksoylar/kakoune-smooth-scroll/commit/5b69ca0451daec477e13fb40251b3f2c5fac7c96

I moved this to sh scope before calling Python which improved flickering, but it is still visible unfortunately. Let me know if you can see anything in the code to reduce the delay further.

Well that makes a lot of sense :slight_smile: This was indeed resolved with your suggestion.

I did that and it is much cleaner, thanks!

Given the flickering is not easy to get rid of, I had an alternative in mind: Instead of hooking to events that already happened, we can have a list of keys that we want to override (this is probably not a huge list), then we can create mappings for each of these keys programmatically. The mapping will just execute the keys in a draft context to get the final window and selections, then smoothly scroll and restore the selections. This would also prevent the jankiness that happens when you scroll with the mouse quickly (since it wouldn’t be mapped) and allow more customization for the user. What do you think?

Edit: Updated cast asciicast

I tried out your improved v2, and it’s a lot better. As you point out, though, there’s still an annoying flash when it jumps back to the original location to scroll more smoothly.

If I change the animation trigger from NormalIdle to NormalKey, it’s much smoother in general, but there’s a few weird corner-cases. For example:

  • open Kakoune’s commands.cc file (2684 lines!)
  • go to the end of the file
  • Run :smooth-scroll-enable
  • Hit gg to return to the beginning of the file

Expected results:

  • smooth animation back to the beginning of the file

Actual results:

  • immediate jump to the beginning of the file
  • pressing <space> (or any other key) immediately jumps to the end of the file then smoothly animates back to the beginning

Another wrinkle I found is that the animation duration is based on the number of lines scrolled. That seems reasonable, but with commands.cc in my terminal it takes like 25 seconds to scroll from one end to another - the last line takes almost a complete second to slide into place.

Compared to “instant” for the non-smooth scrolling, and given things get a bit weird if I press a movement-key while the scrolling is on-going, this is a bit frustrating. How difficult would it be to use a fixed scroll-time of a few hundred milliseconds, instead of scaling by the line-distance?

In addition, it seems like a good idea just to not do anything scrolling-related (including updating the scroll_window option) while scrolling is in progress, or we’ll just wind up with multiple Python workers fighting over the scroll position.

Trying to use mappings to override behaviour seems like a bad idea from the point of view of reliability - if we don’t override user mappings, we can’t intercept those movements; if we do override user mappings we can’t trigger those movements in a draft context.

Speaking of mappings, reading the docs I was reminded that NormalKey is triggered for each key on the right-hand-side of a mapping, which is not really what we want. The RawKey hook fires for keys on the left-hand-side of a mapping, which might be a better plan?

Thanks again for the comments, I made another update to the branch.

Those are good ideas, so I experimented with the three different types of hooks but each of them has shortcomings right now. (Some of these issues might be due to us not guarding against some edge cases properly.)

  • NormalIdle works best (without apparent issues) but also has the longest delay. It is triggered for mouse scrolling too quickly or holding down jk though, which is not desirable
  • NormalKey .* has issues with modes, e.g. any action from goto or view modes does not trigger it until another key is pressed, just like you described. There is less delay so it is smoother, and it is not triggered by mouse scroll.
  • RawKey .* is also very fast and not triggered by mouse scroll. However I see weirder issues like gg doesn’t take you all the way up, which is more apparent on a small file (the cursor placement is corrected at finish when the selection is restored). gj/ge scrolls down too fast and then pressing any other key will scroll up and down again (this behaves like the hook triggers twice?). It also breaks prompt mode if used naively, where a move by incsearch will print the scroll keys to the prompt.

You are right, this is probably mandatory when we are dealing with very large movements. I added this in the latest update with a new option called max_duration. If you set interval to a high value and max_duration to the desired one, all movements would take a max_duration amount of time, but with default settings it only caps the maximum duration.

That’s a good catch, I moved it so that it is not updated when scroll_running.

That’s the main downside I think. I don’t know if mapping built-in keys are very common, excepting w/b/e/hjkl etc. In any case it would be a strict improvement over the current version, which does the same but only for 4 keys. The other downside is that we might not be able to support all moves, e.g. I don’t know how we’d catch /word<ret> (actually that might be possible by mapping <ret> in prompt?). At the end of the day I don’t know if there is a way for us to get rid of the flicker entirely with the hook approach.

I’ve been using the mapping-based version of your plugin from the v2-map branch, and as much as I think an implementation built on hooks would be overall more reliable, I gotta say the map implementation pretty much Just Works. It’s really nice to use!

The only awkwardness I’ve encounted so far has been:

  • I hit <c-b> while my cursor was near the top of the screen, and the view jumped to the new offset instead of scrolling; I think that’s because the cursor only moved a few lines (from the top of one screen to the bottom of the previous) even though the viewport moved a long way?
  • I’ve learned to hit %s pretty quickly as part of a selecting the thing I want, but % scrolls to the end of the buffer, and if I hit s while scrolling is still in progress, I get a whole lot of vjvjvjvjvjvjvjvj in the prompt. Not really much to be done about that, I guess, apart from adjusting the duration limit or mawww providing a different way for plugins to adjust the viewport.

Thanks for giving it a try!

I believe this is this bug that I also noticed: #3616. Looks like -draft mode does not preserve the viewport, so the current implementation doesn’t notice it.

That one is annoying me too occasionally. I added a hook to kill the scrolling process on mode change here but it isn’t always quick enough. Also it doesn’t work for % anyway because the selection isn’t restored (I assume this is due to being in prompt mode). I think decreasing max_duration (I decreased the default one) or removing % altogether from scroll_keys_normal can be workarounds.

BTW I cleaned up and documented the new version now. Right now the configuration is through specifying a list of keys to map in normal/goto/object modes (and view pending the above issue) and a map for behavior options. Unless I come up with a better UI (w.r.t. configuration and enabling/disabling etc.) or more keys to map, I am thinking this version is good enough to merge to master. I would appreciate any feedback on the UI front also.

I’ve been using the latest v2-map branch for a couple of days now, and the only configuration I’ve done is a snippet like this:

hook global WinCreate .* %{
    hook -once window WinDisplay .* smooth-scroll-enable
}

I think that’s pretty reasonable to begin with - I say merge it to master and see if anybody files issues with questions or suggestions.

1 Like

In terms of the mapping based approach, I wonder if the plugin can implement a command using prompt, serving as a wrapper for searching, with smooth-scrolling?

BTW, I think an extra benefit of the mapping approach is that people can choose what commands they would like smooth-scrolling to be enabled. For example, if I consider smooth scrolling using gg or ge on large files too fast and not so desirable, I can simply not map these two particular bindings.

(screen casts seem exciting, haven’t got time to try yet)

Here’s another suggestion for the mapping branch. Could the API be re-structured in the form of various separate commands like smooth-m, smooth-c-u, and let the user bind them on need manually? This makes a difference for people using a non-ordinary keyboard layout (dvorak, colemak, etc.), as they will probably have most of their builtin keys remapped…

Thanks for your feedback! I made a few more updates to the v2 branch now to address some of them. I also finally updated the README, so it’s pretty much good to merge once I update the asciicast (probably with a gif, since asciicast doesn’t seem to be very smooth on lower-end computers).

Yes, right now the mapped keys are configured through options, so it is totally possible to only map keys that you want. I put some examples in the README.

That’s a good point; I modified the mapping code so that if you have an item <c-j>=<c-f> in the options, it will map <c-j> to <c-f>'s functionality with smooth scrolling. Hopefully that helps with your use case; let me know if it does or not. There’s also smooth-scroll-map-key function exposed so you can construct your own mappings from scratch if you feel like it.

Right now I tried to avoid doing too many special cases for specific functionality, but maybe I can take a look in the future. Looks like it would be a decent amount of work to get smooth scrolling (separate from the current mechanism) and make search history accessible etc.

Having got time to play with the plugin today, it is great!

Perhaps my keymap is a bit weird, but I map g{j,k}(if in QWERTY) to <c-d> and <c-u>, and the option-based approach can’t handle such cross-mode re-map AFAIK. But for just a dvorak/colemak remap of alphabetical keys, the option approach works. But I do think it lacks a bit of consistency: you must have your maps without smooth-scrolling done in the normal kakoune way, and those with smooth-scrolling in another.

I wonder if the plugin can expose the smooth-scroll-do-key directly? AFAIK this function does not invoke any internal stuffs in its interface, and in the above example of gj to <c-d>, I can simply do:

map global goto j '<esc>:smooth-scroll-do-key "<c-d>"'

which seem clean enough to me.

I also notice a few tweaks, haven’t dived into them in the source yet:

  1. gg does not seem to work out-of-box for me, but ge works fine.

  2. m, when triggering scrolling, seem to always over-scroll exactly 6 lines, no matter scrolling upward or downward. This one is quite severe, as the selection actually breaks as well due to the over-scrolling.

  3. <c-d> and <c-u> triggers smooth-scrolling only when the line the selection is on before scrolling will be out of screen after <c-d> or <c-u>. While this behaviour is suitable for other keys. In my opinion, <c-d> and <c-u> should trigger smooth scrolling unconditionally, as they unconditionally move the view.

Last but not least, installing the v2 branch via plug.kak requires an extra branch argument to the plug command, which should be documented in the README.md of the v2 branch. Ignore this if you are planning to merge v2 into master in the near future.

Sorry for so many complaints. I actually enjoy the plugin very much. Thanks for your efforts on it ; )

1 Like

No problem, your comments are very helpful to figure out any potential issues with configs other than mine.

You are right that is not supported right now. For that use case it should be fine to map to smooth-scroll-do-key directly, like you suggested. I can expose it as non-hidden and document it, but right now you can accomplish what you want: you just need to pass two arguments (first one is the key to enter the mode, in this case it is empty) and the second argument is the key but angle brackets are escaped, i.e.

map global goto j "<esc>: smooth-scroll-do-key '' <lt>c-f<gt><ret>"

Can you share your config to help debug? I don’t know why gg wouldn’t work while other goto mappings would, unless it clashes with an existing setting. m behavior might be due to scrolloff, although I also have it enabled. Does m behave differently when smooth scroll is disabled?

Yes this is due to how draft mode works like I noted in the caveats section of the new README, linked to a Kakoune issue. These can be handled as a special case too, but maybe later.

I was planning to merge to master this weekend but I think your above two issues are worth investigating.

Can you share your config to help debug? I don’t know why gg wouldn’t work while other goto mappings would, unless it clashes with an existing setting. m behavior might be due to scrolloff , although I also have it enabled. Does m behave differently when smooth scroll is disabled?

I managed to reproduce both with a fairly minimal config:

plug "caksoylar/kakoune-smooth-scroll" branch "v2" config %{
    hook global ClientCreate .* %{
        smooth-scroll-enable
    }
}

plus other lines loading plug.kak, and option settings like tabstop and line numbers. All plugins except for plug.kak and kakoune-smooth-scroll are not loaded, so do all user key bindings. So it is probably not a clash with other parts of the config.

For the m issue, ordinary m works fine, and I actually notice something more today. An ordinary m in kak will select what’s between the pair of tokens, but the aforementioned over-scroll on smooth m will select nothing. That inspired me to take a screen cast using 120fps shooting on my phone, and I can tell more about what’s the problem now.

When triggering a smooth m requiring scrolling, a “correct” smooth scroll will first be done, scrolling to the correct position and selecting the right region. But immediately after that, an extra scroll occurs, scrolling on the same direction again, cancelling the selection. As aforementioned, the correct destination is always six lines below the actual destination. The most important finding is that, the m scrolling part is actually correct, but a unwanted second scrolling somewhat occurs. Again, this can be reproduced in a minimal config environment.

Thanks, I’ll give it a try myself. Can you also let me know your Kakoune version/commit just in case it matters? Also you don’t see any messages in *debug* buffer do you?

OMG how could I forget to check *debug*…
Actually, it does say something, and I have found out perhaps part of the problem now.
Starting from line 181 of smooth-scroll.kak:

amount=$1
        # try to run the python version
        if type python3 >/dev/null 2>&1 && [ -f "$kak_opt_scroll_py" ]; then
            python3 "$kak_opt_scroll_py" "$amount" >/dev/null 2>&1 </dev/null &
            printf 'set-option window scroll_running %s\n' "$!"
            return
        fi

the return won’t work. You need to use exit inside %sh{...} blocks. This would cause fallback shell script below to be executed, which can potentially cause errors. There’s one more such return on line 171. After changing both line to exit 0, both the gg and m issues are solved!

Wow that is a good catch, thanks! Admittedly I didn’t know about the return / exit difference in %sh scope. Somehow both work in my case (with dash) but I pushed an update with your fix.