⌨️

feedkeys と向き合う with Denops

2023/12/28に公開

はじめに

Vim にはユーザーの入力を模倣する手段として feedkeys() という関数があります。
Insert mode でバッファを編集する方法の一つであり、ドットリピートに対応するには避けて通れない道です。

しかしこの関数は非同期であり、関数の呼び出しからバッファに編集が実行されるまでラグがあります。
これは関数の呼び出し自体を await したところで関係ありません。
feedkeys の反映を同期的に待つにはどうすればいいのでしょうか?

結論

先に結論を述べます。

denops-std に helper/keymap.ts を入れたのでこれを使いましょう。
\<BS> などの特殊文字を使うには exprQuote を使ってください。remap も指定できます。

denops#request() の中では使えないので denops#notify() の中で利用してください。

import { Denops } from "https://deno.land/x/denops_std@v5.2.0/mod.ts";
import * as fn from "https://deno.land/x/denops_std@v5.2.0/function/mod.ts";
import { send } from "https://deno.land/x/denops_std@v5.2.0/helper/keymap.ts";
import { exprQuote as q } from "https://deno.land/x/denops_std@v5.2.0/helper/expr_string.ts";

export async function main(denops: Denops) {
  denops.dispatcher = {
    async sendkeys() {
      await send(denops, [
        "foo",
        { keys: "bar", remap: true },
        q`\<Del>`,
      ]);
      console.log(await fn.getline(denops, "."));
    },
  };
  await denops.cmd(`inoremap A <Cmd>call denops#notify('${denops.name}', 'sendkeys', [])<CR>`);
}

解説

feedkeys()x フラグ

feedkeys() のヘルプを読むと、xx! のフラグを使うことで待てるようです。

:h feedkeys
feedkeys({string} [, {mode}])                                       *feedkeys()*
		Characters in {string} are queued for processing as if they
		come from a mapping or were typed by the user.

		By default the string is added to the end of the typeahead
		buffer, thus if a mapping is still being executed the
		characters come after them.  Use the 'i' flag to insert before
		other characters, they will be executed next, before any
		characters from a mapping.

		The function does not wait for processing of keys contained in
		{string}.

		To include special keys into {string}, use double-quotes
		and "\..." notation |expr-quote|. For example,
		feedkeys("\<CR>") simulates pressing of the <Enter> key. But
		feedkeys('\<CR>') pushes 5 characters.
		The |<Ignore>| keycode may be used to exit the
		wait-for-character without doing anything.

		{mode} is a String, which can contain these character flags:
		'm'	Remap keys. This is default.  If {mode} is absent,
			keys are remapped.
		'n'	Do not remap keys.
		't'	Handle keys as if typed; otherwise they are handled as
			if coming from a mapping.  This matters for undo,
			opening folds, etc.
		'i'	Insert the string instead of appending (see above).
		'x'	Execute commands until typeahead is empty.  This is
			similar to using ":normal!".  You can call feedkeys()
			several times without 'x' and then one time with 'x'
			(possibly with an empty {string}) to execute all the
			typeahead.  Note that when Vim ends in Insert mode it
			will behave as if <Esc> is typed, to avoid getting
			stuck, waiting for a character to be typed before the
			script continues.
			Note that if you manage to call feedkeys() while
			executing commands, thus calling it recursively, then
			all typeahead will be consumed by the last call.
		'!'	When used with 'x' will not end Insert mode. Can be
			used in a test when a timer is set to exit Insert mode
			a little later.  Useful for testing CursorHoldI.

		Return value is always 0.

x だけだと Insert mode から抜けてしまうらしいので、x! で試してみましょう。

inoremap A <Cmd>call <SID>feedkeys('foo')<CR>
function s:feedkeys(keys) abort
  call feedkeys(a:keys, 'n')
  call feedkeys('', 'x!')
endfunction

はい。動きません。
バッファは変更されませんし、Normal mode でも Insert mode でもない謎のモードになります(<C-c> で抜けられます)。
これ動かせる人いたら教えてください。

NOTE

なぜかは分かりませんが、denops を経由すると動きます。

batch の中で x! の呼び出しをするとダメなので、denops の await に秘密がありそうです。

import { Denops } from "https://deno.land/x/denops_std@v5.2.0/mod.ts";
import * as fn from "https://deno.land/x/denops_std@v5.2.0/function/mod.ts";
import { assert, is } from "https://deno.land/x/unknownutil@v3.11.0/mod.ts";
// import { batch } from "https://deno.land/x/denops_std@v5.2.0/batch/mod.ts";

export async function main(denops: Denops) {
  denops.dispatcher = {
    async feedkeys(keys: unknown) {
      assert(keys, is.String);
      await fn.feedkeys(denops, keys, "n");
      await fn.feedkeys(denops, "", "x!");
      // これはダメ
      // await batch(denops, async (denops) => {
      //   await fn.feedkeys(denops, keys, "n");
      //   await fn.feedkeys(denops, "", "x!");
      // });
      console.log(await fn.getline(denops, "."));
    },
  };
  await denops.cmd(`inoremap A <Cmd>call denops#notify('${denops.name}', 'feedkeys', ['foo'])<CR>`);
}

Promise を使う

というわけで、Vim script だけでは難しそうです。
TypeScript の力に頼りましょう。

手順は以下の通りです。

  1. denops#notify で機能を呼び出す。
  2. feedkeys() でキーを送信する。
  3. Promise を作成する。
  4. feedkeys() で「Vim 側から Promise を解決する通知を送るキー」を送信し、Promise の解決を待つ。
  5. feedkeys() の結果が反映され、3. で用意した Promise が解決される。

簡略化した実装を使って流れを追ってみましょう。

import { Denops } from "https://deno.land/x/denops_std@v5.2.0/mod.ts";
import * as fn from "https://deno.land/x/denops_std@v5.2.0/function/mod.ts";
import * as lambda from "https://deno.land/x/denops_std@v5.2.0/lambda/mod.ts";
import { assert, is } from "https://deno.land/x/unknownutil@v3.11.0/mod.ts";
import {
  exprQuote as q,
  useExprString,
} from "https://deno.land/x/denops_std@v5.2.0/helper/expr_string.ts";

export async function main(denops: Denops) {
  denops.dispatcher = {
    async feedkeys(keys: unknown) {
      assert(keys, is.String);
      // 2.
      await fn.feedkeys(denops, keys, "n");
      // 3.
      const { promise, resolve } = Promise.withResolvers<void>();
      // 4.
      const id = lambda.register(denops, () => resolve(), { once: true });
      await useExprString(denops, async (denops) => {
        await fn.feedkeys(
          denops,
          q`\<Cmd>call denops#notify('${denops.name}', '${id}', [])\<CR>`,
          "n",
        );
      });
      // 5.
      await promise;
      console.log(await fn.getline(denops, "."));
    },
  };
  // 1.
  await denops.cmd(`inoremap A <Cmd>call denops#notify('${denops.name}', 'feedkeys', ['foo'])<CR>`);
}
  1. この方法は denops#request() の中では実現できません。
    denops#request() は Vim をブロックするので feedkeys() は反映されず、一生待ち続けることになります。
  2. feedkeys() で目的のキーを送信します。
  3. Promise とそれを解決する resolve 関数を生成します。
  4. この resolve() を Vim から呼ぶため、lambda.register() で登録します。
    これで resolve()denops#notify() から呼べるようになります。
    useExprString() やらなんやら書いているのは \<Cmd>\<CR> を送るためです。
  5. Promise の解決を待ちます。

これで console.log の結果は foo が入力されたあとのバッファになります。

helper/keymap.ts

...結構大変ですね?
feedkeys を複数回呼ぶと batch() を使わないと結構遅くなりますし、これを毎回書くのは面倒です。

という訳で denops-std に入れてもらいました。

https://deno.land/x/denops_std@v5.2.0/helper/keymap.ts?s=send

この send() を使えば簡単に、同期的な feedkeys() が使えます。

終わり

feedkeys()x! フラグ、バグってるのか使い方間違ってるのか。

参考

https://github.com/hrsh7th/nvim-kit

Vim.Keymap に同様の実装があります。こちらは Lua のコルーチンを使って反映を待ちます。

GitHubで編集を提案

Discussion