Skip to content

Commit fa4c32a

Browse files
committed
👍 Add popup module as a compatibility layer for popup window in Vim and Neovim.
1 parent 0e0cf96 commit fa4c32a

File tree

5 files changed

+672
-0
lines changed

5 files changed

+672
-0
lines changed

popup/mod.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* A module to provide compatibility layer for popup window in Vim and Neovim.
3+
*
4+
* ```typescript
5+
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
6+
* import * as buffer from "https://deno.land/x/denops_std@$MODULE_VERSION/buffer/mod.ts";
7+
* import * as fn from "https://deno.land/x/denops_std@$MODULE_VERSION/function/mod.ts";
8+
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
9+
*
10+
* export async function main(denops: Denops): Promise<void> {
11+
* // Create a new buffer
12+
* const bufnr = await fn.bufadd(denops, "");
13+
* await fn.bufload(denops, bufnr);
14+
*
15+
* // Write some text to the buffer
16+
* await buffer.replace(denops, bufnr, ["Hello, world!"]);
17+
*
18+
* // Open a popup window showing the buffer
19+
* const popupWindow = await popup.open(denops, {
20+
* bufnr,
21+
* relative: "editor",
22+
* width: 20,
23+
* height: 20,
24+
* row: 1,
25+
* col: 1,
26+
* });
27+
*
28+
* // Wiat 3 seconds
29+
* await new Promise((resolve) => setTimeout(resolve, 3000));
30+
*
31+
* // Close the popup window
32+
* await popupWindow.close();
33+
* }
34+
* ```
35+
*
36+
* Or with `await using` statement:
37+
*
38+
* ```typescript
39+
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
40+
* import * as buffer from "https://deno.land/x/denops_std@$MODULE_VERSION/buffer/mod.ts";
41+
* import * as fn from "https://deno.land/x/denops_std@$MODULE_VERSION/function/mod.ts";
42+
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
43+
*
44+
* export async function main(denops: Denops): Promise<void> {
45+
* // Create a new buffer
46+
* const bufnr = await fn.bufadd(denops, "");
47+
* await fn.bufload(denops, bufnr);
48+
*
49+
* // Write some text to the buffer
50+
* await buffer.replace(denops, bufnr, ["Hello, world!"]);
51+
*
52+
* // Open a popup window showing the buffer
53+
* await using popupWindow = await popup.open(denops, {
54+
* bufnr,
55+
* relative: "editor",
56+
* width: 20,
57+
* height: 20,
58+
* row: 1,
59+
* col: 1,
60+
* });
61+
*
62+
* // Wiat 3 seconds
63+
* await new Promise((resolve) => setTimeout(resolve, 3000));
64+
*
65+
* // The popup window is automatically closed, due to `await using` statement
66+
* }
67+
* ```
68+
*
69+
* Note that this module does NOT work with `batch.collect()`.
70+
*
71+
* @module
72+
*/
73+
74+
import type { Denops } from "../mod.ts";
75+
import * as fn from "../function/mod.ts";
76+
77+
import type { OpenOptions, PopupWindow } from "./types.ts";
78+
import {
79+
closePopup as closePopupVim,
80+
openPopup as openPopupVim,
81+
} from "./vim.ts";
82+
import {
83+
closePopup as closePopupNvim,
84+
openPopup as openPopupNvim,
85+
} from "./nvim.ts";
86+
87+
/**
88+
* Open a popup window showing the buffer in Vim/Neovim compatible way.
89+
*
90+
* ```typescript
91+
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
92+
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
93+
*
94+
* export async function main(denops: Denops): Promise<void> {
95+
* // Open a popup window
96+
* const popupWindow = await popup.open(denops, {
97+
* relative: "editor",
98+
* width: 20,
99+
* height: 20,
100+
* row: 1,
101+
* col: 1,
102+
* });
103+
*
104+
* // Do something with the popup window...
105+
*
106+
* // Close the popup window manually
107+
* await popupWindow.close();
108+
* }
109+
* ```
110+
*
111+
* Or with `await using` statement:
112+
*
113+
* ```typescript
114+
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
115+
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
116+
*
117+
* export async function main(denops: Denops): Promise<void> {
118+
* // Open a popup window with `await using` statement
119+
* await using popupWindow = await popup.open(denops, {
120+
* relative: "editor",
121+
* width: 20,
122+
* height: 20,
123+
* row: 1,
124+
* col: 1,
125+
* });
126+
*
127+
* // Do something with the popup window...
128+
*
129+
* // The popup window is automatically closed, due to `await using` statement
130+
* }
131+
* ```
132+
*
133+
* Note that this function does NOT work in `batch.collect()`.
134+
*/
135+
export async function open(
136+
denops: Denops,
137+
options: OpenOptions,
138+
): Promise<PopupWindow> {
139+
if (options.title && !options.border) {
140+
// Vim allows `title` without `border`, but Neovim does not.
141+
// so we throw an error here to keep consistent behavior.
142+
throw new Error("title requires border to be set");
143+
}
144+
const open = denops.meta.host === "vim" ? openPopupVim : openPopupNvim;
145+
const close = denops.meta.host === "vim" ? closePopupVim : closePopupNvim;
146+
const bufnr = options.bufnr ?? await fn.bufadd(denops, "");
147+
const winid = await open(denops, bufnr, options);
148+
if (!options.noRedraw) {
149+
await denops.redraw();
150+
}
151+
return {
152+
bufnr,
153+
winid,
154+
close: async () => {
155+
await close(denops, winid);
156+
if (!options.noRedraw) {
157+
await denops.redraw();
158+
}
159+
},
160+
[Symbol.asyncDispose]: async () => {
161+
try {
162+
await close(denops, winid);
163+
await denops.redraw();
164+
} catch {
165+
// Fail silently
166+
}
167+
},
168+
};
169+
}
170+
171+
export type { OpenOptions, PopupWindow } from "./types.ts";

