feedkeys と向き合う with Denops
はじめに
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()
のヘルプを読むと、x
や x!
のフラグを使うことで待てるようです。
: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 の力に頼りましょう。
手順は以下の通りです。
-
denops#notify
で機能を呼び出す。 -
feedkeys()
でキーを送信する。 - Promise を作成する。
-
feedkeys()
で「Vim 側から Promise を解決する通知を送るキー」を送信し、Promise の解決を待つ。 -
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>`);
}
- この方法は
denops#request()
の中では実現できません。
denops#request()
は Vim をブロックするのでfeedkeys()
は反映されず、一生待ち続けることになります。 -
feedkeys()
で目的のキーを送信します。 - Promise とそれを解決する resolve 関数を生成します。
- この
resolve()
を Vim から呼ぶため、lambda.register()
で登録します。
これでresolve()
をdenops#notify()
から呼べるようになります。
useExprString()
やらなんやら書いているのは\<Cmd>
や\<CR>
を送るためです。 - Promise の解決を待ちます。
これで console.log
の結果は foo
が入力されたあとのバッファになります。
helper/keymap.ts
...結構大変ですね?
feedkeys
を複数回呼ぶと batch()
を使わないと結構遅くなりますし、これを毎回書くのは面倒です。
という訳で denops-std
に入れてもらいました。
この send()
を使えば簡単に、同期的な feedkeys()
が使えます。
終わり
feedkeys()
の x!
フラグ、バグってるのか使い方間違ってるのか。
参考
Vim.Keymap
に同様の実装があります。こちらは Lua のコルーチンを使って反映を待ちます。
Discussion