├── .github └── workflows │ └── ci.yml ├── Makefile ├── README.md ├── mod.test.ts ├── mod.ts └── popup.vim /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | REPO_PATH: "${{ env.GITHUB_WORKSPACE }}/repo" 6 | DENOPS_PATH: "${{ env.GITHUB_WORKSPACE }}/denops.vim" 7 | VIM_VERSION: "v8.1.2424" 8 | NVIM_VERSION: "v0.4.4" 9 | 10 | on: 11 | push: 12 | branches: 13 | - main 14 | pull_request: 15 | branches: 16 | - main 17 | 18 | jobs: 19 | CI: 20 | runs-on: ubuntu-20.04 21 | steps: 22 | - name: Checkout repo 23 | uses: actions/checkout@v2 24 | with: 25 | path: ${{ env.REPO_PATH }} 26 | 27 | - name: Checkout denops.vim 28 | uses: actions/checkout@v2 29 | with: 30 | repository: "vim-denops/denops.vim" 31 | path: ${{ env.DENOPS_PATH }} 32 | 33 | - name: Setup deno 34 | uses: denoland/setup-deno@main 35 | with: 36 | deno-version: ${{ env.DENO_VERSION }} 37 | 38 | - name: Setup vim 39 | uses: rhysd/action-setup-vim@v1 40 | id: vim 41 | with: 42 | version: ${{ env.VIM_VERSION }} 43 | 44 | - name: Setup nvim 45 | uses: rhysd/action-setup-vim@v1 46 | id: nvim 47 | with: 48 | neovim: true 49 | version: ${{ env.NVIM_VERSION }} 50 | 51 | - name: Check Vim&Nvim 52 | run: | 53 | echo ${DENOPS_TEST_VIM} 54 | ${DENOPS_TEST_VIM} --version 55 | echo ${DENOPS_TEST_NVIM} 56 | ${DENOPS_TEST_NVIM} --version 57 | env: 58 | DENOPS_TEST_VIM: ${{ steps.vim.outputs.executable }} 59 | DENOPS_TEST_NVIM: ${{ steps.nvim.outputs.executable }} 60 | 61 | - name: Test 62 | run: | 63 | make test 64 | env: 65 | DENOPS_TEST_VIM: ${{ steps.vim.outputs.executable }} 66 | DENOPS_TEST_NVIM: ${{ steps.nvim.outputs.executable }} 67 | timeout-minutes: 5 68 | working-directory: ${{ env.REPO_PATH }} 69 | 70 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DENOPS_TEST_VIM := vim 2 | DENOPS_TEST_NVIM := nvim 3 | DENOPS_PATH := ../denops.vim 4 | 5 | .PHONY: fmt 6 | fmt: 7 | deno fmt 8 | 9 | .PHONY: lint 10 | lint: 11 | deno lint 12 | 13 | .PHONY: test 14 | test: 15 | DENOPS_TEST_VIM=${DENOPS_TEST_VIM} \ 16 | DENOPS_TEST_NVIM=${DENOPS_TEST_NVIM} \ 17 | DENOPS_PATH=${DENOPS_PATH} \ 18 | deno test --unstable -A 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # denops-popup 2 | 3 | vim/neovim's floating/popup window polyfill for denops. 4 | 5 | # document 6 | 7 | https://doc.deno.land/https/deno.land/x/denops_popup/mod.ts 8 | -------------------------------------------------------------------------------- /mod.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertRejects } from "https://deno.land/std@0.138.0/testing/asserts.ts"; 2 | import { test } from "https://deno.land/x/denops_std@v3.3.1/test/mod.ts"; 3 | import * as popup from "./mod.ts"; 4 | 5 | test({ 6 | name: "Basic usage", 7 | mode: "all", 8 | fn: async (denops) => { 9 | await denops.cmd("new"); 10 | const bufnr = 2; 11 | const state = { 12 | closed: false, 13 | }; 14 | const winid = await popup.open(denops, bufnr, { 15 | row: 3, 16 | col: 3, 17 | width: 10, 18 | height: 10, 19 | topline: 1, 20 | }, { 21 | onClose: () => { 22 | state.closed = true; 23 | }, 24 | }); 25 | assertEquals(await popup.isVisible(denops, winid), true); 26 | assertEquals(await popup.info(denops, winid), { 27 | row: 3, 28 | col: 3, 29 | width: 10, 30 | height: 10, 31 | topline: 1, 32 | }); 33 | assertEquals(await popup.list(denops), [winid]); 34 | 35 | await popup.move(denops, winid, { 36 | row: 5, 37 | col: 5, 38 | width: 12, 39 | height: 12, 40 | topline: 1, 41 | }); 42 | assertEquals(await popup.isVisible(denops, winid), true); 43 | assertEquals(await popup.info(denops, winid), { 44 | row: 5, 45 | col: 5, 46 | width: 12, 47 | height: 12, 48 | topline: 1, 49 | }); 50 | 51 | assertEquals(state.closed, false); 52 | await popup.close(denops, winid); 53 | assertEquals(state.closed, true); 54 | assertEquals(await popup.isVisible(denops, winid), false); 55 | await assertRejects(async () => { 56 | await popup.info(denops, winid); // should throw error because the window already closed. 57 | }); 58 | assertEquals(await popup.list(denops), []); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "https://deno.land/x/denops_std@v3.3.1/mod.ts"; 2 | import { load } from "https://deno.land/x/denops_std@v3.3.1/helper/mod.ts"; 3 | import { once } from "https://deno.land/x/denops_std@v3.3.1/anonymous/mod.ts"; 4 | import { assertNumber, assertArray, isNumber } from "https://deno.land/x/unknownutil@v2.0.0/mod.ts"; 5 | 6 | const memo = >( 7 | f: (denops: Denops, ...args: A) => R, 8 | ) => { 9 | let v: R | undefined; 10 | return (denops: Denops, ...args: A): R => { 11 | return (denops.meta.mode !== "test" && v) || (v = f(denops, ...args)); 12 | }; 13 | }; 14 | 15 | const noop = () => { 16 | return Promise.resolve(); 17 | }; 18 | 19 | const init = memo(async (denops: Denops) => { 20 | const path = new URL(".", import.meta.url); 21 | path.pathname = path.pathname + "popup.vim"; 22 | await load(denops, path); 23 | }); 24 | 25 | /** 26 | * popup window style definition. 27 | */ 28 | export type PopupWindowStyle = { 29 | row: number; 30 | col: number; 31 | width: number; 32 | height: number; 33 | border?: boolean; 34 | topline?: number; 35 | origin?: 36 | | "topleft" 37 | | "topright" 38 | | "topcenter" 39 | | "bottomleft" 40 | | "bottomright" 41 | | "bottomcenter" 42 | | "centercenter"; 43 | }; 44 | 45 | /** 46 | * popup window information. 47 | */ 48 | export type PopupWindowInfo = { 49 | row: number; 50 | col: number; 51 | width: number; 52 | height: number; 53 | topline: number; 54 | }; 55 | 56 | /** 57 | * Open popup window. 58 | */ 59 | export async function open( 60 | denops: Denops, 61 | bufnr: number, 62 | style: PopupWindowStyle, 63 | event?: { 64 | onClose: () => unknown; 65 | }, 66 | ): Promise { 67 | await init(denops); 68 | const [onClose] = once(denops, event?.onClose ?? noop); 69 | const winid = await denops.call("Denops_popup_window_open", bufnr, style, { 70 | onClose: [denops.name, onClose], 71 | }); 72 | assertNumber(winid); 73 | return winid; 74 | } 75 | 76 | /** 77 | * Move popup window. 78 | */ 79 | export async function move( 80 | denops: Denops, 81 | winid: number, 82 | style: PopupWindowStyle, 83 | ): Promise { 84 | await init(denops); 85 | await assert(denops, winid); 86 | await denops.call("Denops_popup_window_move", winid, style); 87 | } 88 | 89 | /** 90 | * Close popup window. 91 | */ 92 | export async function close(denops: Denops, winid: number): Promise { 93 | await init(denops); 94 | await assert(denops, winid); 95 | await denops.call("Denops_popup_window_close", winid); 96 | } 97 | 98 | /** 99 | * Return PopupWindowInfo for the specified winid. 100 | */ 101 | export async function info( 102 | denops: Denops, 103 | winid: number, 104 | ): Promise { 105 | await init(denops); 106 | await assert(denops, winid); 107 | return await denops.call( 108 | "Denops_popup_window_info", 109 | winid, 110 | ) as PopupWindowInfo; 111 | } 112 | 113 | /** 114 | * Return the specified winid is visible or not. 115 | * 116 | * NOTE: If specified winid is not a popup window, this API always return false. 117 | */ 118 | export async function isVisible( 119 | denops: Denops, 120 | winid: number, 121 | ): Promise { 122 | await init(denops); 123 | const is = await denops.call("Denops_popup_window_is_visible", winid); 124 | assertNumber(is); 125 | return (is === 1) && isPopupWindow(denops, winid); 126 | } 127 | 128 | /** 129 | * Return the specified winid is popup or not. 130 | * 131 | * NOTE: If specified winid is not a valid window, this API always return false. 132 | */ 133 | export async function isPopupWindow( 134 | denops: Denops, 135 | winid: number, 136 | ): Promise { 137 | await init(denops); 138 | const is = await denops.call("Denops_popup_window_is_popup_window", winid); 139 | assertNumber(is); 140 | return is === 1; 141 | } 142 | 143 | const assert = async (denops: Denops, winid: number): Promise => { 144 | if (!(await isPopupWindow(denops, winid))) { 145 | throw new TypeError(`Invalid winid: ${winid} is not a popup window.`); 146 | } 147 | }; 148 | 149 | /** 150 | * Return a list of winid of all popup windows. 151 | */ 152 | export async function list( 153 | denops: Denops, 154 | ): Promise { 155 | await init(denops); 156 | const list = await denops.call("Denops_popup_window_list"); 157 | assertArray(list, isNumber); 158 | return list; 159 | } 160 | -------------------------------------------------------------------------------- /popup.vim: -------------------------------------------------------------------------------- 1 | if exists('g:loaded_denops_popup') 2 | finish 3 | endif 4 | let g:loaded_denops_popup = v:true 5 | 6 | function! Denops_popup_window_open(...) abort 7 | return call(function('s:_open'), a:000) 8 | endfunction 9 | 10 | function! Denops_popup_window_move(...) abort 11 | return call(function('s:_move'), a:000) 12 | endfunction 13 | 14 | function! Denops_popup_window_close(...) abort 15 | return call(function('s:_close'), a:000) 16 | endfunction 17 | 18 | function! Denops_popup_window_info(...) abort 19 | return call(function('s:_info'), a:000) 20 | endfunction 21 | 22 | function! Denops_popup_window_is_visible(...) abort 23 | return call(function('s:_is_visible'), a:000) 24 | endfunction 25 | 26 | function! Denops_popup_window_is_popup_window(...) abort 27 | return call(function('s:_is_popup_window'), a:000) 28 | endfunction 29 | 30 | function! Denops_popup_window_list(...) abort 31 | return call(function('s:_list'), a:000) 32 | endfunction 33 | 34 | " 35 | " _open 36 | " 37 | if has('nvim') 38 | function! s:_open(bufnr, style, event) abort 39 | let l:winid = nvim_open_win(a:bufnr, v:false, s:_style(a:style)) 40 | let s:win_close_listeners[l:winid] = { -> denops#notify(a:event.onClose[0], a:event.onClose[1], []) } 41 | return l:winid 42 | endfunction 43 | else 44 | function! s:_open(bufnr, style, event) abort 45 | let l:style = s:_style(a:style) 46 | let l:style.callback = { -> denops#notify(a:event.onClose[0], a:event.onClose[1], []) } 47 | return popup_create(a:bufnr, l:style) 48 | endfunction 49 | endif 50 | 51 | " 52 | " _move 53 | " 54 | if has('nvim') 55 | function! s:_move(winid, style) abort 56 | call nvim_win_set_config(a:winid, s:_style(a:style)) 57 | endfunction 58 | else 59 | function! s:_move(winid, style) abort 60 | let l:style = s:_style(a:style) 61 | call popup_setoptions(a:winid, l:style) 62 | call popup_move(a:winid, l:style) 63 | endfunction 64 | endif 65 | 66 | " 67 | " _close 68 | " 69 | if has('nvim') 70 | function! s:_close(winid) abort 71 | call nvim_win_close(a:winid, v:true) 72 | endfunction 73 | else 74 | function! s:_close(winid) abort 75 | call popup_close(a:winid) 76 | endfunction 77 | endif 78 | 79 | " 80 | " _info 81 | " 82 | if has('nvim') 83 | function! s:_info(winid) abort 84 | let l:info = getwininfo(a:winid)[0] 85 | return { 86 | \ 'row': l:info.winrow, 87 | \ 'col': l:info.wincol, 88 | \ 'width': l:info.width, 89 | \ 'height': l:info.height, 90 | \ 'topline': l:info.topline, 91 | \ } 92 | endfunction 93 | else 94 | function! s:_info(winid) abort 95 | let l:pos = popup_getpos(a:winid) 96 | return { 97 | \ 'row': l:pos.core_line, 98 | \ 'col': l:pos.core_col, 99 | \ 'width': l:pos.core_width, 100 | \ 'height': l:pos.core_height, 101 | \ 'topline': l:pos.firstline, 102 | \ } 103 | endfunction 104 | endif 105 | 106 | " 107 | " _is_visible 108 | " 109 | if has('nvim') 110 | function! s:_is_visible(winid) abort 111 | try 112 | return !!(type(a:winid) == type(0) && nvim_win_is_valid(a:winid) && nvim_win_get_number(a:winid) != -1) 113 | catch /.*/ 114 | endtry 115 | return 0 116 | endfunction 117 | else 118 | function! s:_is_visible(winid) abort 119 | return !!(type(a:winid) == type(0) && winheight(a:winid) != -1) 120 | endfunction 121 | endif 122 | 123 | " 124 | " _is_popup_window 125 | " 126 | if has('nvim') 127 | function! s:_is_popup_window(winid) abort 128 | try 129 | let l:config = nvim_win_get_config(a:winid) 130 | return !!(empty(l:config) || !empty(get(l:config, 'relative', ''))) 131 | catch /.*/ 132 | endtry 133 | return 0 134 | endfunction 135 | else 136 | function! s:_is_popup_window(winid) abort 137 | return !!(winheight(a:winid) != -1 && win_id2win(a:winid) == 0) 138 | endfunction 139 | endif 140 | 141 | " 142 | " _list 143 | " 144 | if has('nvim') 145 | function! s:_list() abort 146 | let l:info_list = getwininfo() 147 | let l:winid_list = map(info_list, 'v:val["winid"]') 148 | return filter(winid_list, 's:_is_popup_window(v:val)') 149 | endfunction 150 | else 151 | function! s:_list() abort 152 | return filter(popup_list(), 'popup_getpos(v:val).visible') 153 | endfunction 154 | endif 155 | 156 | " 157 | " _style 158 | " 159 | if has('nvim') 160 | function! s:_style(style) abort 161 | let l:style = s:_resolve_origin(a:style) 162 | let l:style = s:_resolve_border(l:style) 163 | let l:style = { 164 | \ 'relative': 'editor', 165 | \ 'row': l:style.row - 1, 166 | \ 'col': l:style.col - 1, 167 | \ 'width': l:style.width, 168 | \ 'height': l:style.height, 169 | \ 'focusable': v:true, 170 | \ 'style': 'minimal', 171 | \ 'border': has_key(l:style, 'border') ? l:style.border : 'none', 172 | \ } 173 | if !exists('*win_execute') " We can't detect neovim features via patch version so we try it by function existence. 174 | unlet l:style.border 175 | endif 176 | return l:style 177 | endfunction 178 | else 179 | function! s:_style(style) abort 180 | let l:style = s:_resolve_origin(a:style) 181 | let l:style = s:_resolve_border(l:style) 182 | return { 183 | \ 'line': l:style.row, 184 | \ 'col': l:style.col, 185 | \ 'pos': 'topleft', 186 | \ 'wrap': v:false, 187 | \ 'moved': [0, 0, 0], 188 | \ 'scrollbar': 0, 189 | \ 'maxwidth': l:style.width, 190 | \ 'maxheight': l:style.height, 191 | \ 'minwidth': l:style.width, 192 | \ 'minheight': l:style.height, 193 | \ 'tabpage': 0, 194 | \ 'firstline': get(l:style, 'topline', 1), 195 | \ 'padding': [0, 0, 0, 0], 196 | \ 'border': has_key(l:style, 'border') ? [1, 1, 1, 1] : [0, 0, 0, 0], 197 | \ 'borderchars': get(l:style, 'border', []), 198 | \ 'fixed': v:true, 199 | \ } 200 | endfunction 201 | endif 202 | 203 | " 204 | " _resolve_origin 205 | " 206 | function! s:_resolve_origin(style) abort 207 | if index(['topleft', 'topright', 'topcenter', 'bottomleft', 'bottomright', 'bottomcenter', 'centercenter'], get(a:style, 'origin', '')) == -1 208 | let a:style.origin = 'topleft' 209 | endif 210 | 211 | if a:style.origin ==# 'topleft' 212 | let a:style.col = a:style.col 213 | let a:style.row = a:style.row 214 | elseif a:style.origin ==# 'topright' 215 | let a:style.col = a:style.col - (a:style.width - 1) 216 | let a:style.row = a:style.row 217 | elseif a:style.origin ==# 'topcenter' 218 | let a:style.col = a:style.col - float2nr(a:style.width / 2) 219 | let a:style.row = a:style.row 220 | elseif a:style.origin ==# 'bottomleft' 221 | let a:style.col = a:style.col 222 | let a:style.row = a:style.row - (a:style.height - 1) 223 | elseif a:style.origin ==# 'bottomright' 224 | let a:style.col = a:style.col - (a:style.width - 1) 225 | let a:style.row = a:style.row - (a:style.height - 1) 226 | elseif a:style.origin ==# 'bottomcenter' 227 | let a:style.col = a:style.col - float2nr(a:style.width / 2) 228 | let a:style.row = a:style.row - (a:style.height - 1) 229 | elseif a:style.origin ==# 'centercenter' 230 | let a:style.col = a:style.col - float2nr(a:style.width / 2) 231 | let a:style.row = a:style.row - float2nr(a:style.height / 2) 232 | endif 233 | return a:style 234 | endfunction 235 | 236 | " 237 | " _resolve_border 238 | " 239 | if has('nvim') 240 | function! s:_resolve_border(style) abort 241 | if !empty(get(a:style, 'border', v:null)) 242 | if &ambiwidth ==# 'single' 243 | let a:style.border = ['┌', '─', '┐', '│', '┘', '─', '└', '│'] 244 | else 245 | let a:style.border = ['+', '-', '+', '|', '+', '-', '+', '|'] 246 | endif 247 | elseif has_key(a:style, 'border') 248 | unlet a:style.border 249 | endif 250 | return a:style 251 | endfunction 252 | else 253 | function! s:_resolve_border(style) abort 254 | if !empty(get(a:style, 'border', v:null)) 255 | if &ambiwidth ==# 'single' 256 | let a:style.border = ['─', '│', '─', '│', '┌', '┐', '┘', '└'] 257 | else 258 | let a:style.border = ['-', '|', '-', '|', '+', '+', '+', '+'] 259 | endif 260 | elseif has_key(a:style, 'border') 261 | unlet a:style.border 262 | endif 263 | return a:style 264 | endfunction 265 | endif 266 | 267 | 268 | let s:win_close_listeners = {} 269 | if has('nvim') 270 | function! s:notify_closed() abort 271 | let l:winid = expand('') 272 | if has_key(s:win_close_listeners, l:winid) 273 | call remove(s:win_close_listeners, l:winid)() 274 | endif 275 | endfunction 276 | augroup denops_popup_win_close_listeners 277 | autocmd! 278 | autocmd WinClosed * call s:notify_closed() 279 | augroup END 280 | endif 281 | 282 | --------------------------------------------------------------------------------