popup/mod_test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts";
2+
import { test } from "https://deno.land/x/denops_test@v1.6.2/mod.ts";
3+
import * as fn from "../function/mod.ts";
4+
import * as popup from "./mod.ts";
5+
6+
test({
7+
mode: "all",
8+
name: "popup",
9+
fn: async (denops, t) => {
10+
// Extend screen size
11+
await denops.cmd("set lines=100 columns=100");
12+
13+
await t.step({
14+
name: `open() opens a popup window and close() closes it`,
15+
fn: async () => {
16+
let popupWindow: popup.PopupWindow;
17+
const inner = async () => {
18+
popupWindow = await popup.open(denops, {
19+
relative: "editor",
20+
width: 50,
21+
height: 50,
22+
row: 1,
23+
col: 1,
24+
});
25+
const { winid } = popupWindow;
26+
assertEquals(await fn.win_gettype(denops, winid), "popup");
27+
assertEquals(await fn.winwidth(denops, winid), 50);
28+
assertEquals(await fn.winheight(denops, winid), 50);
29+
};
30+
await inner();
31+
32+
// Still alive
33+
assertEquals(await fn.win_gettype(denops, popupWindow!.winid), "popup");
34+
35+
// Explicitly close
36+
await popupWindow!.close();
37+
assertEquals(
38+
await fn.win_gettype(denops, popupWindow!.winid),
39+
"unknown",
40+
);
41+
},
42+
});
43+
44+
await t.step({
45+
name: `open() with await using statement`,
46+
fn: async () => {
47+
let winid: number;
48+
const inner = async () => {
49+
await using popupWindow = await popup.open(denops, {
50+
relative: "editor",
51+
width: 50,
52+
height: 50,
53+
row: 1,
54+
col: 1,
55+
});
56+
winid = popupWindow.winid;
57+
assertEquals(await fn.win_gettype(denops, winid), "popup");
58+
assertEquals(await fn.winwidth(denops, winid), 50);
59+
assertEquals(await fn.winheight(denops, winid), 50);
60+
};
61+
await inner();
62+
63+
// Automatically disposed
64+
assertEquals(await fn.win_gettype(denops, winid!), "unknown");
65+
},
66+
});
67+
68+
// With numerous options
69+
const base: popup.OpenOptions = {
70+
relative: "editor",
71+
width: 20, // Max 20 in test
72+
height: 20, // Max 24 in test
73+
row: 50,
74+
col: 50,
75+
};
76+
const optionsSet: popup.OpenOptions[] = [
77+
{ ...base },
78+
{ ...base, relative: "cursor" },
79+
{ ...base, anchor: "NW" },
80+
{ ...base, anchor: "NE" },
81+
{ ...base, anchor: "SW" },
82+
{ ...base, anchor: "SE" },
83+
{ ...base, zindex: 100 },
84+
{ ...base, border: "single" },
85+
{ ...base, border: "double" },
86+
{ ...base, border: "rounded" },
87+
{ ...base, border: ["", "", "", "|", "", "", "", "|"] },
88+
{ ...base, border: "single", title: "Hello world!" },
89+
{ ...base, highlight: {} },
90+
{ ...base, highlight: { normal: "Normal" } },
91+
{ ...base, highlight: { border: "Border" } },
92+
{ ...base, highlight: { normal: "Normal", border: "Border" } },
93+
];
94+
for (const options of optionsSet) {
95+
await t.step({
96+
name: `open() with options: ${JSON.stringify(options)}`,
97+
fn: async () => {
98+
await using popupWindow = await popup.open(denops, options);
99+
assertEquals(
100+
await fn.win_gettype(denops, popupWindow.winid),
101+
"popup",
102+
);
103+
assertEquals(await fn.winwidth(denops, popupWindow.winid), 20);
104+
assertEquals(await fn.winheight(denops, popupWindow.winid), 20);
105+
},
106+
});
107+
}
108+
},
109+
});

