├── autoload └── initial │ ├── command │ └── Initial.vim │ └── internal │ ├── fold.vim │ └── popup.vim ├── denops └── initial │ ├── polyfill.ts │ ├── util.ts │ ├── locator.ts │ ├── indexer.ts │ ├── indexer_test.ts │ ├── overlay.ts │ ├── main.ts │ └── locator_test.ts ├── plugin └── initial.vim ├── deno.jsonc ├── LICENSE ├── README.md └── .github └── workflows └── test.yml /autoload/initial/command/Initial.vim: -------------------------------------------------------------------------------- 1 | function! initial#command#Initial#call(args) abort 2 | let l:length = a:args 3 | \ ->filter({ _, v -> v =~# '^-length=' }) 4 | \ ->map({ _, v -> str2nr(split(v, '=')[1]) }) 5 | \ ->get(0, 1) 6 | try 7 | call denops#request('initial', 'start', [l:length]) 8 | finally 9 | call initial#internal#popup#closeall() 10 | endtry 11 | endfunction 12 | -------------------------------------------------------------------------------- /denops/initial/polyfill.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncDisposableStack, 3 | DisposableStack, 4 | } from "jsr:@nick/dispose@^1.1.0"; 5 | 6 | // DisposableStack and AsyncDisposableStack are not available yet. 7 | // https://github.com/denoland/deno/issues/20821 8 | 9 | // deno-lint-ignore no-explicit-any 10 | (globalThis as any).DisposableStack ??= DisposableStack; 11 | // deno-lint-ignore no-explicit-any 12 | (globalThis as any).AsyncDisposableStack ??= AsyncDisposableStack; 13 | -------------------------------------------------------------------------------- /plugin/initial.vim: -------------------------------------------------------------------------------- 1 | if exists('g:loaded_initial') 2 | finish 3 | endif 4 | let g:loaded_initial = 1 5 | 6 | command! -nargs=* Initial call initial#command#Initial#call([]) 7 | 8 | function! s:style() abort 9 | highlight default link InitialOverlayCurtain Conceal 10 | highlight default link InitialOverlayLabel IncSearch 11 | endfunction 12 | 13 | augroup initial_plugin 14 | autocmd! 15 | autocmd ColorScheme * call s:style() 16 | augroup END 17 | 18 | call s:style() 19 | -------------------------------------------------------------------------------- /autoload/initial/internal/fold.vim: -------------------------------------------------------------------------------- 1 | function! initial#internal#fold#list() abort 2 | let l:folds = [] 3 | let l:lnum = 1 4 | let l:lmax = line('$') 5 | while l:lnum <= l:lmax 6 | if foldclosed(l:lnum) isnot# -1 7 | let l:foldclosedend = foldclosedend(l:lnum) 8 | let l:foldtextresult = foldtextresult(l:lnum) 9 | call add(l:folds, [l:lnum, l:foldclosedend, l:foldtextresult]) 10 | let l:lnum = l:foldclosedend + 1 11 | else 12 | let l:lnum += 1 13 | endif 14 | endwhile 15 | return l:folds 16 | endfunction 17 | -------------------------------------------------------------------------------- /autoload/initial/internal/popup.vim: -------------------------------------------------------------------------------- 1 | function! initial#internal#popup#closeall() abort 2 | let l:winids = range(1, winnr('$')) 3 | \ ->map({_, v -> win_getid(v)}) 4 | \ ->filter({_, v -> win_gettype(v) ==# 'popup'}) 5 | \ ->filter({_, v -> getwinvar(v, '&filetype') ==# 'initial-overlay'}) 6 | call foreach(l:winids, {_, v -> s:close(v)}) 7 | endfunction 8 | 9 | if has('nvim') 10 | function! s:close(winid) abort 11 | call nvim_win_close(a:winid, v:true) 12 | endfunction 13 | else 14 | function! s:close(winid) abort 15 | call popup_close(a:winid) 16 | endfunction 17 | endif 18 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "exclude": [ 4 | "docs/**", 5 | ".coverage/**" 6 | ], 7 | "tasks": { 8 | "check": "deno check ./**/*.ts", 9 | "test": "deno test -A --shuffle --doc", 10 | "test:coverage": "deno task test --coverage=.coverage", 11 | "coverage": "deno coverage .coverage", 12 | "update": "deno run --allow-env --allow-read --allow-write --allow-run=git,deno --allow-net=jsr.io,registry.npmjs.org jsr:@molt/cli **/*.ts", 13 | "update:write": "deno task -q update --write", 14 | "update:commit": "deno task -q update --commit --prefix :package: --pre-commit=fmt,lint" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /denops/initial/util.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "jsr:@denops/std@^7.4.0"; 2 | import * as fn from "jsr:@denops/std@^7.4.0/function"; 3 | 4 | const encoder = new TextEncoder(); 5 | 6 | export type Fold = [start: number, end: number, text: string]; 7 | 8 | export function listFolds( 9 | denops: Denops, 10 | ): Promise { 11 | return denops.call("initial#internal#fold#list") as Promise; 12 | } 13 | 14 | export type WinInfo = { 15 | winid: number; 16 | width: number; 17 | height: number; 18 | winrow: number; 19 | wincol: number; 20 | topline: number; 21 | botline: number; 22 | textoff: number; 23 | }; 24 | 25 | export function getwininfo(denops: Denops): Promise { 26 | return fn.getwininfo(denops) as Promise; 27 | } 28 | 29 | export function defer(callback: () => Promise): AsyncDisposable { 30 | return { 31 | [Symbol.asyncDispose]() { 32 | return callback(); 33 | }, 34 | }; 35 | } 36 | 37 | export function getByteLength(s: string): number { 38 | return encoder.encode(s).length; 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Alisue 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /denops/initial/locator.ts: -------------------------------------------------------------------------------- 1 | // Regex pattern to match the initial of each word in a string. 2 | const pattern = 3 | /\b\w|(?<=\b|_)[a-zA-Z](?=[A-Z]*[a-z]*(?:_|$))|(?<=\b|[a-z])[A-Z]/g; 4 | 5 | export type Location = { 6 | /** 7 | * The row number of the location. The first row is 1. 8 | */ 9 | row: number; 10 | /** 11 | * The column number of the location. The first column is 1. 12 | */ 13 | col: number; 14 | }; 15 | 16 | export type Record = { 17 | /** 18 | * The row number of the record. The first row is 1. 19 | */ 20 | row: number; 21 | /** 22 | * The value of the record. 23 | */ 24 | value: string; 25 | }; 26 | 27 | /** 28 | * The content of the file. 29 | */ 30 | export type Content = readonly Record[]; 31 | 32 | export class Locator { 33 | locate(initial: string, content: Content): Location[] { 34 | const locs: Location[] = []; 35 | const translate = (s: string) => s.toLowerCase(); 36 | const needle = translate(initial); 37 | for (const record of content) { 38 | for (const m of record.value.matchAll(pattern)) { 39 | const text = record.value.slice(m.index); 40 | if (translate(text).startsWith(needle)) { 41 | locs.push({ row: record.row, col: m.index + 1 }); 42 | } 43 | } 44 | } 45 | return locs; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /denops/initial/indexer.ts: -------------------------------------------------------------------------------- 1 | const LETTERS = "abcdefghijklmnopqrstuvwxyz"; 2 | 3 | export type IndexerOptions = { 4 | /** 5 | * The letters to use for indexing. 6 | */ 7 | letters?: string; 8 | }; 9 | 10 | /** 11 | * An indexer that generates unique strings. 12 | * 13 | * It generates shortest combination of letters that are unique to 14 | * describe the specified number of items. 15 | * 16 | * For example, if the count is 26, it generates "a" to "z". 17 | * If the count is 27, it generates "aa" to "ba". 18 | */ 19 | export class Indexer { 20 | #count: number; 21 | #letters: string; 22 | #base: number; 23 | #length: number; 24 | #index = 0; 25 | 26 | constructor(count: number, options: IndexerOptions = {}) { 27 | this.#count = count; 28 | this.#letters = options.letters ?? LETTERS; 29 | this.#base = this.#letters.length; 30 | this.#length = calcLength(count, this.#base); 31 | } 32 | 33 | get length(): number { 34 | return this.#length; 35 | } 36 | 37 | /** 38 | * Get the next unique string. 39 | */ 40 | next(): string { 41 | if (this.#index >= this.#count) { 42 | throw new Error("All unique strings have been generated."); 43 | } 44 | let currentIndex = this.#index; 45 | let result = ""; 46 | for (let i = 0; i < this.#length; i++) { 47 | result = this.#letters[currentIndex % this.#base] + result; 48 | currentIndex = Math.floor(currentIndex / this.#base); 49 | } 50 | result = result.padStart(this.#length, this.#letters[0]); 51 | this.#index++; 52 | return result; 53 | } 54 | } 55 | 56 | function calcLength(count: number, base: number): number { 57 | // Math.ceil(Math.log10(count) / Math.log10(base)) is not accurate 58 | let length = 1; 59 | while (base ** length < count) { 60 | length++; 61 | } 62 | return length; 63 | } 64 | -------------------------------------------------------------------------------- /denops/initial/indexer_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@^1.0.8"; 2 | import { Indexer } from "./indexer.ts"; 3 | 4 | Deno.test("Indexer", async (t) => { 5 | const options = { 6 | letters: "abc", 7 | }; 8 | await t.step( 9 | "should returns a, b, c", 10 | () => { 11 | const indexer = new Indexer(3, options); 12 | const expected = ["a", "b", "c"]; 13 | const results = []; 14 | for (let i = 0; i < expected.length; i++) { 15 | results.push(indexer.next()); 16 | } 17 | assertEquals(results, expected); 18 | }, 19 | ); 20 | 21 | await t.step( 22 | "should returns aa, ab, ac, ba", 23 | () => { 24 | const indexer = new Indexer(4, options); 25 | const expected = ["aa", "ab", "ac", "ba"]; 26 | const results = []; 27 | for (let i = 0; i < expected.length; i++) { 28 | results.push(indexer.next()); 29 | } 30 | assertEquals(results, expected); 31 | }, 32 | ); 33 | 34 | await t.step( 35 | "should returns aa, ab, ac, ba, bb, bc, ca, cb, cc", 36 | () => { 37 | const indexer = new Indexer(9, options); 38 | const expected = [ 39 | "aa", 40 | "ab", 41 | "ac", 42 | "ba", 43 | "bb", 44 | "bc", 45 | "ca", 46 | "cb", 47 | "cc", 48 | ]; 49 | const results = []; 50 | for (let i = 0; i < expected.length; i++) { 51 | results.push(indexer.next()); 52 | } 53 | assertEquals(results, expected); 54 | }, 55 | ); 56 | 57 | await t.step( 58 | "should returns aa, ab, ac, ba, bb, bc, ca, cb, cc", 59 | () => { 60 | const indexer = new Indexer(10, options); 61 | const expected = [ 62 | "aaa", 63 | "aab", 64 | "aac", 65 | "aba", 66 | "abb", 67 | "abc", 68 | "aca", 69 | "acb", 70 | "acc", 71 | "baa", 72 | ]; 73 | const results = []; 74 | for (let i = 0; i < expected.length; i++) { 75 | results.push(indexer.next()); 76 | } 77 | assertEquals(results, expected); 78 | }, 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🅰️ Initial 2 | 3 | [![Test](https://github.com/lambdalisue/vim-initial/actions/workflows/test.yml/badge.svg)](https://github.com/lambdalisue/vim-initial/actions/workflows/test.yml) 4 | [![codecov](https://codecov.io/gh/lambdalisue/vim-initial/graph/badge.svg?token=0WzxKNH22o)](https://codecov.io/gh/lambdalisue/vim-initial) 5 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 6 | 7 | ![CleanShot 2024-11-20 at 21 40 49](https://github.com/user-attachments/assets/f3a4dfeb-7309-4ced-9608-a5ad41b4d6bb) 8 | 9 | **Initial** (_vim-initial_) is yet another jump-assistant plugin for Vim/Neovim 10 | powered by [Denops]. 11 | 12 | This plugin restricts matching targets to **the initial characters** of words, 13 | reducing the number of candidates for filtering. The design is based on the 14 | hypothesis that when users want to jump to a specific location on the screen, 15 | they often focus on the initial characters of words. Thanks to this approach, 16 | after triggering the plugin, users can jump to their desired location by typing 17 | an average of 2–3 keys (excluding the key used to invoke the plugin). 18 | 19 | > [!WARNING] 20 | > This plugin is still in the early stages of development. Its practicality and 21 | > value for users are yet to be determined, and future maintenance depends on 22 | > its reception. 23 | 24 | [Denops]: https://github.com/vim-denops/denops.vim 25 | 26 | ## Requirements 27 | 28 | Users must have [Deno] installed to use this plugin. 29 | 30 | [Deno]: https://deno.land 31 | 32 | ## Installation 33 | 34 | To install [Denops] and this plugin using a plugin manager such as [vim-plug], 35 | add the following lines to your Vim configuration: 36 | 37 | ```vim 38 | Plug 'vim-denops/denops.vim' 39 | Plug 'lambdalisue/vim-initial' 40 | ``` 41 | 42 | [vim-plug]: https://github.com/junegunn/vim-plug 43 | 44 | ## Usage 45 | 46 | First, define a normal mode mapping to invoke the `Initial` command, for 47 | example: 48 | 49 | ```vim 50 | nnoremap t Initial 51 | ``` 52 | 53 | Then, type `t` to invoke the `Initial` command. 54 | 55 | When the `Initial` command is invoked, the plugin enters the **Initial Character 56 | Input Mode**. In this mode, users type the initial character of the desired 57 | word, transitioning to the **Label Jump Mode**. In **Label Jump Mode**, labels 58 | are assigned to words whose initial character matches the input. Typing the 59 | label will jump to the target location. 60 | 61 | If the target count is zero or one during the **Initial Character Input Mode**, 62 | the plugin will automatically cancel or jump without transitioning to the 63 | **Label Jump Mode**. 64 | 65 | To modify the number of initial characters to match, use the `-length={number}` 66 | argument with the `Initial` command. For example: 67 | 68 | ```vim 69 | nnoremap t Initial -length=3 70 | ``` 71 | 72 | ## Acknowledgments 73 | 74 | This plugin is heavily inspired by [fuzzy-motion.vim]. While **Initial** imposes 75 | stricter constraints, such as limiting matches to word initials, many usability 76 | ideas are derived from fuzzy-motion.vim. 77 | 78 | [fuzzy-motion.vim]: https://github.com/yuki-yano/fuzzy-motion.vim 79 | 80 | ## License 81 | 82 | The code in this repository is licensed under the MIT License, as described in 83 | [LICENSE](./LICENSE). Contributors agree that any modifications submitted to 84 | this repository are also licensed under the same terms. 85 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | inputs: 10 | denops_branch: 11 | description: "Denops branch to test" 12 | required: false 13 | default: "main" 14 | 15 | defaults: 16 | run: 17 | shell: bash --noprofile --norc -eo pipefail {0} 18 | 19 | env: 20 | DENOPS_BRANCH: ${{ github.event.inputs.denops_branch || 'main' }} 21 | 22 | jobs: 23 | check: 24 | strategy: 25 | matrix: 26 | runner: 27 | - ubuntu-latest 28 | deno_version: 29 | - "2.x" 30 | runs-on: ${{ matrix.runner }} 31 | steps: 32 | - run: git config --global core.autocrlf false 33 | if: runner.os == 'Windows' 34 | - uses: actions/checkout@v4 35 | - uses: denoland/setup-deno@v2 36 | with: 37 | deno-version: "${{ matrix.deno_version }}" 38 | - uses: actions/cache@v4 39 | with: 40 | key: deno-${{ hashFiles('**/*') }} 41 | restore-keys: deno- 42 | path: | 43 | /home/runner/.cache/deno/deps/https/deno.land 44 | - name: Lint check 45 | run: deno lint 46 | - name: Format check 47 | run: deno fmt --check 48 | - name: Type check 49 | run: deno task check 50 | 51 | test: 52 | strategy: 53 | matrix: 54 | runner: 55 | - windows-latest 56 | - macos-latest 57 | - ubuntu-latest 58 | deno_version: 59 | - "2.x" 60 | host_version: 61 | - vim: "v9.1.0448" 62 | nvim: "v0.10.0" 63 | runs-on: ${{ matrix.runner }} 64 | timeout-minutes: 15 65 | steps: 66 | - run: git config --global core.autocrlf false 67 | if: runner.os == 'Windows' 68 | 69 | - uses: actions/checkout@v4 70 | 71 | - uses: denoland/setup-deno@v2 72 | with: 73 | deno-version: "${{ matrix.deno_version }}" 74 | 75 | - name: Get denops 76 | run: | 77 | git clone https://github.com/vim-denops/denops.vim /tmp/denops.vim 78 | echo "DENOPS_TEST_DENOPS_PATH=/tmp/denops.vim" >> "$GITHUB_ENV" 79 | 80 | - name: Try switching denops branch 81 | run: | 82 | git -C /tmp/denops.vim switch ${{ env.DENOPS_BRANCH }} || true 83 | git -C /tmp/denops.vim branch 84 | 85 | - uses: rhysd/action-setup-vim@v1 86 | id: vim 87 | with: 88 | version: "${{ matrix.host_version.vim }}" 89 | 90 | - uses: rhysd/action-setup-vim@v1 91 | id: nvim 92 | with: 93 | neovim: true 94 | version: "${{ matrix.host_version.nvim }}" 95 | 96 | - name: Export executables 97 | run: | 98 | echo "DENOPS_TEST_VIM_EXECUTABLE=${{ steps.vim.outputs.executable }}" >> "$GITHUB_ENV" 99 | echo "DENOPS_TEST_NVIM_EXECUTABLE=${{ steps.nvim.outputs.executable }}" >> "$GITHUB_ENV" 100 | 101 | - name: Check versions 102 | run: | 103 | deno --version 104 | ${DENOPS_TEST_VIM_EXECUTABLE} --version 105 | ${DENOPS_TEST_NVIM_EXECUTABLE} --version 106 | 107 | - name: Perform pre-cache 108 | run: | 109 | deno cache \ 110 | ${DENOPS_TEST_DENOPS_PATH}/denops/@denops-private/mod.ts ./denops/initial/main.ts 111 | 112 | - name: Test 113 | run: deno task test:coverage 114 | timeout-minutes: 15 115 | 116 | - run: | 117 | deno task coverage --lcov > coverage.lcov 118 | 119 | - uses: codecov/codecov-action@v4 120 | with: 121 | os: ${{ runner.os }} 122 | files: ./coverage.lcov 123 | token: ${{ secrets.CODECOV_TOKEN }} 124 | -------------------------------------------------------------------------------- /denops/initial/overlay.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "jsr:@denops/std@^7.4.0"; 2 | import * as fn from "jsr:@denops/std@^7.4.0/function"; 3 | import * as vimFn from "jsr:@denops/std@^7.4.0/function/vim"; 4 | import * as nvimFn from "jsr:@denops/std@^7.4.0/function/nvim"; 5 | import * as buffer from "jsr:@denops/std@^7.4.0/buffer"; 6 | 7 | import type { Location } from "./locator.ts"; 8 | import { defer, type WinInfo } from "./util.ts"; 9 | 10 | const HIGHLIGHT_CURTAIN = "InitialOverlayCurtain"; 11 | const HIGHLIGHT_LABEL = "InitialOverlayLabel"; 12 | 13 | export async function overlayCurtain( 14 | denops: Denops, 15 | wininfo: WinInfo, 16 | ): Promise { 17 | const bufnr = await fn.winbufnr(denops, wininfo.winid); 18 | const decorations = Array.from({ 19 | length: wininfo.botline - wininfo.topline + 1, 20 | }, (_, i) => ({ 21 | line: wininfo.topline + i, 22 | column: 1, 23 | length: wininfo.width - wininfo.textoff, 24 | highlight: HIGHLIGHT_CURTAIN, 25 | })); 26 | await buffer.decorate(denops, bufnr, decorations); 27 | return defer(() => buffer.undecorate(denops, bufnr)); 28 | } 29 | 30 | export type Label = Location & { 31 | value: string; 32 | visualRow: number; 33 | visualCol: number; 34 | }; 35 | 36 | export function overlayLabels( 37 | denops: Denops, 38 | wininfo: WinInfo, 39 | labels: Label[], 40 | ): Promise { 41 | switch (denops.meta.host) { 42 | case "vim": 43 | return overlayLabelsVim(denops, wininfo, labels); 44 | case "nvim": 45 | return overlayLabelsNvim(denops, wininfo, labels); 46 | } 47 | } 48 | 49 | async function overlayLabelsVim( 50 | denops: Denops, 51 | wininfo: WinInfo, 52 | labels: Label[], 53 | ): Promise { 54 | const normLabels = labels.map(({ visualCol, ...label }) => ({ 55 | ...label, 56 | visualCol: visualCol + wininfo.textoff, 57 | })); 58 | const content = toVimContent( 59 | wininfo.width, 60 | wininfo.height, 61 | normLabels, 62 | ); 63 | const mask = toVimMask( 64 | wininfo.width, 65 | wininfo.height, 66 | normLabels, 67 | ); 68 | await using stack = new AsyncDisposableStack(); 69 | const bufnr = await fn.bufadd(denops, ""); 70 | const winid = await vimFn.popup_create(denops, bufnr, { 71 | line: wininfo.winrow, 72 | col: wininfo.wincol, 73 | highlight: HIGHLIGHT_LABEL, 74 | zindex: 100, 75 | mask, 76 | }); 77 | stack.defer(async () => { 78 | await vimFn.popup_close(denops, winid); 79 | }); 80 | await buffer.replace(denops, bufnr, content); 81 | stack.defer(async () => { 82 | await denops.cmd(`silent! bwipeout! ${bufnr}`); 83 | }); 84 | await fn.win_execute( 85 | denops, 86 | winid, 87 | `setlocal buftype=nofile signcolumn=no nobuflisted nolist nonumber norelativenumber nowrap noswapfile filetype=initial-overlay`, 88 | ); 89 | return stack.move(); 90 | } 91 | 92 | async function overlayLabelsNvim( 93 | denops: Denops, 94 | wininfo: WinInfo, 95 | labels: Label[], 96 | ): Promise { 97 | const bufnr = await fn.winbufnr(denops, wininfo.winid); 98 | const namespace = await nvimFn.nvim_create_namespace( 99 | denops, 100 | "initial-overlay", 101 | ); 102 | for (const label of labels) { 103 | await nvimFn.nvim_buf_set_extmark( 104 | denops, 105 | bufnr, 106 | namespace, 107 | label.row - 1, 108 | label.visualCol - 1, 109 | { 110 | virt_text: [[label.value, [HIGHLIGHT_LABEL]]], 111 | virt_text_win_col: label.visualCol - 1, 112 | hl_mode: "combine", 113 | }, 114 | ); 115 | } 116 | return { 117 | async [Symbol.asyncDispose]() { 118 | await nvimFn.nvim_buf_clear_namespace(denops, bufnr, namespace, 0, -1); 119 | }, 120 | }; 121 | } 122 | 123 | function toVimContent( 124 | width: number, 125 | height: number, 126 | labels: Label[], 127 | ): string[] { 128 | const content = Array.from({ length: height }, () => " ".repeat(width)); 129 | labels.forEach(({ visualRow, visualCol, value }) => { 130 | const y = visualRow - 1; 131 | const x = visualCol - 1; 132 | content[y] = content[y].slice(0, x) + value + 133 | content[y].slice(x + value.length); 134 | }); 135 | return content; 136 | } 137 | 138 | function toVimMask( 139 | width: number, 140 | height: number, 141 | labels: Label[], 142 | ): (readonly [number, number, number, number])[] { 143 | return Array.from({ length: height }, (_, i) => { 144 | const row = i + 1; 145 | const columns = labels 146 | .filter(({ visualRow }) => visualRow === row) 147 | .sort((a, b) => a.visualCol - b.visualCol); 148 | let offset = 1; 149 | const masks = columns 150 | .map(({ visualCol, value }) => { 151 | if (visualCol === offset) { 152 | return undefined; 153 | } 154 | const mask = [offset, visualCol - 1, row, row] as const; 155 | offset = visualCol + value.length; 156 | return mask; 157 | }) 158 | .filter((v) => v !== undefined); 159 | return [...masks, [offset, width, row, row] as const]; 160 | }).flat(); 161 | } 162 | -------------------------------------------------------------------------------- /denops/initial/main.ts: -------------------------------------------------------------------------------- 1 | import "./polyfill.ts"; 2 | 3 | import type { Denops, Entrypoint } from "jsr:@denops/std@^7.4.0"; 4 | import { collect } from "jsr:@denops/std@^7.4.0/batch"; 5 | import * as fn from "jsr:@denops/std@^7.4.0/function"; 6 | import { assert, is } from "jsr:@core/unknownutil@^4.3.0"; 7 | 8 | import { type Location, Locator } from "./locator.ts"; 9 | import { Indexer } from "./indexer.ts"; 10 | import { 11 | defer, 12 | type Fold, 13 | getByteLength, 14 | getwininfo, 15 | listFolds, 16 | } from "./util.ts"; 17 | import { overlayCurtain, overlayLabels } from "./overlay.ts"; 18 | 19 | const INTERRUPT = "\x03"; 20 | const ESC = "\x1b"; 21 | const NL = "\n"; 22 | const CR = "\r"; 23 | 24 | const INITIAL_LENGTH = 1; 25 | 26 | export const main: Entrypoint = (denops) => { 27 | denops.dispatcher = { 28 | "start": (initialLength) => { 29 | assert(initialLength, is.UnionOf([is.Undefined, is.Number])); 30 | return start(denops, { initialLength, signal: denops.interrupted }); 31 | }, 32 | }; 33 | }; 34 | 35 | type StartOptions = { 36 | initialLength?: number; 37 | signal?: AbortSignal; 38 | }; 39 | 40 | async function start( 41 | denops: Denops, 42 | options: StartOptions = {}, 43 | ): Promise { 44 | const { signal } = options; 45 | const initialLength = options.initialLength ?? INITIAL_LENGTH; 46 | const [content_, winid, wininfos, folds] = await collect( 47 | denops, 48 | (denops) => [ 49 | fn.getline(denops, 1, "$"), 50 | fn.win_getid(denops), 51 | getwininfo(denops), 52 | listFolds(denops), 53 | ], 54 | ); 55 | signal?.throwIfAborted(); 56 | const content = content_.map((value, i) => ({ row: i + 1, value })); 57 | const wininfo = wininfos.find(({ winid: id }) => id === winid); 58 | if (!wininfo) { 59 | // Somewhat the target window is not found? 60 | throw new Error(`Window not found: ${winid}`); 61 | } 62 | 63 | const locator = new Locator(); 64 | 65 | // Create visible content 66 | const visibleContent = content 67 | // Remove lines outside of the window 68 | .filter(({ row }) => wininfo.topline <= row && row <= wininfo.botline) 69 | // Remove lines in folds 70 | .filter(({ row }) => 71 | !folds.some(([start, end]) => start < row && row <= end) 72 | ) 73 | // Add fold text 74 | .map((record) => { 75 | const fold = folds.find(([start]) => start === record.row); 76 | if (fold) { 77 | return { 78 | ...record, 79 | // Fold text is truncated to fit the window width 80 | // so we have to mimic that behavior here. 81 | fold: fold[2].slice(0, wininfo.width - wininfo.textoff), 82 | }; 83 | } 84 | return { 85 | ...record, 86 | fold: undefined, 87 | }; 88 | }); 89 | 90 | // Overlay the curtain 91 | await using _redraw = defer(() => denops.cmd(`echo "" | redraw`)); 92 | await using _curtain = await overlayCurtain(denops, wininfo); 93 | signal?.throwIfAborted(); 94 | await denops.cmd( 95 | `redraw | echohl Title | echo "[initial] Initial Character Input Mode" | echohl NONE`, 96 | ); 97 | signal?.throwIfAborted(); 98 | 99 | // Get an 'initial' character from the user. 100 | const initial = await readUserInput(denops, initialLength); 101 | signal?.throwIfAborted(); 102 | if (initial == null) { 103 | return; 104 | } 105 | 106 | // Find locations of 'initial' in the content. 107 | const locations = locator.locate(initial, visibleContent); 108 | 109 | // Shortcut 110 | switch (locations.length) { 111 | case 0: 112 | return; 113 | case 1: 114 | await jumpToLocation(denops, locations[0]); 115 | } 116 | 117 | // Generate labels 118 | const indexer = new Indexer(locations!.length); 119 | const labels = await Promise.all(locations!.map(async (location) => { 120 | const offset = calcOffset(location.row, wininfo.topline, folds); 121 | const key = indexer.next(); 122 | const visualRow = location.row - offset; 123 | const visualCol = await fn.strdisplaywidth( 124 | denops, 125 | content[location.row - 1].value.substring(0, location.col), 126 | ); 127 | return { 128 | ...location, 129 | visualRow, 130 | visualCol, 131 | value: key, 132 | }; 133 | })); 134 | 135 | await using _labels = await overlayLabels(denops, wininfo, labels); 136 | signal?.throwIfAborted(); 137 | await denops.cmd( 138 | `redraw | echohl Title | echo "[initial] Label Jump Mode" | echohl NONE`, 139 | ); 140 | signal?.throwIfAborted(); 141 | 142 | // Wait until the user selects a label and jump to the location if found. 143 | const key = await readUserInput(denops, indexer.length); 144 | if (!key) { 145 | return; 146 | } 147 | signal?.throwIfAborted(); 148 | const label = labels.find(({ value }) => value === key); 149 | if (label) { 150 | // Calculate proper col 151 | const row = label.row; 152 | const col = getByteLength(content[row - 1].value.substring(0, label.col)); 153 | await jumpToLocation(denops, { row: label.row, col }); 154 | } 155 | } 156 | 157 | function calcOffset(row: number, topline: number, folds: Fold[]): number { 158 | return folds 159 | .filter(([start, end]) => topline <= start && end < row) 160 | .map(([start, end]) => end - start) 161 | .reduce((acc, cur) => acc + cur, topline - 1); 162 | } 163 | 164 | async function readUserInput( 165 | denops: Denops, 166 | n: number, 167 | ): Promise { 168 | let key = ""; 169 | while (key.length < n) { 170 | const char = await fn.getcharstr(denops); 171 | switch (char) { 172 | case "": 173 | case INTERRUPT: 174 | case ESC: 175 | case NL: 176 | case CR: 177 | return null; 178 | } 179 | key += char; 180 | } 181 | return key; 182 | } 183 | 184 | async function jumpToLocation( 185 | denops: Denops, 186 | location: Location, 187 | ): Promise { 188 | await fn.cursor(denops, [location.row, location.col]); 189 | await denops.cmd("normal! zv"); 190 | } 191 | -------------------------------------------------------------------------------- /denops/initial/locator_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@^1.0.8"; 2 | import { type Content, type Location, Locator } from "./locator.ts"; 3 | 4 | Deno.test("locator", async (t) => { 5 | const locator = new Locator(); 6 | 7 | await t.step("single character", async (t) => { 8 | await t.step("split by space", async (t) => { 9 | const content: Content = [ 10 | { row: 1, value: "apple banana cherry" }, 11 | { row: 2, value: "APPLE BANANA CHERRY" }, 12 | ]; 13 | 14 | await t.step("should locate 'a'", () => { 15 | const expect: Location[] = [ 16 | { row: 1, col: 1 }, 17 | { row: 2, col: 1 }, 18 | ]; 19 | const actual = locator.locate("a", content); 20 | assertEquals(actual, expect); 21 | }); 22 | 23 | await t.step("should locate 'b'", () => { 24 | const expect: Location[] = [ 25 | { row: 1, col: 7 }, 26 | { row: 2, col: 7 }, 27 | ]; 28 | const actual = locator.locate("b", content); 29 | assertEquals(actual, expect); 30 | }); 31 | 32 | await t.step("should locate 'c'", () => { 33 | const expect: Location[] = [ 34 | { row: 1, col: 14 }, 35 | { row: 2, col: 14 }, 36 | ]; 37 | const actual = locator.locate("c", content); 38 | assertEquals(actual, expect); 39 | }); 40 | }); 41 | 42 | await t.step("split by symbols", async (t) => { 43 | const content: Content = [ 44 | { row: 1, value: "apple;banana;cherry" }, 45 | { row: 2, value: "APPLE;BANANA;CHERRY" }, 46 | ]; 47 | 48 | await t.step("should locate 'a'", () => { 49 | const expect: Location[] = [ 50 | { row: 1, col: 1 }, 51 | { row: 2, col: 1 }, 52 | ]; 53 | const actual = locator.locate("a", content); 54 | assertEquals(actual, expect); 55 | }); 56 | 57 | await t.step("should locate 'b'", () => { 58 | const expect: Location[] = [ 59 | { row: 1, col: 7 }, 60 | { row: 2, col: 7 }, 61 | ]; 62 | const actual = locator.locate("b", content); 63 | assertEquals(actual, expect); 64 | }); 65 | 66 | await t.step("should locate 'c'", () => { 67 | const expect: Location[] = [ 68 | { row: 1, col: 14 }, 69 | { row: 2, col: 14 }, 70 | ]; 71 | const actual = locator.locate("c", content); 72 | assertEquals(actual, expect); 73 | }); 74 | }); 75 | 76 | await t.step("snake case", async (t) => { 77 | const content: Content = [ 78 | { row: 1, value: "apple_banana_cherry" }, 79 | { row: 2, value: "APPLE_BANANA_CHERRY" }, 80 | ]; 81 | 82 | await t.step("should locate 'a'", () => { 83 | const expect: Location[] = [ 84 | { row: 1, col: 1 }, 85 | { row: 2, col: 1 }, 86 | ]; 87 | const actual = locator.locate("a", content); 88 | assertEquals(actual, expect); 89 | }); 90 | 91 | await t.step("should locate 'b'", () => { 92 | const expect: Location[] = [ 93 | { row: 1, col: 7 }, 94 | { row: 2, col: 7 }, 95 | ]; 96 | const actual = locator.locate("b", content); 97 | assertEquals(actual, expect); 98 | }); 99 | 100 | await t.step("should locate 'c'", () => { 101 | const expect: Location[] = [ 102 | { row: 1, col: 14 }, 103 | { row: 2, col: 14 }, 104 | ]; 105 | const actual = locator.locate("c", content); 106 | assertEquals(actual, expect); 107 | }); 108 | }); 109 | 110 | await t.step("camel case", async (t) => { 111 | const content: Content = [ 112 | { row: 1, value: "AppleBananaCherry" }, 113 | ]; 114 | 115 | await t.step("should locate 'a'", () => { 116 | const expect: Location[] = [ 117 | { row: 1, col: 1 }, 118 | ]; 119 | const actual = locator.locate("a", content); 120 | assertEquals(actual, expect); 121 | }); 122 | 123 | await t.step("should locate 'b'", () => { 124 | const expect: Location[] = [ 125 | { row: 1, col: 6 }, 126 | ]; 127 | const actual = locator.locate("b", content); 128 | assertEquals(actual, expect); 129 | }); 130 | 131 | await t.step("should locate 'c'", () => { 132 | const expect: Location[] = [ 133 | { row: 1, col: 12 }, 134 | ]; 135 | const actual = locator.locate("c", content); 136 | assertEquals(actual, expect); 137 | }); 138 | }); 139 | }); 140 | 141 | await t.step("two characters", async (t) => { 142 | await t.step("split by space", async (t) => { 143 | const content: Content = [ 144 | { row: 1, value: "apple banana cherry" }, 145 | { row: 2, value: "APPLE BANANA CHERRY" }, 146 | ]; 147 | 148 | await t.step("should locate 'ap'", () => { 149 | const expect: Location[] = [ 150 | { row: 1, col: 1 }, 151 | { row: 2, col: 1 }, 152 | ]; 153 | const actual = locator.locate("ap", content); 154 | assertEquals(actual, expect); 155 | }); 156 | 157 | await t.step("should locate 'ba'", () => { 158 | const expect: Location[] = [ 159 | { row: 1, col: 7 }, 160 | { row: 2, col: 7 }, 161 | ]; 162 | const actual = locator.locate("ba", content); 163 | assertEquals(actual, expect); 164 | }); 165 | 166 | await t.step("should locate 'ch'", () => { 167 | const expect: Location[] = [ 168 | { row: 1, col: 14 }, 169 | { row: 2, col: 14 }, 170 | ]; 171 | const actual = locator.locate("ch", content); 172 | assertEquals(actual, expect); 173 | }); 174 | }); 175 | 176 | await t.step("split by symbols", async (t) => { 177 | const content: Content = [ 178 | { row: 1, value: "apple;banana;cherry" }, 179 | { row: 2, value: "APPLE;BANANA;CHERRY" }, 180 | ]; 181 | 182 | await t.step("should locate 'ap'", () => { 183 | const expect: Location[] = [ 184 | { row: 1, col: 1 }, 185 | { row: 2, col: 1 }, 186 | ]; 187 | const actual = locator.locate("ap", content); 188 | assertEquals(actual, expect); 189 | }); 190 | 191 | await t.step("should locate 'ba'", () => { 192 | const expect: Location[] = [ 193 | { row: 1, col: 7 }, 194 | { row: 2, col: 7 }, 195 | ]; 196 | const actual = locator.locate("ba", content); 197 | assertEquals(actual, expect); 198 | }); 199 | 200 | await t.step("should locate 'ch'", () => { 201 | const expect: Location[] = [ 202 | { row: 1, col: 14 }, 203 | { row: 2, col: 14 }, 204 | ]; 205 | const actual = locator.locate("ch", content); 206 | assertEquals(actual, expect); 207 | }); 208 | }); 209 | 210 | await t.step("snake case", async (t) => { 211 | const content: Content = [ 212 | { row: 1, value: "apple_banana_cherry" }, 213 | { row: 2, value: "APPLE_BANANA_CHERRY" }, 214 | ]; 215 | 216 | await t.step("should locate 'ap'", () => { 217 | const expect: Location[] = [ 218 | { row: 1, col: 1 }, 219 | { row: 2, col: 1 }, 220 | ]; 221 | const actual = locator.locate("ap", content); 222 | assertEquals(actual, expect); 223 | }); 224 | 225 | await t.step("should locate 'ba'", () => { 226 | const expect: Location[] = [ 227 | { row: 1, col: 7 }, 228 | { row: 2, col: 7 }, 229 | ]; 230 | const actual = locator.locate("ba", content); 231 | assertEquals(actual, expect); 232 | }); 233 | 234 | await t.step("should locate 'ch'", () => { 235 | const expect: Location[] = [ 236 | { row: 1, col: 14 }, 237 | { row: 2, col: 14 }, 238 | ]; 239 | const actual = locator.locate("ch", content); 240 | assertEquals(actual, expect); 241 | }); 242 | }); 243 | 244 | await t.step("camel case", async (t) => { 245 | const content: Content = [ 246 | { row: 1, value: "AppleBananaCherry" }, 247 | ]; 248 | 249 | await t.step("should locate 'ap'", () => { 250 | const expect: Location[] = [ 251 | { row: 1, col: 1 }, 252 | ]; 253 | const actual = locator.locate("ap", content); 254 | assertEquals(actual, expect); 255 | }); 256 | 257 | await t.step("should locate 'ba'", () => { 258 | const expect: Location[] = [ 259 | { row: 1, col: 6 }, 260 | ]; 261 | const actual = locator.locate("ba", content); 262 | assertEquals(actual, expect); 263 | }); 264 | 265 | await t.step("should locate 'ch'", () => { 266 | const expect: Location[] = [ 267 | { row: 1, col: 12 }, 268 | ]; 269 | const actual = locator.locate("ch", content); 270 | assertEquals(actual, expect); 271 | }); 272 | }); 273 | }); 274 | }); 275 | --------------------------------------------------------------------------------