Skip to content

Commit

Permalink
👍 Add popup module as a compatibility layer for popup window in Vim…
Browse files Browse the repository at this point in the history
… and Neovim.
  • Loading branch information
lambdalisue committed Feb 29, 2024
1 parent 188d796 commit 85ad739
Show file tree
Hide file tree
Showing 5 changed files with 668 additions and 0 deletions.
167 changes: 167 additions & 0 deletions popup/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* A module to provide compatibility layer for popup window in Vim and Neovim.
*
* ```typescript
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
* import * as buffer from "https://deno.land/x/denops_std@$MODULE_VERSION/buffer/mod.ts";
* import * as fn from "https://deno.land/x/denops_std@$MODULE_VERSION/function/mod.ts";
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
*
* export async function main(denops: Denops): Promise<void> {
* // Create a new buffer
* const bufnr = await fn.bufadd(denops, "");
* await fn.bufload(denops, bufnr);
*
* // Write some text to the buffer
* await buffer.replace(denops, bufnr, ["Hello, world!"]);
*
* // Open a popup window showing the buffer
* const popupWindow = await popup.open(denops, {
* bufnr,
* relative: "editor",
* width: 20,
* height: 20,
* row: 1,
* col: 1,
* });
*
* // Wiat 3 seconds
* await new Promise((resolve) => setTimeout(resolve, 3000));
*
* // Close the popup window
* await popupWindow.close();
* }
* ```
*
* Or with `await using` statement:
*
* ```typescript
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
* import * as buffer from "https://deno.land/x/denops_std@$MODULE_VERSION/buffer/mod.ts";
* import * as fn from "https://deno.land/x/denops_std@$MODULE_VERSION/function/mod.ts";
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
*
* export async function main(denops: Denops): Promise<void> {
* // Create a new buffer
* const bufnr = await fn.bufadd(denops, "");
* await fn.bufload(denops, bufnr);
*
* // Write some text to the buffer
* await buffer.replace(denops, bufnr, ["Hello, world!"]);
*
* // Open a popup window showing the buffer
* await using popupWindow = await popup.open(denops, {
* bufnr,
* relative: "editor",
* width: 20,
* height: 20,
* row: 1,
* col: 1,
* });
*
* // Wiat 3 seconds
* await new Promise((resolve) => setTimeout(resolve, 3000));
*
* // The popup window is automatically closed, due to `await using` statement
* }
* ```
*
* Note that this module does NOT work with `batch.collect()`.
*
* @module
*/

import type { Denops } from "../mod.ts";
import * as fn from "../function/mod.ts";

import type { OpenOptions, PopupWindow } from "./types.ts";
import {
closePopup as closePopupVim,
openPopup as openPopupVim,
} from "./vim.ts";
import {
closePopup as closePopupNvim,
openPopup as openPopupNvim,
} from "./nvim.ts";

/**
* Open a popup window showing the buffer in Vim/Neovim compatible way.
*
* ```typescript
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
*
* export async function main(denops: Denops): Promise<void> {
* // Open a popup window
* const popupWindow = await popup.open(denops, {
* relative: "editor",
* width: 20,
* height: 20,
* row: 1,
* col: 1,
* });
*
* // Do something with the popup window...
*
* // Close the popup window manually
* await popupWindow.close();
* }
* ```
*
* Or with `await using` statement:
*
* ```typescript
* import type { Denops } from "https://deno.land/x/denops_std@$MODULE_VERSION/mod.ts";
* import * as popup from "https://deno.land/x/denops_std@$MODULE_VERSION/popup/mod.ts";
*
* export async function main(denops: Denops): Promise<void> {
* // Open a popup window with `await using` statement
* await using popupWindow = await popup.open(denops, {
* relative: "editor",
* width: 20,
* height: 20,
* row: 1,
* col: 1,
* });
*
* // Do something with the popup window...
*
* // The popup window is automatically closed, due to `await using` statement
* }
* ```
*
* Note that this function does NOT work in `batch.collect()`.
*/
export async function open(
denops: Denops,
options: OpenOptions,
): Promise<PopupWindow> {
if (options.title && !options.border) {
// Vim allows `title` without `border`, but Neovim does not.
// so we throw an error here to keep consistent behavior.
throw new Error("title requires border to be set");
}

Check warning on line 143 in popup/mod.ts

View check run for this annotation

Codecov / codecov/patch

popup/mod.ts#L140-L143

Added lines #L140 - L143 were not covered by tests
const open = denops.meta.host === "vim" ? openPopupVim : openPopupNvim;
const close = denops.meta.host === "vim" ? closePopupVim : closePopupNvim;
const bufnr = options.bufnr ?? await fn.bufadd(denops, "");
const winid = await open(denops, bufnr, options);
if (!options.noRedraw) {
await denops.redraw();
}
return {
bufnr,
winid,
close: async () => {
if (!options.noRedraw) {
await close(denops, winid);
}
await denops.redraw();
},
[Symbol.asyncDispose]: async () => {
await close(denops, winid);
await denops.redraw();
},
};
}

export type { OpenOptions, PopupWindow } from "./types.ts";
109 changes: 109 additions & 0 deletions popup/mod_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts";
import { test } from "https://deno.land/x/denops_test@v1.6.2/mod.ts";
import * as fn from "../function/mod.ts";
import * as popup from "./mod.ts";