popup/nvim.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Denops } from "../mod.ts";
2+
import * as nvimFn from "../function/nvim/mod.ts";
3+
import { execute } from "../helper/execute.ts";
4+
import { ulid } from "https://deno.land/std@0.217.0/ulid/mod.ts";
5+
6+
import type { Border, OpenOptions } from "./types.ts";
7+
8+
const cacheKey = "denops_std/popup/nvim.ts@1";
9+
10+
async function ensurePrerequisites(denops: Denops): Promise<string> {
11+
if (typeof denops.context[cacheKey] === "string") {
12+
return denops.context[cacheKey];
13+
}
14+
const suffix = ulid();
15+
denops.context[cacheKey] = suffix;
16+
const script = `
17+
function! DenopsStdPopupNvimOpenPopup_${suffix}(bufnr, config, winhighlight) abort
18+
let winid = nvim_open_win(a:bufnr, v:false, a:config)
19+
if a:winhighlight isnot v:null
20+
call nvim_win_set_option(winid, 'winhighlight', a:winhighlight)
21+
endif
22+
return winid
23+
endfunction
24+
`;
25+
await execute(denops, script);
26+
return suffix;
27+
}
28+
29+
export async function openPopup(
30+
denops: Denops,
31+
bufnr: number,
32+
options: Omit<OpenOptions, "bufnr" | "noRedraw">,
33+
): Promise<number> {
34+
const suffix = await ensurePrerequisites(denops);
35+
const nvimOpenWinConfig = toNvimOpenWinConfig(options);
36+
const winhighlight = toNvimWinhighlight(options.highlight);
37+
return await denops.call(
38+
`DenopsStdPopupNvimOpenPopup_${suffix}`,
39+
bufnr,
40+
nvimOpenWinConfig,
41+
winhighlight,
42+
) as number;
43+
}
44+
45+
export async function closePopup(denops: Denops, winid: number): Promise<void> {
46+
await nvimFn.nvim_win_close(denops, winid, true);
47+
}
48+
49+
function toNvimOpenWinConfig(options: OpenOptions): nvimFn.NvimOpenWinConfig {
50+
const v: nvimFn.NvimOpenWinConfig = {
51+
relative: options.relative,
52+
anchor: options.anchor,
53+
width: options.width,
54+
height: options.height,
55+
col: options.col,
56+
row: options.row,
57+
focusable: false, // To keep consistent with the behavior of Vim's `popup_create()`
58+
zindex: options.zindex,
59+
border: options.border ? toNvimBorder(options.border) : undefined,
60+
title: options.title,
61+
};
62+
return Object.fromEntries(
63+
Object
64+
.entries(v)
65+
.filter(([, v]) => v != undefined),
66+
) as nvimFn.NvimOpenWinConfig;
67+
}
68+
69+
function toNvimBorder(
70+
border: Border,
71+
): nvimFn.NvimOpenWinConfig["border"] {
72+
if (typeof border === "string") {
73+
return border;
74+
}
75+
const [lt, t, rt, r, rb, b, lb, l] = border;
76+
return [lt, t, rt, r, rb, b, lb, l];
77+
}
78+
79+
function toNvimWinhighlight(
80+
highlight: OpenOptions["highlight"],
81+
): string | null {
82+
if (!highlight) {
83+
return null;
84+
}
85+
const {
86+
normal = "FloatNormal",
87+
border = "FloatBorder",
88+
} = highlight;
89+
if (normal && border) {
90+
return `Normal:${normal},FloatBorder:${border}`;
91+
} else if (normal) {
92+
return `Normal:${normal}`;
93+
} else if (border) {
94+
return `FloatBorder:${border}`;
95+
}
96+
return null;
97+
}

0 commit comments

Comments
 (0)