From 329ead1f68ef56bee086837560a28331e41f8a80 Mon Sep 17 00:00:00 2001 From: delphinus Date: Mon, 21 Sep 2026 16:15:12 +0900 Subject: [PATCH] feat: put the cursor back where the kakutei undo was asked from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking a kakutei back now walks the cursor to it, so confirming something else leaves the cursor in the middle of the line rather than where the undo was asked from. Typing on is the case the previous commit is for, which makes this the common way out of it: notice the mis-conversion, undo, pick, and then have to walk back by hand to carry on. Remember where the undo was asked from and put the cursor there once the replacement has been written. Something of another length shifts the column by the difference, so the cursor keeps its place in the text rather than its column. ddskk has this as skk-undo-kakutei-return-previous-point and leaves it off by default. It is on here, and not configurable, because the situation only arises from the previous commit: before it, the undo could not move the cursor at all, so there was nothing to come back from. The length adjustment is the one skk-kakutei-subr does. The place reaches Vim by two routes, because the text reaches the buffer by two: - A kakutei out of a henkan is written by the keys the key handling returns, so the place rides along at the end of the same feedkeys(). That needs restoreLnum / restoreCol on the handle result -- the one part of this which touches the return path of every key handling rather than the undo alone. - A completion source has already written when it reports through completeCallback, so there are no keys to ride along with and skkeleton#restore_point() is called right away. Three things have to hold for the cursor to be sent back. The undo has to still own the spot. That is asked at the start of each key handling, and before the pre-edit mismatch resets the state: a source which writes the candidate it is only selecting makes the buffer disagree with the pre-edit, and the reading it is about to complete is very much still pending. Taking the reading all the way back with cancel does drop it. Nothing else may have been started where the cursor now is. One key can confirm and open a henkan after it -- an uppercase key does both -- and the cursor belongs to the one it opened. For a kakutei out of a henkan that is settled once the handling is over; for a completion the confirmation can arrive later still, since CompleteDone fires after the mapping of the key which ended the completion (measured) and an engine of its own may schedule the report on top of that, so it is answered with whether skkeleton is done or is still on the very reading being confirmed. The cursor has to be on the line the undo happened on. A kakutei out of inserts a newline unless eggLikeNewline is set, and a column of the line it came from means nothing on the next one. An undo which never moved the cursor records the column the kakutei ends at anyway, so nothing visibly happens for it. 確認: deno task fmt-check / lint 指摘なし deno check main.ts 通る DENOPS_TEST_DENOPS_PATH を設定して全テスト 前のコミット 118 passed / 0 failed このコミット 126 passed / 0 failed (追加した 8 件ぶん) doc/skkeleton-functions.jax 追加分は表示幅 78 桁以内 autoload/skkeleton.vim Vim 9.1 / Neovim の双方で読める Co-Authored-By: Claude Opus 5 (1M context) --- autoload/skkeleton.vim | 22 ++ denops/skkeleton/completefunc_test.ts | 65 +++++ denops/skkeleton/context.ts | 74 ++++++ denops/skkeleton/function/common.ts | 30 +++ denops/skkeleton/function/common_test.ts | 312 +++++++++++++++++++++++ denops/skkeleton/main.ts | 29 ++- doc/skkeleton-functions.jax | 3 + 7 files changed, 534 insertions(+), 1 deletion(-) diff --git a/autoload/skkeleton.vim b/autoload/skkeleton.vim index ef21fef55..d672f4341 100644 --- a/autoload/skkeleton.vim +++ b/autoload/skkeleton.vim @@ -268,6 +268,15 @@ function! skkeleton#handle(func, opts) abort let result = "\" .. result[5:] .. "\" endif + " Put the cursor back where |skkeleton-functions-kakuteiUndo| walked away + " from. This has to happen after the text has landed, so it rides along at + " the end of the same feedkeys() rather than being called from here. + let restore_col = get(ret, 'restoreCol', 0) + if restore_col > 0 + let result ..= printf("\call skkeleton#restore_point(%d, %d)\", + \ get(ret, 'restoreLnum', 0), restore_col) + endif + call skkeleton#doautocmd() if get(a:opts, 'expr', v:false) @@ -446,6 +455,19 @@ function! skkeleton#locate_kakutei(bufnr, lnum, before, kakutei) abort return v:true endfunction +" Put the cursor at {col}, a byte column of {lnum}, once whatever replaced the +" taken back kakutei has been written. Fed at the end of the same keys as that +" kakutei, so the line is already the final one by the time this runs. +" {lnum} is where the undo happened: a kakutei followed by a newline leaves the +" cursor on another line, and a column of the line it came from means nothing +" there. +function! skkeleton#restore_point(lnum, col) abort + if mode() !=# 'i' || line('.') != a:lnum + return + endif + call cursor(a:lnum, a:col) +endfunction + function! skkeleton#initialize() abort call skkeleton#notify_async('initialize', []) endfunction diff --git a/denops/skkeleton/completefunc_test.ts b/denops/skkeleton/completefunc_test.ts index 1e3f937af..c71495b43 100644 --- a/denops/skkeleton/completefunc_test.ts +++ b/denops/skkeleton/completefunc_test.ts @@ -179,3 +179,68 @@ test({ } }, }); + +// Note: the bundled completefunc reports a confirmation from CompleteDone, +// which Vim fires after the mapping of the key that ended the completion +// (measured). So the same ordering a completion engine of its own can +// produce applies here too, and the cursor of +// |skkeleton-functions-kakuteiUndo| has to survive it +test({ + mode: "nvim", // can input mode test only in nvim + name: "complete_done leaves the cursor to what a key has started since", + async fn(denops: Denops) { + const lib = await currentLibrary.get(); + await lib.registerHenkanResult("okurinasi", "かく", "各"); + await lib.registerHenkanResult("okurinasi", "かくい", "各位"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + await denops.cmd("call skkeleton#config(#{immediatelyCancel: v:false})"); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + const done = async (midasi: string, word: string) => { + await denops.cmd( + "let v:completed_item = #{word: word, user_data: userData}", + { + word, + userData: JSON.stringify({ + tag: "skkeleton", + midasi, + word, + type: "okurinasi", + }), + }, + ); + await denops.cmd("call skkeleton#complete_done()"); + }; + + // the completion confirms 各位 out of ▽かく + for (const key of ["K", "a", "k", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + await denops.call("setline", 1, "各位"); + await denops.call("cursor", 1, 7); + await done("かくい", "各位"); + assertEquals(await denops.call("getline", 1), "各位"); + + // take it back, go to the reading and shorten it + for (const key of ["", "", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await denops.call("getline", 1), "▽かく"); + + // 各 is only selected, and an uppercase key starts a new henkan point + // instead of confirming it. CompleteDone comes after that mapping + await denops.call("setline", 1, "各"); + await denops.call("cursor", 1, 4); + await denops.cmd('call skkeleton#handle("handleKey", {"key": "J"})'); + assertEquals(await denops.call("getline", 1), "各▽j"); + const col = await denops.call("col", "."); + await done("かく", "各"); + + // the cursor belongs to the ▽ that key has started, not to the column the + // undo remembered (which would be 4) + assertEquals(await denops.call("col", "."), col); + }, +}); diff --git a/denops/skkeleton/context.ts b/denops/skkeleton/context.ts index f375ecfb9..05dad259b 100644 --- a/denops/skkeleton/context.ts +++ b/denops/skkeleton/context.ts @@ -48,6 +48,30 @@ type PendingKakuteiResult = Omit & { tail: string; }; +// where the cursor was when |skkeleton-functions-kakuteiUndo| walked away from +// it, so that the kakutei which picks another candidate can put it back +// columns are byte ones, the way Vim counts them +type RememberedPoint = { + // the line it was on: the cursor is not sent back to a column of a line it + // has since left, as it does when a kakutei is followed by a newline + lnum: number; + col: number; + // the byte length of the taken back kakutei, so that whatever is confirmed + // instead shifts the cursor by the difference + bytes: number; +}; + +// where Vim has to put the cursor back once it has applied a key handling +type CursorPoint = { + lnum: number; + col: number; +}; + +// Vim counts columns in bytes +function byteLength(str: string): number { + return new TextEncoder().encode(str).length; +} + export class Context { denops?: Denops; state: State = initializeState({}); @@ -68,6 +92,56 @@ export class Context { }; lastKakutei: KakuteiResult | undefined; pendingKakutei: PendingKakuteiResult | undefined; + #rememberedPoint: RememberedPoint | undefined; + // where Vim has to put the cursor once it has applied the output of this key + // handling, or undefined for leaving it where the output ends + restorePoint: CursorPoint | undefined; + + // remember where the cursor is standing, before kakuteiUndo() walks it back + // to the kakutei it is about to delete + recordPointRestore(kakutei: string, bufferText: string) { + const col = byteLength(this.prevInput) + 1; + // Note: the cursor was inside the kakutei, so the column it was at means + // nothing once something of another length has replaced it + this.#rememberedPoint = col >= byteLength(bufferText) + 1 + ? { lnum: this.lnum, col, bytes: byteLength(kakutei) } + : void 0; + } + + // the byte column to put the cursor back at, now that {kakutei} is what has + // been written where the undo walked away from, or 0 when there is nothing to + // put back + // the column is shifted by however much longer or shorter {kakutei} is than + // what was taken back, so the cursor keeps its place in the text + // an undo which never moved the cursor gives the column the kakutei ends at + // anyway, so nothing visibly happens for it + usePointRestore(kakutei: string): CursorPoint | undefined { + const restore = this.#rememberedPoint; + this.#rememberedPoint = void 0; + return restore + ? { + lnum: restore.lnum, + col: restore.col + byteLength(kakutei) - restore.bytes, + } + : void 0; + } + + // give up on putting the cursor back + // called once skkeleton has nothing pending where the undo happened: the + // reading may have been taken all the way back with cancel, or skkeleton + // disabled, and a column remembered for a spot nobody is editing any more + // would move the cursor on some unrelated kakutei later on + forgetPointRestore() { + this.#rememberedPoint = void 0; + } + + // whether skkeleton is still writing where the undo left off: a henkan, a + // reading being typed, or a pre-edit waiting to become one + get hasPendingInput(): boolean { + const state = this.state; + return state.type !== "input" || state.mode !== "direct" || + state.feed !== "" || this.preEdit.current !== ""; + } // remember a kakutei so that |skkeleton-functions-kakuteiUndo| can take it // back diff --git a/denops/skkeleton/function/common.ts b/denops/skkeleton/function/common.ts index 321be23a3..d1b98bf5d 100644 --- a/denops/skkeleton/function/common.ts +++ b/denops/skkeleton/function/common.ts @@ -37,6 +37,13 @@ export async function kakutei(context: Context) { context.kakuteiWithUndoPoint(ret); // Note: remember what is needed to take this kakutei back context.recordKakutei("henkan", ret, snapshot); + // Note: a henkan which an undo has restored owes the cursor its place + // back, now that the replacement is known and its length with it + // the text is written by the keys this handling returns, so the + // point rides along with them (see |skkeleton#restore_point()|) + // whether it is used at all is decided once the handling is over: + // the same key can confirm a henkan and open a new one after it + context.restorePoint = context.usePointRestore(ret); break; } case "input": { @@ -80,6 +87,25 @@ export async function completionKakutei( ) { return; } + // Note: the engine has already written, so unlike a kakutei out of a henkan + // there are no keys for the point to ride along with and Vim is told + // right away + // asked before the lookup below, because the buffer holds the text even + // when the candidate turns out not to be in the dictionary + const restore = context.usePointRestore(inserted); + // Note: the report can arrive after a key handling has started something else + // where the cursor now is -- a new henkan point, or a reading being + // typed on -- and the cursor belongs to that + const onThisReading = state.mode !== "direct" && state.henkanFeed === midasi; + if ( + restore && (!context.hasPendingInput || onThisReading) && context.denops + ) { + await context.denops.call( + "skkeleton#restore_point", + restore.lnum, + restore.col, + ); + } // Note: the completion has never been in a henkan state, so the candidates // are looked up instead of being restored from a snapshot // this happens after the learning, hence the confirmed candidate comes @@ -163,6 +189,10 @@ export async function kakuteiUndo(context: Context) { context.kakutei("\b".repeat(graphemeLength(last.kakutei))); const restored = { ...last.state }; context.state = restored; + // Note: prevInput still tells where the cursor was when the undo was asked + // for, which is where the kakutei picking another candidate puts it + // back + context.recordPointRestore(last.kakutei, last.bufferText); context.invalidateKakutei(); if (context.mode !== last.mode) { await modeChange(context, last.mode); diff --git a/denops/skkeleton/function/common_test.ts b/denops/skkeleton/function/common_test.ts index aacd36471..fef882114 100644 --- a/denops/skkeleton/function/common_test.ts +++ b/denops/skkeleton/function/common_test.ts @@ -827,3 +827,315 @@ Deno.test({ assertEquals(context.mode, "hira"); }, }); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo puts the cursor back where it was asked from", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + // Note: 試 is a byte shorter than the others, so the column it is put back + // to cannot be the one it was saved at + await l.registerHenkanResult("okurinasi", "てすと", "試"); + await l.registerHenkanResult("okurinasi", "てすと", "手酢戸"); + await l.registerHenkanResult("okurinasi", "てすと", "テスト"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + await denops.cmd("startinsert"); + + for (const key of ["T", "e", "s", "u", "t", "o", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + for (const key of ["s", "u", "r", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "テストする"); + // the cursor is at the end of the line, which is where the undo is asked + // from and where it has to come back to + assertEquals(await fn.col(denops, "."), 16); + + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "▼テストする"); + + // pick 試, which is 6 bytes shorter than テスト + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "試する"); + // back to the end of the line, not to where 試 ends (which would be 4) + assertEquals(await fn.col(denops, "."), 10); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo puts the cursor back after a completion has confirmed", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "てすと", "手酢戸"); + await l.registerHenkanResult("okurinasi", "てすと", "テスト"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + // Note: cancel has to stop at the reading for the completion to have + // something to complete + await denops.cmd("call skkeleton#config(#{immediatelyCancel: v:false})"); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + for (const key of ["T", "e", "s", "u", "t", "o", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + for (const key of ["s", "u", "r", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "テストする"); + assertEquals(await fn.col(denops, "."), 16); + + // take it back, then go from the henkan back to the reading with cancel + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "▽てすとする"); + + // mimics a completion engine replacing the pre-edit with the item it + // confirms and reporting it back through the completion source + await denops.cmd("call setline('.', '手酢戸する')"); + await denops.cmd("call cursor(1, 10)"); + await denops.dispatcher.completeCallback( + "てすと", + "手酢戸", + "okurinasi", + "手酢戸", + ); + assertEquals(await fn.getline(denops, "."), "手酢戸する"); + // back to the end of the line, not to where 手酢戸 ends (which would be 10) + assertEquals(await fn.col(denops, "."), 16); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo forgets the cursor once nothing is left to confirm", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "てすと", "手酢戸"); + await l.registerHenkanResult("okurinasi", "てすと", "テスト"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + // Note: spelled out because config is shared between the tests in this + // file, and cancelling all the way at once is what this one needs + await denops.cmd("call skkeleton#config(#{immediatelyCancel: v:true})"); + await denops.cmd("startinsert"); + + for (const key of ["T", "e", "s", "u", "t", "o", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + for (const key of ["s", "u", "r", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "テストする"); + + // take it back and then give it up, which leaves nothing to be confirmed + // where the undo happened + for (const key of ["", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "する"); + + // an unrelated kakutei has to end where it ends, not at the column the + // abandoned undo remembered (which would be 16) + for (const key of ["T", "e", "s", "u", "t", "o", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "テストする"); + assertEquals(await fn.col(denops, "."), 10); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo puts the cursor back after a completion of a completion", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "ほかん", "補間"); + await l.registerHenkanResult("okurinasi", "ほかん", "補完"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + await denops.cmd("call skkeleton#config(#{immediatelyCancel: v:false})"); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + // a completion confirms 補完, then the rest is typed on + for (const key of ["H", "o"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + await denops.cmd("call setline('.', '補完')"); + await denops.cmd("call cursor(1, 7)"); + await denops.dispatcher.completeCallback( + "ほかん", + "補完", + "okurinasi", + "補完", + ); + for (const key of ["s", "u", "r", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "補完する"); + assertEquals(await fn.col(denops, "."), 13); + + // take it back and go from the henkan to the reading + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "▼補完する"); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "▽ほかんする"); + + // an engine with auto_insert writes its preview into the buffer while the + // item is only being selected. The next key handling sees that as a + // mismatch against the pre-edit and resets the state over it, which must + // not be taken for the reading having been given up on + await denops.cmd("call setline('.', '補間する')"); + await denops.cmd("call cursor(1, 7)"); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + + // the selected item is then confirmed + await denops.dispatcher.completeCallback( + "ほかん", + "補間", + "okurinasi", + "補間", + ); + assertEquals(await fn.getline(denops, "."), "補間する"); + assertEquals(await fn.col(denops, "."), 13); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo leaves the cursor to what a key has started since", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "かく", "各"); + await l.registerHenkanResult("okurinasi", "かくい", "各位"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + await denops.cmd("call skkeleton#config(#{immediatelyCancel: v:false})"); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + // a completion confirms 各位 out of ▽かく + for (const key of ["K", "a", "k", "u"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + await denops.cmd("call setline('.', '各位')"); + await denops.cmd("call cursor(1, 7)"); + await denops.dispatcher.completeCallback( + "かくい", + "各位", + "okurinasi", + "各位", + ); + assertEquals(await fn.getline(denops, "."), "各位"); + + // take it back, go to the reading and shorten it + for (const key of ["", "", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "▽かく"); + + // 各 is only selected, and an uppercase key starts a new henkan point + // instead of confirming it + await denops.cmd("call setline('.', '各')"); + await denops.cmd("call cursor(1, 4)"); + await denops.cmd('call skkeleton#handle("handleKey", {"key": "J"})'); + assertEquals(await fn.getline(denops, "."), "各▽j"); + const col = await fn.col(denops, "."); + + // the engine reports the selected candidate afterwards, from a scheduled + // callback. The cursor belongs to the ▽ that key has started, not to the + // column the undo remembered (which would be 4) + await denops.dispatcher.completeCallback("かく", "各", "okurinasi", "各"); + assertEquals(await fn.col(denops, "."), col); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo leaves the cursor to a henkan the same key has opened", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "かく", "各"); + await l.registerHenkanResult("okurinasi", "かく", "核"); + await l.registerHenkanResult("okurinasi", "かくい", "各位"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + await denops.cmd( + "call skkeleton#config(#{immediatelyCancel: v:false, eggLikeNewline: v:true})", + ); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + // 各位 is confirmed and the rest is typed on + for (const key of ["K", "a", "k", "u", "i", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + for (const key of ["h", "o", "g", "e", "h", "o", "g", "e"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "各位ほげほげ"); + assertEquals(await fn.col(denops, "."), 19); + + // take it back, shorten the reading and convert again + for (const key of ["", "", "", " "]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "▼核ほげほげ"); + + // an uppercase key confirms 核 and opens a henkan after it in one go. The + // cursor belongs to the one it has opened, not to the column the undo + // remembered (which would be 16, in the middle of ほげほげ) + await denops.cmd('call skkeleton#handle("handleKey", {"key": "J"})'); + assertEquals(await fn.getline(denops, "."), "核▽jほげほげ"); + assertEquals(await fn.col(denops, "."), 8); + }, +}); + +test({ + mode: "nvim", // can input mode test only in nvim + name: "kakutei undo does not follow the cursor onto a new line", + async fn(denops: Denops) { + const l = await currentLibrary.get(); + await l.registerHenkanResult("okurinasi", "かく", "核"); + await denops.cmd( + 'call skkeleton#register_keymap("input", "", "kakuteiUndo")', + ); + // Note: a kakutei out of inserts a newline without this + await denops.cmd("call skkeleton#config(#{eggLikeNewline: v:false})"); + await denops.cmd("set virtualedit=onemore"); + await denops.cmd("startinsert"); + + for (const key of ["K", "a", "k", "u", " ", ""]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + for (const key of ["h", "o", "g", "e"]) { + await denops.cmd(`call skkeleton#handle("handleKey", {"key": "${key}"})`); + } + assertEquals(await fn.getline(denops, "."), "核ほげ"); + + // the undo walks back, and confirming with both writes the candidate + // and breaks the line + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + assertEquals(await fn.getline(denops, "."), "▼核ほげ"); + await denops.cmd('call skkeleton#handle("handleKey", {"key": ""})'); + + // the cursor is on the line the newline has started, so the column the undo + // remembered means nothing and is not used + assertEquals(await fn.getline(denops, 1), "核"); + assertEquals(await fn.getline(denops, 2), "ほげ"); + assertEquals(await fn.line(denops, "."), 2); + assertEquals(await fn.col(denops, "."), 1); + }, +}); diff --git a/denops/skkeleton/main.ts b/denops/skkeleton/main.ts index 49650a9cb..3cf230422 100644 --- a/denops/skkeleton/main.ts +++ b/denops/skkeleton/main.ts @@ -44,6 +44,11 @@ type HandleResult = { phase: string; }; result: string; + // where to put the cursor once {result} has been applied, or 0 for leaving it + // where the applied text ends + // |skkeleton-functions-kakuteiUndo| is the only thing asking for this so far + restoreLnum: number; + restoreCol: number; }; const isOpts = is.ObjectOf({ @@ -233,7 +238,15 @@ async function handle( } function buildResult(result: string): HandleResult { - const state = currentContext.get().state; + const context = currentContext.get(); + const state = context.state; + // Note: asked for once and then forgotten, so that it does not travel along + // with every following key handling + // dropped when the handling has left something pending: an uppercase + // key confirms a henkan and opens a new one after it in one go, and + // the cursor belongs to the one it has opened + const restore = context.hasPendingInput ? void 0 : context.restorePoint; + context.restorePoint = void 0; let phase = ""; if (state.type === "input") { if (state.mode === "okurinasi") { @@ -252,6 +265,8 @@ function buildResult(result: string): HandleResult { phase, }, result, + restoreLnum: restore?.lnum ?? 0, + restoreCol: restore?.col ?? 0, }; } @@ -301,6 +316,18 @@ export const main: Entrypoint = async (denops) => { context.prevInput = prevInput; context.bufnr = bufnr; context.lnum = lnum; + // Note: a column |skkeleton-functions-kakuteiUndo| has remembered is only + // good while skkeleton is still writing where the undo happened. + // Once there is nothing pending -- the reading taken all the way + // back with cancel, say -- nobody is going to confirm anything + // there, and the column must not move the cursor on some unrelated + // kakutei later on. + // asked before the mismatch below resets the state: a completion + // engine writing its preview into the buffer is a mismatch, and + // the reading it is about to complete is very much still pending + if (!context.hasPendingInput) { + context.forgetPointRestore(); + } // only now is it known where a kakutei has been written to the buffer const resolved = context.resolvePendingKakutei(); // 補完の後などpreEditとバッファが不一致している状態の時にリセットする diff --git a/doc/skkeleton-functions.jax b/doc/skkeleton-functions.jax index bf93756eb..4a573ab39 100644 --- a/doc/skkeleton-functions.jax +++ b/doc/skkeleton-functions.jax @@ -22,6 +22,9 @@ kakuteiUndo *skkeleton-functions-kakuteiUndo* もしません。 カーソルを戻すのは挿入モードのみです。コマンドラインモードと端末モー ドでは、従来どおりカーソルの直前に残っている場合のみ動作します。 + 選び直した候補を確定すると、カーソルは確定アンドゥを実行した位置に + 戻ります。選び直した候補の長さが元と違う場合は、その差だけずれた位置 + になります。 |skkeleton-function-purgeCandidate| で候補を削除した後と、辞書登録 による確定も取り消せません。skkeletonを無効化した場合も忘れます。 ユーザー辞書への学習は取り消されません。