test({
mode: "all",
name: "popup",
fn: async (denops, t) => {
// Extend screen size
await denops.cmd("set lines=100 columns=100");

await t.step({
name: `open() opens a popup window and close() closes it`,
fn: async () => {
let popupWindow: popup.PopupWindow;
const inner = async () => {
popupWindow = await popup.open(denops, {
relative: "editor",
width: 50,
height: 50,
row: 1,
col: 1,
});
const { winid } = popupWindow;
assertEquals(await fn.win_gettype(denops, winid), "popup");
assertEquals(await fn.winwidth(denops, winid), 50);
assertEquals(await fn.winheight(denops, winid), 50);
};
await inner();

// Still alive
assertEquals(await fn.win_gettype(denops, popupWindow!.winid), "popup");

// Explicitly close
await popupWindow!.close();
assertEquals(
await fn.win_gettype(denops, popupWindow!.winid),
"unknown",
);
},
});

await t.step({
name: `open() with await using statement`,
fn: async () => {
let winid: number;
const inner = async () => {
await using popupWindow = await popup.open(denops, {
relative: "editor",
width: 50,
height: 50,
row: 1,
col: 1,
});
winid = popupWindow.winid;
assertEquals(await fn.win_gettype(denops, winid), "popup");
assertEquals(await fn.winwidth(denops, winid), 50);
assertEquals(await fn.winheight(denops, winid), 50);
};
await inner();

// Automatically disposed
assertEquals(await fn.win_gettype(denops, winid!), "unknown");
},
});

// With numerous options
const base: popup.OpenOptions = {
relative: "editor",
width: 20, // Max 20 in test
height: 20, // Max 24 in test
row: 50,
col: 50,
};
const optionsSet: popup.OpenOptions[] = [
{ ...base },
{ ...base, relative: "cursor" },
{ ...base, anchor: "NW" },
{ ...base, anchor: "NE" },
{ ...base, anchor: "SW" },
{ ...base, anchor: "SE" },
{ ...base, zindex: 100 },
{ ...base, border: "single" },
{ ...base, border: "double" },
{ ...base, border: "rounded" },
{ ...base, border: ["", "", "", "|", "", "", "", "|"] },
{ ...base, border: "single", title: "Hello world!" },
{ ...base, highlight: {} },
{ ...base, highlight: { normal: "Normal" } },
{ ...base, highlight: { border: "Border" } },
{ ...base, highlight: { normal: "Normal", border: "Border" } },
];
for (const options of optionsSet) {
await t.step({
name: `open() with options: ${JSON.stringify(options)}`,
fn: async () => {
await using popupWindow = await popup.open(denops, options);
assertEquals(
await fn.win_gettype(denops, popupWindow.winid),
"popup",
);
assertEquals(await fn.winwidth(denops, popupWindow.winid), 20);
assertEquals(await fn.winheight(denops, popupWindow.winid), 20);
},
});
}
},
});
97 changes: 97 additions & 0 deletions popup/nvim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Denops } from "../mod.ts";
import * as nvimFn from "../function/nvim/mod.ts";
import { execute } from "../helper/execute.ts";
import { ulid } from "https://deno.land/std@0.217.0/ulid/mod.ts";

import type { Border, OpenOptions } from "./types.ts";

const cacheKey = "denops_std/popup/nvim.ts@1";

async function ensurePrerequisites(denops: Denops): Promise<string> {
if (typeof denops.context[cacheKey] === "string") {
return denops.context[cacheKey];
}
const suffix = ulid();
denops.context[cacheKey] = suffix;
const script = `
function! DenopsStdPopupNvimOpenPopup_${suffix}(bufnr, config, winhighlight) abort
let winid = nvim_open_win(a:bufnr, v:false, a:config)
if a:winhighlight isnot v:null
call nvim_win_set_option(winid, 'winhighlight', a:winhighlight)
endif
return winid
endfunction
`;
await execute(denops, script);
return suffix;
}

export async function openPopup(
denops: Denops,
bufnr: number,
options: Omit<OpenOptions, "bufnr" | "noRedraw">,
): Promise<number> {
const suffix = await ensurePrerequisites(denops);
const nvimOpenWinConfig = toNvimOpenWinConfig(options);
const winhighlight = toNvimWinhighlight(options.highlight);
return await denops.call(
`DenopsStdPopupNvimOpenPopup_${suffix}`,
bufnr,
nvimOpenWinConfig,
winhighlight,
) as number;
}

export async function closePopup(denops: Denops, winid: number): Promise<void> {
await nvimFn.nvim_win_close(denops, winid, true);
}

function toNvimOpenWinConfig(options: OpenOptions): nvimFn.NvimOpenWinConfig {
const v: nvimFn.NvimOpenWinConfig = {
relative: options.relative,
anchor: options.anchor,
width: options.width,
height: options.height,
col: options.col,
row: options.row,
focusable: false, // To keep consistent with the behavior of Vim's `popup_create()`
zindex: options.zindex,
border: options.border ? toNvimBorder(options.border) : undefined,
title: options.title,
};
return Object.fromEntries(
Object
.entries(v)
.filter(([, v]) => v != undefined),
) as nvimFn.NvimOpenWinConfig;
}

function toNvimBorder(
border: Border,
): nvimFn.NvimOpenWinConfig["border"] {
if (typeof border === "string") {
return border;
}
const [lt, t, rt, r, rb, b, lb, l] = border;
return [lt, t, rt, r, rb, b, lb, l];
}

function toNvimWinhighlight(
highlight: OpenOptions["highlight"],
): string | null {
if (!highlight) {
return null;
}
const {
normal = "FloatNormal",
border = "FloatBorder",
} = highlight;
if (normal && border) {
return `Normal:${normal},FloatBorder:${border}`;
} else if (normal) {
return `Normal:${normal}`;
} else if (border) {
return `FloatBorder:${border}`;
}
return null;

Check warning on line 96 in popup/nvim.ts

View check run for this annotation

Codecov / codecov/patch

popup/nvim.ts#L92-L96

Added lines #L92 - L96 were not covered by tests
}

0 comments on commit 85ad739

Please sign in to comment.