Skip to content
Merged
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
22 changes: 22 additions & 0 deletions autoload/skkeleton.vim
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,15 @@ function! skkeleton#handle(func, opts) abort
let result = "\<Cmd>" .. result[5:] .. "\<CR>"
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("\<Cmd>call skkeleton#restore_point(%d, %d)\<CR>",
\ get(ret, 'restoreLnum', 0), restore_col)
endif

call skkeleton#doautocmd()

if get(a:opts, 'expr', v:false)
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions denops/skkeleton/completefunc_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<C-u>", "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 ["<c-u>", "<c-g>", "<c-h>"]) {
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);
},
});
74 changes: 74 additions & 0 deletions denops/skkeleton/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ type PendingKakuteiResult = Omit<KakuteiResult, "bufferText"> & {
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({});
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions denops/skkeleton/function/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading