Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions smooth-scroll.kak
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,6 @@ define-command smooth-scroll-enable -docstring "enable smooth scrolling for wind
set-option window scroll_mode %val{hook_param}
}

# when we exit normal mode, kill the scrolling process if it is currently running
hook -group scroll window ModeChange push:normal:.* %{
evaluate-commands %sh{
if [ -n "$kak_opt_scroll_running" ]; then
kill "$kak_opt_scroll_running"
printf 'set-option window scroll_running ""\n'
fi
}
}

# started scrolling, make cursor invisible to make it less jarring
hook -group scroll window WinSetOption scroll_running=\d+ %{
set-face window PrimaryCursor @default
Expand Down Expand Up @@ -211,8 +201,14 @@ define-command smooth-scroll-move -params 1 -hidden -docstring %{
if [ "$abs_amount" -gt 1 ]; then
# try to run the python version
if type python3 >/dev/null 2>&1 && [ -f "$kak_opt_scroll_py" ]; then
python3 -S "$kak_opt_scroll_py" "$amount" >/dev/null 2>&1 </dev/null &
printf 'set-option window scroll_running %s\n' "$!"
printf 'set-option window scroll_running 1\n' \
> "$kak_command_fifo"
python3 -S "$kak_opt_scroll_py" \
"$amount" \
"$kak_command_fifo" \
"$kak_response_fifo"
printf 'set-option window scroll_running ""\n' \
> "$kak_command_fifo"
exit 0
fi

Expand Down
82 changes: 17 additions & 65 deletions smooth-scroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,76 +12,23 @@
SEND_INTERVAL = 2e-3 # min time interval (in s) between two sent scroll events


class KakSender:
"""Helper to communicate with Kakoune's remote API using Unix sockets."""

def __init__(self) -> None:
self.session = os.environ['kak_session']
self.client = os.environ['kak_client']
self.socket_path = self._get_socket_path(self.session)

def send_cmd(self, cmd: str, client: bool = False) -> bool:
"""
Send a command string to the Kakoune session. Sent data is a
concatenation of:
- Header
- Magic byte indicating type is "command" (\x02)
- Length of whole message in uint32
- Content
- Length of command string in uint32
- Command string
Return whether the communication was successful.
"""
if client:
cmd = f"evaluate-commands -client {self.client} %😬{cmd}😬"
b_cmd = cmd.encode('utf-8')
sock = socket.socket(socket.AF_UNIX)
sock.connect(self.socket_path)
b_content = self._encode_length(len(b_cmd)) + b_cmd
b_header = b'\x02' + self._encode_length(len(b_content) + 5)
b_message = b_header + b_content
return sock.send(b_message) == len(b_message)

def send_keys(self, keys: str) -> bool:
"""Send a sequence of keys to the client in the Kakoune session."""
cmd = f"execute-keys -client {self.client} {keys}"
return self.send_cmd(cmd)

@staticmethod
def _encode_length(str_length: int) -> bytes:
return str_length.to_bytes(4, byteorder=sys.byteorder)

@staticmethod
def _get_socket_path(session: str) -> str:
xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR')
if xdg_runtime_dir is None:
tmpdir = os.environ.get('TMPDIR', '/tmp')
session_path = os.path.join(
tmpdir, f"kakoune-{os.environ['USER']}", session
)
if not os.path.exists(session_path): # pre-Kakoune db9ef82
session_path = os.path.join(
tmpdir, 'kakoune', os.environ['USER'], session
)
else:
session_path = os.path.join(xdg_runtime_dir, 'kakoune', session)
return session_path


class Scroller:
"""Class to send smooth scrolling events to Kakoune."""

def __init__(self, interval: float, speed: int, max_duration: float) -> None:
def __init__(
self, interval: float, speed: int, max_duration: float
) -> None:
"""
Save scrolling parameters and initialize sender object. `interval`
is the average step duration, `speed` is the size of each scroll step
(0 implies inertial scrolling) and `max_duration` limits the total
scrolling duration.
"""
self.sender = KakSender()
self.interval = interval
self.speed = speed
self.max_duration = max_duration
self.command_fifo = sys.argv[2]
self.response_fifo = sys.argv[3]

def scroll_once(self, step: int, interval: float) -> None:
"""
Expand All @@ -91,8 +38,16 @@ def scroll_once(self, step: int, interval: float) -> None:
t_start = time.time()
speed = abs(step)
keys = f"{speed}j{speed}vj" if step > 0 else f"{speed}k{speed}vk"
self.sender.send_keys(keys)
self.sender.send_cmd("trigger-user-hook ScrollStep", client=True)
with open(self.command_fifo, "w") as handle:
handle.write(
f"""
execute-keys {keys}<c-l>
trigger-user-hook ScrollStep
echo -to-file {self.response_fifo} ''
"""
)
with open(self.response_fifo, "r") as handle:
handle.read()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question here since I don't grok response fifo very well: Is it strictly necessary to have kak write to the response fifo and us read it for this to work, or is it just to make sure kak finished processing before we send another event? If it is the latter, would the throughput improve if we remove it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin has a "duration" option, but unless there's some way for us to measure how long Kakoune spends doing stuff, we're only measuring the duration of sending messages, not the duration of the actual animation. Telling Kakoune to write to $kak_response_fifo after processing the message, and waiting for it to do so, gives us the timing information we need.

Deleting it slowed things way down for me - instead of drawing two or three frames over the intended quarter-second, it drew a smoother 5-10 frames over a full second.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the point of getting more accurate timing information about the actual change; I assume below is what is happening if Kakoune cannot process the command inside interval duration:

  1. With response fifo, we will correctly tell that it took longer than the interval and wait until we send the next event, regardless of interval. I assume the max_duration condition is triggered after a few scroll events, so it will finish in a quarter second by jumping to the end.
  2. Without response fifo, we might underestimate run time and still sleep, probably until interval is elapsed, but send a new event immediately after it is elapse. We might end up just sending all scroll events one-by-one and never trigger max_duration. So we queue up many scroll events instead and Kakoune gets backed up and takes time to finish up the queue.

In hindsight this issue is present in the original implementation as well, but I guess the scroll event is just faster to complete Kakoune-side? I don't know if writing to response fifo would slow it down much, or <c-l> is costlier than the regular screen redraw Kakoune makes on a line scroll. I can try to benchmark the cost of these Kakoune-side and see if it is much significantly than just executing exec jvj.

Another idea: Since in this case we can know how long a scroll event exactly takes, we could actually try to play catch up by sending intermediate scroll events for multiple lines at once, when we determine we are behind timingwise. The bookkeeping to do that doesn't seem very trivial though.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should note I've been testing on a tiny, slow netbook, so any performance problems would be magnified. The original "blast keys at the socket" implementation is actually pretty smooth, but the implementation in this PR is basically unusable (one of the reasons it's a draft PR).

vk probably just redraws a single line, while <c-l> redraws the entire screen so it can recover from "the screen isn't in the state Kakoune thinks it's in". This can be a significant difference on a big terminal, but my little laptop only has a 720p display so I'm not using a very big terminal, I'd be a little surprised if this were a big issue.

Animation timing is a whole thing, yeah. If you're lucky enough that drawing a frame is reliably faster than the delay between frames, then yeah, you don't have to worry too much. On the other hand, if you can't reliably meet your target frame rate, you need to structure your calculation so that the "time" unit is seconds, not frames. Once that's done, it shouldn't be too complex to say "at time t we should have scrolled 7 lines, so far we have scrolled 2, therefore we need to scroll 7-2 = 5 additional lines"

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my relatively beefy desktop I didn't see too much performance difference, but I see some overshooting with page scroll keys which shouldn't really be caused by this change (unless there is some issue with Kakoune's command fifo processing). I think I'll need to do some debugging and see what's going on :/

Right, if we restructure the code by precomputing the cumulative intervals and storing them, then looking it up at every step your suggestion should work for catching up.

By the way it might help for you to bump up the SEND_INTERVAL constant on a slower machine, so that at the very least the initial lines would be grouped up in larger batches. It doesn't really help with catching up once behind but at least it would make it harder to fall behind.

t_end = time.time()
elapsed = t_end - t_start
if elapsed < interval:
Expand Down Expand Up @@ -167,14 +122,11 @@ def scroll(self, amount: int) -> None:
else: # inertial scroll
self.inertial_scroll(amount, duration)

# report we are done
self.sender.send_cmd('set-option window scroll_running ""', client=True)


def parse_options(option_name: str) -> dict:
"""Parse a Kakoune map option and return a str-to-str dict."""
items = [
elt.split('=', maxsplit=1)
elt.split("=", maxsplit=1)
for elt in os.environ[f"kak_opt_{option_name}"].split()
]
return {v[0]: v[1] for v in items}
Expand All @@ -199,5 +151,5 @@ def main() -> None:
scroller.scroll(amount)


if __name__ == '__main__':
if __name__ == "__main__":
main()