diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 9638137b8..b8be02e16 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -14,10 +14,10 @@ jobs: uses: actions/checkout@v4 - name: Install nix - uses: cachix/install-nix-action@v26 + uses: cachix/install-nix-action@V27 - name: Authenticate with Cachix - uses: cachix/cachix-action@v14 + uses: cachix/cachix-action@v15 with: name: helix authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index de15f143c..491edb204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,207 @@ +# 24.07 (2024-07-14) + +Thanks to all of the contributors! This release has changes from 160 contributors. + +Breaking changes: + +Features: + +- Add a textobject for entries/elements of list-like things ([#8150](https://github.com/helix-editor/helix/pull/8150)) +- Add a picker showing files changed in VCS ([#5645](https://github.com/helix-editor/helix/pull/5645)) +- Use a temporary file for writes ([#9236](https://github.com/helix-editor/helix/pull/9236), [#10339](https://github.com/helix-editor/helix/pull/10339), [#10790](https://github.com/helix-editor/helix/pull/10790)) +- Allow cycling through LSP signature-help signatures with `A-n`/`A-p` ([#9974](https://github.com/helix-editor/helix/pull/9974), [#10654](https://github.com/helix-editor/helix/pull/10654), [#10655](https://github.com/helix-editor/helix/pull/10655)) +- Use tree-sitter when finding matching brackets and closest pairs ([#8294](https://github.com/helix-editor/helix/pull/8294), [#10613](https://github.com/helix-editor/helix/pull/10613), [#10777](https://github.com/helix-editor/helix/pull/10777)) +- Auto-save all buffers after a delay ([#10899](https://github.com/helix-editor/helix/pull/10899), [#11047](https://github.com/helix-editor/helix/pull/11047)) + +Commands: + +- `select_all_siblings` (`A-a`) - select all siblings of each selection ([87c4161](https://github.com/helix-editor/helix/commit/87c4161)) +- `select_all_children` (`A-I`) - select all children of each selection ([fa67c5c](https://github.com/helix-editor/helix/commit/fa67c5c)) +- `:read` - insert the contents of the given file at each selection ([#10447](https://github.com/helix-editor/helix/pull/10447)) + +Usability improvements: + +- Support scrolling popup contents using the mouse ([#10053](https://github.com/helix-editor/helix/pull/10053)) +- Sort the jumplist picker so that most recent items come first ([#10095](https://github.com/helix-editor/helix/pull/10095)) +- Improve `goto_file`'s (`gf`) automatic path detection strategy ([#9065](https://github.com/helix-editor/helix/pull/9065)) +- Respect language server definition order in code action menu ([#9590](https://github.com/helix-editor/helix/pull/9590)) +- Allow using a count with `goto_next_buffer` (`gn`) and `goto_previous_buffer` (`gp`) ([#10463](https://github.com/helix-editor/helix/pull/10463)) +- Improve the positioning of popups ([#10257](https://github.com/helix-editor/helix/pull/10257), [#10573](https://github.com/helix-editor/helix/pull/10573)) +- Reset all changes overlapped by selections in `:reset-diff-change` ([#10728](https://github.com/helix-editor/helix/pull/10728)) +- Await pending writes in the `suspend` command (`C-z`) ([#10797](https://github.com/helix-editor/helix/pull/10797)) +- Remove special handling of line ending characters in `replace` (`r`) ([#10786](https://github.com/helix-editor/helix/pull/10786)) +- Use the selected register as a history register for `rename_symbol` (`r`) ([#10932](https://github.com/helix-editor/helix/pull/10932)) +- Use the configured insert-mode cursor for prompt entry ([#10945](https://github.com/helix-editor/helix/pull/10945)) +- Add tilted quotes to the matching brackets list ([#10971](https://github.com/helix-editor/helix/pull/10971)) +- Prevent improper files like `/dev/urandom` from being used as file arguments ([#10733](https://github.com/helix-editor/helix/pull/10733)) +- Allow multiple language servers to provide `:lsp-workspace-command`s ([#10176](https://github.com/helix-editor/helix/pull/10176), [#11105](https://github.com/helix-editor/helix/pull/11105)) +- Trim output of commands executed through `:pipe` ([#10952](https://github.com/helix-editor/helix/pull/10952)) + +Fixes: + +- Use `lldb-dap` instead of `lldb-vscode` in default DAP configuration ([#10091](https://github.com/helix-editor/helix/pull/10091)) +- Fix creation of uneven splits when closing windows ([#10004](https://github.com/helix-editor/helix/pull/10004)) +- Avoid setting a register in `delete_selection_noyank`, fixing the command's use in command sequences ([#10050](https://github.com/helix-editor/helix/pull/10050), [#10148](https://github.com/helix-editor/helix/pull/10148)) +- Fix jump alphabet config resetting when using `:config-reload` ([#10156](https://github.com/helix-editor/helix/pull/10156)) +- Overlay LSP unnecessary/deprecated diagnostic tag highlights onto regular diagnostic highlights ([#10084](https://github.com/helix-editor/helix/pull/10084)) +- Fix crash on LSP text edits with invalid ranges ([#9649](https://github.com/helix-editor/helix/pull/9649)) +- Handle partial failure when sending multiple LSP `textDocument/didSave` notifications ([#10168](https://github.com/helix-editor/helix/pull/10168)) +- Fix off-by-one error for completion-replace option ([#10279](https://github.com/helix-editor/helix/pull/10279)) +- Fix mouse right-click selection behavior ([#10067](https://github.com/helix-editor/helix/pull/10067)) +- Fix scrolling to the end within a popup ([#10181](https://github.com/helix-editor/helix/pull/10181)) +- Fix jump label highlight locations when jumping in non-ascii text ([#10317](https://github.com/helix-editor/helix/pull/10317)) +- Fix crashes from tree-sitter query captures that return non-grapheme aligned ranges ([#10310](https://github.com/helix-editor/helix/pull/10310)) +- Include VCS change in `mi`/`ma` textobject infobox ([#10496](https://github.com/helix-editor/helix/pull/10496)) +- Override crossterm's support for `NO_COLOR` ([#10514](https://github.com/helix-editor/helix/pull/10514)) +- Respect mode when starting a search ([#10505](https://github.com/helix-editor/helix/pull/10505)) +- Simplify first-in-line computation for indent queries ([#10527](https://github.com/helix-editor/helix/pull/10527)) +- Ignore .svn version controlled files in file pickers ([#10536](https://github.com/helix-editor/helix/pull/10536)) +- Fix overloading language servers with `completionItem/resolve` requests ([38ee845](https://github.com/helix-editor/helix/commit/38ee845), [#10873](https://github.com/helix-editor/helix/pull/10873)) +- Specify direction for `select_next_sibling` / `select_prev_sibling` ([#10542](https://github.com/helix-editor/helix/pull/10542)) +- Fix restarting language servers ([#10614](https://github.com/helix-editor/helix/pull/10614)) +- Don't stop at the first URL in `goto_file` ([#10622](https://github.com/helix-editor/helix/pull/10622)) +- Fix overflows in window size calculations for small terminals ([#10620](https://github.com/helix-editor/helix/pull/10620)) +- Allow missing or empty completion lists in DAP ([#10332](https://github.com/helix-editor/helix/pull/10332)) +- Revert statusline refactor that could cause the statusline to blank out on files with long paths ([#10642](https://github.com/helix-editor/helix/pull/10642)) +- Synchronize files after writing ([#10735](https://github.com/helix-editor/helix/pull/10735)) +- Avoid `cnorm` for cursor-type detection in certain terminals ([#10769](https://github.com/helix-editor/helix/pull/10769)) +- Reset inlay hints when stopping or restarting a language server ([#10741](https://github.com/helix-editor/helix/pull/10741)) +- Fix logic for updating `--version` when development VCS HEAD changes ([#10896](https://github.com/helix-editor/helix/pull/10896)) +- Set a max value for the count ([#10930](https://github.com/helix-editor/helix/pull/10930)) +- Deserialize number IDs in DAP module types ([#10943](https://github.com/helix-editor/helix/pull/10943)) +- Fix the behavior of `jump_backwords` when the jumplist is at capacity ([#10968](https://github.com/helix-editor/helix/pull/10968)) +- Fix injection layer heritage tracking for reused tree-sitter injection layers ([#1098](https://github.com/helix-editor/helix/pull/1098)) +- Fix pluralization of "buffers" in the statusline for `:q`, `:q!`, `:wq` ([#11018](https://github.com/helix-editor/helix/pull/11018)) +- Declare LSP formatting client capabilities ([#11064](https://github.com/helix-editor/helix/pull/11064)) +- Commit uncommitted changes before attempting undo/earlier ([#11090](https://github.com/helix-editor/helix/pull/11090)) +- Expand tilde for selected paths in `goto_file` ([#10964](https://github.com/helix-editor/helix/pull/10964)) +- Commit undo checkpoints before `:write[-all]`, fixing the modification indicator ([#11062](https://github.com/helix-editor/helix/pull/11062)) + +Themes: + +- Add jump label styles to `nightfox` ([#10052](https://github.com/helix-editor/helix/pull/10052)) +- Add jump label styles to Solarized themes ([#10056](https://github.com/helix-editor/helix/pull/10056)) +- Add jump label styles to `cyan_light` ([#10058](https://github.com/helix-editor/helix/pull/10058)) +- Add jump label styles to `onelight` ([#10061](https://github.com/helix-editor/helix/pull/10061)) +- Add `flexoki-dark` and `flexoki-light` ([#10002](https://github.com/helix-editor/helix/pull/10002)) +- Add default theme keys for LSP diagnostics tags to existing themes ([#10064](https://github.com/helix-editor/helix/pull/10064)) +- Add jump label styles to base16 themes ([#10076](https://github.com/helix-editor/helix/pull/10076)) +- Dim primary selection in `kanagawa` ([#10094](https://github.com/helix-editor/helix/pull/10094), [#10500](https://github.com/helix-editor/helix/pull/10500)) +- Add jump label styles to tokyonight themes ([#10106](https://github.com/helix-editor/helix/pull/10106)) +- Add jump label styles to papercolor themes ([#10104](https://github.com/helix-editor/helix/pull/10104)) +- Add jump label styles to Darcula themes ([#10116](https://github.com/helix-editor/helix/pull/10116)) +- Add jump label styles to `autumn` ([#10134](https://github.com/helix-editor/helix/pull/10134)) +- Add jump label styles to Ayu themes ([#10133](https://github.com/helix-editor/helix/pull/10133)) +- Add jump label styles to `dark_high_contrast` ([#10133](https://github.com/helix-editor/helix/pull/10133)) +- Update material themes ([#10290](https://github.com/helix-editor/helix/pull/10290)) +- Add jump label styles to `varua` ([#10299](https://github.com/helix-editor/helix/pull/10299)) +- Add ruler style to `adwaita-dark` ([#10260](https://github.com/helix-editor/helix/pull/10260)) +- Remove `ui.highlight` effects from `solarized_dark` ([#10261](https://github.com/helix-editor/helix/pull/10261)) +- Fix statusline color in material themes ([#10308](https://github.com/helix-editor/helix/pull/10308)) +- Brighten `nord` selection highlight ([#10307](https://github.com/helix-editor/helix/pull/10307)) +- Add inlay-hint styles to monokai themes ([#10334](https://github.com/helix-editor/helix/pull/10334)) +- Add bufferline and cursorline colors to `vim_dark_high_contrast` ([#10444](https://github.com/helix-editor/helix/pull/10444)) +- Switch themes with foreground rulers to background ([#10309](https://github.com/helix-editor/helix/pull/10309)) +- Fix statusline colors for `everblush` ([#10394](https://github.com/helix-editor/helix/pull/10394)) +- Use `yellow1` for `gruvbox` warning diagnostics ([#10506](https://github.com/helix-editor/helix/pull/10506)) +- Add jump label styles to Modus themes ([#10538](https://github.com/helix-editor/helix/pull/10538)) +- Refactor `dark_plus` and switch maintainers ([#10543](https://github.com/helix-editor/helix/pull/10543), [#10574](https://github.com/helix-editor/helix/pull/10574)) +- Add debug highlights to `dark_plus` ([#10593](https://github.com/helix-editor/helix/pull/10593)) +- Fix per-mode cursor colors in the default theme ([#10608](https://github.com/helix-editor/helix/pull/10608)) +- Add `tag` and `attribute` highlights to `dark_high_contrast` ([#10705](https://github.com/helix-editor/helix/pull/10705)) +- Improve readability of virtual text with `noctis` theme ([#10910](https://github.com/helix-editor/helix/pull/10910)) +- Sync `catppuccin` themes with upstream ([#10954](https://github.com/helix-editor/helix/pull/10954)) +- Improve jump colors for `github_dark` themes ([#10946](https://github.com/helix-editor/helix/pull/10946)) +- Add modeline and default virtual highlights to `base16_default` ([#10858](https://github.com/helix-editor/helix/pull/10858)) +- Add `iroaseta` ([#10381](https://github.com/helix-editor/helix/pull/10381)) +- Refactor `gruvbox` ([#10773](https://github.com/helix-editor/helix/pull/10773), [#11071](https://github.com/helix-editor/helix/pull/11071)) +- Add cursorcolumn and cursorline to `base16_transparent` ([#11099](https://github.com/helix-editor/helix/pull/11099)) +- Update cursorline color for `fleet_dark` ([#11046](https://github.com/helix-editor/helix/pull/11046)) +- Add `kanagawa-dragon` ([#10172](https://github.com/helix-editor/helix/pull/10172)) + +New languages: + +- BitBake ([#10010](https://github.com/helix-editor/helix/pull/10010)) +- Earthfile ([#10111](https://github.com/helix-editor/helix/pull/10111), [#10489](https://github.com/helix-editor/helix/pull/10489), [#10779](https://github.com/helix-editor/helix/pull/10779)) +- TCL ([#9837](https://github.com/helix-editor/helix/pull/9837)) +- ADL ([#10029](https://github.com/helix-editor/helix/pull/10029)) +- LDIF ([#10330](https://github.com/helix-editor/helix/pull/10330)) +- XTC ([#10448](https://github.com/helix-editor/helix/pull/10448)) +- Move ([f06a166](https://github.com/helix-editor/helix/commit/f06a166)) +- Pest ([#10616](https://github.com/helix-editor/helix/pull/10616)) +- GJS/GTS ([#9940](https://github.com/helix-editor/helix/pull/9940)) +- Inko ([#10656](https://github.com/helix-editor/helix/pull/10656)) +- Mojo ([#10743](https://github.com/helix-editor/helix/pull/10743)) +- Elisp ([#10644](https://github.com/helix-editor/helix/pull/10644)) + +Updated languages and queries: + +- Recognize `mkdn` files as markdown ([#10065](https://github.com/helix-editor/helix/pull/10065)) +- Add comment injections for Gleam ([#10062](https://github.com/helix-editor/helix/pull/10062)) +- Recognize BuildKite commands in YAML injections ([#10090](https://github.com/helix-editor/helix/pull/10090)) +- Add F# block comment token configuration ([#10108](https://github.com/helix-editor/helix/pull/10108)) +- Update tree-sitter-templ and queries ([#10114](https://github.com/helix-editor/helix/pull/10114)) +- Recognize `Tiltfile` as Starlark ([#10072](https://github.com/helix-editor/helix/pull/10072)) +- Remove `todo.txt` from files recognized as todotxt ([5fece00](https://github.com/helix-editor/helix/commit/5fece00)) +- Highlight `type` keyword in Python from PEP695 ([#10165](https://github.com/helix-editor/helix/pull/10165)) +- Update tree-sitter-koka, add language server config ([#10119](https://github.com/helix-editor/helix/pull/10119)) +- Recognize node and Python history files ([#10120](https://github.com/helix-editor/helix/pull/10120)) +- Recognize more shell files as bash ([#10120](https://github.com/helix-editor/helix/pull/10120)) +- Recognize the bun shebang as typescript ([#10120](https://github.com/helix-editor/helix/pull/10120)) +- Add a configuration for the angular language server ([#10166](https://github.com/helix-editor/helix/pull/10166)) +- Add textobject queries for Solidity ([#10318](https://github.com/helix-editor/helix/pull/10318)) +- Recognize `meson.options` as Meson ([#10323](https://github.com/helix-editor/helix/pull/10323)) +- Improve Solidity highlighting ([4fc0a4d](https://github.com/helix-editor/helix/commit/4fc0a4d)) +- Recognize `_.tpl` files as Helm ([#10344](https://github.com/helix-editor/helix/pull/10344)) +- Update tree-sitter-ld and highlights ([#10379](https://github.com/helix-editor/helix/pull/10379)) +- Add `lldb-dap` configuration for Odin ([#10175](https://github.com/helix-editor/helix/pull/10175)) +- Update tree-sitter-rust ([#10365](https://github.com/helix-editor/helix/pull/10365)) +- Update tree-sitter-typst ([#10321](https://github.com/helix-editor/helix/pull/10321)) +- Recognize `hyprpaper.conf`, `hypridle.conf` and `hyprlock.conf` as Hyprlang ([#10383](https://github.com/helix-editor/helix/pull/10383)) +- Improve HTML highlighting ([#10503](https://github.com/helix-editor/helix/pull/10503)) +- Add `rust-script` and `cargo` as shebangs for Rust ([#10484](https://github.com/helix-editor/helix/pull/10484)) +- Fix precedence of tag highlights in Svelte ([#10487](https://github.com/helix-editor/helix/pull/10487)) +- Update tree-sitter-bash ([#10526](https://github.com/helix-editor/helix/pull/10526)) +- Recognize `*.ignore` files as ignore ([#10579](https://github.com/helix-editor/helix/pull/10579)) +- Add configuration to enable inlay hints in metals ([#10597](https://github.com/helix-editor/helix/pull/10597)) +- Enable highlighting private members in ECMA languages ([#10554](https://github.com/helix-editor/helix/pull/10554)) +- Add comment injection to typst queries ([#10628](https://github.com/helix-editor/helix/pull/10628)) +- Add textobject queries for Hurl ([#10594](https://github.com/helix-editor/helix/pull/10594)) +- Add `try` keyword to Rust ([#10641](https://github.com/helix-editor/helix/pull/10641)) +- Add `is not` and `not in` to Python highlights ([#10647](https://github.com/helix-editor/helix/pull/10647)) +- Remove ' and ⟨⟩ from Lean autopair configuration ([#10688](https://github.com/helix-editor/helix/pull/10688)) +- Match TOML/YAML highlights for JSON keys ([#10676](https://github.com/helix-editor/helix/pull/10676)) +- Recognize WORKSPACE files as Starlark ([#10713](https://github.com/helix-editor/helix/pull/10713)) +- Switch Odin tree-sitter grammar and highlights ([#10698](https://github.com/helix-editor/helix/pull/10698)) +- Update `tree-sitter-slint` ([#10749](https://github.com/helix-editor/helix/pull/10749)) +- Add missing operators for Solidity highlights ([#10735](https://github.com/helix-editor/helix/pull/10735)) +- Update `tree-sitter-inko` ([#10805](https://github.com/helix-editor/helix/pull/10805)) +- Add `py`, `hs`, `rs` and `typ` injection regexes ([#10785](https://github.com/helix-editor/helix/pull/10785)) +- Update Swift grammar and queries ([#10802](https://github.com/helix-editor/helix/pull/10802)) +- Update Cairo grammar and queries ([#10919](https://github.com/helix-editor/helix/pull/10919), [#11067](https://github.com/helix-editor/helix/pull/11067)) +- Update Rust grammar ([#10973](https://github.com/helix-editor/helix/pull/10973)) +- Add block comment tokens for typst ([#10955](https://github.com/helix-editor/helix/pull/10955)) +- Recognize `jsonl` as JSON ([#11004](https://github.com/helix-editor/helix/pull/11004)) +- Add rulers and text-width at 100 columns for Lean language ([#10969](https://github.com/helix-editor/helix/pull/10969)) +- Improve VDHL highlights ([#10845](https://github.com/helix-editor/helix/pull/10845)) +- Recognize `hsc` as Haskell ([#11074](https://github.com/helix-editor/helix/pull/11074)) +- Fix heredoc and `$''` highlights in Bash ([#11118](https://github.com/helix-editor/helix/pull/11118)) +- Add LSP configuration for `basedpyright` ([#11121](https://github.com/helix-editor/helix/pull/11121)) +- Recognize `npmrc` and `.nmprc` files as INI ([#11131](https://github.com/helix-editor/helix/pull/11131)) +- Recognize `~/.config/git/ignore` as git-ignore ([#11131](https://github.com/helix-editor/helix/pull/11131)) +- Recognize `pdm.lock` and `uv.lock` as TOML ([#11131](https://github.com/helix-editor/helix/pull/11131)) +- Recognize `.yml` as well as `.yaml` for Helm chart templates ([#11135](https://github.com/helix-editor/helix/pull/11135)) +- Add regex injections for Bash ([#11112](https://github.com/helix-editor/helix/pull/11112)) +- Update tree-sitter-todo ([#11097](https://github.com/helix-editor/helix/pull/11097)) + +Packaging: + +- Make `Helix.appdata.xml` spec-compliant ([#10051](https://github.com/helix-editor/helix/pull/10051)) +- Expose all flake outputs through flake-compat ([#10673](https://github.com/helix-editor/helix/pull/10673)) +- Bump the MSRV to 1.74.0 ([#10714](https://github.com/helix-editor/helix/pull/10714)) +- Improve FiSH completions ([#10853](https://github.com/helix-editor/helix/pull/10853)) +- Improve ZSH completions ([#10853](https://github.com/helix-editor/helix/pull/10853)) + # 24.03 (2024-03-30) As always, a big thank you to all of the contributors! This release saw changes from 125 contributors. diff --git a/Cargo.lock b/Cargo.lock index 017a248a8..3b43e0210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.82" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "arc-swap" @@ -101,9 +101,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bstr" @@ -136,9 +136,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.95" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" +checksum = "324c74f2155653c90b04f25b2a47a8a631360cb908f92a772695f430c7e31052" [[package]] name = "cfg-if" @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" dependencies = [ "error-code", ] @@ -209,12 +209,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" -[[package]] -name = "cov-mark" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ffa3d3e0138386cd4361f63537765cac7ee40698028844635a54495a92f67f3" - [[package]] name = "crc32fast" version = "1.3.2" @@ -273,7 +267,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "crossterm_winapi", "filedescriptor", "futures-core", @@ -351,6 +345,15 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "dunce" version = "1.0.4" @@ -422,9 +425,9 @@ checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" [[package]] name = "fastrand" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fern" @@ -538,9 +541,9 @@ checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" [[package]] name = "gix" -version = "0.62.0" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5631c64fb4cd48eee767bf98a3cbc5c9318ef3bb71074d4c099a2371510282b6" +checksum = "984c5018adfa7a4536ade67990b3ebc6e11ab57b3d6cd9968de0947ca99b4b06" dependencies = [ "gix-actor", "gix-attributes", @@ -588,9 +591,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c3a3bde455ad2ee8ba8a195745241ce0b770a8a26faae59fcf409d01b28c46" +checksum = "d69c59d392c7e6c94385b6fd6089d6df0fe945f32b4357687989f3aee253cd7f" dependencies = [ "bstr", "gix-date", @@ -637,9 +640,9 @@ dependencies = [ [[package]] name = "gix-command" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90009020dc4b3de47beed28e1334706e0a330ddd17f5cfeb097df3b15a54b77" +checksum = "6c22e086314095c43ffe5cdc5c0922d5439da4fd726f3b0438c56147c34dc225" dependencies = [ "bstr", "gix-path", @@ -663,9 +666,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.36.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7580e05996e893347ad04e1eaceb92e1c0e6a3ffe517171af99bf6b6df0ca6e5" +checksum = "53fafe42957e11d98e354a66b6bd70aeea00faf2f62dd11164188224a507c840" dependencies = [ "bstr", "gix-config-value", @@ -688,7 +691,7 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbd06203b1a9b33a78c88252a625031b094d9e1b647260070c25b09910c0a804" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "bstr", "gix-path", "libc", @@ -697,9 +700,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180b130a4a41870edfbd36ce4169c7090bca70e195da783dea088dd973daa59c" +checksum = "367ee9093b0c2b04fd04c5c7c8b6a1082713534eab537597ae343663a518fa99" dependencies = [ "bstr", "itoa", @@ -709,9 +712,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5fbc24115b957346cd23fb0f47d830eb799c46c89cdcf2f5acc9bf2938c2d01" +checksum = "40b9bd8b2d07b6675a840b56a6c177d322d45fa082672b0dad8f063b25baf0a4" dependencies = [ "bstr", "gix-command", @@ -729,9 +732,9 @@ dependencies = [ [[package]] name = "gix-dir" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6943a1f213ad7a060a0548ece229be53f3c2151534b126446ce3533eaf5f14c" +checksum = "60c99f8c545abd63abe541d20ab6cda347de406c0a3f1c80aadc12d9b0e94974" dependencies = [ "bstr", "gix-discover", @@ -749,9 +752,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bab49087ed3710caf77e473dc0efc54ca33d8ccc6441359725f121211482b1" +checksum = "fc27c699b63da66b50d50c00668bc0b7e90c3a382ef302865e891559935f3dbf" dependencies = [ "bstr", "dunce", @@ -765,9 +768,9 @@ dependencies = [ [[package]] name = "gix-features" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4254037d20a247a0367aa79333750146a369719f0c6617fec4f5752cc62b37" +checksum = "ac7045ac9fe5f9c727f38799d002a7ed3583cd777e3322a7c4b43e3cf437dc69" dependencies = [ "crc32fast", "flate2", @@ -784,9 +787,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0d1f01af62bfd2fb3dd291acc2b29d4ab3e96ad52a679174626508ce98ef12" +checksum = "00ce6ea5ac8fca7adbc63c48a1b9e0492c222c386aa15f513405f1003f2f4ab2" dependencies = [ "bstr", "encoding_rs", @@ -805,10 +808,11 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2184c40e7910529677831c8b481acf788ffd92427ed21fad65b6aa637e631b8" +checksum = "3f78f7d6dcda7a5809efd73a33b145e3dce7421c460df21f32126f9732736b0c" dependencies = [ + "fastrand", "gix-features", "gix-utils", ] @@ -819,7 +823,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "682bdc43cb3c00dbedfcc366de2a849b582efd8d886215dbad2ea662ec156bb5" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "bstr", "gix-features", "gix-path", @@ -842,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242" dependencies = [ "gix-hash", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "parking_lot", ] @@ -861,11 +865,11 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.32.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3383122cf18655ef4c097c0b935bba5eb56983947959aaf3b0ceb1949d4dd371" +checksum = "2d8c5a5f1c58edcbc5692b174cda2703aba82ed17d7176ff4c1752eb48b1b167" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "bstr", "filetime", "fnv", @@ -877,7 +881,8 @@ dependencies = [ "gix-object", "gix-traverse", "gix-utils", - "hashbrown 0.14.3", + "gix-validate", + "hashbrown 0.14.5", "itoa", "libc", "memmap2", @@ -888,9 +893,9 @@ dependencies = [ [[package]] name = "gix-lock" -version = "13.0.0" +version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "651e46174dc5e7d18b7b809d31937b6de3681b1debd78618c99162cc30fcf3e1" +checksum = "e3bc7fe297f1f4614774989c00ec8b1add59571dc9b024b4c00acb7dedd4e19d" dependencies = [ "gix-tempfile", "gix-utils", @@ -899,9 +904,9 @@ dependencies = [ [[package]] name = "gix-macros" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dff438f14e67e7713ab9332f5fd18c8f20eb7eb249494f6c2bf170522224032" +checksum = "999ce923619f88194171a67fb3e6d613653b8d4d6078b529b15a765da0edcc17" dependencies = [ "proc-macro2", "quote", @@ -910,9 +915,9 @@ dependencies = [ [[package]] name = "gix-object" -version = "0.42.1" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4f8efae72030df1c4a81d02dbe2348e748d9b9a11e108ed6efbd846326e051" +checksum = "1fe2dc4a41191c680c942e6ebd630c8107005983c4679214fdb1007dcf5ae1df" dependencies = [ "bstr", "gix-actor", @@ -929,9 +934,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8bbb43d2fefdc4701ffdf9224844d05b136ae1b9a73c2f90710c8dd27a93503" +checksum = "e92b9790e2c919166865d0825b26cc440a387c175bed1b43a2fa99c0e9d45e98" dependencies = [ "arc-swap", "gix-date", @@ -949,9 +954,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.50.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58bad27c7677fa6b587aab3a1aca0b6c97373bd371a0a4290677c838c9bcaf1" +checksum = "7a8da51212dbff944713edb2141ed7e002eea326b8992070374ce13a6cb610b3" dependencies = [ "clru", "gix-chunk", @@ -994,11 +999,11 @@ dependencies = [ [[package]] name = "gix-pathspec" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d479789f3abd10f68a709454ce04cd68b54092ee882c8622ae3aa1bb9bf8496c" +checksum = "a76cab098dc10ba2d89f634f66bf196dea4d7db4bf10b75c7a9c201c55a2ee19" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "bstr", "gix-attributes", "gix-config-value", @@ -1020,9 +1025,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd4aba68b925101cb45d6df328979af0681364579db889098a0de75b36c77b65" +checksum = "0b36752b448647acd59c9668fdd830b16d07db1e6d9c3b3af105c1605a6e23d9" dependencies = [ "gix-actor", "gix-date", @@ -1056,9 +1061,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e34196e1969bd5d36e2fbc4467d893999132219d503e23474a8ad2b221cb1e8" +checksum = "63e08f8107ed1f93a83bcfbb4c38084c7cb3f6cd849793f1d5eec235f9b13b2b" dependencies = [ "bstr", "gix-date", @@ -1072,9 +1077,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7d393ae814eeaae41a333c0ff684b243121cc61ccdc5bbe9897094588047d" +checksum = "4181db9cfcd6d1d0fd258e91569dbb61f94cb788b441b5294dd7f1167a3e788f" dependencies = [ "gix-commitgraph", "gix-date", @@ -1091,7 +1096,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fddc27984a643b20dd03e97790555804f98cf07404e0e552c0ad8133266a79a1" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "gix-path", "libc", "windows-sys 0.52.0", @@ -1099,9 +1104,9 @@ dependencies = [ [[package]] name = "gix-status" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c413bfd2952e4ee92e48438dac3c696f3555e586a34d184a427f6bedd1e4f9" +checksum = "2f4373d989713809554d136f51bc7da565adf45c91aa4d86ef6a79801621bfc8" dependencies = [ "bstr", "filetime", @@ -1121,9 +1126,9 @@ dependencies = [ [[package]] name = "gix-submodule" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb7ea05666362472fecd44c1fc35fe48a5b9b841b431cc4f85b95e6f20c23ec" +checksum = "921cd49924ac14b6611b22e5fb7bbba74d8780dc7ad26153304b64d1272460ac" dependencies = [ "bstr", "gix-config", @@ -1136,9 +1141,9 @@ dependencies = [ [[package]] name = "gix-tempfile" -version = "13.0.0" +version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d337955b7af00fb87120d053d87cdfb422a80b9ff7a3aa4057a99c79422dc30" +checksum = "d3b0e276cd08eb2a22e9f286a4f13a222a01be2defafa8621367515375644b99" dependencies = [ "dashmap", "gix-fs", @@ -1156,11 +1161,11 @@ checksum = "f924267408915fddcd558e3f37295cc7d6a3e50f8bd8b606cee0808c3915157e" [[package]] name = "gix-traverse" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4029ec209b0cc480d209da3837a42c63801dd8548f09c1f4502c60accb62aeb" +checksum = "f20cb69b63eb3e4827939f42c05b7756e3488ef49c25c412a876691d568ee2a0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "gix-commitgraph", "gix-date", "gix-hash", @@ -1198,9 +1203,9 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39fc6e06044985eac19dd34d474909e517307582e462b2eb4c8fa51b6241545" +checksum = "82c27dd34a49b1addf193c92070bcbf3beaf6e10f16a78544de6372e146a0acf" dependencies = [ "bstr", "thiserror", @@ -1208,9 +1213,9 @@ dependencies = [ [[package]] name = "gix-worktree" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359a87dfef695b5f91abb9a424c947edca82768f34acfc269659f66174a510b4" +checksum = "53f6b7de83839274022aff92157d7505f23debf739d257984a300a35972ca94e" dependencies = [ "bstr", "gix-attributes", @@ -1222,6 +1227,7 @@ dependencies = [ "gix-index", "gix-object", "gix-path", + "gix-validate", ] [[package]] @@ -1282,9 +1288,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", @@ -1292,17 +1298,17 @@ dependencies = [ [[package]] name = "helix-core" -version = "24.3.0" +version = "24.7.0" dependencies = [ "ahash", "arc-swap", - "bitflags 2.5.0", + "bitflags 2.6.0", "chrono", "dunce", "encoding_rs", "etcetera", "globset", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "helix-loader", "helix-stdx", "imara-diff", @@ -1325,11 +1331,12 @@ dependencies = [ "unicode-general-category", "unicode-segmentation", "unicode-width", + "url", ] [[package]] name = "helix-dap" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "fern", @@ -1344,12 +1351,12 @@ dependencies = [ [[package]] name = "helix-event" -version = "24.3.0" +version = "24.7.0" dependencies = [ "ahash", "anyhow", "futures-executor", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "log", "once_cell", "parking_lot", @@ -1358,7 +1365,7 @@ dependencies = [ [[package]] name = "helix-loader" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "cc", @@ -1377,7 +1384,7 @@ dependencies = [ [[package]] name = "helix-lsp" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "arc-swap", @@ -1401,13 +1408,13 @@ dependencies = [ [[package]] name = "helix-parsec" -version = "24.3.0" +version = "24.7.0" [[package]] name = "helix-stdx" -version = "24.3.0" +version = "24.7.0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "dunce", "etcetera", "regex-cursor", @@ -1420,7 +1427,7 @@ dependencies = [ [[package]] name = "helix-term" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "arc-swap", @@ -1455,6 +1462,7 @@ dependencies = [ "smallvec", "tempfile", "termini", + "thiserror", "tokio", "tokio-stream", "toml", @@ -1463,9 +1471,9 @@ dependencies = [ [[package]] name = "helix-tui" -version = "24.3.0" +version = "24.7.0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "cassowary", "crossterm", "helix-core", @@ -1479,7 +1487,7 @@ dependencies = [ [[package]] name = "helix-vcs" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "arc-swap", @@ -1495,11 +1503,11 @@ dependencies = [ [[package]] name = "helix-view" -version = "24.3.0" +version = "24.7.0" dependencies = [ "anyhow", "arc-swap", - "bitflags 2.5.0", + "bitflags 2.6.0", "chardetng", "clipboard-win", "crossterm", @@ -1521,6 +1529,7 @@ dependencies = [ "serde_json", "slotmap", "tempfile", + "thiserror", "tokio", "tokio-stream", "toml", @@ -1597,12 +1606,12 @@ dependencies = [ [[package]] name = "imara-diff" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e98c1d0ad70fc91b8b9654b1f33db55e59579d3b3de2bffdced0fdb810570cb8" +checksum = "af13c8ceb376860ff0c6a66d83a8cdd4ecd9e464da24621bbffcd02b49619434" dependencies = [ "ahash", - "hashbrown 0.12.3", + "hashbrown 0.14.5", ] [[package]] @@ -1612,7 +1621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", - "hashbrown 0.14.3", + "hashbrown 0.14.5", ] [[package]] @@ -1666,15 +1675,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libloading" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if", "windows-targets 0.52.0", @@ -1707,9 +1716,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lsp-types" @@ -1771,9 +1780,9 @@ dependencies = [ [[package]] name = "nucleo" -version = "0.2.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae5331f4bcce475cf28cb29c95366c3091af4b0aa7703f1a6bc858f29718fdf3" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" dependencies = [ "nucleo-matcher", "parking_lot", @@ -1782,15 +1791,20 @@ dependencies = [ [[package]] name = "nucleo-matcher" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b702b402fe286162d1f00b552a046ce74365d2ac473a2607ff36ba650f9bd57" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" dependencies = [ - "cov-mark", "memchr", "unicode-segmentation", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.15" @@ -1836,9 +1850,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "open" -version = "5.1.2" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "449f0ff855d85ddbf1edd5b646d65249ead3f5e422aaa86b7d2d0b049b103e32" +checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3" dependencies = [ "is-wsl", "libc", @@ -1847,9 +1861,9 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -1892,6 +1906,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.76" @@ -1909,11 +1929,11 @@ checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" [[package]] name = "pulldown-cmark" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce76ce678ffc8e5675b22aa1405de0b7037e2fdf8913fea40d1926c6fe1e6e7" +checksum = "8746739f11d39ce5ad5c2520a9b75285310dbfe78c541ccf832d38615765aec0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "memchr", "unicase", ] @@ -1996,9 +2016,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.4" +version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", @@ -2054,11 +2074,11 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.32" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -2094,18 +2114,18 @@ checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" [[package]] name = "serde" -version = "1.0.198" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", @@ -2114,9 +2134,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" dependencies = [ "itoa", "ryu", @@ -2136,9 +2156,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ "serde", ] @@ -2325,18 +2345,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.58" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.58" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c" dependencies = [ "proc-macro2", "quote", @@ -2354,13 +2374,16 @@ dependencies = [ [[package]] name = "time" -version = "0.3.23" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e399c068f43a5d116fedaf73b203fa4f9c519f17e2b34f63221d3792f81446" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ + "deranged", "itoa", "libc", + "num-conv", "num_threads", + "powerfmt", "serde", "time-core", "time-macros", @@ -2368,16 +2391,17 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.10" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ba15a897f3c86766b757e5ac7221554c6750054d74d5b28844fce5fb36a6c4" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ + "num-conv", "time-core", ] @@ -2398,9 +2422,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.37.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -2417,9 +2441,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", @@ -2439,9 +2463,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.12" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", "serde_spanned", @@ -2451,18 +2475,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.8" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c12219811e0c1ba077867254e5ad62ee2c9c190b0d957110750ac0cda1ae96cd" +checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ "indexmap", "serde", @@ -2473,9 +2497,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.22.5" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688200d842c76dd88f9a7719ecb0483f79f5a766fb1c100756d5d8a059abc71b" +checksum = "df7cc499ceadd4dcdf7ec6d4cbc34ece92c3fa07821e287aedecd4416c516dca" dependencies = [ "cc", "regex", @@ -2492,9 +2516,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-bom" @@ -2537,15 +2561,15 @@ checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", @@ -2896,7 +2920,7 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "xtask" -version = "24.3.0" +version = "24.7.0" dependencies = [ "helix-core", "helix-loader", diff --git a/Cargo.toml b/Cargo.toml index 6206281b7..e7f784428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,11 +38,12 @@ package.helix-term.opt-level = 2 [workspace.dependencies] tree-sitter = { version = "0.22" } -nucleo = "0.2.0" +nucleo = "0.5.0" slotmap = "1.0.7" +thiserror = "1.0" [workspace.package] -version = "24.3.0" +version = "24.7.0" edition = "2021" authors = ["Blaž Hrastnik "] categories = ["editor"] diff --git a/README.md b/README.md index 3f166db1b..3b639214d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Note: Only certain languages have indentation definitions at the moment. Check [Installation documentation](https://docs.helix-editor.com/install.html). -[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) +[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg?exclude_unsupported=1)](https://repology.org/project/helix/versions) # Contributing diff --git a/base16_theme.toml b/base16_theme.toml index 4a071a60b..ce03b9b65 100644 --- a/base16_theme.toml +++ b/base16_theme.toml @@ -28,6 +28,9 @@ "label" = "magenta" "namespace" = "magenta" "ui.help" = { fg = "white", bg = "black" } +"ui.statusline.insert" = { fg = "black", bg = "green" } +"ui.statusline.select" = { fg = "black", bg = "blue" } +"ui.virtual" = { fg = "gray", modifiers = ["italic"] } "ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] } "ui.virtual.ruler" = { bg = "black" } diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index ba330cf77..027b885a8 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -3,12 +3,19 @@ [Helix](./title-page.md) - [Installation](./install.md) + - [Package Managers](./package-managers.md) + - [Building from source](./building-from-source.md) - [Usage](./usage.md) + - [Registers](./registers.md) + - [Surround](./surround.md) + - [Textobjects](./textobjects.md) + - [Syntax aware motions](./syntax-aware-motions.md) - [Keymap](./keymap.md) - [Commands](./commands.md) - [Language support](./lang-support.md) - [Migrating from Vim](./from-vim.md) - [Configuration](./configuration.md) + - [Editor](./editor.md) - [Themes](./themes.md) - [Key remapping](./remapping.md) - [Languages](./languages.md) diff --git a/book/src/building-from-source.md b/book/src/building-from-source.md new file mode 100644 index 000000000..42ed57a27 --- /dev/null +++ b/book/src/building-from-source.md @@ -0,0 +1,164 @@ +## Building from source + +- [Configuring Helix's runtime files](#configuring-helixs-runtime-files) + - [Linux and macOS](#linux-and-macos) + - [Windows](#windows) + - [Multiple runtime directories](#multiple-runtime-directories) + - [Note to packagers](#note-to-packagers) +- [Validating the installation](#validating-the-installation) +- [Configure the desktop shortcut](#configure-the-desktop-shortcut) + +Requirements: + +Clone the Helix GitHub repository into a directory of your choice. The +examples in this documentation assume installation into either `~/src/` on +Linux and macOS, or `%userprofile%\src\` on Windows. + +- The [Rust toolchain](https://www.rust-lang.org/tools/install) +- The [Git version control system](https://git-scm.com/) +- A C++14 compatible compiler to build the tree-sitter grammars, for example GCC or Clang + +If you are using the `musl-libc` standard library instead of `glibc` the following environment variable must be set during the build to ensure tree-sitter grammars can be loaded correctly: + +```sh +RUSTFLAGS="-C target-feature=-crt-static" +``` + +1. Clone the repository: + + ```sh + git clone https://github.com/helix-editor/helix + cd helix + ``` + +2. Compile from source: + + ```sh + cargo install --path helix-term --locked + ``` + + This command will create the `hx` executable and construct the tree-sitter + grammars in the local `runtime` folder. + +> 💡 If you do not want to fetch or build grammars, set an environment variable `HELIX_DISABLE_AUTO_GRAMMAR_BUILD` + +> 💡 Tree-sitter grammars can be fetched and compiled if not pre-packaged. Fetch +> grammars with `hx --grammar fetch` and compile them with +> `hx --grammar build`. This will install them in +> the `runtime` directory within the user's helix config directory (more +> [details below](#multiple-runtime-directories)). + +### Configuring Helix's runtime files + +#### Linux and macOS + +The **runtime** directory is one below the Helix source, so either export a +`HELIX_RUNTIME` environment variable to point to that directory and add it to +your `~/.bashrc` or equivalent: + +```sh +export HELIX_RUNTIME=~/src/helix/runtime +``` + +Or, create a symbolic link: + +```sh +ln -Ts $PWD/runtime ~/.config/helix/runtime +``` + +If the above command fails to create a symbolic link because the file exists either move `~/.config/helix/runtime` to a new location or delete it, then run the symlink command above again. + +#### Windows + +Either set the `HELIX_RUNTIME` environment variable to point to the runtime files using the Windows setting (search for +`Edit environment variables for your account`) or use the `setx` command in +Cmd: + +```sh +setx HELIX_RUNTIME "%userprofile%\source\repos\helix\runtime" +``` + +> 💡 `%userprofile%` resolves to your user directory like +> `C:\Users\Your-Name\` for example. + +Or, create a symlink in `%appdata%\helix\` that links to the source code directory: + +| Method | Command | +| ---------- | -------------------------------------------------------------------------------------- | +| PowerShell | `New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"` | +| Cmd | `cd %appdata%\helix`
`mklink /D runtime "%userprofile%\src\helix\runtime"` | + +> 💡 On Windows, creating a symbolic link may require running PowerShell or +> Cmd as an administrator. + +#### Multiple runtime directories + +When Helix finds multiple runtime directories it will search through them for files in the +following order: + +1. `runtime/` sibling directory to `$CARGO_MANIFEST_DIR` directory (this is intended for + developing and testing helix only). +2. `runtime/` subdirectory of OS-dependent helix user config directory. +3. `$HELIX_RUNTIME` +4. Distribution-specific fallback directory (set at compile time—not run time— + with the `HELIX_DEFAULT_RUNTIME` environment variable) +5. `runtime/` subdirectory of path to Helix executable. + +This order also sets the priority for selecting which file will be used if multiple runtime +directories have files with the same name. + +#### Note to packagers + +If you are making a package of Helix for end users, to provide a good out of +the box experience, you should set the `HELIX_DEFAULT_RUNTIME` environment +variable at build time (before invoking `cargo build`) to a directory which +will store the final runtime files after installation. For example, say you want +to package the runtime into `/usr/lib/helix/runtime`. The rough steps a build +script could follow are: + +1. `export HELIX_DEFAULT_RUNTIME=/usr/lib/helix/runtime` +1. `cargo build --profile opt --locked --path helix-term` +1. `cp -r runtime $BUILD_DIR/usr/lib/helix/` +1. `cp target/opt/hx $BUILD_DIR/usr/bin/hx` + +This way the resulting `hx` binary will always look for its runtime directory in +`/usr/lib/helix/runtime` if the user has no custom runtime in `~/.config/helix` +or `HELIX_RUNTIME`. + +### Validating the installation + +To make sure everything is set up as expected you should run the Helix health +check: + +```sh +hx --health +``` + +For more information on the health check results refer to +[Health check](https://github.com/helix-editor/helix/wiki/Healthcheck). + +### Configure the desktop shortcut + +If your desktop environment supports the +[XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html) +you can configure Helix to show up in the application menu by copying the +provided `.desktop` and icon files to their correct folders: + +```sh +cp contrib/Helix.desktop ~/.local/share/applications +cp contrib/helix.png ~/.icons # or ~/.local/share/icons +``` +It is recommended to convert the links in the `.desktop` file to absolute paths to avoid potential problems: + +```sh +sed -i -e "s|Exec=hx %F|Exec=$(readlink -f ~/.cargo/bin/hx) %F|g" \ + -e "s|Icon=helix|Icon=$(readlink -f ~/.icons/helix.png)|g" ~/.local/share/applications/Helix.desktop +``` + +To use another terminal than the system default, you can modify the `.desktop` +file. For example, to use `kitty`: + +```sh +sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop +sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop +``` diff --git a/book/src/configuration.md b/book/src/configuration.md index 5e22cebf8..0cd12568b 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -33,372 +33,3 @@ signal to the Helix process on Unix operating systems, such as by using the comm Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository. Its settings will be merged with the configuration directory `config.toml` and the built-in configuration. -## Editor - -### `[editor]` Section - -| Key | Description | Default | -|--|--|---------| -| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` | -| `mouse` | Enable mouse mode | `true` | -| `middle-click-paste` | Middle click paste support | `true` | -| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` | -| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | -| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` | -| `cursorline` | Highlight all lines with a cursor | `false` | -| `cursorcolumn` | Highlight all columns with a cursor | `false` | -| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | -| `auto-completion` | Enable automatic pop up of auto-completion | `true` | -| `auto-format` | Enable automatic formatting on save | `true` | -| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | -| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` | -| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` | -| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` | -| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | -| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | -| `auto-info` | Whether to display info boxes | `true` | -| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` | -| `undercurl` | Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative | `false` | -| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` | -| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` | -| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | -| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | -| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` | -| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` | -| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` | -| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` | -| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` -| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"` - -### `[editor.statusline]` Section - -Allows configuring the statusline at the bottom of the editor. - -The configuration distinguishes between three areas of the status line: - -`[ ... ... LEFT ... ... | ... ... ... CENTER ... ... ... | ... ... RIGHT ... ... ]` - -Statusline elements can be defined as follows: - -```toml -[editor.statusline] -left = ["mode", "spinner"] -center = ["file-name"] -right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] -separator = "│" -mode.normal = "NORMAL" -mode.insert = "INSERT" -mode.select = "SELECT" -``` -The `[editor.statusline]` key takes the following sub-keys: - -| Key | Description | Default | -| --- | --- | --- | -| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name", "read-only-indicator", "file-modification-indicator"]` | -| `center` | A list of elements aligned to the middle of the statusline | `[]` | -| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "register", "position", "file-encoding"]` | -| `separator` | The character used to separate elements in the statusline | `"│"` | -| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` | -| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` | -| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` | - -The following statusline elements can be configured: - -| Key | Description | -| ------ | ----------- | -| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) | -| `spinner` | A progress spinner indicating LSP activity | -| `file-name` | The path/name of the opened file | -| `file-absolute-path` | The absolute path/name of the opened file | -| `file-base-name` | The basename of the opened file | -| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) | -| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | -| `file-line-ending` | The file line endings (CRLF or LF) | -| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written | -| `total-line-numbers` | The total line numbers of the opened file | -| `file-type` | The type of the opened file | -| `diagnostics` | The number of warnings and/or errors | -| `workspace-diagnostics` | The number of warnings and/or errors on workspace | -| `selections` | The number of active selections | -| `primary-selection-length` | The number of characters currently in primary selection | -| `position` | The cursor position | -| `position-percentage` | The cursor position as a percentage of the total number of lines | -| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) | -| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) | -| `version-control` | The current branch name or detached commit hash of the opened workspace | -| `register` | The current selected register | - -### `[editor.lsp]` Section - -| Key | Description | Default | -| --- | ----------- | ------- | -| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | -| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | -| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | -| `display-inlay-hints` | Display inlay hints[^2] | `false` | -| `display-signature-help-docs` | Display docs under signature help popup | `true` | -| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` | -| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` | - -[^1]: By default, a progress spinner is shown in the statusline beside the file path. - -[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them! - -### `[editor.cursor-shape]` Section - -Defines the shape of cursor in each mode. -Valid values for these options are `block`, `bar`, `underline`, or `hidden`. - -> 💡 Due to limitations of the terminal environment, only the primary cursor can -> change shape. - -| Key | Description | Default | -| --- | ----------- | ------- | -| `normal` | Cursor shape in [normal mode][normal mode] | `block` | -| `insert` | Cursor shape in [insert mode][insert mode] | `block` | -| `select` | Cursor shape in [select mode][select mode] | `block` | - -[normal mode]: ./keymap.md#normal-mode -[insert mode]: ./keymap.md#insert-mode -[select mode]: ./keymap.md#select--extend-mode - -### `[editor.file-picker]` Section - -Set options for file picker and global search. Ignoring a file means it is -not visible in the Helix file picker and global search. - -All git related options are only enabled in a git repository. - -| Key | Description | Default | -|--|--|---------| -|`hidden` | Enables ignoring hidden files | `true` -|`follow-symlinks` | Follow symlinks instead of ignoring them | `true` -|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | `true` -|`parents` | Enables reading ignore files from parent directories | `true` -|`ignore` | Enables reading `.ignore` files | `true` -|`git-ignore` | Enables reading `.gitignore` files | `true` -|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true` -|`git-exclude` | Enables reading `.git/info/exclude` files | `true` -|`max-depth` | Set with an integer value for maximum depth to recurse | Unset by default - -Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files. - -Additionally, you can use Helix-specific ignore files by creating a local `.helix/ignore` file in the current workspace or a global `ignore` file located in your Helix config directory: -- Linux and Mac: `~/.config/helix/ignore` -- Windows: `%AppData%\helix\ignore` - -Example: - -```ini -# unignore in file picker and global search -!.github/ -!.gitignore -!.gitattributes -``` - -### `[editor.auto-pairs]` Section - -Enables automatic insertion of pairs to parentheses, brackets, etc. Can be a -simple boolean value, or a specific mapping of pairs of single characters. - -To disable auto-pairs altogether, set `auto-pairs` to `false`: - -```toml -[editor] -auto-pairs = false # defaults to `true` -``` - -The default pairs are (){}[]''""``, but these can be customized by -setting `auto-pairs` to a TOML table: - -```toml -[editor.auto-pairs] -'(' = ')' -'{' = '}' -'[' = ']' -'"' = '"' -'`' = '`' -'<' = '>' -``` - -Additionally, this setting can be used in a language config. Unless -the editor setting is `false`, this will override the editor config in -documents with this language. - -Example `languages.toml` that adds `<>` and removes `''` - -```toml -[[language]] -name = "rust" - -[language.auto-pairs] -'(' = ')' -'{' = '}' -'[' = ']' -'"' = '"' -'`' = '`' -'<' = '>' -``` - -### `[editor.search]` Section - -Search specific options. - -| Key | Description | Default | -|--|--|---------| -| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` | -| `wrap-around`| Whether the search should wrap after depleting the matches | `true` | - -### `[editor.whitespace]` Section - -Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace. - -| Key | Description | Default | -|-----|-------------|---------| -| `render` | Whether to render whitespace. May either be `all` or `none`, or a table with sub-keys `space`, `nbsp`, `nnbsp`, `tab`, and `newline` | `none` | -| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `nnbsp`, `newline` or `tabpad` | See example below | - -Example - -```toml -[editor.whitespace] -render = "all" -# or control each character -[editor.whitespace.render] -space = "all" -tab = "all" -nbsp = "none" -nnbsp = "none" -newline = "none" - -[editor.whitespace.characters] -space = "·" -nbsp = "⍽" -nnbsp = "␣" -tab = "→" -newline = "⏎" -tabpad = "·" # Tabs will look like "→···" (depending on tab width) -``` - -### `[editor.indent-guides]` Section - -Options for rendering vertical indent guides. - -| Key | Description | Default | -| --- | --- | --- | -| `render` | Whether to render indent guides | `false` | -| `character` | Literal character to use for rendering the indent guide | `│` | -| `skip-levels` | Number of indent levels to skip | `0` | - -Example: - -```toml -[editor.indent-guides] -render = true -character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" -skip-levels = 1 -``` - -### `[editor.gutters]` Section - -For simplicity, `editor.gutters` accepts an array of gutter types, which will -use default settings for all gutter components. - -```toml -[editor] -gutters = ["diff", "diagnostics", "line-numbers", "spacer"] -``` - -To customize the behavior of gutters, the `[editor.gutters]` section must -be used. This section contains top level settings, as well as settings for -specific gutter components as subsections. - -| Key | Description | Default | -| --- | --- | --- | -| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | - -Example: - -```toml -[editor.gutters] -layout = ["diff", "diagnostics", "line-numbers", "spacer"] -``` - -#### `[editor.gutters.line-numbers]` Section - -Options for the line number gutter - -| Key | Description | Default | -| --- | --- | --- | -| `min-width` | The minimum number of characters to use | `3` | - -Example: - -```toml -[editor.gutters.line-numbers] -min-width = 1 -``` - -#### `[editor.gutters.diagnostics]` Section - -Currently unused - -#### `[editor.gutters.diff]` Section - -The `diff` gutter option displays colored bars indicating whether a `git` diff represents that a line was added, removed or changed. -These colors are controlled by the theme attributes `diff.plus`, `diff.minus` and `diff.delta`. - -Other diff providers will eventually be supported by a future plugin system. - -There are currently no options for this section. - -#### `[editor.gutters.spacer]` Section - -Currently unused - -### `[editor.soft-wrap]` Section - -Options for soft wrapping lines that exceed the view width: - -| Key | Description | Default | -| --- | --- | --- | -| `enable` | Whether soft wrapping is enabled. | `false` | -| `max-wrap` | Maximum free space left at the end of the line. | `20` | -| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` | -| `wrap-indicator` | Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap` | `↪ ` | -| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` | - -Example: - -```toml -[editor.soft-wrap] -enable = true -max-wrap = 25 # increase value to reduce forced mid-word wrapping -max-indent-retain = 0 -wrap-indicator = "" # set wrap-indicator to "" to hide it -``` - -### `[editor.smart-tab]` Section - -Options for navigating and editing using tab key. - -| Key | Description | Default | -|------------|-------------|---------| -| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` | -| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` | - - -Due to lack of support for S-tab in some terminals, the default keybindings don't fully embrace smart-tab editing experience. If you enjoy smart-tab navigation and a terminal that supports the [Enhanced Keyboard protocol](https://github.com/helix-editor/helix/wiki/Terminal-Support#enhanced-keyboard-protocol), consider setting extra keybindings: - -``` -[keys.normal] -tab = "move_parent_node_end" -S-tab = "move_parent_node_start" - -[keys.insert] -S-tab = "move_parent_node_start" - -[keys.select] -tab = "extend_parent_node_end" -S-tab = "extend_parent_node_start" -``` diff --git a/book/src/editor.md b/book/src/editor.md new file mode 100644 index 000000000..82d5f8461 --- /dev/null +++ b/book/src/editor.md @@ -0,0 +1,435 @@ +## Editor + +- [`[editor]` Section](#editor-section) +- [`[editor.statusline]` Section](#editorstatusline-section) +- [`[editor.lsp]` Section](#editorlsp-section) +- [`[editor.cursor-shape]` Section](#editorcursor-shape-section) +- [`[editor.file-picker]` Section](#editorfile-picker-section) +- [`[editor.auto-pairs]` Section](#editorauto-pairs-section) +- [`[editor.search]` Section](#editorsearch-section) +- [`[editor.whitespace]` Section](#editorwhitespace-section) +- [`[editor.indent-guides]` Section](#editorindent-guides-section) +- [`[editor.gutters]` Section](#editorgutters-section) + - [`[editor.gutters.line-numbers]` Section](#editorguttersline-numbers-section) + - [`[editor.gutters.diagnostics]` Section](#editorguttersdiagnostics-section) + - [`[editor.gutters.diff]` Section](#editorguttersdiff-section) + - [`[editor.gutters.spacer]` Section](#editorguttersspacer-section) +- [`[editor.soft-wrap]` Section](#editorsoft-wrap-section) +- [`[editor.smart-tab]` Section](#editorsmart-tab-section) +- [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section) + +### `[editor]` Section + +| Key | Description | Default | +|--|--|---------| +| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` | +| `mouse` | Enable mouse mode | `true` | +| `middle-click-paste` | Middle click paste support | `true` | +| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` | +| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | +| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` | +| `cursorline` | Highlight all lines with a cursor | `false` | +| `cursorcolumn` | Highlight all columns with a cursor | `false` | +| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | +| `auto-completion` | Enable automatic pop up of auto-completion | `true` | +| `auto-format` | Enable automatic formatting on save | `true` | +| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` | +| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` | +| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` | +| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | +| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | +| `auto-info` | Whether to display info boxes | `true` | +| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` | +| `undercurl` | Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative | `false` | +| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` | +| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` | +| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | +| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | +| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` | +| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` | +| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` | +| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` | +| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` +| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"` +| `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable" + +### `[editor.statusline]` Section + +Allows configuring the statusline at the bottom of the editor. + +The configuration distinguishes between three areas of the status line: + +`[ ... ... LEFT ... ... | ... ... ... CENTER ... ... ... | ... ... RIGHT ... ... ]` + +Statusline elements can be defined as follows: + +```toml +[editor.statusline] +left = ["mode", "spinner"] +center = ["file-name"] +right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] +separator = "│" +mode.normal = "NORMAL" +mode.insert = "INSERT" +mode.select = "SELECT" +``` +The `[editor.statusline]` key takes the following sub-keys: + +| Key | Description | Default | +| --- | --- | --- | +| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name", "read-only-indicator", "file-modification-indicator"]` | +| `center` | A list of elements aligned to the middle of the statusline | `[]` | +| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "register", "position", "file-encoding"]` | +| `separator` | The character used to separate elements in the statusline | `"│"` | +| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` | +| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` | +| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` | + +The following statusline elements can be configured: + +| Key | Description | +| ------ | ----------- | +| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) | +| `spinner` | A progress spinner indicating LSP activity | +| `file-name` | The path/name of the opened file | +| `file-absolute-path` | The absolute path/name of the opened file | +| `file-base-name` | The basename of the opened file | +| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) | +| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | +| `file-line-ending` | The file line endings (CRLF or LF) | +| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written | +| `total-line-numbers` | The total line numbers of the opened file | +| `file-type` | The type of the opened file | +| `diagnostics` | The number of warnings and/or errors | +| `workspace-diagnostics` | The number of warnings and/or errors on workspace | +| `selections` | The number of active selections | +| `primary-selection-length` | The number of characters currently in primary selection | +| `position` | The cursor position | +| `position-percentage` | The cursor position as a percentage of the total number of lines | +| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) | +| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) | +| `version-control` | The current branch name or detached commit hash of the opened workspace | +| `register` | The current selected register | + +### `[editor.lsp]` Section + +| Key | Description | Default | +| --- | ----------- | ------- | +| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | +| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | +| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | +| `display-inlay-hints` | Display inlay hints[^2] | `false` | +| `display-signature-help-docs` | Display docs under signature help popup | `true` | +| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` | +| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` | + +[^1]: By default, a progress spinner is shown in the statusline beside the file path. + +[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them! + +### `[editor.cursor-shape]` Section + +Defines the shape of cursor in each mode. +Valid values for these options are `block`, `bar`, `underline`, or `hidden`. + +> 💡 Due to limitations of the terminal environment, only the primary cursor can +> change shape. + +| Key | Description | Default | +| --- | ----------- | ------- | +| `normal` | Cursor shape in [normal mode][normal mode] | `block` | +| `insert` | Cursor shape in [insert mode][insert mode] | `block` | +| `select` | Cursor shape in [select mode][select mode] | `block` | + +[normal mode]: ./keymap.md#normal-mode +[insert mode]: ./keymap.md#insert-mode +[select mode]: ./keymap.md#select--extend-mode + +### `[editor.file-picker]` Section + +Set options for file picker and global search. Ignoring a file means it is +not visible in the Helix file picker and global search. + +All git related options are only enabled in a git repository. + +| Key | Description | Default | +|--|--|---------| +|`hidden` | Enables ignoring hidden files | `true` +|`follow-symlinks` | Follow symlinks instead of ignoring them | `true` +|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | `true` +|`parents` | Enables reading ignore files from parent directories | `true` +|`ignore` | Enables reading `.ignore` files | `true` +|`git-ignore` | Enables reading `.gitignore` files | `true` +|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true` +|`git-exclude` | Enables reading `.git/info/exclude` files | `true` +|`max-depth` | Set with an integer value for maximum depth to recurse | Unset by default + +Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files. + +Additionally, you can use Helix-specific ignore files by creating a local `.helix/ignore` file in the current workspace or a global `ignore` file located in your Helix config directory: +- Linux and Mac: `~/.config/helix/ignore` +- Windows: `%AppData%\helix\ignore` + +Example: + +```ini +# unignore in file picker and global search +!.github/ +!.gitignore +!.gitattributes +``` + +### `[editor.auto-pairs]` Section + +Enables automatic insertion of pairs to parentheses, brackets, etc. Can be a +simple boolean value, or a specific mapping of pairs of single characters. + +To disable auto-pairs altogether, set `auto-pairs` to `false`: + +```toml +[editor] +auto-pairs = false # defaults to `true` +``` + +The default pairs are (){}[]''""``, but these can be customized by +setting `auto-pairs` to a TOML table: + +```toml +[editor.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' +``` + +Additionally, this setting can be used in a language config. Unless +the editor setting is `false`, this will override the editor config in +documents with this language. + +Example `languages.toml` that adds `<>` and removes `''` + +```toml +[[language]] +name = "rust" + +[language.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' +``` + +### `[editor.auto-save]` Section + +Control auto save behavior. + +| Key | Description | Default | +|--|--|---------| +| `focus-lost` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | +| `after-delay.enable` | Enable automatic saving after `auto-save.after-delay.timeout` milliseconds have passed since last edit. | `false` | +| `after-delay.timeout` | Time in milliseconds since last edit before auto save timer triggers. | `3000` | + +### `[editor.search]` Section + +Search specific options. + +| Key | Description | Default | +|--|--|---------| +| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` | +| `wrap-around`| Whether the search should wrap after depleting the matches | `true` | + +### `[editor.whitespace]` Section + +Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace. + +| Key | Description | Default | +|-----|-------------|---------| +| `render` | Whether to render whitespace. May either be `all` or `none`, or a table with sub-keys `space`, `nbsp`, `nnbsp`, `tab`, and `newline` | `none` | +| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `nnbsp`, `newline` or `tabpad` | See example below | + +Example + +```toml +[editor.whitespace] +render = "all" +# or control each character +[editor.whitespace.render] +space = "all" +tab = "all" +nbsp = "none" +nnbsp = "none" +newline = "none" + +[editor.whitespace.characters] +space = "·" +nbsp = "⍽" +nnbsp = "␣" +tab = "→" +newline = "⏎" +tabpad = "·" # Tabs will look like "→···" (depending on tab width) +``` + +### `[editor.indent-guides]` Section + +Options for rendering vertical indent guides. + +| Key | Description | Default | +| --- | --- | --- | +| `render` | Whether to render indent guides | `false` | +| `character` | Literal character to use for rendering the indent guide | `│` | +| `skip-levels` | Number of indent levels to skip | `0` | + +Example: + +```toml +[editor.indent-guides] +render = true +character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" +skip-levels = 1 +``` + +### `[editor.gutters]` Section + +For simplicity, `editor.gutters` accepts an array of gutter types, which will +use default settings for all gutter components. + +```toml +[editor] +gutters = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +To customize the behavior of gutters, the `[editor.gutters]` section must +be used. This section contains top level settings, as well as settings for +specific gutter components as subsections. + +| Key | Description | Default | +| --- | --- | --- | +| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | + +Example: + +```toml +[editor.gutters] +layout = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +#### `[editor.gutters.line-numbers]` Section + +Options for the line number gutter + +| Key | Description | Default | +| --- | --- | --- | +| `min-width` | The minimum number of characters to use | `3` | + +Example: + +```toml +[editor.gutters.line-numbers] +min-width = 1 +``` + +#### `[editor.gutters.diagnostics]` Section + +Currently unused + +#### `[editor.gutters.diff]` Section + +The `diff` gutter option displays colored bars indicating whether a `git` diff represents that a line was added, removed or changed. +These colors are controlled by the theme attributes `diff.plus`, `diff.minus` and `diff.delta`. + +Other diff providers will eventually be supported by a future plugin system. + +There are currently no options for this section. + +#### `[editor.gutters.spacer]` Section + +Currently unused + +### `[editor.soft-wrap]` Section + +Options for soft wrapping lines that exceed the view width: + +| Key | Description | Default | +| --- | --- | --- | +| `enable` | Whether soft wrapping is enabled. | `false` | +| `max-wrap` | Maximum free space left at the end of the line. | `20` | +| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` | +| `wrap-indicator` | Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap` | `↪ ` | +| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` | + +Example: + +```toml +[editor.soft-wrap] +enable = true +max-wrap = 25 # increase value to reduce forced mid-word wrapping +max-indent-retain = 0 +wrap-indicator = "" # set wrap-indicator to "" to hide it +``` + +### `[editor.smart-tab]` Section + +Options for navigating and editing using tab key. + +| Key | Description | Default | +|------------|-------------|---------| +| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` | +| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` | + + +Due to lack of support for S-tab in some terminals, the default keybindings don't fully embrace smart-tab editing experience. If you enjoy smart-tab navigation and a terminal that supports the [Enhanced Keyboard protocol](https://github.com/helix-editor/helix/wiki/Terminal-Support#enhanced-keyboard-protocol), consider setting extra keybindings: + +``` +[keys.normal] +tab = "move_parent_node_end" +S-tab = "move_parent_node_start" + +[keys.insert] +S-tab = "move_parent_node_start" + +[keys.select] +tab = "extend_parent_node_end" +S-tab = "extend_parent_node_start" +``` + +### `[editor.inline-diagnostics]` Section + +Options for rendering diagnostics inside the text like shown below + +``` +fn main() { + let foo = bar; + └─ no such value in this scope +} +```` + +| Key | Description | Default | +|------------|-------------|---------| +| `cursor-line` | The minimum severity that a diagnostic must have to be shown inline on the line that contains the primary cursor. Set to `disable` to not show any diagnostics inline. This option does not have any effect when in insert-mode and will only take effect 350ms after moving the cursor to a different line. | `"disable"` | +| `other-lines` | The minimum severity that a diagnostic must have to be shown inline on a line that does not contain the cursor-line. Set to `disable` to not show any diagnostics inline. | `"disable"` | +| `prefix-len` | How many horizontal bars `─` are rendered before the diagnostic text. | `1` | +| `max-wrap` | Equivalent of the `editor.soft-wrap.max-wrap` option for diagnostics. | `20` | +| `max-diagnostics` | Maximum number of diagnostics to render inline for a given line | `10` | + +The (first) diagnostic with the highest severity that is not shown inline is rendered at the end of the line (as long as its severity is higher than the `end-of-line-diagnostics` config option): + +``` +fn main() { + let baz = 1; + let foo = bar; a local variable with a similar name exists: baz + └─ no such value in this scope +} +``` + + +The new diagnostic rendering is not yet enabled by default. As soon as end of line or inline diagnostics are enabled the old diagnostics rendering is automatically disabled. The recommended default setting are: + +``` +end-of-line-diagnostics = "hint" +[editor.inline-diagnostics] +cursor-line = "warning" # show warnings and errors on the cursorline inline +``` diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 8418138c8..5afde0972 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -42,6 +42,7 @@ | edoc | ✓ | | | | | eex | ✓ | | | | | ejs | ✓ | | | | +| elisp | ✓ | | | | | elixir | ✓ | ✓ | ✓ | `elixir-ls` | | elm | ✓ | ✓ | | `elm-language-server` | | elvish | ✓ | | | `elvish` | @@ -62,6 +63,7 @@ | git-config | ✓ | | | | | git-ignore | ✓ | | | | | git-rebase | ✓ | | | | +| gjs | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` | | gleam | ✓ | ✓ | | `gleam` | | glimmer | ✓ | | | `ember-language-server` | | glsl | ✓ | ✓ | ✓ | | @@ -73,6 +75,7 @@ | gowork | ✓ | | | `gopls` | | graphql | ✓ | ✓ | | `graphql-lsp` | | groovy | ✓ | | | | +| gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` | | hare | ✓ | | | | | haskell | ✓ | ✓ | | `haskell-language-server-wrapper` | | haskell-persistent | ✓ | | | | @@ -88,6 +91,7 @@ | idris | | | | `idris2-lsp` | | iex | ✓ | | | | | ini | ✓ | | | | +| inko | ✓ | ✓ | ✓ | | | janet | ✓ | | | | | java | ✓ | ✓ | ✓ | `jdtls` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` | @@ -122,6 +126,7 @@ | mermaid | ✓ | | | | | meson | ✓ | | ✓ | | | mint | | | | `mint` | +| mojo | ✓ | ✓ | ✓ | `mojo-lsp-server` | | move | ✓ | | | | | msbuild | ✓ | | ✓ | | | nasm | ✓ | ✓ | | | @@ -185,7 +190,7 @@ | supercollider | ✓ | | | | | svelte | ✓ | | ✓ | `svelteserver` | | sway | ✓ | ✓ | ✓ | `forc` | -| swift | ✓ | | | `sourcekit-lsp` | +| swift | ✓ | ✓ | | `sourcekit-lsp` | | t32 | ✓ | | | | | tablegen | ✓ | ✓ | ✓ | | | tact | ✓ | ✓ | ✓ | | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index cf1b550dc..100c65953 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -2,7 +2,7 @@ | --- | --- | | `:quit`, `:q` | Close the current view. | | `:quit!`, `:q!` | Force close the current view, ignoring unsaved changes. | -| `:open`, `:o` | Open a file from disk into the current view. | +| `:open`, `:o`, `:edit`, `:e` | Open a file from disk into the current view. | | `:buffer-close`, `:bc`, `:bclose` | Close the current buffer. | | `:buffer-close!`, `:bc!`, `:bclose!` | Close the current buffer forcefully, ignoring unsaved changes. | | `:buffer-close-others`, `:bco`, `:bcloseother` | Close all buffers but the currently focused one. | diff --git a/book/src/guides/adding_languages.md b/book/src/guides/adding_languages.md index 0763a3c5c..aa4bf0c5c 100644 --- a/book/src/guides/adding_languages.md +++ b/book/src/guides/adding_languages.md @@ -1,4 +1,4 @@ -# Adding new languages to Helix +## Adding new languages to Helix In order to add a new language to Helix, you will need to follow the steps below. @@ -16,7 +16,7 @@ below. > 💡 If you are adding a new Language Server configuration, make sure to update > the -> [Language Server Wiki](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) +> [Language Server Wiki](https://github.com/helix-editor/helix/wiki/Language-Server-Configurations) > with the installation instructions. ## Grammar configuration diff --git a/book/src/guides/indent.md b/book/src/guides/indent.md index be140384a..1e520731a 100644 --- a/book/src/guides/indent.md +++ b/book/src/guides/indent.md @@ -1,4 +1,4 @@ -# Adding indent queries +## Adding indent queries Helix uses tree-sitter to correctly indent new lines. This requires a tree- sitter grammar and an `indent.scm` query file placed in `runtime/queries/ diff --git a/book/src/guides/injection.md b/book/src/guides/injection.md index 0a1d2c9a2..c0f750941 100644 --- a/book/src/guides/injection.md +++ b/book/src/guides/injection.md @@ -1,4 +1,4 @@ -# Adding Injection Queries +## Adding Injection Queries Writing language injection queries allows one to highlight a specific node as a different language. In addition to the [standard][upstream-docs] language injection options used by tree-sitter, there diff --git a/book/src/guides/textobject.md b/book/src/guides/textobject.md index a31baef93..4d33ab676 100644 --- a/book/src/guides/textobject.md +++ b/book/src/guides/textobject.md @@ -1,4 +1,4 @@ -# Adding textobject queries +## Adding textobject queries Helix supports textobjects that are language specific, such as functions, classes, etc. These textobjects require an accompanying tree-sitter grammar and a `textobjects.scm` query file diff --git a/book/src/install.md b/book/src/install.md index 07865e698..debc82b01 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -1,38 +1,10 @@ # Installing Helix - -- [Pre-built binaries](#pre-built-binaries) -- [Linux, macOS, Windows and OpenBSD packaging status](#linux-macos-windows-and-openbsd-packaging-status) -- [Linux](#linux) - - [Ubuntu](#ubuntu) - - [Fedora/RHEL](#fedorarhel) - - [Arch Linux extra](#arch-linux-extra) - - [NixOS](#nixos) - - [Flatpak](#flatpak) - - [Snap](#snap) - - [AppImage](#appimage) -- [macOS](#macos) - - [Homebrew Core](#homebrew-core) - - [MacPorts](#macports) -- [Windows](#windows) - - [Winget](#winget) - - [Scoop](#scoop) - - [Chocolatey](#chocolatey) - - [MSYS2](#msys2) -- [Building from source](#building-from-source) - - [Configuring Helix's runtime files](#configuring-helixs-runtime-files) - - [Linux and macOS](#linux-and-macos) - - [Windows](#windows) - - [Multiple runtime directories](#multiple-runtime-directories) - - [Validating the installation](#validating-the-installation) - - [Configure the desktop shortcut](#configure-the-desktop-shortcut) - - To install Helix, follow the instructions specific to your operating system. Note that: - To get the latest nightly version of Helix, you need to - [build from source](#building-from-source). + [build from source](./building-from-source.md). - To take full advantage of Helix, install the language servers for your preferred programming languages. See the @@ -41,291 +13,7 @@ Note that: ## Pre-built binaries -Download pre-built binaries from the -[GitHub Releases page](https://github.com/helix-editor/helix/releases). Add the binary to your system's `$PATH` to use it from the command -line. - -## Linux, macOS, Windows and OpenBSD packaging status - -[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) - -## Linux - -The following third party repositories are available: - -### Ubuntu - -Add the `PPA` for Helix: - -```sh -sudo add-apt-repository ppa:maveonair/helix-editor -sudo apt update -sudo apt install helix -``` - -### Fedora/RHEL - -```sh -sudo dnf install helix -``` - -### Arch Linux extra - -Releases are available in the `extra` repository: - -```sh -sudo pacman -S helix -``` - -> 💡 When installed from the `extra` repository, run Helix with `helix` instead of `hx`. -> -> For example: -> ```sh -> helix --health -> ``` -> to check health - -Additionally, a [helix-git](https://aur.archlinux.org/packages/helix-git/) package is available -in the AUR, which builds the master branch. - -### NixOS - -Helix is available in [nixpkgs](https://github.com/nixos/nixpkgs) through the `helix` attribute, -the unstable channel usually carries the latest release. - -Helix is also available as a [flake](https://nixos.wiki/wiki/Flakes) in the project -root. Use `nix develop` to spin up a reproducible development shell. Outputs are -cached for each push to master using [Cachix](https://www.cachix.org/). The -flake is configured to automatically make use of this cache assuming the user -accepts the new settings on first use. - -If you are using a version of Nix without flakes enabled, -[install Cachix CLI](https://docs.cachix.org/installation) and use -`cachix use helix` to configure Nix to use cached outputs when possible. - -### Flatpak - -Helix is available on [Flathub](https://flathub.org/en-GB/apps/com.helix_editor.Helix): - -```sh -flatpak install flathub com.helix_editor.Helix -flatpak run com.helix_editor.Helix -``` - -### Snap - -Helix is available on [Snapcraft](https://snapcraft.io/helix) and can be installed with: - -```sh -snap install --classic helix -``` - -This will install Helix as both `/snap/bin/helix` and `/snap/bin/hx`, so make sure `/snap/bin` is in your `PATH`. - -### AppImage - -Install Helix using the Linux [AppImage](https://appimage.org/) format. -Download the official Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page. - -```sh -chmod +x helix-*.AppImage # change permission for executable mode -./helix-*.AppImage # run helix -``` - -## macOS - -### Homebrew Core - -```sh -brew install helix -``` - -### MacPorts - -```sh -port install helix -``` - -## Windows - -Install on Windows using [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/) -or [MSYS2](https://msys2.org/). - -### Winget -Windows Package Manager winget command-line tool is by default available on Windows 11 and modern versions of Windows 10 as a part of the App Installer. -You can get [App Installer from the Microsoft Store](https://www.microsoft.com/p/app-installer/9nblggh4nns1#activetab=pivot:overviewtab). If it's already installed, make sure it is updated with the latest version. - -```sh -winget install Helix.Helix -``` - -### Scoop - -```sh -scoop install helix -``` - -### Chocolatey - -```sh -choco install helix -``` - -### MSYS2 - -For 64-bit Windows 8.1 or above: - -```sh -pacman -S mingw-w64-ucrt-x86_64-helix -``` - -## Building from source - -Requirements: - -Clone the Helix GitHub repository into a directory of your choice. The -examples in this documentation assume installation into either `~/src/` on -Linux and macOS, or `%userprofile%\src\` on Windows. - -- The [Rust toolchain](https://www.rust-lang.org/tools/install) -- The [Git version control system](https://git-scm.com/) -- A C++14 compatible compiler to build the tree-sitter grammars, for example GCC or Clang - -If you are using the `musl-libc` standard library instead of `glibc` the following environment variable must be set during the build to ensure tree-sitter grammars can be loaded correctly: - -```sh -RUSTFLAGS="-C target-feature=-crt-static" -``` - -1. Clone the repository: - - ```sh - git clone https://github.com/helix-editor/helix - cd helix - ``` - -2. Compile from source: - - ```sh - cargo install --path helix-term --locked - ``` - - This command will create the `hx` executable and construct the tree-sitter - grammars in the local `runtime` folder. - -> 💡 If you do not want to fetch or build grammars, set an environment variable `HELIX_DISABLE_AUTO_GRAMMAR_BUILD` - -> 💡 Tree-sitter grammars can be fetched and compiled if not pre-packaged. Fetch -> grammars with `hx --grammar fetch` and compile them with -> `hx --grammar build`. This will install them in -> the `runtime` directory within the user's helix config directory (more -> [details below](#multiple-runtime-directories)). - -### Configuring Helix's runtime files - -#### Linux and macOS - -The **runtime** directory is one below the Helix source, so either export a -`HELIX_RUNTIME` environment variable to point to that directory and add it to -your `~/.bashrc` or equivalent: - -```sh -export HELIX_RUNTIME=~/src/helix/runtime -``` - -Or, create a symbolic link: - -```sh -ln -Ts $PWD/runtime ~/.config/helix/runtime -``` - -If the above command fails to create a symbolic link because the file exists either move `~/.config/helix/runtime` to a new location or delete it, then run the symlink command above again. - -#### Windows - -Either set the `HELIX_RUNTIME` environment variable to point to the runtime files using the Windows setting (search for -`Edit environment variables for your account`) or use the `setx` command in -Cmd: - -```sh -setx HELIX_RUNTIME "%userprofile%\source\repos\helix\runtime" -``` - -> 💡 `%userprofile%` resolves to your user directory like -> `C:\Users\Your-Name\` for example. - -Or, create a symlink in `%appdata%\helix\` that links to the source code directory: - -| Method | Command | -| ---------- | -------------------------------------------------------------------------------------- | -| PowerShell | `New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"` | -| Cmd | `cd %appdata%\helix`
`mklink /D runtime "%userprofile%\src\helix\runtime"` | - -> 💡 On Windows, creating a symbolic link may require running PowerShell or -> Cmd as an administrator. - -#### Multiple runtime directories - -When Helix finds multiple runtime directories it will search through them for files in the -following order: - -1. `runtime/` sibling directory to `$CARGO_MANIFEST_DIR` directory (this is intended for - developing and testing helix only). -2. `runtime/` subdirectory of OS-dependent helix user config directory. -3. `$HELIX_RUNTIME` -4. Distribution-specific fallback directory (set at compile time—not run time— - with the `HELIX_DEFAULT_RUNTIME` environment variable) -5. `runtime/` subdirectory of path to Helix executable. - -This order also sets the priority for selecting which file will be used if multiple runtime -directories have files with the same name. - -#### Note to packagers - -If you are making a package of Helix for end users, to provide a good out of -the box experience, you should set the `HELIX_DEFAULT_RUNTIME` environment -variable at build time (before invoking `cargo build`) to a directory which -will store the final runtime files after installation. For example, say you want -to package the runtime into `/usr/lib/helix/runtime`. The rough steps a build -script could follow are: - -1. `export HELIX_DEFAULT_RUNTIME=/usr/lib/helix/runtime` -1. `cargo build --profile opt --locked --path helix-term` -1. `cp -r runtime $BUILD_DIR/usr/lib/helix/` -1. `cp target/opt/hx $BUILD_DIR/usr/bin/hx` - -This way the resulting `hx` binary will always look for its runtime directory in -`/usr/lib/helix/runtime` if the user has no custom runtime in `~/.config/helix` -or `HELIX_RUNTIME`. - -### Validating the installation - -To make sure everything is set up as expected you should run the Helix health -check: - -```sh -hx --health -``` - -For more information on the health check results refer to -[Health check](https://github.com/helix-editor/helix/wiki/Healthcheck). - -### Configure the desktop shortcut - -If your desktop environment supports the -[XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html) -you can configure Helix to show up in the application menu by copying the -provided `.desktop` and icon files to their correct folders: - -```sh -cp contrib/Helix.desktop ~/.local/share/applications -cp contrib/helix.png ~/.icons # or ~/.local/share/icons -``` - -To use another terminal than the system default, you can modify the `.desktop` -file. For example, to use `kitty`: +Download pre-built binaries from the [GitHub Releases page](https://github.com/helix-editor/helix/releases). +Add the `hx` binary to your system's `$PATH` to use it from the command line, and copy the `runtime` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS). +The runtime location can be overriden via the HELIX_RUNTIME environment variable. -```sh -sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop -sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop -``` diff --git a/book/src/keymap.md b/book/src/keymap.md index 65a223efb..402b83ef7 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -1,4 +1,4 @@ -# Keymap +## Keymap - [Normal mode](#normal-mode) - [Movement](#movement) @@ -12,8 +12,9 @@ - [Match mode](#match-mode) - [Window mode](#window-mode) - [Space mode](#space-mode) - - [Comment mode](#comment-mode) - [Popup](#popup) + - [Completion Menu](#completion-menu) + - [Signature-help Popup](#signature-help-popup) - [Unimpaired](#unimpaired) - [Insert mode](#insert-mode) - [Select / extend mode](#select--extend-mode) @@ -24,7 +25,7 @@ > 💡 Mappings marked (**TS**) require a tree-sitter grammar for the file type. -> ⚠️ Some terminals' default key mappings conflict with Helix's. If any of the mappings described on this page do not work as expected, check your terminal's mappings to ensure they do not conflict. See the (wiki)[https://github.com/helix-editor/helix/wiki/Terminal-Support] for known conflicts. +> ⚠️ Some terminals' default key mappings conflict with Helix's. If any of the mappings described on this page do not work as expected, check your terminal's mappings to ensure they do not conflict. See the [wiki](https://github.com/helix-editor/helix/wiki/Terminal-Support) for known conflicts. ## Normal mode @@ -88,7 +89,7 @@ Normal mode is the default mode when you launch helix. You can return to it from | `"` `` | Select a register to yank to or paste from | `select_register` | | `>` | Indent selection | `indent` | | `<` | Unindent selection | `unindent` | -| `=` | Format selection (currently nonfunctional/disabled) (**LSP**) | `format_selections` | +| `=` | Format selection (**LSP**) | `format_selections` | | `d` | Delete selection | `delete_selection` | | `Alt-d` | Delete selection, without yanking | `delete_selection_noyank` | | `c` | Change selection (delete and enter insert mode) | `change_selection` | @@ -232,8 +233,7 @@ Jumps to various locations. Accessed by typing `m` in [normal mode](#normal-mode). -See the relevant section in [Usage](./usage.md) for an explanation about -[surround](./usage.md#surround) and [textobject](./usage.md#navigating-using-tree-sitter-textobjects) usage. +Please refer to the relevant sections for detailed explanations about [surround](./surround.md) and [textobjects](./textobjects.md). | Key | Description | Command | | ----- | ----------- | ------- | @@ -309,13 +309,31 @@ This layer is a kludge of mappings, mostly pickers. ##### Popup -Displays documentation for item under cursor. +Displays documentation for item under cursor. Remapping currently not supported. | Key | Description | | ---- | ----------- | | `Ctrl-u` | Scroll up | | `Ctrl-d` | Scroll down | +##### Completion Menu + +Displays documentation for the selected completion item. Remapping currently not supported. + +| Key | Description | +| ---- | ----------- | +| `Shift-Tab`, `Ctrl-p`, `Up` | Previous entry | +| `Tab`, `Ctrl-n`, `Down` | Next entry | + +##### Signature-help Popup + +Displays the signature of the selected completion item. Remapping currently not supported. + +| Key | Description | +| ---- | ----------- | +| `Alt-p` | Previous signature | +| `Alt-n` | Next signature | + #### Unimpaired These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). diff --git a/book/src/lang-support.md b/book/src/lang-support.md index aede33fa0..3b3261645 100644 --- a/book/src/lang-support.md +++ b/book/src/lang-support.md @@ -1,4 +1,4 @@ -# Language Support +## Language Support The following languages and Language Servers are supported. To use Language Server features, you must first [configure][lsp-config-wiki] the diff --git a/book/src/languages.md b/book/src/languages.md index 33ecbb92f..fe105cced 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -1,4 +1,4 @@ -# Languages +## Languages Language-specific settings and settings for language servers are configured in `languages.toml` files. diff --git a/book/src/package-managers.md b/book/src/package-managers.md new file mode 100644 index 000000000..3cfd31003 --- /dev/null +++ b/book/src/package-managers.md @@ -0,0 +1,150 @@ +## Package managers + +- [Linux](#linux) + - [Ubuntu](#ubuntu) + - [Fedora/RHEL](#fedorarhel) + - [Arch Linux extra](#arch-linux-extra) + - [NixOS](#nixos) + - [Flatpak](#flatpak) + - [Snap](#snap) + - [AppImage](#appimage) +- [macOS](#macos) + - [Homebrew Core](#homebrew-core) + - [MacPorts](#macports) +- [Windows](#windows) + - [Winget](#winget) + - [Scoop](#scoop) + - [Chocolatey](#chocolatey) + - [MSYS2](#msys2) + +[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) + +## Linux + +The following third party repositories are available: + +### Ubuntu + +Add the `PPA` for Helix: + +```sh +sudo add-apt-repository ppa:maveonair/helix-editor +sudo apt update +sudo apt install helix +``` + +### Fedora/RHEL + +```sh +sudo dnf install helix +``` + +### Arch Linux extra + +Releases are available in the `extra` repository: + +```sh +sudo pacman -S helix +``` + +> 💡 When installed from the `extra` repository, run Helix with `helix` instead of `hx`. +> +> For example: +> ```sh +> helix --health +> ``` +> to check health + +Additionally, a [helix-git](https://aur.archlinux.org/packages/helix-git/) package is available +in the AUR, which builds the master branch. + +### NixOS + +Helix is available in [nixpkgs](https://github.com/nixos/nixpkgs) through the `helix` attribute, +the unstable channel usually carries the latest release. + +Helix is also available as a [flake](https://wiki.nixos.org/wiki/Flakes) in the project +root. Use `nix develop` to spin up a reproducible development shell. Outputs are +cached for each push to master using [Cachix](https://www.cachix.org/). The +flake is configured to automatically make use of this cache assuming the user +accepts the new settings on first use. + +If you are using a version of Nix without flakes enabled, +[install Cachix CLI](https://docs.cachix.org/installation) and use +`cachix use helix` to configure Nix to use cached outputs when possible. + +### Flatpak + +Helix is available on [Flathub](https://flathub.org/en-GB/apps/com.helix_editor.Helix): + +```sh +flatpak install flathub com.helix_editor.Helix +flatpak run com.helix_editor.Helix +``` + +### Snap + +Helix is available on [Snapcraft](https://snapcraft.io/helix) and can be installed with: + +```sh +snap install --classic helix +``` + +This will install Helix as both `/snap/bin/helix` and `/snap/bin/hx`, so make sure `/snap/bin` is in your `PATH`. + +### AppImage + +Install Helix using the Linux [AppImage](https://appimage.org/) format. +Download the official Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page. + +```sh +chmod +x helix-*.AppImage # change permission for executable mode +./helix-*.AppImage # run helix +``` + +## macOS + +### Homebrew Core + +```sh +brew install helix +``` + +### MacPorts + +```sh +port install helix +``` + +## Windows + +Install on Windows using [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/) +or [MSYS2](https://msys2.org/). + +### Winget +Windows Package Manager winget command-line tool is by default available on Windows 11 and modern versions of Windows 10 as a part of the App Installer. +You can get [App Installer from the Microsoft Store](https://www.microsoft.com/p/app-installer/9nblggh4nns1#activetab=pivot:overviewtab). If it's already installed, make sure it is updated with the latest version. + +```sh +winget install Helix.Helix +``` + +### Scoop + +```sh +scoop install helix +``` + +### Chocolatey + +```sh +choco install helix +``` + +### MSYS2 + +For 64-bit Windows 8.1 or above: + +```sh +pacman -S mingw-w64-ucrt-x86_64-helix +``` diff --git a/book/src/registers.md b/book/src/registers.md new file mode 100644 index 000000000..2edf7e27a --- /dev/null +++ b/book/src/registers.md @@ -0,0 +1,54 @@ +## Registers + +- [User-defined registers](#user-defined-registers) +- [Default registers](#default-registers) +- [Special registers](#special-registers) + +In Helix, registers are storage locations for text and other data, such as the +result of a search. Registers can be used to cut, copy, and paste text, similar +to the clipboard in other text editors. Usage is similar to Vim, with `"` being +used to select a register. + +### User-defined registers + +Helix allows you to create your own named registers for storing text, for +example: + +- `"ay` - Yank the current selection to register `a`. +- `"op` - Paste the text in register `o` after the selection. + +If a register is selected before invoking a change or delete command, the selection will be stored in the register and the action will be carried out: + +- `"hc` - Store the selection in register `h` and then change it (delete and enter insert mode). +- `"md` - Store the selection in register `m` and delete it. + +### Default registers + +Commands that use registers, like yank (`y`), use a default register if none is specified. +These registers are used as defaults: + +| Register character | Contains | +| --- | --- | +| `/` | Last search | +| `:` | Last executed command | +| `"` | Last yanked text | +| `@` | Last recorded macro | + +### Special registers + +Some registers have special behavior when read from and written to. + +| Register character | When read | When written | +| --- | --- | --- | +| `_` | No values are returned | All values are discarded | +| `#` | Selection indices (first selection is `1`, second is `2`, etc.) | This register is not writable | +| `.` | Contents of the current selections | This register is not writable | +| `%` | Name of the current file | This register is not writable | +| `+` | Reads from the system clipboard | Joins and yanks to the system clipboard | +| `*` | Reads from the primary clipboard | Joins and yanks to the primary clipboard | + +When yanking multiple selections to the clipboard registers, the selections +are joined with newlines. Pasting from these registers will paste multiple +selections if the clipboard was last yanked to by the Helix session. Otherwise +the clipboard contents are pasted as one selection. + diff --git a/book/src/remapping.md b/book/src/remapping.md index d762c6add..863c55570 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -1,4 +1,4 @@ -# Key remapping +## Key remapping Helix currently supports one-way key remapping through a simple TOML configuration file. (More powerful solutions such as rebinding via commands will be diff --git a/book/src/surround.md b/book/src/surround.md new file mode 100644 index 000000000..187d2876a --- /dev/null +++ b/book/src/surround.md @@ -0,0 +1,24 @@ +## Surround + +Helix includes built-in functionality similar to [vim-surround](https://github.com/tpope/vim-surround). +The keymappings have been inspired from [vim-sandwich](https://github.com/machakann/vim-sandwich): + +![Surround demo](https://user-images.githubusercontent.com/23398472/122865801-97073180-d344-11eb-8142-8f43809982c6.gif) + +| Key Sequence | Action | +| --------------------------------- | --------------------------------------- | +| `ms` (after selecting text) | Add surround characters to selection | +| `mr` | Replace the closest surround characters | +| `md` | Delete the closest surround characters | + +You can use counts to act on outer pairs. + +Surround can also act on multiple selections. For example, to change every occurrence of `(use)` to `[use]`: + +1. `%` to select the whole file +2. `s` to split the selections on a search term +3. Input `use` and hit Enter +4. `mr([` to replace the parentheses with square brackets + +Multiple characters are currently not supported, but planned for future release. + diff --git a/book/src/syntax-aware-motions.md b/book/src/syntax-aware-motions.md new file mode 100644 index 000000000..17f396fb4 --- /dev/null +++ b/book/src/syntax-aware-motions.md @@ -0,0 +1,66 @@ +## Moving the selection with syntax-aware motions + +`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) allow you to move the +selection according to its location in the syntax tree. For example, many languages have the +following syntax for function calls: + +```js +func(arg1, arg2, arg3); +``` + +A function call might be parsed by tree-sitter into a tree like the following. + +```tsq +(call + function: (identifier) ; func + arguments: + (arguments ; (arg1, arg2, arg3) + (identifier) ; arg1 + (identifier) ; arg2 + (identifier))) ; arg3 +``` + +Use `:tree-sitter-subtree` to view the syntax tree of the primary selection. In +a more intuitive tree format: + +``` + ┌────┐ + │call│ + ┌─────┴────┴─────┐ + │ │ +┌─────▼────┐ ┌────▼────┐ +│identifier│ │arguments│ +│ "func" │ ┌────┴───┬─────┴───┐ +└──────────┘ │ │ │ + │ │ │ + ┌─────────▼┐ ┌────▼─────┐ ┌▼─────────┐ + │identifier│ │identifier│ │identifier│ + │ "arg1" │ │ "arg2" │ │ "arg3" │ + └──────────┘ └──────────┘ └──────────┘ +``` + +If you have a selection that wraps `arg1` (see the tree above), and you use +`Alt-n`, it will select the next sibling in the syntax tree: `arg2`. + +```js +// before +func([arg1], arg2, arg3) +// after +func(arg1, [arg2], arg3); +``` + +Similarly, `Alt-o` will expand the selection to the parent node, in this case, the +arguments node. + +```js +func[(arg1, arg2, arg3)]; +``` + +There is also some nuanced behavior that prevents you from getting stuck on a +node with no sibling. When using `Alt-p` with a selection on `arg1`, the previous +child node will be selected. In the event that `arg1` does not have a previous +sibling, the selection will move up the syntax tree and select the previous +element. As a result, using `Alt-p` with a selection on `arg1` will move the +selection to the "func" `identifier`. + +[lang-support]: ./lang-support.md diff --git a/book/src/textobjects.md b/book/src/textobjects.md new file mode 100644 index 000000000..541acab95 --- /dev/null +++ b/book/src/textobjects.md @@ -0,0 +1,47 @@ +## Selecting and manipulating text with textobjects + +In Helix, textobjects are a way to select, manipulate and operate on a piece of +text in a structured way. They allow you to refer to blocks of text based on +their structure or purpose, such as a word, sentence, paragraph, or even a +function or block of code. + +![Textobject demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif) +![Textobject tree-sitter demo](https://user-images.githubusercontent.com/23398472/132537398-2a2e0a54-582b-44ab-a77f-eb818942203d.gif) + +- `ma` - Select around the object (`va` in Vim, `` in Kakoune) +- `mi` - Select inside the object (`vi` in Vim, `` in Kakoune) + +| Key after `mi` or `ma` | Textobject selected | +| --- | --- | +| `w` | Word | +| `W` | WORD | +| `p` | Paragraph | +| `(`, `[`, `'`, etc. | Specified surround pairs | +| `m` | The closest surround pair | +| `f` | Function | +| `t` | Type (or Class) | +| `a` | Argument/parameter | +| `c` | Comment | +| `T` | Test | +| `g` | Change | + +> 💡 `f`, `t`, etc. need a tree-sitter grammar active for the current +document and a special tree-sitter query file to work properly. [Only +some grammars](./lang-support.md) currently have the query file implemented. +Contributions are welcome! + +## Navigating using tree-sitter textobjects + +Navigating between functions, classes, parameters, and other elements is +possible using tree-sitter and textobject queries. For +example to move to the next function use `]f`, to move to previous +type use `[t`, and so on. + +![Tree-sitter-nav-demo](https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif) + +For the full reference see the [unimpaired](./keymap.html#unimpaired) section of the key bind +documentation. + +> 💡 This feature relies on tree-sitter textobjects +> and requires the corresponding query file to work properly. + diff --git a/book/src/themes.md b/book/src/themes.md index e3b95c0a7..f9f8393c3 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -1,4 +1,4 @@ -# Themes +## Themes To use a theme add `theme = ""` to the top of your [`config.toml`](./configuration.md) file, or select it during runtime using `:theme `. @@ -297,6 +297,8 @@ These scopes are used for theming the editor interface: | `ui.bufferline.background` | Style for bufferline background | | `ui.popup` | Documentation popups (e.g. Space + k) | | `ui.popup.info` | Prompt for multiple key options | +| `ui.picker.header` | Column names in pickers with multiple columns | +| `ui.picker.header.active` | The column name in pickers with multiple columns where the cursor is entering into. | | `ui.window` | Borderlines separating splits | | `ui.help` | Description box for commands | | `ui.text` | Default text style, command prompts, popup text, etc. | diff --git a/book/src/usage.md b/book/src/usage.md index e01482193..859cb6709 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -1,15 +1,5 @@ # Using Helix - -- [Registers](#registers) - - [User-defined registers](#user-defined-registers) - - [Special registers](#special-registers) -- [Surround](#surround) -- [Selecting and manipulating text with textobjects](#selecting-and-manipulating-text-with-textobjects) -- [Navigating using tree-sitter textobjects](#navigating-using-tree-sitter-textobjects) -- [Moving the selection with syntax-aware motions](#moving-the-selection-with-syntax-aware-motions) - - For a full interactive introduction to Helix, refer to the [tutor](https://github.com/helix-editor/helix/blob/master/runtime/tutor) which can be accessed via the command `hx --tutor` or `:tutor`. @@ -17,192 +7,3 @@ can be accessed via the command `hx --tutor` or `:tutor`. > 💡 Currently, not all functionality is fully documented, please refer to the > [key mappings](./keymap.md) list. -## Registers - -In Helix, registers are storage locations for text and other data, such as the -result of a search. Registers can be used to cut, copy, and paste text, similar -to the clipboard in other text editors. Usage is similar to Vim, with `"` being -used to select a register. - -### User-defined registers - -Helix allows you to create your own named registers for storing text, for -example: - -- `"ay` - Yank the current selection to register `a`. -- `"op` - Paste the text in register `o` after the selection. - -If a register is selected before invoking a change or delete command, the selection will be stored in the register and the action will be carried out: - -- `"hc` - Store the selection in register `h` and then change it (delete and enter insert mode). -- `"md` - Store the selection in register `m` and delete it. - -### Default registers - -Commands that use registers, like yank (`y`), use a default register if none is specified. -These registers are used as defaults: - -| Register character | Contains | -| --- | --- | -| `/` | Last search | -| `:` | Last executed command | -| `"` | Last yanked text | -| `@` | Last recorded macro | - -### Special registers - -Some registers have special behavior when read from and written to. - -| Register character | When read | When written | -| --- | --- | --- | -| `_` | No values are returned | All values are discarded | -| `#` | Selection indices (first selection is `1`, second is `2`, etc.) | This register is not writable | -| `.` | Contents of the current selections | This register is not writable | -| `%` | Name of the current file | This register is not writable | -| `+` | Reads from the system clipboard | Joins and yanks to the system clipboard | -| `*` | Reads from the primary clipboard | Joins and yanks to the primary clipboard | - -When yanking multiple selections to the clipboard registers, the selections -are joined with newlines. Pasting from these registers will paste multiple -selections if the clipboard was last yanked to by the Helix session. Otherwise -the clipboard contents are pasted as one selection. - -## Surround - -Helix includes built-in functionality similar to [vim-surround](https://github.com/tpope/vim-surround). -The keymappings have been inspired from [vim-sandwich](https://github.com/machakann/vim-sandwich): - -![Surround demo](https://user-images.githubusercontent.com/23398472/122865801-97073180-d344-11eb-8142-8f43809982c6.gif) - -| Key Sequence | Action | -| --------------------------------- | --------------------------------------- | -| `ms` (after selecting text) | Add surround characters to selection | -| `mr` | Replace the closest surround characters | -| `md` | Delete the closest surround characters | - -You can use counts to act on outer pairs. - -Surround can also act on multiple selections. For example, to change every occurrence of `(use)` to `[use]`: - -1. `%` to select the whole file -2. `s` to split the selections on a search term -3. Input `use` and hit Enter -4. `mr([` to replace the parentheses with square brackets - -Multiple characters are currently not supported, but planned for future release. - -## Selecting and manipulating text with textobjects - -In Helix, textobjects are a way to select, manipulate and operate on a piece of -text in a structured way. They allow you to refer to blocks of text based on -their structure or purpose, such as a word, sentence, paragraph, or even a -function or block of code. - -![Textobject demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif) -![Textobject tree-sitter demo](https://user-images.githubusercontent.com/23398472/132537398-2a2e0a54-582b-44ab-a77f-eb818942203d.gif) - -- `ma` - Select around the object (`va` in Vim, `` in Kakoune) -- `mi` - Select inside the object (`vi` in Vim, `` in Kakoune) - -| Key after `mi` or `ma` | Textobject selected | -| --- | --- | -| `w` | Word | -| `W` | WORD | -| `p` | Paragraph | -| `(`, `[`, `'`, etc. | Specified surround pairs | -| `m` | The closest surround pair | -| `f` | Function | -| `t` | Type (or Class) | -| `a` | Argument/parameter | -| `c` | Comment | -| `T` | Test | -| `g` | Change | - -> 💡 `f`, `t`, etc. need a tree-sitter grammar active for the current -document and a special tree-sitter query file to work properly. [Only -some grammars][lang-support] currently have the query file implemented. -Contributions are welcome! - -## Navigating using tree-sitter textobjects - -Navigating between functions, classes, parameters, and other elements is -possible using tree-sitter and textobject queries. For -example to move to the next function use `]f`, to move to previous -type use `[t`, and so on. - -![Tree-sitter-nav-demo][tree-sitter-nav-demo] - -For the full reference see the [unimpaired][unimpaired-keybinds] section of the key bind -documentation. - -> 💡 This feature relies on tree-sitter textobjects -> and requires the corresponding query file to work properly. - -## Moving the selection with syntax-aware motions - -`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) allow you to move the -selection according to its location in the syntax tree. For example, many languages have the -following syntax for function calls: - -```js -func(arg1, arg2, arg3); -``` - -A function call might be parsed by tree-sitter into a tree like the following. - -```tsq -(call - function: (identifier) ; func - arguments: - (arguments ; (arg1, arg2, arg3) - (identifier) ; arg1 - (identifier) ; arg2 - (identifier))) ; arg3 -``` - -Use `:tree-sitter-subtree` to view the syntax tree of the primary selection. In -a more intuitive tree format: - -``` - ┌────┐ - │call│ - ┌─────┴────┴─────┐ - │ │ -┌─────▼────┐ ┌────▼────┐ -│identifier│ │arguments│ -│ "func" │ ┌────┴───┬─────┴───┐ -└──────────┘ │ │ │ - │ │ │ - ┌─────────▼┐ ┌────▼─────┐ ┌▼─────────┐ - │identifier│ │identifier│ │identifier│ - │ "arg1" │ │ "arg2" │ │ "arg3" │ - └──────────┘ └──────────┘ └──────────┘ -``` - -If you have a selection that wraps `arg1` (see the tree above), and you use -`Alt-n`, it will select the next sibling in the syntax tree: `arg2`. - -```js -// before -func([arg1], arg2, arg3) -// after -func(arg1, [arg2], arg3); -``` - -Similarly, `Alt-o` will expand the selection to the parent node, in this case, the -arguments node. - -```js -func[(arg1, arg2, arg3)]; -``` - -There is also some nuanced behavior that prevents you from getting stuck on a -node with no sibling. When using `Alt-p` with a selection on `arg1`, the previous -child node will be selected. In the event that `arg1` does not have a previous -sibling, the selection will move up the syntax tree and select the previous -element. As a result, using `Alt-p` with a selection on `arg1` will move the -selection to the "func" `identifier`. - -[lang-support]: ./lang-support.md -[unimpaired-keybinds]: ./keymap.md#unimpaired -[tree-sitter-nav-demo]: https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif diff --git a/contrib/Helix.appdata.xml b/contrib/Helix.appdata.xml index a46ef796b..90b3d415a 100644 --- a/contrib/Helix.appdata.xml +++ b/contrib/Helix.appdata.xml @@ -47,6 +47,9 @@ + + https://github.com/helix-editor/helix/releases/tag/24.07 + https://helix-editor.com/news/release-24-03-highlights/ diff --git a/contrib/completion/hx.fish b/contrib/completion/hx.fish index 119776057..f05dba8dd 100644 --- a/contrib/completion/hx.fish +++ b/contrib/completion/hx.fish @@ -1,15 +1,18 @@ #!/usr/bin/env fish # Fish completion script for Helix editor -set -l langs (hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g') - complete -c hx -s h -l help -d "Prints help information" complete -c hx -l tutor -d "Loads the tutorial" -complete -c hx -l health -x -a "$langs" -d "Checks for errors in editor setup" -complete -c hx -s g -l grammar -x -a "fetch build" -d "Fetches or builds tree-sitter grammars" +complete -c hx -l health -xa "(__hx_langs_ops)" -d "Checks for errors" +complete -c hx -s g -l grammar -x -a "fetch build" -d "Fetch or build tree-sitter grammars" complete -c hx -s v -o vv -o vvv -d "Increases logging verbosity" complete -c hx -s V -l version -d "Prints version information" -complete -c hx -l vsplit -d "Splits all given files vertically into different windows" -complete -c hx -l hsplit -d "Splits all given files horizontally into different windows" -complete -c hx -s c -l config -r -d "Specifies a file to use for completion" -complete -c hx -l log -r -d "Specifies a file to write log data into" +complete -c hx -l vsplit -d "Splits all given files vertically" +complete -c hx -l hsplit -d "Splits all given files horizontally" +complete -c hx -s c -l config -r -d "Specifies a file to use for config" +complete -c hx -l log -r -d "Specifies a file to use for logging" +complete -c hx -s w -l working-dir -d "Specify initial working directory" -xa "(__fish_complete_directories)" + +function __hx_langs_ops + hx --health languages | tail -n '+2' | string replace -fr '^(\S+) .*' '$1' +end diff --git a/contrib/completion/hx.zsh b/contrib/completion/hx.zsh index aaad6f844..2631d2283 100644 --- a/contrib/completion/hx.zsh +++ b/contrib/completion/hx.zsh @@ -14,16 +14,18 @@ _hx() { "--health[Checks for errors in editor setup]:language:->health" \ "-g[Fetches or builds tree-sitter grammars]:action:->grammar" \ "--grammar[Fetches or builds tree-sitter grammars]:action:->grammar" \ - "--vsplit[Splits all given files vertically into different windows]" \ - "--hsplit[Splits all given files horizontally into different windows]" \ + "--vsplit[Splits all given files vertically]" \ + "--hsplit[Splits all given files horizontally]" \ "-c[Specifies a file to use for configuration]" \ "--config[Specifies a file to use for configuration]" \ - "--log[Specifies a file to write log data into]" \ + "-w[Specify initial working directory]" \ + "--working-dir[Specify initial working directory]" \ + "--log[Specifies a file to use for logging]" \ "*:file:_files" case "$state" in health) - local languages=($(hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g')) + local languages=($(hx --health | tail -n '+11' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g;s/[✘✓]//g')) _values 'language' $languages ;; grammar) @@ -31,4 +33,3 @@ _hx() { ;; esac } - diff --git a/default.nix b/default.nix index 50e7e6819..d2c51ec3a 100644 --- a/default.nix +++ b/default.nix @@ -5,4 +5,4 @@ let sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7"; }; in - (import compat {src = ./.;}).defaultNix.default + (import compat {src = ./.;}).defaultNix diff --git a/docs/releases.md b/docs/releases.md index e39cfd910..84f95c97e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -5,19 +5,18 @@ Helix releases are versioned in the Calendar Versioning scheme: `22.05.1`. In these instructions we'll use `` as a placeholder for the tag being published. -* Merge the changelog PR -* Add new `` entry in `contrib/Helix.appdata.xml` with release information according to the [AppStream spec](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-Releases.html) +* Merge the PR with the release updates. That branch should: + * Update the version: + * Update the `workspace.package.version` key in `Cargo.toml`. Cargo only accepts + SemVer versions so a CalVer version of `22.07` for example must be formatted + as `22.7.0`. Patch/bugfix releases should increment the SemVer patch number. A + patch release for 22.07 would be `22.7.1`. + * Run `cargo check` and commit the resulting change to `Cargo.lock` + * Add changelog notes to `CHANGELOG.md` + * Add new `` entry in `contrib/Helix.appdata.xml` with release information according to the [AppStream spec](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-Releases.html) * Tag and push - * `git tag -s -m "" -a && git push` - * Make sure to switch to master and pull first -* Edit the `Cargo.toml` file and change the date in the `version` field to the next planned release - * Due to Cargo having a strict requirement on SemVer with 3 or more version - numbers, a `0` is required in the micro version; however, unless we are - publishing a patch release after a major release, the `.0` is dropped in - the user facing version. - * Releases are planned to happen every two months, so `22.05.0` would change to `22.07.0` - * If we are pushing a patch/bugfix release in the same month as the previous - release, bump the micro version, e.g. `22.07.0` to `22.07.1` + * Switch to master and pull + * `git tag -s -m "" -a && git push` (note the `-s` which signs the tag) * Wait for the Release CI to finish * It will automatically turn the git tag into a GitHub release when it uploads artifacts * Edit the new release @@ -31,7 +30,7 @@ being published. * Post to reddit * [Example post](https://www.reddit.com/r/rust/comments/uzp5ze/helix_editor_2205_released/) -[homebrew formula]: https://github.com/Homebrew/homebrew-core/blob/master/Formula/helix.rb +[homebrew formula]: https://github.com/Homebrew/homebrew-core/blob/master/Formula/h/helix.rb ## Changelog Curation diff --git a/flake.nix b/flake.nix index e113d94b9..c230dc317 100644 --- a/flake.nix +++ b/flake.nix @@ -114,10 +114,7 @@ if pkgs.stdenv.isLinux then pkgs.stdenv else pkgs.clangStdenv; - rustFlagsEnv = - if stdenv.isLinux - then ''$RUSTFLAGS -C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment'' - else "$RUSTFLAGS"; + rustFlagsEnv = pkgs.lib.optionalString stdenv.isLinux "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment"; rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; craneLibMSRV = (crane.mkLib pkgs).overrideToolchain rustToolchain; craneLibStable = (crane.mkLib pkgs).overrideToolchain pkgs.pkgsBuildHost.rust-bin.stable.latest.default; @@ -183,7 +180,7 @@ shellHook = '' export HELIX_RUNTIME="$PWD/runtime" export RUST_BACKTRACE="1" - export RUSTFLAGS="${rustFlagsEnv}" + export RUSTFLAGS="''${RUSTFLAGS:-""} ${rustFlagsEnv}" ''; }; }) diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 16d6b457f..392b4a4ca 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -30,17 +30,18 @@ tree-sitter.workspace = true once_cell = "1.19" arc-swap = "1" regex = "1" -bitflags = "2.5" +bitflags = "2.6" ahash = "0.8.11" -hashbrown = { version = "0.14.3", features = ["raw"] } +hashbrown = { version = "0.14.5", features = ["raw"] } dunce = "1.0" +url = "2.5.0" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" -imara-diff = "0.1.0" +imara-diff = "0.1.6" encoding_rs = "0.8" diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index d41119d38..4e89361d2 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -4,7 +4,8 @@ use std::fmt; use serde::{Deserialize, Serialize}; /// Describes the severity level of a [`Diagnostic`]. -#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Severity { Hint, Info, @@ -25,6 +26,12 @@ pub struct Range { pub end: usize, } +impl Range { + pub fn contains(self, pos: usize) -> bool { + (self.start..self.end).contains(&pos) + } +} + #[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)] pub enum NumberOrString { Number(i32), @@ -71,3 +78,10 @@ impl fmt::Display for LanguageServerId { write!(f, "{:?}", self.0) } } + +impl Diagnostic { + #[inline] + pub fn severity(&self) -> Severity { + self.severity.unwrap_or(Severity::Warning) + } +} diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index cbe2da3b6..3cfc15708 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -10,8 +10,9 @@ //! called a "block" and the caller must advance it as needed. use std::borrow::Cow; +use std::cmp::Ordering; use std::fmt::Debug; -use std::mem::{replace, take}; +use std::mem::replace; #[cfg(test)] mod test; @@ -37,52 +38,104 @@ pub enum GraphemeSource { }, } +impl GraphemeSource { + /// Returns whether this grapheme is virtual inline text + pub fn is_virtual(self) -> bool { + matches!(self, GraphemeSource::VirtualText { .. }) + } + + pub fn is_eof(self) -> bool { + // all doc chars except the EOF char have non-zero codepoints + matches!(self, GraphemeSource::Document { codepoints: 0 }) + } + + pub fn doc_chars(self) -> usize { + match self { + GraphemeSource::Document { codepoints } => codepoints as usize, + GraphemeSource::VirtualText { .. } => 0, + } + } +} + #[derive(Debug, Clone)] pub struct FormattedGrapheme<'a> { - pub grapheme: Grapheme<'a>, + pub raw: Grapheme<'a>, pub source: GraphemeSource, + pub visual_pos: Position, + /// Document line at the start of the grapheme + pub line_idx: usize, + /// Document char position at the start of the grapheme + pub char_idx: usize, +} + +impl FormattedGrapheme<'_> { + pub fn is_virtual(&self) -> bool { + self.source.is_virtual() + } + + pub fn doc_chars(&self) -> usize { + self.source.doc_chars() + } + + pub fn is_whitespace(&self) -> bool { + self.raw.is_whitespace() + } + + pub fn width(&self) -> usize { + self.raw.width() + } + + pub fn is_word_boundary(&self) -> bool { + self.raw.is_word_boundary() + } } -impl<'a> FormattedGrapheme<'a> { - pub fn new( +#[derive(Debug, Clone)] +struct GraphemeWithSource<'a> { + grapheme: Grapheme<'a>, + source: GraphemeSource, +} + +impl<'a> GraphemeWithSource<'a> { + fn new( g: GraphemeStr<'a>, visual_x: usize, tab_width: u16, source: GraphemeSource, - ) -> FormattedGrapheme<'a> { - FormattedGrapheme { + ) -> GraphemeWithSource<'a> { + GraphemeWithSource { grapheme: Grapheme::new(g, visual_x, tab_width), source, } } - /// Returns whether this grapheme is virtual inline text - pub fn is_virtual(&self) -> bool { - matches!(self.source, GraphemeSource::VirtualText { .. }) - } - - pub fn placeholder() -> Self { - FormattedGrapheme { + fn placeholder() -> Self { + GraphemeWithSource { grapheme: Grapheme::Other { g: " ".into() }, source: GraphemeSource::Document { codepoints: 0 }, } } - pub fn doc_chars(&self) -> usize { - match self.source { - GraphemeSource::Document { codepoints } => codepoints as usize, - GraphemeSource::VirtualText { .. } => 0, - } + fn doc_chars(&self) -> usize { + self.source.doc_chars() } - pub fn is_whitespace(&self) -> bool { + fn is_whitespace(&self) -> bool { self.grapheme.is_whitespace() } - pub fn width(&self) -> usize { + fn is_newline(&self) -> bool { + matches!(self.grapheme, Grapheme::Newline) + } + + fn is_eof(&self) -> bool { + self.source.is_eof() + } + + fn width(&self) -> usize { self.grapheme.width() } - pub fn is_word_boundary(&self) -> bool { + fn is_word_boundary(&self) -> bool { self.grapheme.is_word_boundary() } } @@ -96,6 +149,7 @@ pub struct TextFormat { pub wrap_indicator: Box, pub wrap_indicator_highlight: Option, pub viewport_width: u16, + pub soft_wrap_at_text_width: bool, } // test implementation is basically only used for testing or when softwrap is always disabled @@ -109,6 +163,7 @@ impl Default for TextFormat { wrap_indicator: Box::from(" "), viewport_width: 17, wrap_indicator_highlight: None, + soft_wrap_at_text_width: false, } } } @@ -127,10 +182,7 @@ pub struct DocumentFormatter<'t> { line_pos: usize, exhausted: bool, - /// Line breaks to be reserved for virtual text - /// at the next line break - virtual_lines: usize, - inline_anntoation_graphemes: Option<(Graphemes<'t>, Option)>, + inline_annotation_graphemes: Option<(Graphemes<'t>, Option)>, // softwrap specific /// The indentation of the current line @@ -139,9 +191,9 @@ pub struct DocumentFormatter<'t> { indent_level: Option, /// In case a long word needs to be split a single grapheme might need to be wrapped /// while the rest of the word stays on the same line - peeked_grapheme: Option<(FormattedGrapheme<'t>, usize)>, + peeked_grapheme: Option>, /// A first-in first-out (fifo) buffer for the Graphemes of any given word - word_buf: Vec>, + word_buf: Vec>, /// The index of the next grapheme that will be yielded from the `word_buf` word_i: usize, } @@ -157,35 +209,35 @@ impl<'t> DocumentFormatter<'t> { text_fmt: &'t TextFormat, annotations: &'t TextAnnotations, char_idx: usize, - ) -> (Self, usize) { + ) -> Self { // TODO divide long lines into blocks to avoid bad performance for long lines let block_line_idx = text.char_to_line(char_idx.min(text.len_chars())); let block_char_idx = text.line_to_char(block_line_idx); annotations.reset_pos(block_char_idx); - ( - DocumentFormatter { - text_fmt, - annotations, - visual_pos: Position { row: 0, col: 0 }, - graphemes: RopeGraphemes::new(text.slice(block_char_idx..)), - char_pos: block_char_idx, - exhausted: false, - virtual_lines: 0, - indent_level: None, - peeked_grapheme: None, - word_buf: Vec::with_capacity(64), - word_i: 0, - line_pos: block_line_idx, - inline_anntoation_graphemes: None, - }, - block_char_idx, - ) + + DocumentFormatter { + text_fmt, + annotations, + visual_pos: Position { row: 0, col: 0 }, + graphemes: RopeGraphemes::new(text.slice(block_char_idx..)), + char_pos: block_char_idx, + exhausted: false, + indent_level: None, + peeked_grapheme: None, + word_buf: Vec::with_capacity(64), + word_i: 0, + line_pos: block_line_idx, + inline_annotation_graphemes: None, + } } - fn next_inline_annotation_grapheme(&mut self) -> Option<(&'t str, Option)> { + fn next_inline_annotation_grapheme( + &mut self, + char_pos: usize, + ) -> Option<(&'t str, Option)> { loop { if let Some(&mut (ref mut annotation, highlight)) = - self.inline_anntoation_graphemes.as_mut() + self.inline_annotation_graphemes.as_mut() { if let Some(grapheme) = annotation.next() { return Some((grapheme, highlight)); @@ -193,9 +245,9 @@ impl<'t> DocumentFormatter<'t> { } if let Some((annotation, highlight)) = - self.annotations.next_inline_annotation_at(self.char_pos) + self.annotations.next_inline_annotation_at(char_pos) { - self.inline_anntoation_graphemes = Some(( + self.inline_annotation_graphemes = Some(( UnicodeSegmentation::graphemes(&*annotation.text, true), highlight, )) @@ -205,21 +257,19 @@ impl<'t> DocumentFormatter<'t> { } } - fn advance_grapheme(&mut self, col: usize) -> Option> { + fn advance_grapheme(&mut self, col: usize, char_pos: usize) -> Option> { let (grapheme, source) = - if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme() { + if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme(char_pos) { (grapheme.into(), GraphemeSource::VirtualText { highlight }) } else if let Some(grapheme) = self.graphemes.next() { - self.virtual_lines += self.annotations.annotation_lines_at(self.char_pos); let codepoints = grapheme.len_chars() as u32; - let overlay = self.annotations.overlay_at(self.char_pos); + let overlay = self.annotations.overlay_at(char_pos); let grapheme = match overlay { Some((overlay, _)) => overlay.grapheme.as_str().into(), None => Cow::from(grapheme).into(), }; - self.char_pos += codepoints as usize; (grapheme, GraphemeSource::Document { codepoints }) } else { if self.exhausted { @@ -228,19 +278,19 @@ impl<'t> DocumentFormatter<'t> { self.exhausted = true; // EOF grapheme is required for rendering // and correct position computations - return Some(FormattedGrapheme { + return Some(GraphemeWithSource { grapheme: Grapheme::Other { g: " ".into() }, source: GraphemeSource::Document { codepoints: 0 }, }); }; - let grapheme = FormattedGrapheme::new(grapheme, col, self.text_fmt.tab_width, source); + let grapheme = GraphemeWithSource::new(grapheme, col, self.text_fmt.tab_width, source); Some(grapheme) } /// Move a word to the next visual line - fn wrap_word(&mut self, virtual_lines_before_word: usize) -> usize { + fn wrap_word(&mut self) -> usize { // softwrap this word to the next line let indent_carry_over = if let Some(indent) = self.indent_level { if indent as u16 <= self.text_fmt.max_indent_retain { @@ -254,15 +304,17 @@ impl<'t> DocumentFormatter<'t> { 0 }; + let virtual_lines = + self.annotations + .virtual_lines_at(self.char_pos, self.visual_pos, self.line_pos); self.visual_pos.col = indent_carry_over as usize; - self.virtual_lines -= virtual_lines_before_word; - self.visual_pos.row += 1 + virtual_lines_before_word; + self.visual_pos.row += 1 + virtual_lines; let mut i = 0; let mut word_width = 0; let wrap_indicator = UnicodeSegmentation::graphemes(&*self.text_fmt.wrap_indicator, true) .map(|g| { i += 1; - let grapheme = FormattedGrapheme::new( + let grapheme = GraphemeWithSource::new( g.into(), self.visual_pos.col + word_width, self.text_fmt.tab_width, @@ -282,46 +334,71 @@ impl<'t> DocumentFormatter<'t> { .change_position(visual_x, self.text_fmt.tab_width); word_width += grapheme.width(); } + if let Some(grapheme) = &mut self.peeked_grapheme { + let visual_x = self.visual_pos.col + word_width; + grapheme + .grapheme + .change_position(visual_x, self.text_fmt.tab_width); + } word_width } + fn peek_grapheme(&mut self, col: usize, char_pos: usize) -> Option<&GraphemeWithSource<'t>> { + if self.peeked_grapheme.is_none() { + self.peeked_grapheme = self.advance_grapheme(col, char_pos); + } + self.peeked_grapheme.as_ref() + } + + fn next_grapheme(&mut self, col: usize, char_pos: usize) -> Option> { + self.peek_grapheme(col, char_pos); + self.peeked_grapheme.take() + } + fn advance_to_next_word(&mut self) { self.word_buf.clear(); let mut word_width = 0; - let virtual_lines_before_word = self.virtual_lines; - let mut virtual_lines_before_grapheme = self.virtual_lines; + let mut word_chars = 0; + + if self.exhausted { + return; + } loop { - // softwrap word if necessary - if word_width + self.visual_pos.col >= self.text_fmt.viewport_width as usize { - // wrapping this word would move too much text to the next line - // split the word at the line end instead - if word_width > self.text_fmt.max_wrap as usize { - // Usually we stop accomulating graphemes as soon as softwrapping becomes necessary. - // However if the last grapheme is multiple columns wide it might extend beyond the EOL. - // The condition below ensures that this grapheme is not cutoff and instead wrapped to the next line - if word_width + self.visual_pos.col > self.text_fmt.viewport_width as usize { - self.peeked_grapheme = self.word_buf.pop().map(|grapheme| { - (grapheme, self.virtual_lines - virtual_lines_before_grapheme) - }); - self.virtual_lines = virtual_lines_before_grapheme; - } + let mut col = self.visual_pos.col + word_width; + let char_pos = self.char_pos + word_chars; + match col.cmp(&(self.text_fmt.viewport_width as usize)) { + // The EOF char and newline chars are always selectable in helix. That means + // that wrapping happens "too-early" if a word fits a line perfectly. This + // is intentional so that all selectable graphemes are always visisble (and + // therefore the cursor never dissapears). However if the user manually set a + // lower softwrap width then this is undesirable. Just increasing the viewport- + // width by one doesn't work because if a line is wrapped multiple times then + // some words may extend past the specified width. + // + // So we special case a word that ends exactly at line bounds and is followed + // by a newline/eof character here. + Ordering::Equal + if self.text_fmt.soft_wrap_at_text_width + && self.peek_grapheme(col, char_pos).map_or(false, |grapheme| { + grapheme.is_newline() || grapheme.is_eof() + }) => {} + Ordering::Equal if word_width > self.text_fmt.max_wrap as usize => return, + Ordering::Greater if word_width > self.text_fmt.max_wrap as usize => { + self.peeked_grapheme = self.word_buf.pop(); return; } - - word_width = self.wrap_word(virtual_lines_before_word); + Ordering::Equal | Ordering::Greater => { + word_width = self.wrap_word(); + col = self.visual_pos.col + word_width; + } + Ordering::Less => (), } - virtual_lines_before_grapheme = self.virtual_lines; - - let grapheme = if let Some((grapheme, virtual_lines)) = self.peeked_grapheme.take() { - self.virtual_lines += virtual_lines; - grapheme - } else if let Some(grapheme) = self.advance_grapheme(self.visual_pos.col + word_width) { - grapheme - } else { + let Some(grapheme) = self.next_grapheme(col, char_pos) else { return; }; + word_chars += grapheme.doc_chars(); // Track indentation if !grapheme.is_whitespace() && self.indent_level.is_none() { @@ -340,19 +417,18 @@ impl<'t> DocumentFormatter<'t> { } } - /// returns the document line pos of the **next** grapheme that will be yielded - pub fn line_pos(&self) -> usize { - self.line_pos + /// returns the char index at the end of the last yielded grapheme + pub fn next_char_pos(&self) -> usize { + self.char_pos } - - /// returns the visual pos of the **next** grapheme that will be yielded - pub fn visual_pos(&self) -> Position { + /// returns the visual position at the end of the last yielded grapheme + pub fn next_visual_pos(&self) -> Position { self.visual_pos } } impl<'t> Iterator for DocumentFormatter<'t> { - type Item = (FormattedGrapheme<'t>, Position); + type Item = FormattedGrapheme<'t>; fn next(&mut self) -> Option { let grapheme = if self.text_fmt.soft_wrap { @@ -362,23 +438,40 @@ impl<'t> Iterator for DocumentFormatter<'t> { } let grapheme = replace( self.word_buf.get_mut(self.word_i)?, - FormattedGrapheme::placeholder(), + GraphemeWithSource::placeholder(), ); self.word_i += 1; grapheme } else { - self.advance_grapheme(self.visual_pos.col)? + self.advance_grapheme(self.visual_pos.col, self.char_pos)? + }; + + let grapheme = FormattedGrapheme { + raw: grapheme.grapheme, + source: grapheme.source, + visual_pos: self.visual_pos, + line_idx: self.line_pos, + char_idx: self.char_pos, }; - let pos = self.visual_pos; - if grapheme.grapheme == Grapheme::Newline { - self.visual_pos.row += 1; - self.visual_pos.row += take(&mut self.virtual_lines); + self.char_pos += grapheme.doc_chars(); + if !grapheme.is_virtual() { + self.annotations.process_virtual_text_anchors(&grapheme); + } + if grapheme.raw == Grapheme::Newline { + // move to end of newline char + self.visual_pos.col += 1; + let virtual_lines = + self.annotations + .virtual_lines_at(self.char_pos, self.visual_pos, self.line_pos); + self.visual_pos.row += 1 + virtual_lines; self.visual_pos.col = 0; - self.line_pos += 1; + if !grapheme.is_virtual() { + self.line_pos += 1; + } } else { self.visual_pos.col += grapheme.width(); } - Some((grapheme, pos)) + Some(grapheme) } } diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index d2b6ddc74..415ce8f6a 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -12,6 +12,7 @@ impl TextFormat { wrap_indicator_highlight: None, // use a prime number to allow lining up too often with repeat viewport_width: 17, + soft_wrap_at_text_width: false, } } } @@ -21,20 +22,23 @@ impl<'t> DocumentFormatter<'t> { use std::fmt::Write; let mut res = String::new(); let viewport_width = self.text_fmt.viewport_width; + let soft_wrap_at_text_width = self.text_fmt.soft_wrap_at_text_width; let mut line = 0; - for (grapheme, pos) in self { - if pos.row != line { + for grapheme in self { + if grapheme.visual_pos.row != line { line += 1; - assert_eq!(pos.row, line); - write!(res, "\n{}", ".".repeat(pos.col)).unwrap(); + assert_eq!(grapheme.visual_pos.row, line); + write!(res, "\n{}", ".".repeat(grapheme.visual_pos.col)).unwrap(); + } + if !soft_wrap_at_text_width { assert!( - pos.col <= viewport_width as usize, + grapheme.visual_pos.col <= viewport_width as usize, "softwrapped failed {}<={viewport_width}", - pos.col + grapheme.visual_pos.col ); } - write!(res, "{}", grapheme.grapheme).unwrap(); + write!(res, "{}", grapheme.raw).unwrap(); } res @@ -48,7 +52,6 @@ fn softwrap_text(text: &str) -> String { &TextAnnotations::default(), 0, ) - .0 .collect_to_str() } @@ -99,6 +102,22 @@ fn long_word_softwrap() { ); } +fn softwrap_text_at_text_width(text: &str) -> String { + let mut text_fmt = TextFormat::new_test(true); + text_fmt.soft_wrap_at_text_width = true; + let annotations = TextAnnotations::default(); + let mut formatter = + DocumentFormatter::new_at_prev_checkpoint(text.into(), &text_fmt, &annotations, 0); + formatter.collect_to_str() +} +#[test] +fn long_word_softwrap_text_width() { + assert_eq!( + softwrap_text_at_text_width("xxxxxxxx1xxxx2xxx\nxxxxxxxx1xxxx2xxx"), + "xxxxxxxx1xxxx2xxx \nxxxxxxxx1xxxx2xxx " + ); +} + fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay]) -> String { DocumentFormatter::new_at_prev_checkpoint( text.into(), @@ -106,7 +125,6 @@ fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay TextAnnotations::default().add_overlay(overlays, None), char_pos, ) - .0 .collect_to_str() } @@ -143,7 +161,6 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - TextAnnotations::default().add_inline_annotations(annotations, None), 0, ) - .0 .collect_to_str() } @@ -182,7 +199,6 @@ fn annotation_and_overlay() { .add_overlay(overlay.as_slice(), None), 0, ) - .0 .collect_to_str(), "fooo bar " ); diff --git a/helix-core/src/fuzzy.rs b/helix-core/src/fuzzy.rs index 549c6b0e5..da46518f9 100644 --- a/helix-core/src/fuzzy.rs +++ b/helix-core/src/fuzzy.rs @@ -1,6 +1,6 @@ use std::ops::DerefMut; -use nucleo::pattern::{Atom, AtomKind, CaseMatching}; +use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; use nucleo::Config; use parking_lot::Mutex; @@ -38,6 +38,12 @@ pub fn fuzzy_match>( if path { matcher.config.set_match_paths(); } - let pattern = Atom::new(pattern, CaseMatching::Smart, AtomKind::Fuzzy, false); + let pattern = Atom::new( + pattern, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + false, + ); pattern.match_list(items, &mut matcher) } diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index a02d6e4dd..91f11e620 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -28,6 +28,11 @@ pub enum Grapheme<'a> { } impl<'a> Grapheme<'a> { + pub fn new_decoration(g: &'static str) -> Grapheme<'a> { + assert_ne!(g, "\t"); + Grapheme::new(g.into(), 0, 0) + } + pub fn new(g: GraphemeStr<'a>, visual_x: usize, tab_width: u16) -> Grapheme<'a> { match g { g if g == "\t" => Grapheme::Tab { diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index ca72fc3a2..fd2b6c959 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -740,7 +740,7 @@ fn init_indent_query<'a, 'b>( crate::syntax::PARSER.with(|ts_parser| { let mut ts_parser = ts_parser.borrow_mut(); - let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + let mut cursor = ts_parser.cursors.pop().unwrap_or_default(); let query_result = query_indents( query, syntax, diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 1abd90d10..9165560d0 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod test; pub mod text_annotations; pub mod textobject; mod transaction; +pub mod uri; pub mod wrap; pub mod unicode { @@ -52,8 +53,8 @@ pub use {regex, tree_sitter}; pub use graphemes::RopeGraphemes; pub use position::{ - char_idx_at_visual_offset, coords_at_pos, pos_at_coords, visual_offset_from_anchor, - visual_offset_from_block, Position, VisualOffsetError, + char_idx_at_visual_offset, coords_at_pos, pos_at_coords, softwrapped_dimensions, + visual_offset_from_anchor, visual_offset_from_block, Position, VisualOffsetError, }; #[allow(deprecated)] pub use position::{pos_at_visual_coords, visual_coords_at_pos}; @@ -66,3 +67,5 @@ pub use diagnostic::Diagnostic; pub use line_ending::{LineEnding, NATIVE_LINE_ENDING}; pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction}; + +pub use uri::Uri; diff --git a/helix-core/src/match_brackets.rs b/helix-core/src/match_brackets.rs index 95d6a3dc4..7520d3e46 100644 --- a/helix-core/src/match_brackets.rs +++ b/helix-core/src/match_brackets.rs @@ -9,11 +9,13 @@ use crate::Syntax; const MAX_PLAINTEXT_SCAN: usize = 10000; const MATCH_LIMIT: usize = 16; -pub const BRACKETS: [(char, char); 7] = [ +pub const BRACKETS: [(char, char); 9] = [ ('(', ')'), ('{', '}'), ('[', ']'), ('<', '>'), + ('‘', '’'), + ('“', '”'), ('«', '»'), ('「', '」'), ('(', ')'), @@ -75,60 +77,64 @@ fn find_pair( ) -> Option { let pos = doc.char_to_byte(pos_); - let root = syntax.tree_for_byte_range(pos, pos + 1).root_node(); - let mut node = root.descendant_for_byte_range(pos, pos + 1)?; + let root = syntax.tree_for_byte_range(pos, pos).root_node(); + let mut node = root.descendant_for_byte_range(pos, pos)?; loop { - if node.is_named() { - let (start_byte, end_byte) = surrounding_bytes(doc, &node)?; - let (start_char, end_char) = (doc.byte_to_char(start_byte), doc.byte_to_char(end_byte)); - - if is_valid_pair_on_pos(doc, start_char, end_char) { - if end_byte == pos { - return Some(start_char); - } - - // We return the end char if the cursor is either on the start char - // or at some arbitrary position between start and end char. - if traverse_parents || start_byte == pos { - return Some(end_char); + if node.is_named() && node.child_count() >= 2 { + let open = node.child(0).unwrap(); + let close = node.child(node.child_count() - 1).unwrap(); + + if let (Some((start_pos, open)), Some((end_pos, close))) = + (as_char(doc, &open), as_char(doc, &close)) + { + if PAIRS.contains(&(open, close)) { + if end_pos == pos_ { + return Some(start_pos); + } + + // We return the end char if the cursor is either on the start char + // or at some arbitrary position between start and end char. + if traverse_parents || start_pos == pos_ { + return Some(end_pos); + } } } } // this node itselt wasn't a pair but maybe its siblings are - // check if we are *on* the pair (special cased so we don't look - // at the current node twice and to jump to the start on that case) - if let Some(open) = as_close_pair(doc, &node) { - if let Some(pair_start) = find_pair_end(doc, node.prev_sibling(), open, Backward) { + if let Some((start_char, end_char)) = as_close_pair(doc, &node) { + if let Some(pair_start) = + find_pair_end(doc, node.prev_sibling(), start_char, end_char, Backward) + { return Some(pair_start); } } - - if !traverse_parents { - // check if we are *on* the opening pair (special cased here as - // an opptimization since we only care about bracket on the cursor - // here) - if let Some(close) = as_open_pair(doc, &node) { - if let Some(pair_end) = find_pair_end(doc, node.next_sibling(), close, Forward) { - return Some(pair_end); - } - } - if node.is_named() { - break; + if let Some((start_char, end_char)) = as_open_pair(doc, &node) { + if let Some(pair_end) = + find_pair_end(doc, node.next_sibling(), start_char, end_char, Forward) + { + return Some(pair_end); } } - for close in - iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT) - { - let Some(open) = as_close_pair(doc, &close) else { - continue; - }; - if find_pair_end(doc, Some(node), open, Backward).is_some() { - return doc.try_byte_to_char(close.start_byte()).ok(); + if traverse_parents { + for sibling in + iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT) + { + let Some((start_char, end_char)) = as_close_pair(doc, &sibling) else { + continue; + }; + if find_pair_end(doc, sibling.prev_sibling(), start_char, end_char, Backward) + .is_some() + { + return doc.try_byte_to_char(sibling.start_byte()).ok(); + } } + } else if node.is_named() { + break; } + let Some(parent) = node.parent() else { break; }; @@ -241,29 +247,13 @@ pub fn is_valid_pair(ch: char) -> bool { PAIRS.iter().any(|(l, r)| *l == ch || *r == ch) } -fn is_valid_pair_on_pos(doc: RopeSlice, start_char: usize, end_char: usize) -> bool { - PAIRS.contains(&(doc.char(start_char), doc.char(end_char))) -} - -fn surrounding_bytes(doc: RopeSlice, node: &Node) -> Option<(usize, usize)> { - let len = doc.len_bytes(); - - let start_byte = node.start_byte(); - let end_byte = node.end_byte().saturating_sub(1); - - if start_byte >= len || end_byte >= len { - return None; - } - - Some((start_byte, end_byte)) -} - /// Tests if this node is a pair close char and returns the expected open char -fn as_close_pair(doc: RopeSlice, node: &Node) -> Option { +/// and close char contained in this node +fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> { let close = as_char(doc, node)?.1; PAIRS .iter() - .find_map(|&(open, close_)| (close_ == close).then_some(open)) + .find_map(|&(open, close_)| (close_ == close).then_some((close, open))) } /// Checks if `node` or its siblings (at most MATCH_LIMIT nodes) is the specified closing char @@ -274,6 +264,7 @@ fn as_close_pair(doc: RopeSlice, node: &Node) -> Option { fn find_pair_end( doc: RopeSlice, node: Option, + start_char: char, end_char: char, direction: Direction, ) -> Option { @@ -281,20 +272,30 @@ fn find_pair_end( Forward => Node::next_sibling, Backward => Node::prev_sibling, }; + let mut depth = 0; iter::successors(node, advance) .take(MATCH_LIMIT) .find_map(|node| { let (pos, c) = as_char(doc, &node)?; - (end_char == c).then_some(pos) + if c == end_char { + if depth == 0 { + return Some(pos); + } + depth -= 1; + } else if c == start_char { + depth += 1; + } + None }) } -/// Tests if this node is a pair close char and returns the expected open char -fn as_open_pair(doc: RopeSlice, node: &Node) -> Option { +/// Tests if this node is a pair open char and returns the expected close char +/// and open char contained in this node +fn as_open_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> { let open = as_char(doc, node)?.1; PAIRS .iter() - .find_map(|&(open_, close)| (open_ == open).then_some(close)) + .find_map(|&(open_, close)| (open_ == open).then_some((open, close))) } /// If node is a single char return it (and its char position) diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 54eb02fd0..f5c2b2ed5 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -79,19 +79,19 @@ pub fn move_vertically_visual( Direction::Backward => -(count as isize), }; - // TODO how to handle inline annotations that span an entire visual line (very unlikely). - // Compute visual offset relative to block start to avoid trasversing the block twice row_off += visual_pos.row as isize; - let new_pos = char_idx_at_visual_offset( + let (mut new_pos, virtual_rows) = char_idx_at_visual_offset( slice, block_off, row_off, new_col as usize, text_fmt, annotations, - ) - .0; + ); + if dir == Direction::Forward { + new_pos += (virtual_rows != 0) as usize; + } // Special-case to avoid moving to the end of the last non-empty line. if behaviour == Movement::Extend && slice.line(slice.char_to_line(new_pos)).len_chars() == 0 { diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index ee764bc64..1b3789110 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -1,4 +1,8 @@ -use std::{borrow::Cow, cmp::Ordering}; +use std::{ + borrow::Cow, + cmp::Ordering, + ops::{Add, AddAssign, Sub, SubAssign}, +}; use crate::{ chars::char_is_line_ending, @@ -16,6 +20,38 @@ pub struct Position { pub col: usize, } +impl AddAssign for Position { + fn add_assign(&mut self, rhs: Self) { + self.row += rhs.row; + self.col += rhs.col; + } +} + +impl SubAssign for Position { + fn sub_assign(&mut self, rhs: Self) { + self.row -= rhs.row; + self.col -= rhs.col; + } +} + +impl Sub for Position { + type Output = Position; + + fn sub(mut self, rhs: Self) -> Self::Output { + self -= rhs; + self + } +} + +impl Add for Position { + type Output = Position; + + fn add(mut self, rhs: Self) -> Self::Output { + self += rhs; + self + } +} + impl Position { pub const fn new(row: usize, col: usize) -> Self { Self { row, col } @@ -121,22 +157,31 @@ pub fn visual_offset_from_block( annotations: &TextAnnotations, ) -> (Position, usize) { let mut last_pos = Position::default(); - let (formatter, block_start) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut char_pos = block_start; + let block_start = formatter.next_char_pos(); - for (grapheme, vpos) in formatter { - last_pos = vpos; - char_pos += grapheme.doc_chars(); - - if char_pos > pos { - return (last_pos, block_start); + while let Some(grapheme) = formatter.next() { + last_pos = grapheme.visual_pos; + if formatter.next_char_pos() > pos { + return (grapheme.visual_pos, block_start); } } (last_pos, block_start) } +/// Returns the height of the given text when softwrapping +pub fn softwrapped_dimensions(text: RopeSlice, text_fmt: &TextFormat) -> (usize, u16) { + let last_pos = + visual_offset_from_block(text, 0, usize::MAX, text_fmt, &TextAnnotations::default()).0; + if last_pos.row == 0 { + (1, last_pos.col as u16) + } else { + (last_pos.row + 1, text_fmt.viewport_width) + } +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum VisualOffsetError { PosBeforeAnchorRow, @@ -153,22 +198,21 @@ pub fn visual_offset_from_anchor( annotations: &TextAnnotations, max_rows: usize, ) -> Result<(Position, usize), VisualOffsetError> { - let (formatter, block_start) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut char_pos = block_start; let mut anchor_line = None; let mut found_pos = None; let mut last_pos = Position::default(); + let block_start = formatter.next_char_pos(); if pos < block_start { return Err(VisualOffsetError::PosBeforeAnchorRow); } - for (grapheme, vpos) in formatter { - last_pos = vpos; - char_pos += grapheme.doc_chars(); + while let Some(grapheme) = formatter.next() { + last_pos = grapheme.visual_pos; - if char_pos > pos { + if formatter.next_char_pos() > pos { if let Some(anchor_line) = anchor_line { last_pos.row -= anchor_line; return Ok((last_pos, block_start)); @@ -176,7 +220,7 @@ pub fn visual_offset_from_anchor( found_pos = Some(last_pos); } } - if char_pos > anchor && anchor_line.is_none() { + if formatter.next_char_pos() > anchor && anchor_line.is_none() { if let Some(mut found_pos) = found_pos { return if found_pos.row == last_pos.row { found_pos.row = 0; @@ -190,7 +234,7 @@ pub fn visual_offset_from_anchor( } if let Some(anchor_line) = anchor_line { - if vpos.row >= anchor_line + max_rows { + if grapheme.visual_pos.row >= anchor_line + max_rows { return Err(VisualOffsetError::PosAfterMaxRow); } } @@ -368,39 +412,43 @@ pub fn char_idx_at_visual_block_offset( text_fmt: &TextFormat, annotations: &TextAnnotations, ) -> (usize, usize) { - let (formatter, mut char_idx) = + let mut formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); - let mut last_char_idx = char_idx; - let mut last_char_idx_on_line = None; + let mut last_char_idx = formatter.next_char_pos(); + let mut found_non_virtual_on_row = false; let mut last_row = 0; - for (grapheme, grapheme_pos) in formatter { - match grapheme_pos.row.cmp(&row) { + for grapheme in &mut formatter { + match grapheme.visual_pos.row.cmp(&row) { Ordering::Equal => { - if grapheme_pos.col + grapheme.width() > column { + if grapheme.visual_pos.col + grapheme.width() > column { if !grapheme.is_virtual() { - return (char_idx, 0); - } else if let Some(char_idx) = last_char_idx_on_line { - return (char_idx, 0); + return (grapheme.char_idx, 0); + } else if found_non_virtual_on_row { + return (last_char_idx, 0); } } else if !grapheme.is_virtual() { - last_char_idx_on_line = Some(char_idx) + found_non_virtual_on_row = true; + last_char_idx = grapheme.char_idx; } } + Ordering::Greater if found_non_virtual_on_row => return (last_char_idx, 0), Ordering::Greater => return (last_char_idx, row - last_row), - _ => (), + Ordering::Less => { + if !grapheme.is_virtual() { + last_row = grapheme.visual_pos.row; + last_char_idx = grapheme.char_idx; + } + } } - - last_char_idx = char_idx; - last_row = grapheme_pos.row; - char_idx += grapheme.doc_chars(); } - (char_idx, 0) + (formatter.next_char_pos(), 0) } #[cfg(test)] mod test { use super::*; + use crate::text_annotations::InlineAnnotation; use crate::Rope; #[test] @@ -761,6 +809,30 @@ mod test { assert_eq!(pos_at_visual_coords(slice, (10, 10).into(), 4), 0); } + #[test] + fn test_char_idx_at_visual_row_offset_inline_annotation() { + let text = Rope::from("foo\nbar"); + let slice = text.slice(..); + let mut text_fmt = TextFormat::default(); + let annotations = [InlineAnnotation { + text: "x".repeat(100).into(), + char_idx: 3, + }]; + text_fmt.soft_wrap = true; + + assert_eq!( + char_idx_at_visual_offset( + slice, + 0, + 1, + 0, + &text_fmt, + TextAnnotations::default().add_inline_annotations(&annotations, None) + ), + (2, 1) + ); + } + #[test] fn test_char_idx_at_visual_row_offset() { let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ\nfoo"); diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 48eaf289c..eff1fcd70 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -13,7 +13,7 @@ use crate::{ }; use helix_stdx::rope::{self, RopeSliceExt}; use smallvec::{smallvec, SmallVec}; -use std::borrow::Cow; +use std::{borrow::Cow, iter, slice}; use tree_sitter::Node; /// A single selection range. @@ -175,7 +175,7 @@ impl Range { /// function runs in O(N) (N is number of changes) and can therefore /// cause performance problems if run for a large number of ranges as the /// complexity is then O(MN) (for multicuror M=N usually). Instead use - /// [Selection::map] or [ChangeSet::update_positions] instead + /// [Selection::map] or [ChangeSet::update_positions]. pub fn map(mut self, changes: &ChangeSet) -> Self { use std::cmp::Ordering; if changes.is_empty() { @@ -503,6 +503,16 @@ impl Selection { &self.ranges } + /// Returns an iterator over the line ranges of each range in the selection. + /// + /// Adjacent and overlapping line ranges of the [Range]s in the selection are merged. + pub fn line_ranges<'a>(&'a self, text: RopeSlice<'a>) -> LineRangeIter<'a> { + LineRangeIter { + ranges: self.ranges.iter().peekable(), + text, + } + } + pub fn primary_index(&self) -> usize { self.primary_index } @@ -531,6 +541,8 @@ impl Selection { } /// Normalizes a `Selection`. + /// + /// Ranges are sorted by [Range::from], with overlapping ranges merged. fn normalize(mut self) -> Self { if self.len() < 2 { return self; @@ -727,6 +739,33 @@ impl From for Selection { } } +pub struct LineRangeIter<'a> { + ranges: iter::Peekable>, + text: RopeSlice<'a>, +} + +impl<'a> Iterator for LineRangeIter<'a> { + type Item = (usize, usize); + + fn next(&mut self) -> Option { + let (start, mut end) = self.ranges.next()?.line_range(self.text); + while let Some((next_start, next_end)) = + self.ranges.peek().map(|range| range.line_range(self.text)) + { + // Merge overlapping and adjacent ranges. + // This subtraction cannot underflow because the ranges are sorted. + if next_start - end <= 1 { + end = next_end; + self.ranges.next(); + } else { + break; + } + } + + Some((start, end)) + } +} + // TODO: checkSelection -> check if valid for doc length && sorted pub fn keep_or_remove_matches( @@ -1165,6 +1204,32 @@ mod test { assert_eq!(Range::new(12, 0).line_range(s), (0, 2)); } + #[test] + fn selection_line_ranges() { + let (text, selection) = crate::test::print( + r#" L0 + #[|these]# line #(|ranges)# are #(|merged)# L1 + L2 + single one-line #(|range)# L3 + L4 + single #(|multiline L5 + range)# L6 + L7 + these #(|multiline L8 + ranges)# are #(|also L9 + merged)# L10 + L11 + adjacent #(|ranges)# L12 + are merged #(|the same way)# L13 + "#, + ); + let rope = Rope::from_str(&text); + assert_eq!( + vec![(1, 1), (3, 3), (5, 6), (8, 10), (12, 13)], + selection.line_ranges(rope.slice(..)).collect::>(), + ); + } + #[test] fn test_cursor() { let r = Rope::from_str("\r\nHi\r\nthere!"); diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 3cf818f60..93f618c09 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -21,7 +21,7 @@ use std::{ borrow::Cow, cell::RefCell, collections::{HashMap, HashSet, VecDeque}, - fmt::{self, Display}, + fmt::{self, Display, Write}, hash::{Hash, Hasher}, mem::replace, path::{Path, PathBuf}, @@ -510,6 +510,7 @@ pub enum DebugArgumentValue { pub struct DebugTemplate { pub name: String, pub request: String, + #[serde(default)] pub completion: Vec, pub args: HashMap, } @@ -728,8 +729,11 @@ pub fn read_query(language: &str, filename: &str) -> String { .replace_all(&query, |captures: ®ex::Captures| { captures[1] .split(',') - .map(|language| format!("\n{}\n", read_query(language, filename))) - .collect::() + .fold(String::new(), |mut output, language| { + // `write!` to a String cannot fail. + write!(output, "\n{}\n", read_query(language, filename)).unwrap(); + output + }) }) .to_string() } @@ -1244,7 +1248,7 @@ impl Syntax { PARSER.with(|ts_parser| { let ts_parser = &mut ts_parser.borrow_mut(); ts_parser.parser.set_timeout_micros(1000 * 500); // half a second is pretty generours - let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + let mut cursor = ts_parser.cursors.pop().unwrap_or_default(); // TODO: might need to set cursor range cursor.set_byte_range(0..usize::MAX); cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT); @@ -1359,13 +1363,14 @@ impl Syntax { let depth = layer.depth + 1; // TODO: can't inline this since matches borrows self.layers for (config, ranges) in injections { + let parent = Some(layer_id); let new_layer = LanguageLayer { tree: None, config, depth, ranges, flags: LayerUpdateFlags::empty(), - parent: Some(layer_id), + parent: None, }; // Find an identical existing layer @@ -1377,6 +1382,7 @@ impl Syntax { // ...or insert a new one. let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer)); + self.layers[layer_id].parent = parent; queue.push_back(layer_id); } @@ -1418,7 +1424,7 @@ impl Syntax { // Reuse a cursor from the pool if available. let mut cursor = PARSER.with(|ts_parser| { let highlighter = &mut ts_parser.borrow_mut(); - highlighter.cursors.pop().unwrap_or_else(QueryCursor::new) + highlighter.cursors.pop().unwrap_or_default() }); // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 1576914e3..ff28a8dd2 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -1,8 +1,12 @@ use std::cell::Cell; +use std::cmp::Ordering; +use std::fmt::Debug; use std::ops::Range; +use std::ptr::NonNull; +use crate::doc_formatter::FormattedGrapheme; use crate::syntax::Highlight; -use crate::Tendril; +use crate::{Position, Tendril}; /// An inline annotation is continuous text shown /// on the screen before the grapheme that starts at @@ -75,19 +79,98 @@ impl Overlay { } } -/// Line annotations allow for virtual text between normal -/// text lines. They cause `height` empty lines to be inserted -/// below the document line that contains `anchor_char_idx`. +/// Line annotations allow inserting virtual text lines between normal text +/// lines. These lines can be filled with text in the rendering code as their +/// contents have no effect beyond visual appearance. /// -/// These lines can be filled with text in the rendering code -/// as their contents have no effect beyond visual appearance. +/// The height of virtual text is usually not known ahead of time as virtual +/// text often requires softwrapping. Furthermore the height of some virtual +/// text like side-by-side diffs depends on the height of the text (again +/// influenced by softwrap) and other virtual text. Therefore line annotations +/// are computed on the fly instead of ahead of time like other annotations. /// -/// To insert a line after a document line simply set -/// `anchor_char_idx` to `doc.line_to_char(line_idx)` -#[derive(Debug, Clone)] -pub struct LineAnnotation { - pub anchor_char_idx: usize, - pub height: usize, +/// The core of this trait `insert_virtual_lines` function. It is called at the +/// end of every visual line and allows the `LineAnnotation` to insert empty +/// virtual lines. Apart from that the `LineAnnotation` trait has multiple +/// methods that allow it to track anchors in the document. +/// +/// When a new traversal of a document starts `reset_pos` is called. Afterwards +/// the other functions are called with indices that are larger then the +/// one passed to `reset_pos`. This allows performing a binary search (use +/// `partition_point`) in `reset_pos` once and then to only look at the next +/// anchor during each method call. +/// +/// The `reset_pos`, `skip_conceal` and `process_anchor` functions all return a +/// `char_idx` anchor. This anchor is stored when transversing the document and +/// when the grapheme at the anchor is traversed the `process_anchor` function +/// is called. +/// +/// # Note +/// +/// All functions only receive immutable references to `self`. +/// `LineAnnotation`s that want to store an internal position or +/// state of some kind should use `Cell`. Using interior mutability for +/// caches is preferable as otherwise a lot of lifetimes become invariant +/// which complicates APIs a lot. +pub trait LineAnnotation { + /// Resets the internal position to `char_idx`. This function is called + /// when a new traversal of a document starts. + /// + /// All `char_idx` passed to `insert_virtual_lines` are strictly monotonically increasing + /// with the first `char_idx` greater or equal to the `char_idx` + /// passed to this function. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn reset_pos(&mut self, _char_idx: usize) -> usize { + usize::MAX + } + + /// Called when a text is concealed that contains an anchor registered by this `LineAnnotation`. + /// In this case the line decorations **must** ensure that virtual text anchored within that + /// char range is skipped. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// **after the end of conceal_end_char_idx** + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn skip_concealed_anchors(&mut self, conceal_end_char_idx: usize) -> usize { + self.reset_pos(conceal_end_char_idx) + } + + /// Process an anchor (horizontal position is provided) and returns the next anchor. + /// + /// # Returns + /// + /// The `char_idx` of the next anchor this `LineAnnotation` is interested in, + /// replaces the currently registered anchor. Return `usize::MAX` to ignore + fn process_anchor(&mut self, _grapheme: &FormattedGrapheme) -> usize { + usize::MAX + } + + /// This function is called at the end of a visual line to insert virtual text + /// + /// # Returns + /// + /// The number of additional virtual lines to reserve + /// + /// # Note + /// + /// The `line_end_visual_pos` parameter indicates the visual vertical distance + /// from the start of block where the traversal starts. This includes the offset + /// from other `LineAnnotations`. This allows inline annotations to consider + /// the height of the text and "align" two different documents (like for side + /// by side diffs). These annotations that want to "align" two documents should + /// therefore be added last so that other virtual text is also considered while aligning + fn insert_virtual_lines( + &mut self, + line_end_char_idx: usize, + line_end_visual_pos: Position, + doc_line: usize, + ) -> Position; } #[derive(Debug)] @@ -143,13 +226,68 @@ fn reset_pos(layers: &[Layer], pos: usize, get_pos: impl Fn(&A) -> u } } +/// Safety: We store LineAnnotation in a NonNull pointer. This is necessary to work +/// around an unfortunate inconsistency in rusts variance system that unnnecesarily +/// makes the lifetime invariant if implemented with safe code. This makes the +/// DocFormatter API very cumbersome/basically impossible to work with. +/// +/// Normally object types `dyn Foo + 'a` are covariant so if we used `Box` below +/// everything would be alright. However we want to use `Cell>` +/// to be able to call the mutable function on `LineAnnotation`. The problem is that +/// some types like `Cell` make all their arguments invariant. This is important for soundness +/// normally for the same reasons that `&'a mut T` is invariant over `T` +/// (see ). However for `&'a mut` (`dyn Foo + 'b`) +/// there is a specical rule in the language to make `'b` covariant (otherwise trait objects would be +/// super annoying to use). See for +/// why this is sound. Sadly that rule doesn't apply to `Cell` +/// (or other invariant types like `UnsafeCell` or `*mut (dyn Foo + 'a)`). +/// +/// We sidestep the problem by using `NonNull` which is covariant. In the +/// special case of trait objects this is sound (easily checked by adding a +/// `PhantomData<&'a mut Foo + 'a)>` field). We don't need an explicit `Cell` +/// type here because we never hand out any refereces to the trait objects. That +/// means any reference to the pointer can create a valid multable reference +/// that is covariant over `'a` (or in other words it's a raw pointer, as long as +/// we don't hand out references we are free to do whatever we want). +struct RawBox(NonNull); + +impl RawBox { + /// Safety: Only a single mutable reference + /// created by this function may exist at a given time. + #[allow(clippy::mut_from_ref)] + unsafe fn get(&self) -> &mut T { + &mut *self.0.as_ptr() + } +} +impl From> for RawBox { + fn from(box_: Box) -> Self { + // obviously safe because Box::into_raw never returns null + unsafe { Self(NonNull::new_unchecked(Box::into_raw(box_))) } + } +} + +impl Drop for RawBox { + fn drop(&mut self) { + unsafe { drop(Box::from_raw(self.0.as_ptr())) } + } +} + /// Annotations that change that is displayed when the document is render. /// Also commonly called virtual text. -#[derive(Default, Debug, Clone)] +#[derive(Default)] pub struct TextAnnotations<'a> { inline_annotations: Vec>>, overlays: Vec>>, - line_annotations: Vec>, + line_annotations: Vec<(Cell, RawBox)>, +} + +impl Debug for TextAnnotations<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TextAnnotations") + .field("inline_annotations", &self.inline_annotations) + .field("overlays", &self.overlays) + .finish_non_exhaustive() + } } impl<'a> TextAnnotations<'a> { @@ -157,9 +295,9 @@ impl<'a> TextAnnotations<'a> { pub fn reset_pos(&self, char_idx: usize) { reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); reset_pos(&self.overlays, char_idx, |annot| annot.char_idx); - reset_pos(&self.line_annotations, char_idx, |annot| { - annot.anchor_char_idx - }); + for (next_anchor, layer) in &self.line_annotations { + next_anchor.set(unsafe { layer.get().reset_pos(char_idx) }); + } } pub fn collect_overlay_highlights( @@ -196,7 +334,9 @@ impl<'a> TextAnnotations<'a> { layer: &'a [InlineAnnotation], highlight: Option, ) -> &mut Self { - self.inline_annotations.push((layer, highlight).into()); + if !layer.is_empty() { + self.inline_annotations.push((layer, highlight).into()); + } self } @@ -211,7 +351,9 @@ impl<'a> TextAnnotations<'a> { /// If multiple layers contain overlay at the same position /// the overlay from the layer added last will be show. pub fn add_overlay(&mut self, layer: &'a [Overlay], highlight: Option) -> &mut Self { - self.overlays.push((layer, highlight).into()); + if !layer.is_empty() { + self.overlays.push((layer, highlight).into()); + } self } @@ -219,8 +361,9 @@ impl<'a> TextAnnotations<'a> { /// /// The line annotations **must be sorted** by their `char_idx`. /// Multiple line annotations with the same `char_idx` **are not allowed**. - pub fn add_line_annotation(&mut self, layer: &'a [LineAnnotation]) -> &mut Self { - self.line_annotations.push((layer, ()).into()); + pub fn add_line_annotation(&mut self, layer: Box) -> &mut Self { + self.line_annotations + .push((Cell::new(usize::MAX), layer.into())); self } @@ -250,21 +393,35 @@ impl<'a> TextAnnotations<'a> { overlay } - pub(crate) fn annotation_lines_at(&self, char_idx: usize) -> usize { - self.line_annotations - .iter() - .map(|layer| { - let mut lines = 0; - while let Some(annot) = layer.annotations.get(layer.current_index.get()) { - if annot.anchor_char_idx == char_idx { - layer.current_index.set(layer.current_index.get() + 1); - lines += annot.height - } else { - break; + pub(crate) fn process_virtual_text_anchors(&self, grapheme: &FormattedGrapheme) { + for (next_anchor, layer) in &self.line_annotations { + loop { + match next_anchor.get().cmp(&grapheme.char_idx) { + Ordering::Less => next_anchor + .set(unsafe { layer.get().skip_concealed_anchors(grapheme.char_idx) }), + Ordering::Equal => { + next_anchor.set(unsafe { layer.get().process_anchor(grapheme) }) } - } - lines - }) - .sum() + Ordering::Greater => break, + }; + } + } + } + + pub(crate) fn virtual_lines_at( + &self, + char_idx: usize, + line_end_visual_pos: Position, + doc_line: usize, + ) -> usize { + let mut virt_off = Position::new(0, 0); + for (_, layer) in &self.line_annotations { + virt_off += unsafe { + layer + .get() + .insert_virtual_lines(char_idx, line_end_visual_pos + virt_off, doc_line) + }; + } + virt_off.row } } diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index f5a49cc11..f24f20942 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -378,7 +378,9 @@ impl ChangeSet { macro_rules! map { ($map: expr, $i: expr) => { loop { - let Some((pos, assoc)) = positions.peek_mut() else { return; }; + let Some((pos, assoc)) = positions.peek_mut() else { + return; + }; if **pos < old_pos { // Positions are not sorted, revert to the last Operation that // contains this position and continue iterating from there. @@ -405,7 +407,10 @@ impl ChangeSet { debug_assert!(old_pos <= **pos, "Reverse Iter across changeset works"); continue 'outer; } - let Some(new_pos) = $map(**pos, *assoc) else { break; }; + #[allow(clippy::redundant_closure_call)] + let Some(new_pos) = $map(**pos, *assoc) else { + break; + }; **pos = new_pos; positions.next(); } diff --git a/helix-core/src/uri.rs b/helix-core/src/uri.rs new file mode 100644 index 000000000..4e03c58b1 --- /dev/null +++ b/helix-core/src/uri.rs @@ -0,0 +1,122 @@ +use std::path::{Path, PathBuf}; + +/// A generic pointer to a file location. +/// +/// Currently this type only supports paths to local files. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum Uri { + File(PathBuf), +} + +impl Uri { + // This clippy allow mirrors url::Url::from_file_path + #[allow(clippy::result_unit_err)] + pub fn to_url(&self) -> Result { + match self { + Uri::File(path) => url::Url::from_file_path(path), + } + } + + pub fn as_path(&self) -> Option<&Path> { + match self { + Self::File(path) => Some(path), + } + } + + pub fn as_path_buf(self) -> Option { + match self { + Self::File(path) => Some(path), + } + } +} + +impl From for Uri { + fn from(path: PathBuf) -> Self { + Self::File(path) + } +} + +impl TryFrom for PathBuf { + type Error = (); + + fn try_from(uri: Uri) -> Result { + match uri { + Uri::File(path) => Ok(path), + } + } +} + +#[derive(Debug)] +pub struct UrlConversionError { + source: url::Url, + kind: UrlConversionErrorKind, +} + +#[derive(Debug)] +pub enum UrlConversionErrorKind { + UnsupportedScheme, + UnableToConvert, +} + +impl std::fmt::Display for UrlConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.kind { + UrlConversionErrorKind::UnsupportedScheme => { + write!(f, "unsupported scheme in URL: {}", self.source.scheme()) + } + UrlConversionErrorKind::UnableToConvert => { + write!(f, "unable to convert URL to file path: {}", self.source) + } + } + } +} + +impl std::error::Error for UrlConversionError {} + +fn convert_url_to_uri(url: &url::Url) -> Result { + if url.scheme() == "file" { + url.to_file_path() + .map(|path| Uri::File(helix_stdx::path::normalize(path))) + .map_err(|_| UrlConversionErrorKind::UnableToConvert) + } else { + Err(UrlConversionErrorKind::UnsupportedScheme) + } +} + +impl TryFrom for Uri { + type Error = UrlConversionError; + + fn try_from(url: url::Url) -> Result { + convert_url_to_uri(&url).map_err(|kind| Self::Error { source: url, kind }) + } +} + +impl TryFrom<&url::Url> for Uri { + type Error = UrlConversionError; + + fn try_from(url: &url::Url) -> Result { + convert_url_to_uri(url).map_err(|kind| Self::Error { + source: url.clone(), + kind, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use url::Url; + + #[test] + fn unknown_scheme() { + let url = Url::parse("csharp:/metadata/foo/bar/Baz.cs").unwrap(); + assert!(matches!( + Uri::try_from(url), + Err(UrlConversionError { + kind: UrlConversionErrorKind::UnsupportedScheme, + .. + }) + )); + } +} diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs index 31946c56e..56b4d2ba9 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -36,7 +36,7 @@ fn test_treesitter_indent_rust_helix() { .unwrap(); let files = String::from_utf8(files.stdout).unwrap(); - let ignored_files = vec![ + let ignored_files = [ // Contains many macros that tree-sitter does not parse in a meaningful way and is otherwise not very interesting "helix-term/src/health.rs", ]; @@ -45,6 +45,7 @@ fn test_treesitter_indent_rust_helix() { if ignored_files.contains(&file) { continue; } + #[allow(clippy::single_range_in_vec_init)] let ignored_lines: Vec> = match file { "helix-term/src/application.rs" => vec![ // We can't handle complicated indent rules inside macros (`json!` in this case) since diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index 3521f5890..c37340cc6 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -20,8 +20,8 @@ anyhow = "1.0" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -thiserror = "1.0" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] } +thiserror.workspace = true [dev-dependencies] fern = "0.6" diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs index 2f5b3d0fc..ed4515fe9 100644 --- a/helix-dap/src/client.rs +++ b/helix-dap/src/client.rs @@ -157,8 +157,8 @@ impl Client { ) } - pub fn starting_request_args(&self) -> &Option { - &self.starting_request_args + pub fn starting_request_args(&self) -> Option<&Value> { + self.starting_request_args.as_ref() } pub async fn tcp_process( diff --git a/helix-dap/src/types.rs b/helix-dap/src/types.rs index bbaf53a60..9cec05e65 100644 --- a/helix-dap/src/types.rs +++ b/helix-dap/src/types.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; @@ -311,7 +311,8 @@ pub struct Variable { #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Module { - pub id: String, // TODO: || number + #[serde(deserialize_with = "from_number")] + pub id: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, @@ -331,6 +332,23 @@ pub struct Module { pub address_range: Option, } +fn from_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberOrString { + Number(i64), + String(String), + } + + match NumberOrString::deserialize(deserializer)? { + NumberOrString::Number(n) => Ok(n.to_string()), + NumberOrString::String(s) => Ok(s), + } +} + pub mod requests { use super::*; #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] @@ -887,4 +905,18 @@ pub mod events { pub offset: usize, pub count: usize, } + + #[test] + fn test_deserialize_module_id_from_number() { + let raw = r#"{"id": 0, "name": "Name"}"#; + let module: super::Module = serde_json::from_str(raw).expect("Error!"); + assert_eq!(module.id, "0"); + } + + #[test] + fn test_deserialize_module_id_from_string() { + let raw = r#"{"id": "0", "name": "Name"}"#; + let module: super::Module = serde_json::from_str(raw).expect("Error!"); + assert_eq!(module.id, "0"); + } } diff --git a/helix-event/Cargo.toml b/helix-event/Cargo.toml index 616c323dc..e7c877355 100644 --- a/helix-event/Cargo.toml +++ b/helix-event/Cargo.toml @@ -13,7 +13,7 @@ homepage.workspace = true [dependencies] ahash = "0.8.11" -hashbrown = "0.14.0" +hashbrown = "0.14.5" tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] } # the event registry is essentially read only but must be an rwlock so we can # setup new events on initialization, hardware-lock-elision hugely benefits this case diff --git a/helix-event/src/debounce.rs b/helix-event/src/debounce.rs index 30b6f671b..5971f72b3 100644 --- a/helix-event/src/debounce.rs +++ b/helix-event/src/debounce.rs @@ -28,7 +28,10 @@ pub trait AsyncHook: Sync + Send + 'static + Sized { // so it should only be reached in case of total CPU overload. // However, a bounded channel is much more efficient so it's nice to use here let (tx, rx) = mpsc::channel(128); - tokio::spawn(run(self, rx)); + // only spawn worker if we are inside runtime to avoid having to spawn a runtime for unrelated unit tests + if tokio::runtime::Handle::try_current().is_ok() { + tokio::spawn(run(self, rx)); + } tx } } diff --git a/helix-event/src/lib.rs b/helix-event/src/lib.rs index 894de5e8d..de018a79d 100644 --- a/helix-event/src/lib.rs +++ b/helix-event/src/lib.rs @@ -34,7 +34,9 @@ use anyhow::Result; pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx}; pub use debounce::{send_blocking, AsyncHook}; -pub use redraw::{lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard}; +pub use redraw::{ + lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard, RequestRedrawOnDrop, +}; pub use registry::Event; mod cancel; diff --git a/helix-event/src/redraw.rs b/helix-event/src/redraw.rs index 8fadb8aea..d1a188995 100644 --- a/helix-event/src/redraw.rs +++ b/helix-event/src/redraw.rs @@ -51,3 +51,12 @@ pub fn start_frame() { pub fn lock_frame() -> RenderLockGuard { RENDER_LOCK.read() } + +/// A zero sized type that requests a redraw via [request_redraw] when the type [Drop]s. +pub struct RequestRedrawOnDrop; + +impl Drop for RequestRedrawOnDrop { + fn drop(&mut self) { + request_redraw(); + } +} diff --git a/helix-loader/build.rs b/helix-loader/build.rs index a4562c00c..ea0689839 100644 --- a/helix-loader/build.rs +++ b/helix-loader/build.rs @@ -50,6 +50,7 @@ fn main() { .ok() .filter(|output| output.status.success()) .and_then(|x| String::from_utf8(x.stdout).ok()) + .map(|x| x.trim().to_string()) else { return; }; @@ -67,6 +68,7 @@ fn main() { .ok() .filter(|output| output.status.success()) .and_then(|x| String::from_utf8(x.stdout).ok()) + .map(|x| x.trim().to_string()) else { return; }; diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index 7977c6df8..99e911544 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -422,7 +422,7 @@ fn build_tree_sitter_library( } } - let recompile = needs_recompile(&library_path, &parser_path, &scanner_path) + let recompile = needs_recompile(&library_path, &parser_path, scanner_path.as_ref()) .context("Failed to compare source and binary timestamps")?; if !recompile { @@ -568,7 +568,7 @@ fn build_tree_sitter_library( fn needs_recompile( lib_path: &Path, parser_c_path: &Path, - scanner_path: &Option, + scanner_path: Option<&PathBuf>, ) -> Result { if !lib_path.exists() { return Ok(true); diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index f87f15f21..ab9251ebe 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -26,9 +26,9 @@ log = "0.4" lsp-types = { version = "0.95" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -thiserror = "1.0" -tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.38", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.15" -parking_lot = "0.12.1" +parking_lot = "0.12.3" arc-swap = "1" slotmap.workspace = true +thiserror.workspace = true diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 254625a3a..643aa9a26 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -123,7 +123,7 @@ impl Client { { client.add_workspace_folder( root_uri, - &workspace_folders_caps.change_notifications, + workspace_folders_caps.change_notifications.as_ref(), ); } }); @@ -136,7 +136,10 @@ impl Client { .and_then(|cap| cap.workspace_folders.as_ref()) .filter(|cap| cap.supported.unwrap_or(false)) { - self.add_workspace_folder(root_uri, &workspace_folders_caps.change_notifications); + self.add_workspace_folder( + root_uri, + workspace_folders_caps.change_notifications.as_ref(), + ); true } else { // the server doesn't support multi workspaces, we need a new client @@ -147,7 +150,7 @@ impl Client { fn add_workspace_folder( &self, root_uri: Option, - change_notifications: &Option>, + change_notifications: Option<&OneOf>, ) { // root_uri is None just means that there isn't really any LSP workspace // associated with this file. For servers that support multiple workspaces @@ -162,7 +165,7 @@ impl Client { self.workspace_folders .lock() .push(workspace_for_uri(root_uri.clone())); - if &Some(OneOf::Left(false)) == change_notifications { + if Some(&OneOf::Left(false)) == change_notifications { // server specifically opted out of DidWorkspaceChange notifications // let's assume the server will request the workspace folders itself // and that we can therefore reuse the client (but are done now) @@ -616,6 +619,9 @@ impl Client { prepare_support_default_behavior: None, honors_change_annotations: Some(false), }), + formatting: Some(lsp::DocumentFormattingClientCapabilities { + dynamic_registration: Some(false), + }), code_action: Some(lsp::CodeActionClientCapabilities { code_action_literal_support: Some(lsp::CodeActionLiteralSupport { code_action_kind: lsp::CodeActionKindLiteralSupport { diff --git a/helix-lsp/src/file_event.rs b/helix-lsp/src/file_event.rs index 8f4c16756..c7297d67f 100644 --- a/helix-lsp/src/file_event.rs +++ b/helix-lsp/src/file_event.rs @@ -139,7 +139,7 @@ impl Handler { registration_id ); - let entry = state.entry(client_id).or_insert_with(ClientState::default); + let entry = state.entry(client_id).or_default(); entry.client = client; let mut builder = GlobSetBuilder::new(); diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 0a3c6a33d..ec89e1f82 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -678,7 +678,7 @@ impl Registry { pub fn remove_by_id(&mut self, id: LanguageServerId) { let Some(client) = self.inner.remove(id) else { log::error!("client was already removed"); - return + return; }; self.file_event_handler.remove_client(id); let instances = self diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index f17e08854..5aef65905 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -17,7 +17,7 @@ etcetera = "0.8" ropey = { version = "1.6.1", default-features = false } which = "6.0" regex-cursor = "0.1.4" -bitflags = "2.4" +bitflags = "2.6" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52", features = ["Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } diff --git a/helix-stdx/src/env.rs b/helix-stdx/src/env.rs index 59aba0adc..51450d225 100644 --- a/helix-stdx/src/env.rs +++ b/helix-stdx/src/env.rs @@ -14,13 +14,23 @@ pub fn current_working_dir() -> PathBuf { return path.clone(); } - let path = std::env::current_dir() - .map(crate::path::normalize) - .expect("Couldn't determine current working directory"); - let mut cwd = CWD.write().unwrap(); - *cwd = Some(path.clone()); + // implementation of crossplatform pwd -L + // we want pwd -L so that symlinked directories are handled correctly + let mut cwd = std::env::current_dir().expect("Couldn't determine current working directory"); + + let pwd = std::env::var_os("PWD"); + #[cfg(windows)] + let pwd = pwd.or_else(|| std::env::var_os("CD")); + + if let Some(pwd) = pwd.map(PathBuf::from) { + if pwd.canonicalize().ok().as_ref() == Some(&cwd) { + cwd = pwd; + } + } + let mut dst = CWD.write().unwrap(); + *dst = Some(cwd.clone()); - path + cwd } pub fn set_current_working_dir(path: impl AsRef) -> std::io::Result<()> { diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index b00e97488..cc90396e4 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -14,7 +14,7 @@ homepage.workspace = true [features] default = ["git"] -unicode-lines = ["helix-core/unicode-lines"] +unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"] integration = ["helix-event/integration_test"] git = ["helix-vcs/git"] @@ -53,13 +53,14 @@ log = "0.4" nucleo.workspace = true ignore = "0.4" # markdown doc rendering -pulldown-cmark = { version = "0.10", default-features = false } +pulldown-cmark = { version = "0.11", default-features = false } # file type detection content_inspector = "0.2.4" +thiserror = "1.0" # opening URLs -open = "5.1.2" -url = "2.5.0" +open = "5.3.0" +url = "2.5.2" # config toml = "0.8" @@ -73,7 +74,7 @@ grep-searcher = "0.1.13" [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } -libc = "0.2.153" +libc = "0.2.155" [target.'cfg(target_os = "macos")'.dependencies] crossterm = { version = "0.27", features = ["event-stream", "use-dev-tty"] } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 6bdf60bca..b7123e972 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -9,8 +9,9 @@ use helix_lsp::{ use helix_stdx::path::get_relative_path; use helix_view::{ align_view, - document::DocumentSavedEventResult, + document::{DocumentOpenError, DocumentSavedEventResult}, editor::{ConfigEvent, EditorEvent}, + events::DiagnosticsDidChange, graphics::Rect, theme, tree::Layout, @@ -186,9 +187,15 @@ impl Application { Some(Layout::Horizontal) => Action::HorizontalSplit, None => Action::Load, }; - let doc_id = editor - .open(&file, action) - .context(format!("open '{}'", file.to_string_lossy()))?; + let doc_id = match editor.open(&file, action) { + // Ignore irregular files during application init. + Err(DocumentOpenError::IrregularFile) => { + nr_of_files -= 1; + continue; + } + Err(err) => return Err(anyhow::anyhow!(err)), + Ok(doc_id) => doc_id, + }; // with Action::Load all documents have the same view // NOTE: this isn't necessarily true anymore. If // `--vsplit` or `--hsplit` are used, the file which is @@ -199,15 +206,21 @@ impl Application { doc.set_selection(view_id, pos); } } - editor.set_status(format!( - "Loaded {} file{}.", - nr_of_files, - if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo - )); - // align the view to center after all files are loaded, - // does not affect views without pos since it is at the top - let (view, doc) = current!(editor); - align_view(doc, view, Align::Center); + + // if all files were invalid, replace with empty buffer + if nr_of_files == 0 { + editor.new_file(Action::VerticalSplit); + } else { + editor.set_status(format!( + "Loaded {} file{}.", + nr_of_files, + if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo + )); + // align the view to center after all files are loaded, + // does not affect views without pos since it is at the top + let (view, doc) = current!(editor); + align_view(doc, view, Align::Center); + } } else { editor.new_file(Action::VerticalSplit); } @@ -278,7 +291,7 @@ impl Application { self.compositor.render(area, surface, &mut cx); let (pos, kind) = self.compositor.cursor(area, &self.editor); // reset cursor cache - self.editor.cursor_cache.set(None); + self.editor.cursor_cache.reset(); let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); self.terminal.draw(pos, kind).unwrap(); @@ -723,10 +736,10 @@ impl Application { } } Notification::PublishDiagnostics(mut params) => { - let path = match params.uri.to_file_path() { - Ok(path) => helix_stdx::path::normalize(&path), - Err(_) => { - log::error!("Unsupported file URI: {}", params.uri); + let uri = match helix_core::Uri::try_from(params.uri) { + Ok(uri) => uri, + Err(err) => { + log::error!("{err}"); return; } }; @@ -737,11 +750,11 @@ impl Application { } // have to inline the function because of borrow checking... let doc = self.editor.documents.values_mut() - .find(|doc| doc.path().map(|p| p == &path).unwrap_or(false)) + .find(|doc| doc.uri().is_some_and(|u| u == uri)) .filter(|doc| { if let Some(version) = params.version { if version != doc.version() { - log::info!("Version ({version}) is out of date for {path:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); + log::info!("Version ({version}) is out of date for {uri:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); return false; } } @@ -753,13 +766,13 @@ impl Application { let lang_conf = doc.language.clone(); if let Some(lang_conf) = &lang_conf { - if let Some(old_diagnostics) = self.editor.diagnostics.get(&path) { + if let Some(old_diagnostics) = self.editor.diagnostics.get(&uri) { if !lang_conf.persistent_diagnostic_sources.is_empty() { // Sort diagnostics first by severity and then by line numbers. // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order params .diagnostics - .sort_unstable_by_key(|d| (d.severity, d.range.start)); + .sort_by_key(|d| (d.severity, d.range.start)); } for source in &lang_conf.persistent_diagnostic_sources { let new_diagnostics = params @@ -786,7 +799,7 @@ impl Application { // Insert the original lsp::Diagnostics here because we may have no open document // for diagnosic message and so we can't calculate the exact position. // When using them later in the diagnostics picker, we calculate them on-demand. - let diagnostics = match self.editor.diagnostics.entry(path) { + let diagnostics = match self.editor.diagnostics.entry(uri) { Entry::Occupied(o) => { let current_diagnostics = o.into_mut(); // there may entries of other language servers, which is why we can't overwrite the whole entry @@ -800,9 +813,8 @@ impl Application { // Sort diagnostics first by severity and then by line numbers. // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order - diagnostics.sort_unstable_by_key(|(d, server_id)| { - (d.severity, d.range.start, *server_id) - }); + diagnostics + .sort_by_key(|(d, server_id)| (d.severity, d.range.start, *server_id)); if let Some(doc) = doc { let diagnostic_of_language_server_and_not_in_unchanged_sources = @@ -823,6 +835,12 @@ impl Application { &unchanged_diag_sources, Some(server_id), ); + + let doc = doc.id(); + helix_event::dispatch(DiagnosticsDidChange { + editor: &mut self.editor, + doc, + }); } } Notification::ShowMessage(params) => { @@ -1120,20 +1138,22 @@ impl Application { .. } = params; - let path = match uri.to_file_path() { - Ok(path) => path, + let uri = match helix_core::Uri::try_from(uri) { + Ok(uri) => uri, Err(err) => { - log::error!("unsupported file URI: {}: {:?}", uri, err); + log::error!("{err}"); return lsp::ShowDocumentResult { success: false }; } }; + // If `Uri` gets another variant other than `Path` this may not be valid. + let path = uri.as_path().expect("URIs are valid paths"); let action = match take_focus { Some(true) => helix_view::editor::Action::Replace, _ => helix_view::editor::Action::VerticalSplit, }; - let doc_id = match self.editor.open(&path, action) { + let doc_id = match self.editor.open(path, action) { Ok(id) => id, Err(err) => { log::error!("failed to open path: {:?}: {:?}", uri, err); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7be2ea095..7e0bee92b 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3,6 +3,7 @@ pub(crate) mod lsp; pub(crate) mod typed; pub use dap::*; +use futures_util::FutureExt; use helix_event::status; use helix_stdx::{ path::expand_tilde, @@ -10,10 +11,7 @@ use helix_stdx::{ }; use helix_vcs::{FileChange, Hunk}; pub use lsp::*; -use tui::{ - text::Span, - widgets::{Cell, Row}, -}; +use tui::text::Span; pub use typed::*; use helix_core::{ @@ -26,7 +24,7 @@ use helix_core::{ history::UndoKind, increment, indent, indent::IndentStyle, - line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, + line_ending::{get_line_ending_of_str, line_end_char_index}, match_brackets, movement::{self, move_vertically_visual, Direction}, object, pos_at_coords, @@ -61,14 +59,14 @@ use crate::{ compositor::{self, Component, Compositor}, filter_picker_entry, job::Callback, - keymap::ReverseKeymap, - ui::{self, menu::Item, overlay::overlaid, Picker, Popup, Prompt, PromptEvent}, + ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Jobs}; use std::{ cmp::Ordering, collections::{HashMap, HashSet}, + error::Error, fmt, future::Future, io::Read, @@ -142,6 +140,17 @@ impl<'a> Context<'a> { pub fn count(&self) -> usize { self.count.map_or(1, |v| v.get()) } + + /// Waits on all pending jobs, and then tries to flush all pending write + /// operations for all documents. + pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { + compositor::Context { + editor: self.editor, + jobs: self.jobs, + scroll: None, + } + .block_try_flush_writes() + } } #[inline] @@ -326,7 +335,7 @@ impl MappableCommand { append_mode, "Append after selection", command_mode, "Enter command mode", file_picker, "Open file picker", - file_picker_in_current_buffer_directory, "Open file picker at current buffers's directory", + file_picker_in_current_buffer_directory, "Open file picker at current buffer's directory", file_picker_in_current_directory, "Open file picker at current working directory", code_action, "Perform code action", buffer_picker, "Open buffer picker", @@ -1191,19 +1200,15 @@ fn goto_file_impl(cx: &mut Context, action: Action) { let (view, doc) = current_ref!(cx.editor); let text = doc.text(); let selections = doc.selection(view.id); + let primary = selections.primary(); let rel_path = doc .relative_path() .map(|path| path.parent().unwrap().to_path_buf()) .unwrap_or_default(); - let mut paths: Vec<_> = selections - .iter() - .map(|r| text.slice(r.from()..r.to()).to_string()) - .collect(); - let primary = selections.primary(); - // Checks whether there is only one selection with a width of 1 - if selections.len() == 1 && primary.len() == 1 { - paths.clear(); + let paths: Vec<_> = if selections.len() == 1 && primary.len() == 1 { + // Secial case: if there is only one one-width selection, try to detect the + // path under the cursor. let is_valid_path_char = |c: &char| { #[cfg(target_os = "windows")] let valid_chars = &[ @@ -1238,29 +1243,29 @@ fn goto_file_impl(cx: &mut Context, action: Action) { .take_while(is_valid_path_char) .count(); - let path: Cow = text + let path: String = text .slice((start_pos - prefix_len)..(start_pos + postfix_len)) .into(); - log::debug!("Goto file path: {}", path); + log::debug!("goto_file auto-detected path: {}", path); - match expand_tilde(PathBuf::from(path.as_ref())).to_str() { - Some(path) => paths.push(path.to_string()), - None => cx.editor.set_error("Couldn't get string out of path."), - }; - } + vec![path] + } else { + // Otherwise use each selection, trimmed. + selections + .fragments(text.slice(..)) + .map(|sel| sel.trim().to_string()) + .filter(|sel| !sel.is_empty()) + .collect() + }; for sel in paths { - let p = sel.trim(); - if p.is_empty() { - continue; - } - - if let Ok(url) = Url::parse(p) { + if let Ok(url) = Url::parse(&sel) { open_url(cx, url, action); continue; } - let path = &rel_path.join(p); + let path = expand_tilde(Cow::from(PathBuf::from(sel))); + let path = &rel_path.join(path); if path.is_dir() { let picker = ui::file_picker(path.into(), &cx.editor.config()); cx.push_layer(Box::new(overlaid(picker))); @@ -1594,19 +1599,11 @@ fn replace(cx: &mut Context) { if let Some(ch) = ch { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { if !range.is_empty() { - let text: String = + let text: Tendril = RopeGraphemes::new(doc.text().slice(range.from()..range.to())) - .map(|g| { - let cow: Cow = g.into(); - if str_is_line_ending(&cow) { - cow - } else { - ch.into() - } - }) + .map(|_g| ch) .collect(); - - (range.from(), range.to(), Some(text.into())) + (range.from(), range.to(), Some(text)) } else { // No change. (range.from(), range.to(), None) @@ -1714,6 +1711,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor &mut annotations, ) }); + drop(annotations); doc.set_selection(view.id, selection); return; } @@ -2257,216 +2255,193 @@ fn global_search(cx: &mut Context) { } } - impl ui::menu::Item for FileResult { - type Data = Option; - - fn format(&self, current_path: &Self::Data) -> Row { - let relative_path = helix_stdx::path::get_relative_path(&self.path) - .to_string_lossy() - .into_owned(); - if current_path - .as_ref() - .map(|p| p == &self.path) - .unwrap_or(false) - { - format!("{} (*)", relative_path).into() - } else { - relative_path.into() - } - } + struct GlobalSearchConfig { + smart_case: bool, + file_picker_config: helix_view::editor::FilePickerConfig, } let config = cx.editor.config(); - let smart_case = config.search.smart_case; - let file_picker_config = config.file_picker.clone(); + let config = GlobalSearchConfig { + smart_case: config.search.smart_case, + file_picker_config: config.file_picker.clone(), + }; - let reg = cx.register.unwrap_or('/'); - let completions = search_completions(cx, Some(reg)); - ui::raw_regex_prompt( - cx, - "global-search:".into(), - Some(reg), - move |_editor: &Editor, input: &str| { - completions - .iter() - .filter(|comp| comp.starts_with(input)) - .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) - .collect() - }, - move |cx, _, input, event| { - if event != PromptEvent::Validate { - return; - } - cx.editor.registers.last_search_register = reg; + let columns = [ + PickerColumn::new("path", |item: &FileResult, _| { + let path = helix_stdx::path::get_relative_path(&item.path); + format!("{}:{}", path.to_string_lossy(), item.line_num + 1).into() + }), + PickerColumn::hidden("contents"), + ]; - let current_path = doc_mut!(cx.editor).path().cloned(); - let documents: Vec<_> = cx - .editor - .documents() - .map(|doc| (doc.path().cloned(), doc.text().to_owned())) - .collect(); + let get_files = |query: &str, + editor: &mut Editor, + config: std::sync::Arc, + injector: &ui::picker::Injector<_, _>| { + if query.is_empty() { + return async { Ok(()) }.boxed(); + } - if let Ok(matcher) = RegexMatcherBuilder::new() - .case_smart(smart_case) - .build(input) - { - let search_root = helix_stdx::env::current_working_dir(); - if !search_root.exists() { - cx.editor - .set_error("Current working directory does not exist"); - return; - } + let search_root = helix_stdx::env::current_working_dir(); + if !search_root.exists() { + return async { Err(anyhow::anyhow!("Current working directory does not exist")) } + .boxed(); + } + + let documents: Vec<_> = editor + .documents() + .map(|doc| (doc.path().cloned(), doc.text().to_owned())) + .collect(); - let (picker, injector) = Picker::stream(current_path); - - let dedup_symlinks = file_picker_config.deduplicate_links; - let absolute_root = search_root - .canonicalize() - .unwrap_or_else(|_| search_root.clone()); - let injector_ = injector.clone(); - - std::thread::spawn(move || { - let searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(b'\x00')) - .build(); - - let mut walk_builder = WalkBuilder::new(search_root); - - walk_builder - .hidden(file_picker_config.hidden) - .parents(file_picker_config.parents) - .ignore(file_picker_config.ignore) - .follow_links(file_picker_config.follow_symlinks) - .git_ignore(file_picker_config.git_ignore) - .git_global(file_picker_config.git_global) - .git_exclude(file_picker_config.git_exclude) - .max_depth(file_picker_config.max_depth) - .filter_entry(move |entry| { - filter_picker_entry(entry, &absolute_root, dedup_symlinks) + let matcher = match RegexMatcherBuilder::new() + .case_smart(config.smart_case) + .build(query) + { + Ok(matcher) => { + // Clear any "Failed to compile regex" errors out of the statusline. + editor.clear_status(); + matcher + } + Err(err) => { + log::info!("Failed to compile search pattern in global search: {}", err); + return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed(); + } + }; + + let dedup_symlinks = config.file_picker_config.deduplicate_links; + let absolute_root = search_root + .canonicalize() + .unwrap_or_else(|_| search_root.clone()); + + let injector = injector.clone(); + async move { + let searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .build(); + WalkBuilder::new(search_root) + .hidden(config.file_picker_config.hidden) + .parents(config.file_picker_config.parents) + .ignore(config.file_picker_config.ignore) + .follow_links(config.file_picker_config.follow_symlinks) + .git_ignore(config.file_picker_config.git_ignore) + .git_global(config.file_picker_config.git_global) + .git_exclude(config.file_picker_config.git_exclude) + .max_depth(config.file_picker_config.max_depth) + .filter_entry(move |entry| { + filter_picker_entry(entry, &absolute_root, dedup_symlinks) + }) + .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) + .add_custom_ignore_filename(".helix/ignore") + .build_parallel() + .run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let injector = injector.clone(); + let documents = &documents; + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; + + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let mut stop = false; + let sink = sinks::UTF8(|line_num, _line_content| { + stop = injector + .push(FileResult::new(entry.path(), line_num as usize - 1)) + .is_err(); + + Ok(!stop) + }); + let doc = documents.iter().find(|&(doc_path, _)| { + doc_path + .as_ref() + .map_or(false, |doc_path| doc_path == entry.path()) }); - walk_builder - .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")); - walk_builder.add_custom_ignore_filename(".helix/ignore"); - - walk_builder.build_parallel().run(|| { - let mut searcher = searcher.clone(); - let matcher = matcher.clone(); - let injector = injector_.clone(); - let documents = &documents; - Box::new(move |entry: Result| -> WalkState { - let entry = match entry { - Ok(entry) => entry, - Err(_) => return WalkState::Continue, - }; - - match entry.file_type() { - Some(entry) if entry.is_file() => {} - // skip everything else - _ => return WalkState::Continue, - }; - - let mut stop = false; - let sink = sinks::UTF8(|line_num, _| { - stop = injector - .push(FileResult::new(entry.path(), line_num as usize - 1)) - .is_err(); - - Ok(!stop) - }); - let doc = documents.iter().find(|&(doc_path, _)| { - doc_path - .as_ref() - .map_or(false, |doc_path| doc_path == entry.path()) - }); - - let result = if let Some((_, doc)) = doc { - // there is already a buffer for this file - // search the buffer instead of the file because it's faster - // and captures new edits without requiring a save - if searcher.multi_line_with_matcher(&matcher) { - // in this case a continous buffer is required - // convert the rope to a string - let text = doc.to_string(); - searcher.search_slice(&matcher, text.as_bytes(), sink) - } else { - searcher.search_reader( - &matcher, - RopeReader::new(doc.slice(..)), - sink, - ) - } + let result = if let Some((_, doc)) = doc { + // there is already a buffer for this file + // search the buffer instead of the file because it's faster + // and captures new edits without requiring a save + if searcher.multi_line_with_matcher(&matcher) { + // in this case a continous buffer is required + // convert the rope to a string + let text = doc.to_string(); + searcher.search_slice(&matcher, text.as_bytes(), sink) } else { - searcher.search_path(&matcher, entry.path(), sink) - }; - - if let Err(err) = result { - log::error!( - "Global search error: {}, {}", - entry.path().display(), - err - ); + searcher.search_reader( + &matcher, + RopeReader::new(doc.slice(..)), + sink, + ) } - if stop { - WalkState::Quit - } else { - WalkState::Continue - } - }) - }); + } else { + searcher.search_path(&matcher, entry.path(), sink) + }; + + if let Err(err) = result { + log::error!("Global search error: {}, {}", entry.path().display(), err); + } + if stop { + WalkState::Quit + } else { + WalkState::Continue + } + }) }); + Ok(()) + } + .boxed() + }; - cx.jobs.callback(async move { - let call = move |_: &mut Editor, compositor: &mut Compositor| { - let picker = Picker::with_stream( - picker, - injector, - move |cx, FileResult { path, line_num }, action| { - let doc = match cx.editor.open(path, action) { - Ok(id) => doc_mut!(cx.editor, &id), - Err(e) => { - cx.editor.set_error(format!( - "Failed to open file '{}': {}", - path.display(), - e - )); - return; - } - }; + let reg = cx.register.unwrap_or('/'); + cx.editor.registers.last_search_register = reg; - let line_num = *line_num; - let view = view_mut!(cx.editor); - let text = doc.text(); - if line_num >= text.len_lines() { - cx.editor.set_error( + let picker = Picker::new( + columns, + 1, // contents + [], + config, + move |cx, FileResult { path, line_num, .. }, action| { + let doc = match cx.editor.open(path, action) { + Ok(id) => doc_mut!(cx.editor, &id), + Err(e) => { + cx.editor + .set_error(format!("Failed to open file '{}': {}", path.display(), e)); + return; + } + }; + + let line_num = *line_num; + let view = view_mut!(cx.editor); + let text = doc.text(); + if line_num >= text.len_lines() { + cx.editor.set_error( "The line you jumped to does not exist anymore because the file has changed.", ); - return; - } - let start = text.line_to_char(line_num); - let end = text.line_to_char((line_num + 1).min(text.len_lines())); + return; + } + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); - doc.set_selection(view.id, Selection::single(start, end)); - if action.align_view(view, doc.id()) { - align_view(doc, view, Align::Center); - } - }, - ) - .with_preview( - |_editor, FileResult { path, line_num }| { - Some((path.clone().into(), Some((*line_num, *line_num)))) - }, - ); - compositor.push(Box::new(overlaid(picker))) - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }) - } else { - // Otherwise do nothing - // log::warn!("Global Search Invalid Pattern") + doc.set_selection(view.id, Selection::single(start, end)); + if action.align_view(view, doc.id()) { + align_view(doc, view, Align::Center); } }, - ); + ) + .with_preview(|_editor, FileResult { path, line_num, .. }| { + Some((path.as_path().into(), Some((*line_num, *line_num)))) + }) + .with_history_register(Some(reg)) + .with_dynamic_query(get_files, Some(275)); + + cx.push_layer(Box::new(overlaid(picker))); } enum Extend { @@ -2894,31 +2869,6 @@ fn buffer_picker(cx: &mut Context) { focused_at: std::time::Instant, } - impl ui::menu::Item for BufferMeta { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - let path = self - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - let path = match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => SCRATCH_BUFFER_NAME, - }; - - let mut flags = String::new(); - if self.is_modified { - flags.push('+'); - } - if self.is_current { - flags.push('*'); - } - - Row::new([self.id.to_string(), flags, path.to_string()]) - } - } - let new_meta = |doc: &Document| BufferMeta { id: doc.id(), path: doc.path().cloned(), @@ -2937,7 +2887,31 @@ fn buffer_picker(cx: &mut Context) { // mru items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); - let picker = Picker::new(items, (), |cx, meta, action| { + let columns = [ + PickerColumn::new("id", |meta: &BufferMeta, _| meta.id.to_string().into()), + PickerColumn::new("flags", |meta: &BufferMeta, _| { + let mut flags = String::new(); + if meta.is_modified { + flags.push('+'); + } + if meta.is_current { + flags.push('*'); + } + flags.into() + }), + PickerColumn::new("path", |meta: &BufferMeta, _| { + let path = meta + .path + .as_deref() + .map(helix_stdx::path::get_relative_path); + path.as_deref() + .and_then(Path::to_str) + .unwrap_or(SCRATCH_BUFFER_NAME) + .to_string() + .into() + }), + ]; + let picker = Picker::new(columns, 2, items, (), |cx, meta, action| { cx.editor.switch(meta.id, action); }) .with_preview(|editor, meta| { @@ -2961,33 +2935,6 @@ fn jumplist_picker(cx: &mut Context) { is_current: bool, } - impl ui::menu::Item for JumpMeta { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - let path = self - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - let path = match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => SCRATCH_BUFFER_NAME, - }; - - let mut flags = Vec::new(); - if self.is_current { - flags.push("*"); - } - - let flag = if flags.is_empty() { - "".into() - } else { - format!(" ({})", flags.join("")) - }; - format!("{} {}{} {}", self.id, path, flag, self.text).into() - } - } - for (view, _) in cx.editor.tree.views_mut() { for doc_id in view.jumps.iter().map(|e| e.0).collect::>().iter() { let doc = doc_mut!(cx.editor, doc_id); @@ -3014,17 +2961,43 @@ fn jumplist_picker(cx: &mut Context) { } }; + let columns = [ + ui::PickerColumn::new("id", |item: &JumpMeta, _| item.id.to_string().into()), + ui::PickerColumn::new("path", |item: &JumpMeta, _| { + let path = item + .path + .as_deref() + .map(helix_stdx::path::get_relative_path); + path.as_deref() + .and_then(Path::to_str) + .unwrap_or(SCRATCH_BUFFER_NAME) + .to_string() + .into() + }), + ui::PickerColumn::new("flags", |item: &JumpMeta, _| { + let mut flags = Vec::new(); + if item.is_current { + flags.push("*"); + } + + if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")).into() + } + }), + ui::PickerColumn::new("contents", |item: &JumpMeta, _| item.text.as_str().into()), + ]; + let picker = Picker::new( - cx.editor - .tree - .views() - .flat_map(|(view, _)| { - view.jumps - .iter() - .rev() - .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) - }) - .collect(), + columns, + 1, // path + cx.editor.tree.views().flat_map(|(view, _)| { + view.jumps + .iter() + .rev() + .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) + }), (), |cx, meta, action| { cx.editor.switch(meta.id, action); @@ -3054,33 +3027,6 @@ fn changed_file_picker(cx: &mut Context) { style_renamed: Style, } - impl Item for FileChange { - type Data = FileChangeData; - - fn format(&self, data: &Self::Data) -> Row { - let process_path = |path: &PathBuf| { - path.strip_prefix(&data.cwd) - .unwrap_or(path) - .display() - .to_string() - }; - - let (sign, style, content) = match self { - Self::Untracked { path } => ("[+]", data.style_untracked, process_path(path)), - Self::Modified { path } => ("[~]", data.style_modified, process_path(path)), - Self::Conflict { path } => ("[x]", data.style_conflict, process_path(path)), - Self::Deleted { path } => ("[-]", data.style_deleted, process_path(path)), - Self::Renamed { from_path, to_path } => ( - "[>]", - data.style_renamed, - format!("{} -> {}", process_path(from_path), process_path(to_path)), - ), - }; - - Row::new([Cell::from(Span::styled(sign, style)), Cell::from(content)]) - } - } - let cwd = helix_stdx::env::current_working_dir(); if !cwd.exists() { cx.editor @@ -3094,8 +3040,41 @@ fn changed_file_picker(cx: &mut Context) { let deleted = cx.editor.theme.get("diff.minus"); let renamed = cx.editor.theme.get("diff.delta.moved"); + let columns = [ + PickerColumn::new("change", |change: &FileChange, data: &FileChangeData| { + match change { + FileChange::Untracked { .. } => Span::styled("+ untracked", data.style_untracked), + FileChange::Modified { .. } => Span::styled("~ modified", data.style_modified), + FileChange::Conflict { .. } => Span::styled("x conflict", data.style_conflict), + FileChange::Deleted { .. } => Span::styled("- deleted", data.style_deleted), + FileChange::Renamed { .. } => Span::styled("> renamed", data.style_renamed), + } + .into() + }), + PickerColumn::new("path", |change: &FileChange, data: &FileChangeData| { + let display_path = |path: &PathBuf| { + path.strip_prefix(&data.cwd) + .unwrap_or(path) + .display() + .to_string() + }; + match change { + FileChange::Untracked { path } => display_path(path), + FileChange::Modified { path } => display_path(path), + FileChange::Conflict { path } => display_path(path), + FileChange::Deleted { path } => display_path(path), + FileChange::Renamed { from_path, to_path } => { + format!("{} -> {}", display_path(from_path), display_path(to_path)) + } + } + .into() + }), + ]; + let picker = Picker::new( - Vec::new(), + columns, + 1, // path + [], FileChangeData { cwd: cwd.clone(), style_untracked: added, @@ -3116,7 +3095,7 @@ fn changed_file_picker(cx: &mut Context) { } }, ) - .with_preview(|_editor, meta| Some((meta.path().to_path_buf().into(), None))); + .with_preview(|_editor, meta| Some((meta.path().into(), None))); let injector = picker.injector(); cx.editor @@ -3132,35 +3111,6 @@ fn changed_file_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -impl ui::menu::Item for MappableCommand { - type Data = ReverseKeymap; - - fn format(&self, keymap: &Self::Data) -> Row { - let fmt_binding = |bindings: &Vec>| -> String { - bindings.iter().fold(String::new(), |mut acc, bind| { - if !acc.is_empty() { - acc.push(' '); - } - for key in bind { - acc.push_str(&key.key_sequence_format()); - } - acc - }) - }; - - match self { - MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { - Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [:{}]", doc, name).into(), - }, - MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { - Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [{}]", doc, name).into(), - }, - } - } -} - pub fn command_palette(cx: &mut Context) { let register = cx.register; let count = cx.count; @@ -3171,16 +3121,45 @@ pub fn command_palette(cx: &mut Context) { [&cx.editor.mode] .reverse_map(); - let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); - commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { - MappableCommand::Typable { - name: cmd.name.to_owned(), - doc: cmd.doc.to_owned(), - args: Vec::new(), - } - })); + let commands = MappableCommand::STATIC_COMMAND_LIST.iter().cloned().chain( + typed::TYPABLE_COMMAND_LIST + .iter() + .map(|cmd| MappableCommand::Typable { + name: cmd.name.to_owned(), + args: Vec::new(), + doc: cmd.doc.to_owned(), + }), + ); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let columns = [ + ui::PickerColumn::new("name", |item, _| match item { + MappableCommand::Typable { name, .. } => format!(":{name}").into(), + MappableCommand::Static { name, .. } => (*name).into(), + }), + ui::PickerColumn::new( + "bindings", + |item: &MappableCommand, keymap: &crate::keymap::ReverseKeymap| { + keymap + .get(item.name()) + .map(|bindings| { + bindings.iter().fold(String::new(), |mut acc, bind| { + if !acc.is_empty() { + acc.push(' '); + } + for key in bind { + acc.push_str(&key.key_sequence_format()); + } + acc + }) + }) + .unwrap_or_default() + .into() + }, + ), + ui::PickerColumn::new("doc", |item: &MappableCommand, _| item.doc().into()), + ]; + + let picker = Picker::new(columns, 0, commands, keymap, move |cx, command, _action| { let mut ctx = Context { register, count, @@ -3571,6 +3550,8 @@ fn goto_first_diag(cx: &mut Context) { None => return, }; doc.set_selection(view.id, selection); + view.diagnostics_handler + .immediately_show_diagnostic(doc, view.id); } fn goto_last_diag(cx: &mut Context) { @@ -3580,6 +3561,8 @@ fn goto_last_diag(cx: &mut Context) { None => return, }; doc.set_selection(view.id, selection); + view.diagnostics_handler + .immediately_show_diagnostic(doc, view.id); } fn goto_next_diag(cx: &mut Context) { @@ -3602,6 +3585,8 @@ fn goto_next_diag(cx: &mut Context) { None => return, }; doc.set_selection(view.id, selection); + view.diagnostics_handler + .immediately_show_diagnostic(doc, view.id); }; cx.editor.apply_motion(motion); @@ -3630,6 +3615,8 @@ fn goto_prev_diag(cx: &mut Context) { None => return, }; doc.set_selection(view.id, selection); + view.diagnostics_handler + .immediately_show_diagnostic(doc, view.id); }; cx.editor.apply_motion(motion) } @@ -4523,7 +4510,11 @@ fn format_selections(cx: &mut Context) { .text_document_range_formatting( doc.identifier(), range, - lsp::FormattingOptions::default(), + lsp::FormattingOptions { + tab_size: doc.tab_width() as u32, + insert_spaces: matches!(doc.indent_style, IndentStyle::Spaces(_)), + ..Default::default() + }, None, ) .unwrap(); @@ -5754,13 +5745,20 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { let output = if let Some(output) = shell_output.as_ref() { output.clone() } else { - let fragment = range.slice(text); - match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { - Ok(result) => { + let input = range.slice(text); + match shell_impl(shell, cmd, pipe.then(|| input.into())) { + Ok(mut output) => { + if !input.ends_with("\n") && !output.is_empty() && output.ends_with('\n') { + output.pop(); + if output.ends_with('\r') { + output.pop(); + } + } + if !pipe { - shell_output = Some(result.clone()); + shell_output = Some(output.clone()); } - result + output } Err(err) => { cx.editor.set_error(err.to_string()); @@ -5828,7 +5826,10 @@ fn shell_prompt(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBeha fn suspend(_cx: &mut Context) { #[cfg(not(windows))] - signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); + { + _cx.block_try_flush_writes().ok(); + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); + } } fn add_newline_above(cx: &mut Context) { diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index eda94c44f..0b754bc21 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; -use tui::{text::Spans, widgets::Row}; +use tui::text::Spans; use std::collections::HashMap; use std::future::Future; @@ -22,38 +22,6 @@ use anyhow::{anyhow, bail}; use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id}; -impl ui::menu::Item for StackFrame { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - self.name.as_str().into() // TODO: include thread_states in the label - } -} - -impl ui::menu::Item for DebugTemplate { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - self.name.as_str().into() - } -} - -impl ui::menu::Item for Thread { - type Data = ThreadStates; - - fn format(&self, thread_states: &Self::Data) -> Row { - format!( - "{} ({})", - self.name, - thread_states - .get(&self.id) - .map(|state| state.as_str()) - .unwrap_or("unknown") - ) - .into() - } -} - fn thread_picker( cx: &mut Context, callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static, @@ -73,13 +41,27 @@ fn thread_picker( let debugger = debugger!(editor); let thread_states = debugger.thread_states.clone(); - let picker = Picker::new(threads, thread_states, move |cx, thread, _action| { - callback_fn(cx.editor, thread) - }) + let columns = [ + ui::PickerColumn::new("name", |item: &Thread, _| item.name.as_str().into()), + ui::PickerColumn::new("state", |item: &Thread, thread_states: &ThreadStates| { + thread_states + .get(&item.id) + .map(|state| state.as_str()) + .unwrap_or("unknown") + .into() + }), + ]; + let picker = Picker::new( + columns, + 0, + threads, + thread_states, + move |cx, thread, _action| callback_fn(cx.editor, thread), + ) .with_preview(move |editor, thread| { let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; let frame = frames.first()?; - let path = frame.source.as_ref()?.path.clone()?; + let path = frame.source.as_ref()?.path.as_ref()?.as_path(); let pos = Some(( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), @@ -172,9 +154,9 @@ pub fn dap_start_impl( let mut args: HashMap<&str, Value> = HashMap::new(); - if let Some(params) = params { - for (k, t) in &template.args { - let mut value = t.clone(); + for (k, t) in &template.args { + let mut value = t.clone(); + if let Some(ref params) = params { for (i, x) in params.iter().enumerate() { let mut param = x.to_string(); if let Some(DebugConfigCompletion::Advanced(cfg)) = template.completion.get(i) { @@ -198,22 +180,22 @@ pub fn dap_start_impl( DebugArgumentValue::Boolean(_) => value, }; } + } - match value { - DebugArgumentValue::String(string) => { - if let Ok(integer) = string.parse::() { - args.insert(k, to_value(integer).unwrap()); - } else { - args.insert(k, to_value(string).unwrap()); - } - } - DebugArgumentValue::Array(arr) => { - args.insert(k, to_value(arr).unwrap()); - } - DebugArgumentValue::Boolean(bool) => { - args.insert(k, to_value(bool).unwrap()); + match value { + DebugArgumentValue::String(string) => { + if let Ok(integer) = string.parse::() { + args.insert(k, to_value(integer).unwrap()); + } else { + args.insert(k, to_value(string).unwrap()); } } + DebugArgumentValue::Array(arr) => { + args.insert(k, to_value(arr).unwrap()); + } + DebugArgumentValue::Boolean(bool) => { + args.insert(k, to_value(bool).unwrap()); + } } } @@ -268,21 +250,34 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); + let columns = [ui::PickerColumn::new( + "template", + |item: &DebugTemplate, _| item.name.as_str().into(), + )]; + cx.push_layer(Box::new(overlaid(Picker::new( + columns, + 0, templates, (), |cx, template, _action| { - let completions = template.completion.clone(); - let name = template.name.clone(); - let callback = Box::pin(async move { - let call: Callback = - Callback::EditorCompositor(Box::new(move |_editor, compositor| { - let prompt = debug_parameter_prompt(completions, name, Vec::new()); - compositor.push(Box::new(prompt)); - })); - Ok(call) - }); - cx.jobs.callback(callback); + if template.completion.is_empty() { + if let Err(err) = dap_start_impl(cx, Some(&template.name), None, None) { + cx.editor.set_error(err.to_string()); + } + } else { + let completions = template.completion.clone(); + let name = template.name.clone(); + let callback = Box::pin(async move { + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, name, Vec::new()); + compositor.push(Box::new(prompt)); + })); + Ok(call) + }); + cx.jobs.callback(callback); + } }, )))); } @@ -730,7 +725,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let frames = debugger.stack_frames[&thread_id].clone(); - let picker = Picker::new(frames, (), move |cx, frame, _action| { + let columns = [ui::PickerColumn::new("frame", |item: &StackFrame, _| { + item.name.as_str().into() // TODO: include thread_states in the label + })]; + let picker = Picker::new(columns, 0, frames, (), move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find let pos = debugger.stack_frames[&thread_id] @@ -749,10 +747,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { frame .source .as_ref() - .and_then(|source| source.path.clone()) + .and_then(|source| source.path.as_ref()) .map(|path| { ( - path.into(), + path.as_path().into(), Some(( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index e7ba9f0fc..3b9efb431 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -9,14 +9,13 @@ use helix_lsp::{ Client, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; -use tui::{ - text::{Span, Spans}, - widgets::Row, -}; +use tui::{text::Span, widgets::Row}; use super::{align_view, push_jump, Align, Context, Editor}; -use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection}; +use helix_core::{ + syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri, +}; use helix_stdx::path; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId}, @@ -29,7 +28,7 @@ use helix_view::{ use crate::{ compositor::{self, Compositor}, job::Callback, - ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent}, + ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, }; use std::{ @@ -37,7 +36,7 @@ use std::{ collections::{BTreeMap, HashSet}, fmt::Write, future::Future, - path::{Path, PathBuf}, + path::Path, }; /// Gets the first language server that is attached to a document which supports a specific feature. @@ -62,67 +61,10 @@ macro_rules! language_server_with_feature { }}; } -impl ui::menu::Item for lsp::Location { - /// Current working directory. - type Data = PathBuf; - - fn format(&self, cwdir: &Self::Data) -> Row { - // The preallocation here will overallocate a few characters since it will account for the - // URL's scheme, which is not used most of the time since that scheme will be "file://". - // Those extra chars will be used to avoid allocating when writing the line number (in the - // common case where it has 5 digits or less, which should be enough for a cast majority - // of usages). - let mut res = String::with_capacity(self.uri.as_str().len()); - - if self.uri.scheme() == "file" { - // With the preallocation above and UTF-8 paths already, this closure will do one (1) - // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. - let mut write_path_to_res = || -> Option<()> { - let path = self.uri.to_file_path().ok()?; - res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy()); - Some(()) - }; - write_path_to_res(); - } else { - // Never allocates since we declared the string with this capacity already. - res.push_str(self.uri.as_str()); - } - - // Most commonly, this will not allocate, especially on Unix systems where the root prefix - // is a simple `/` and not `C:\` (with whatever drive letter) - write!(&mut res, ":{}", self.range.start.line + 1) - .expect("Will only failed if allocating fail"); - res.into() - } -} - struct SymbolInformationItem { symbol: lsp::SymbolInformation, offset_encoding: OffsetEncoding, -} - -impl ui::menu::Item for SymbolInformationItem { - /// Path to currently focussed document - type Data = Option; - - fn format(&self, current_doc_path: &Self::Data) -> Row { - if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { - self.symbol.name.as_str().into() - } else { - match self.symbol.location.uri.to_file_path() { - Ok(path) => { - let get_relative_path = path::get_relative_path(path.as_path()); - format!( - "{} ({})", - &self.symbol.name, - get_relative_path.to_string_lossy() - ) - .into() - } - Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(), - } - } - } + uri: Uri, } struct DiagnosticStyles { @@ -133,60 +75,15 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - path: PathBuf, + uri: Uri, diag: lsp::Diagnostic, offset_encoding: OffsetEncoding, } -impl ui::menu::Item for PickerDiagnostic { - type Data = (DiagnosticStyles, DiagnosticsFormat); - - fn format(&self, (styles, format): &Self::Data) -> Row { - let mut style = self - .diag - .severity - .map(|s| match s { - DiagnosticSeverity::HINT => styles.hint, - DiagnosticSeverity::INFORMATION => styles.info, - DiagnosticSeverity::WARNING => styles.warning, - DiagnosticSeverity::ERROR => styles.error, - _ => Style::default(), - }) - .unwrap_or_default(); - - // remove background as it is distracting in the picker list - style.bg = None; - - let code = match self.diag.code.as_ref() { - Some(NumberOrString::Number(n)) => format!(" ({n})"), - Some(NumberOrString::String(s)) => format!(" ({s})"), - None => String::new(), - }; - - let path = match format { - DiagnosticsFormat::HideSourcePath => String::new(), - DiagnosticsFormat::ShowSourcePath => { - let path = path::get_truncated_path(&self.path); - format!("{}: ", path.to_string_lossy()) - } - }; - - Spans::from(vec![ - Span::raw(path), - Span::styled(&self.diag.message, style), - Span::styled(code, style), - ]) - .into() - } -} - -fn location_to_file_location(location: &lsp::Location) -> FileLocation { - let path = location.uri.to_file_path().unwrap(); - let line = Some(( - location.range.start.line as usize, - location.range.end.line as usize, - )); - (path.into(), line) +fn uri_to_file_location<'a>(uri: &'a Uri, range: &lsp::Range) -> Option> { + let path = uri.as_path()?; + let line = Some((range.start.line as usize, range.end.line as usize)); + Some((path.into(), line)) } fn jump_to_location( @@ -241,20 +138,39 @@ fn jump_to_position( } } -type SymbolPicker = Picker; - -fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { - // TODO: drop current_path comparison and instead use workspace: bool flag? - Picker::new(symbols, current_path, move |cx, item, action| { - jump_to_location( - cx.editor, - &item.symbol.location, - item.offset_encoding, - action, - ); - }) - .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location))) - .truncate_start(false) +fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str { + match kind { + lsp::SymbolKind::FILE => "file", + lsp::SymbolKind::MODULE => "module", + lsp::SymbolKind::NAMESPACE => "namespace", + lsp::SymbolKind::PACKAGE => "package", + lsp::SymbolKind::CLASS => "class", + lsp::SymbolKind::METHOD => "method", + lsp::SymbolKind::PROPERTY => "property", + lsp::SymbolKind::FIELD => "field", + lsp::SymbolKind::CONSTRUCTOR => "construct", + lsp::SymbolKind::ENUM => "enum", + lsp::SymbolKind::INTERFACE => "interface", + lsp::SymbolKind::FUNCTION => "function", + lsp::SymbolKind::VARIABLE => "variable", + lsp::SymbolKind::CONSTANT => "constant", + lsp::SymbolKind::STRING => "string", + lsp::SymbolKind::NUMBER => "number", + lsp::SymbolKind::BOOLEAN => "boolean", + lsp::SymbolKind::ARRAY => "array", + lsp::SymbolKind::OBJECT => "object", + lsp::SymbolKind::KEY => "key", + lsp::SymbolKind::NULL => "null", + lsp::SymbolKind::ENUM_MEMBER => "enummem", + lsp::SymbolKind::STRUCT => "struct", + lsp::SymbolKind::EVENT => "event", + lsp::SymbolKind::OPERATOR => "operator", + lsp::SymbolKind::TYPE_PARAMETER => "typeparam", + _ => { + log::warn!("Unknown symbol kind: {:?}", kind); + "" + } + } } #[derive(Copy, Clone, PartialEq)] @@ -263,22 +179,24 @@ enum DiagnosticsFormat { HideSourcePath, } +type DiagnosticsPicker = Picker; + fn diag_picker( cx: &Context, - diagnostics: BTreeMap>, + diagnostics: BTreeMap>, format: DiagnosticsFormat, -) -> Picker { +) -> DiagnosticsPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? // flatten the map to a vec of (url, diag) pairs let mut flat_diag = Vec::new(); - for (path, diags) in diagnostics { + for (uri, diags) in diagnostics { flat_diag.reserve(diags.len()); for (diag, ls) in diags { if let Some(ls) = cx.editor.language_server_by_id(ls) { flat_diag.push(PickerDiagnostic { - path: path.clone(), + uri: uri.clone(), diag, offset_encoding: ls.offset_encoding(), }); @@ -293,22 +211,75 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; + let mut columns = vec![ + ui::PickerColumn::new( + "severity", + |item: &PickerDiagnostic, styles: &DiagnosticStyles| { + match item.diag.severity { + Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint), + Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info), + Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning), + Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error), + _ => Span::raw(""), + } + .into() + }, + ), + ui::PickerColumn::new("code", |item: &PickerDiagnostic, _| { + match item.diag.code.as_ref() { + Some(NumberOrString::Number(n)) => n.to_string().into(), + Some(NumberOrString::String(s)) => s.as_str().into(), + None => "".into(), + } + }), + ui::PickerColumn::new("message", |item: &PickerDiagnostic, _| { + item.diag.message.as_str().into() + }), + ]; + let mut primary_column = 2; // message + + if format == DiagnosticsFormat::ShowSourcePath { + columns.insert( + // between message code and message + 2, + ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| { + if let Some(path) = item.uri.as_path() { + path::get_truncated_path(path) + .to_string_lossy() + .to_string() + .into() + } else { + Default::default() + } + }), + ); + primary_column += 1; + } + Picker::new( + columns, + primary_column, flat_diag, - (styles, format), + styles, move |cx, PickerDiagnostic { - path, + uri, diag, offset_encoding, }, action| { - jump_to_position(cx.editor, path, diag.range, *offset_encoding, action) + let Some(path) = uri.as_path() else { + return; + }; + jump_to_position(cx.editor, path, diag.range, *offset_encoding, action); + let (view, doc) = current!(cx.editor); + view.diagnostics_handler + .immediately_show_diagnostic(doc, view.id); }, ) - .with_preview(move |_editor, PickerDiagnostic { path, diag, .. }| { + .with_preview(move |_editor, PickerDiagnostic { uri, diag, .. }| { let line = Some((diag.range.start.line as usize, diag.range.end.line as usize)); - Some((path.clone().into(), line)) + Some((uri.as_path()?.into(), line)) }) .truncate_start(false) } @@ -317,6 +288,7 @@ pub fn symbol_picker(cx: &mut Context) { fn nested_to_flat( list: &mut Vec, file: &lsp::TextDocumentIdentifier, + uri: &Uri, symbol: lsp::DocumentSymbol, offset_encoding: OffsetEncoding, ) { @@ -331,9 +303,10 @@ pub fn symbol_picker(cx: &mut Context) { container_name: None, }, offset_encoding, + uri: uri.clone(), }); for child in symbol.children.into_iter().flatten() { - nested_to_flat(list, file, child, offset_encoding); + nested_to_flat(list, file, uri, child, offset_encoding); } } let doc = doc!(cx.editor); @@ -347,6 +320,9 @@ pub fn symbol_picker(cx: &mut Context) { let request = language_server.document_symbols(doc.identifier()).unwrap(); let offset_encoding = language_server.offset_encoding(); let doc_id = doc.identifier(); + let doc_uri = doc + .uri() + .expect("docs with active language servers must be backed by paths"); async move { let json = request.await?; @@ -361,6 +337,7 @@ pub fn symbol_picker(cx: &mut Context) { lsp::DocumentSymbolResponse::Flat(symbols) => symbols .into_iter() .map(|symbol| SymbolInformationItem { + uri: doc_uri.clone(), symbol, offset_encoding, }) @@ -368,7 +345,13 @@ pub fn symbol_picker(cx: &mut Context) { lsp::DocumentSymbolResponse::Nested(symbols) => { let mut flat_symbols = Vec::new(); for symbol in symbols { - nested_to_flat(&mut flat_symbols, &doc_id, symbol, offset_encoding) + nested_to_flat( + &mut flat_symbols, + &doc_id, + &doc_uri, + symbol, + offset_encoding, + ) } flat_symbols } @@ -377,7 +360,6 @@ pub fn symbol_picker(cx: &mut Context) { } }) .collect(); - let current_url = doc.url(); if futures.is_empty() { cx.editor @@ -392,7 +374,37 @@ pub fn symbol_picker(cx: &mut Context) { symbols.append(&mut lsp_items); } let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); + let columns = [ + ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { + display_symbol_kind(item.symbol.kind).into() + }), + // Some symbols in the document symbol picker may have a URI that isn't + // the current file. It should be rare though, so we concatenate that + // URI in with the symbol name in this picker. + ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { + item.symbol.name.as_str().into() + }), + ]; + + let picker = Picker::new( + columns, + 1, // name column + symbols, + (), + move |cx, item, action| { + jump_to_location( + cx.editor, + &item.symbol.location, + item.offset_encoding, + action, + ); + }, + ) + .with_preview(move |_editor, item| { + uri_to_file_location(&item.uri, &item.symbol.location.range) + }) + .truncate_start(false); + compositor.push(Box::new(overlaid(picker))) }; @@ -401,6 +413,8 @@ pub fn symbol_picker(cx: &mut Context) { } pub fn workspace_symbol_picker(cx: &mut Context) { + use crate::ui::picker::Injector; + let doc = doc!(cx.editor); if doc .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) @@ -412,25 +426,37 @@ pub fn workspace_symbol_picker(cx: &mut Context) { return; } - let get_symbols = move |pattern: String, editor: &mut Editor| { + let get_symbols = |pattern: &str, editor: &mut Editor, _data, injector: &Injector<_, _>| { let doc = doc!(editor); let mut seen_language_servers = HashSet::new(); let mut futures: FuturesOrdered<_> = doc .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) .filter(|ls| seen_language_servers.insert(ls.id())) .map(|language_server| { - let request = language_server.workspace_symbols(pattern.clone()).unwrap(); + let request = language_server + .workspace_symbols(pattern.to_string()) + .unwrap(); let offset_encoding = language_server.offset_encoding(); async move { let json = request.await?; - let response = + let response: Vec<_> = serde_json::from_value::>>(json)? .unwrap_or_default() .into_iter() - .map(|symbol| SymbolInformationItem { - symbol, - offset_encoding, + .filter_map(|symbol| { + let uri = match Uri::try_from(&symbol.location.uri) { + Ok(uri) => uri, + Err(err) => { + log::warn!("discarding symbol with invalid URI: {err}"); + return None; + } + }; + Some(SymbolInformationItem { + symbol, + uri, + offset_encoding, + }) }) .collect(); @@ -443,44 +469,66 @@ pub fn workspace_symbol_picker(cx: &mut Context) { editor.set_error("No configured language server supports workspace symbols"); } + let injector = injector.clone(); async move { - let mut symbols = Vec::new(); // TODO if one symbol request errors, all other requests are discarded (even if they're valid) - while let Some(mut lsp_items) = futures.try_next().await? { - symbols.append(&mut lsp_items); + while let Some(lsp_items) = futures.try_next().await? { + for item in lsp_items { + injector.push(item)?; + } } - anyhow::Ok(symbols) + Ok(()) } .boxed() }; + let columns = [ + ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { + display_symbol_kind(item.symbol.kind).into() + }), + ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { + item.symbol.name.as_str().into() + }) + .without_filtering(), + ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| { + if let Some(path) = item.uri.as_path() { + path::get_relative_path(path) + .to_string_lossy() + .to_string() + .into() + } else { + item.symbol.location.uri.to_string().into() + } + }), + ]; + + let picker = Picker::new( + columns, + 1, // name column + [], + (), + move |cx, item, action| { + jump_to_location( + cx.editor, + &item.symbol.location, + item.offset_encoding, + action, + ); + }, + ) + .with_preview(|_editor, item| uri_to_file_location(&item.uri, &item.symbol.location.range)) + .with_dynamic_query(get_symbols, None) + .truncate_start(false); - let current_url = doc.url(); - let initial_symbols = get_symbols("".to_owned(), cx.editor); - - cx.jobs.callback(async move { - let symbols = initial_symbols.await?; - let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); - let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); - compositor.push(Box::new(overlaid(dyn_picker))) - }; - - Ok(Callback::EditorCompositor(Box::new(call))) - }); + cx.push_layer(Box::new(overlaid(picker))); } pub fn diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - if let Some(current_path) = doc.path() { - let diagnostics = cx - .editor - .diagnostics - .get(current_path) - .cloned() - .unwrap_or_default(); + if let Some(uri) = doc.uri() { + let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default(); let picker = diag_picker( cx, - [(current_path.clone(), diagnostics)].into(), + [(uri, diagnostics)].into(), DiagnosticsFormat::HideSourcePath, ); cx.push_layer(Box::new(overlaid(picker))); @@ -741,13 +789,6 @@ pub fn code_action(cx: &mut Context) { }); } -impl ui::menu::Item for lsp::Command { - type Data = (); - fn format(&self, _data: &Self::Data) -> Row { - self.title.as_str().into() - } -} - pub fn execute_lsp_command( editor: &mut Editor, language_server_id: LanguageServerId, @@ -817,10 +858,67 @@ fn goto_impl( } [] => unreachable!("`locations` should be non-empty for `goto_impl`"), _locations => { - let picker = Picker::new(locations, cwdir, move |cx, location, action| { + let columns = [ui::PickerColumn::new( + "location", + |item: &lsp::Location, cwdir: &std::path::PathBuf| { + // The preallocation here will overallocate a few characters since it will account for the + // URL's scheme, which is not used most of the time since that scheme will be "file://". + // Those extra chars will be used to avoid allocating when writing the line number (in the + // common case where it has 5 digits or less, which should be enough for a cast majority + // of usages). + let mut res = String::with_capacity(item.uri.as_str().len()); + + if item.uri.scheme() == "file" { + // With the preallocation above and UTF-8 paths already, this closure will do one (1) + // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. + if let Ok(path) = item.uri.to_file_path() { + // We don't convert to a `helix_core::Uri` here because we've already checked the scheme. + // This path won't be normalized but it's only used for display. + res.push_str( + &path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy(), + ); + } + } else { + // Never allocates since we declared the string with this capacity already. + res.push_str(item.uri.as_str()); + } + + // Most commonly, this will not allocate, especially on Unix systems where the root prefix + // is a simple `/` and not `C:\` (with whatever drive letter) + write!(&mut res, ":{}", item.range.start.line + 1) + .expect("Will only failed if allocating fail"); + res.into() + }, + )]; + + let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }) - .with_preview(move |_editor, location| Some(location_to_file_location(location))); + .with_preview(move |_editor, location| { + use crate::ui::picker::PathOrId; + + let lines = Some(( + location.range.start.line as usize, + location.range.end.line as usize, + )); + + // TODO: we should avoid allocating by doing the Uri conversion ahead of time. + // + // To do this, introduce a `Location` type in `helix-core` that reuses the core + // `Uri` type instead of the LSP `Url` type and replaces the LSP `Range` type. + // Refactor the callers of `goto_impl` to pass iterators that translate the + // LSP location type to the custom one in core, or have them collect and pass + // `Vec`s. Replace the `uri_to_file_location` function with + // `location_to_file_location` that takes only `&helix_core::Location` as + // parameters. + // + // By doing this we can also eliminate the duplicated URI info in the + // `SymbolInformationItem` type and introduce a custom Symbol type in `helix-core` + // which will be reused in the future for tree-sitter based symbol pickers. + let path = Uri::try_from(&location.uri).ok()?.as_path_buf()?; + #[allow(deprecated)] + Some((PathOrId::from_path_buf(path), lines)) + }); compositor.push(Box::new(overlaid(picker))); } } @@ -1029,11 +1127,12 @@ pub fn rename_symbol(cx: &mut Context) { fn create_rename_prompt( editor: &Editor, prefill: String, + history_register: Option, language_server_id: Option, ) -> Box { let prompt = ui::Prompt::new( "rename-to:".into(), - None, + history_register, ui::completers::none, move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { if event != PromptEvent::Validate { @@ -1070,6 +1169,7 @@ pub fn rename_symbol(cx: &mut Context) { } let (view, doc) = current_ref!(cx.editor); + let history_register = cx.register; if doc .language_servers_with_feature(LanguageServerFeature::RenameSymbol) @@ -1112,14 +1212,14 @@ pub fn rename_symbol(cx: &mut Context) { } }; - let prompt = create_rename_prompt(editor, prefill, Some(ls_id)); + let prompt = create_rename_prompt(editor, prefill, history_register, Some(ls_id)); compositor.push(prompt); }, ); } else { let prefill = get_prefill_from_word_boundary(cx.editor); - let prompt = create_rename_prompt(cx.editor, prefill, None); + let prompt = create_rename_prompt(cx.editor, prefill, history_register, None); cx.push_layer(prompt); } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index f38ae6bba..88ee22527 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -164,9 +164,10 @@ fn buffer_close_by_ids_impl( cx.editor.switch(*first, Action::Replace); } bail!( - "{} unsaved buffer(s) remaining: {:?}", + "{} unsaved buffer{} remaining: {:?}", modified_names.len(), - modified_names + if modified_names.len() == 1 { "" } else { "s" }, + modified_names, ); } @@ -338,9 +339,12 @@ fn write_impl( let path = path.map(AsRef::as_ref); if config.insert_final_newline { - insert_final_newline(doc, view); + insert_final_newline(doc, view.id); } + // Save an undo checkpoint for any outstanding changes. + doc.append_changes_to_history(view); + let fmt = if config.auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -365,13 +369,12 @@ fn write_impl( Ok(()) } -fn insert_final_newline(doc: &mut Document, view: &mut View) { +fn insert_final_newline(doc: &mut Document, view_id: ViewId) { let text = doc.text(); if line_ending::get_line_ending(&text.slice(..)).is_none() { let eof = Selection::point(text.len_chars()); let insert = Transaction::insert(text, &eof, doc.line_ending.as_str().into()); - doc.apply(&insert, view.id); - doc.append_changes_to_history(view); + doc.apply(&insert, view_id); } } @@ -658,9 +661,10 @@ pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> editor.switch(*first, Action::Replace); } bail!( - "{} unsaved buffer(s) remaining: {:?}", + "{} unsaved buffer{} remaining: {:?}", modified_names.len(), - modified_names + if modified_names.len() == 1 { "" } else { "s" }, + modified_names, ); } Ok(()) @@ -701,11 +705,15 @@ pub fn write_all_impl( for (doc_id, target_view) in saves { let doc = doc_mut!(cx.editor, &doc_id); + let view = view_mut!(cx.editor, target_view); if config.insert_final_newline { - insert_final_newline(doc, view_mut!(cx.editor, target_view)); + insert_final_newline(doc, target_view); } + // Save an undo checkpoint for any outstanding changes. + doc.append_changes_to_history(view); + let fmt = if config.auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -1368,37 +1376,49 @@ fn lsp_workspace_command( if event != PromptEvent::Validate { return Ok(()); } + let doc = doc!(cx.editor); - let Some((language_server_id, options)) = doc + let ls_id_commands = doc .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) - .find_map(|ls| { + .flat_map(|ls| { ls.capabilities() .execute_command_provider - .as_ref() - .map(|options| (ls.id(), options)) - }) - else { - cx.editor - .set_status("No active language servers for this document support workspace commands"); - return Ok(()); - }; + .iter() + .flat_map(|options| options.commands.iter()) + .map(|command| (ls.id(), command)) + }); if args.is_empty() { - let commands = options - .commands - .iter() - .map(|command| helix_lsp::lsp::Command { - title: command.clone(), - command: command.clone(), - arguments: None, + let commands = ls_id_commands + .map(|(ls_id, command)| { + ( + ls_id, + helix_lsp::lsp::Command { + title: command.clone(), + command: command.clone(), + arguments: None, + }, + ) }) .collect::>(); let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), move |cx, command, _action| { - execute_lsp_command(cx.editor, language_server_id, command.clone()); - }); + let columns = [ui::PickerColumn::new( + "title", + |(_ls_id, command): &(_, helix_lsp::lsp::Command), _| { + command.title.as_str().into() + }, + )]; + let picker = ui::Picker::new( + columns, + 0, + commands, + (), + move |cx, (ls_id, command), _action| { + execute_lsp_command(cx.editor, *ls_id, command.clone()); + }, + ); compositor.push(Box::new(overlaid(picker))) }, )); @@ -1407,21 +1427,32 @@ fn lsp_workspace_command( cx.jobs.callback(callback); } else { let command = args.join(" "); - if options.commands.iter().any(|c| c == &command) { - execute_lsp_command( - cx.editor, - language_server_id, - helix_lsp::lsp::Command { - title: command.clone(), - arguments: None, - command, - }, - ); - } else { - cx.editor.set_status(format!( - "`{command}` is not supported for this language server" - )); - return Ok(()); + let matches: Vec<_> = ls_id_commands + .filter(|(_ls_id, c)| *c == &command) + .collect(); + + match matches.as_slice() { + [(ls_id, _command)] => { + execute_lsp_command( + cx.editor, + *ls_id, + helix_lsp::lsp::Command { + title: command.clone(), + arguments: None, + command, + }, + ); + } + [] => { + cx.editor.set_status(format!( + "`{command}` is not supported for any language server" + )); + } + _ => { + cx.editor.set_status(format!( + "`{command}` supported by multiple language servers" + )); + } } } Ok(()) @@ -1495,6 +1526,8 @@ fn lsp_stop( for doc in cx.editor.documents_mut() { if let Some(client) = doc.remove_language_server_by_name(ls_name) { doc.clear_diagnostics(Some(client.id())); + doc.reset_all_inlay_hints(); + doc.inlay_hints_oudated = true; } } } @@ -2305,37 +2338,36 @@ fn reset_diff_change( let diff = handle.load(); let doc_text = doc.text().slice(..); - let line = doc.selection(view.id).primary().cursor_line(doc_text); - - let Some(hunk_idx) = diff.hunk_at(line as u32, true) else { - bail!("There is no change at the cursor") - }; - let hunk = diff.nth_hunk(hunk_idx); let diff_base = diff.diff_base(); - let before_start = diff_base.line_to_char(hunk.before.start as usize); - let before_end = diff_base.line_to_char(hunk.before.end as usize); - let text: Tendril = diff - .diff_base() - .slice(before_start..before_end) - .chunks() - .collect(); - let anchor = doc_text.line_to_char(hunk.after.start as usize); + let mut changes = 0; + let transaction = Transaction::change( doc.text(), - [( - anchor, - doc_text.line_to_char(hunk.after.end as usize), - (!text.is_empty()).then_some(text), - )] - .into_iter(), + diff.hunks_intersecting_line_ranges(doc.selection(view.id).line_ranges(doc_text)) + .map(|hunk| { + changes += 1; + let start = diff_base.line_to_char(hunk.before.start as usize); + let end = diff_base.line_to_char(hunk.before.end as usize); + let text: Tendril = diff_base.slice(start..end).chunks().collect(); + ( + doc_text.line_to_char(hunk.after.start as usize), + doc_text.line_to_char(hunk.after.end as usize), + (!text.is_empty()).then_some(text), + ) + }), ); + if changes == 0 { + bail!("There are no changes under any selection"); + } + drop(diff); // make borrow check happy doc.apply(&transaction, view.id); - // select inserted text - let text_len = before_end - before_start; - doc.set_selection(view.id, Selection::single(anchor, anchor + text_len)); doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); + cx.editor.set_status(format!( + "Reset {changes} change{}", + if changes == 1 { "" } else { "s" } + )); Ok(()) } @@ -2505,7 +2537,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ }, TypableCommand { name: "open", - aliases: &["o"], + aliases: &["o", "edit", "e"], doc: "Open a file from disk into the current view.", fun: open, signature: CommandSignature::all(completers::filename), diff --git a/helix-term/src/events.rs b/helix-term/src/events.rs index 49b44f775..415213c22 100644 --- a/helix-term/src/events.rs +++ b/helix-term/src/events.rs @@ -1,6 +1,6 @@ use helix_event::{events, register_event}; use helix_view::document::Mode; -use helix_view::events::{DocumentDidChange, SelectionDidChange}; +use helix_view::events::{DiagnosticsDidChange, DocumentDidChange, SelectionDidChange}; use crate::commands; use crate::keymap::MappableCommand; @@ -17,4 +17,5 @@ pub fn register() { register_event::(); register_event::(); register_event::(); + register_event::(); } diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index d45809d36..b27e34e29 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -5,13 +5,16 @@ use helix_event::AsyncHook; use crate::config::Config; use crate::events; +use crate::handlers::auto_save::AutoSaveHandler; use crate::handlers::completion::CompletionHandler; use crate::handlers::signature_help::SignatureHelpHandler; pub use completion::trigger_auto_completion; pub use helix_view::handlers::Handlers; +mod auto_save; pub mod completion; +mod diagnostics; mod signature_help; pub fn setup(config: Arc>) -> Handlers { @@ -19,11 +22,17 @@ pub fn setup(config: Arc>) -> Handlers { let completions = CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); + let auto_save = AutoSaveHandler::new().spawn(); + let handlers = Handlers { completions, signature_hints, + auto_save, }; + completion::register_hooks(&handlers); signature_help::register_hooks(&handlers); + auto_save::register_hooks(&handlers); + diagnostics::register_hooks(&handlers); handlers } diff --git a/helix-term/src/handlers/auto_save.rs b/helix-term/src/handlers/auto_save.rs new file mode 100644 index 000000000..4e154df80 --- /dev/null +++ b/helix-term/src/handlers/auto_save.rs @@ -0,0 +1,117 @@ +use std::{ + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, + time::Duration, +}; + +use anyhow::Ok; +use arc_swap::access::Access; + +use helix_event::{register_hook, send_blocking}; +use helix_view::{ + document::Mode, + events::DocumentDidChange, + handlers::{AutoSaveEvent, Handlers}, + Editor, +}; +use tokio::time::Instant; + +use crate::{ + commands, compositor, + events::OnModeSwitch, + job::{self, Jobs}, +}; + +#[derive(Debug)] +pub(super) struct AutoSaveHandler { + save_pending: Arc, +} + +impl AutoSaveHandler { + pub fn new() -> AutoSaveHandler { + AutoSaveHandler { + save_pending: Default::default(), + } + } +} + +impl helix_event::AsyncHook for AutoSaveHandler { + type Event = AutoSaveEvent; + + fn handle_event( + &mut self, + event: Self::Event, + existing_debounce: Option, + ) -> Option { + match event { + Self::Event::DocumentChanged { save_after } => { + Some(Instant::now() + Duration::from_millis(save_after)) + } + Self::Event::LeftInsertMode => { + if existing_debounce.is_some() { + // If the change happened more recently than the debounce, let the + // debounce run down before saving. + existing_debounce + } else { + // Otherwise if there is a save pending, save immediately. + if self.save_pending.load(atomic::Ordering::Relaxed) { + self.finish_debounce(); + } + None + } + } + } + } + + fn finish_debounce(&mut self) { + let save_pending = self.save_pending.clone(); + job::dispatch_blocking(move |editor, _| { + if editor.mode() == Mode::Insert { + // Avoid saving while in insert mode since this mixes up + // the modification indicator and prevents future saves. + save_pending.store(true, atomic::Ordering::Relaxed); + } else { + request_auto_save(editor); + save_pending.store(false, atomic::Ordering::Relaxed); + } + }) + } +} + +fn request_auto_save(editor: &mut Editor) { + let context = &mut compositor::Context { + editor, + scroll: Some(0), + jobs: &mut Jobs::new(), + }; + + if let Err(e) = commands::typed::write_all_impl(context, false, false) { + context.editor.set_error(format!("{}", e)); + } +} + +pub(super) fn register_hooks(handlers: &Handlers) { + let tx = handlers.auto_save.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + let config = event.doc.config.load(); + if config.auto_save.after_delay.enable { + send_blocking( + &tx, + AutoSaveEvent::DocumentChanged { + save_after: config.auto_save.after_delay.timeout, + }, + ); + } + Ok(()) + }); + + let tx = handlers.auto_save.clone(); + register_hook!(move |event: &mut OnModeSwitch<'_, '_>| { + if event.old_mode == Mode::Insert { + send_blocking(&tx, AutoSaveEvent::LeftInsertMode) + } + Ok(()) + }); +} diff --git a/helix-term/src/handlers/completion/resolve.rs b/helix-term/src/handlers/completion/resolve.rs index fb5179e13..0b2c90672 100644 --- a/helix-term/src/handlers/completion/resolve.rs +++ b/helix-term/src/handlers/completion/resolve.rs @@ -42,10 +42,30 @@ impl ResolveHandler { if item.resolved { return; } - let needs_resolve = item.item.documentation.is_none() - || item.item.detail.is_none() - || item.item.additional_text_edits.is_none(); - if !needs_resolve { + // We consider an item to be fully resolved if it has non-empty, none-`None` details, + // docs and additional text-edits. Ideally we could use `is_some` instead of this + // check but some language servers send values like `Some([])` for additional text + // edits although the items need to be resolved. This is probably a consequence of + // how `null` works in the JavaScript world. + let is_resolved = item + .item + .documentation + .as_ref() + .is_some_and(|docs| match docs { + lsp::Documentation::String(text) => !text.is_empty(), + lsp::Documentation::MarkupContent(markup) => !markup.value.is_empty(), + }) + && item + .item + .detail + .as_ref() + .is_some_and(|detail| !detail.is_empty()) + && item + .item + .additional_text_edits + .as_ref() + .is_some_and(|edits| !edits.is_empty()); + if is_resolved { item.resolved = true; return; } @@ -111,7 +131,9 @@ impl AsyncHook for ResolveTimeout { } fn finish_debounce(&mut self) { - let Some(request) = self.next_request.take() else { return }; + let Some(request) = self.next_request.take() else { + return; + }; let (tx, rx) = helix_event::cancelation(); self.in_flight = Some((tx, request.item.clone())); tokio::spawn(request.execute(rx)); diff --git a/helix-term/src/handlers/diagnostics.rs b/helix-term/src/handlers/diagnostics.rs new file mode 100644 index 000000000..3e44d416d --- /dev/null +++ b/helix-term/src/handlers/diagnostics.rs @@ -0,0 +1,24 @@ +use helix_event::{register_hook, send_blocking}; +use helix_view::document::Mode; +use helix_view::events::DiagnosticsDidChange; +use helix_view::handlers::diagnostics::DiagnosticEvent; +use helix_view::handlers::Handlers; + +use crate::events::OnModeSwitch; + +pub(super) fn register_hooks(_handlers: &Handlers) { + register_hook!(move |event: &mut DiagnosticsDidChange<'_>| { + if event.editor.mode != Mode::Insert { + for (view, _) in event.editor.tree.views_mut() { + send_blocking(&view.diagnostics_handler.events, DiagnosticEvent::Refresh) + } + } + Ok(()) + }); + register_hook!(move |event: &mut OnModeSwitch<'_, '_>| { + for (view, _) in event.cx.editor.tree.views_mut() { + view.diagnostics_handler.active = event.new_mode != Mode::Insert; + } + Ok(()) + }); +} diff --git a/helix-term/src/handlers/signature_help.rs b/helix-term/src/handlers/signature_help.rs index 0bb1d3d16..aaa97b9a0 100644 --- a/helix-term/src/handlers/signature_help.rs +++ b/helix-term/src/handlers/signature_help.rs @@ -119,8 +119,7 @@ pub fn request_signature_help( // Do not show the message if signature help was invoked // automatically on backspace, trigger characters, etc. if invoked == SignatureHelpInvoked::Manual { - editor - .set_error("No configured language server supports signature-help"); + editor.set_error("No configured language server supports signature-help"); } return; }; @@ -238,19 +237,33 @@ pub fn show_signature_help( .collect(); let old_popup = compositor.find_id::>(SignatureHelp::ID); - let mut active_signature = old_popup - .as_ref() - .map(|popup| popup.contents().active_signature()) - .unwrap_or_else(|| response.active_signature.unwrap_or_default() as usize); + let lsp_signature = response.active_signature.map(|s| s as usize); - if active_signature >= signatures.len() { - active_signature = signatures.len() - 1; - } + // take the new suggested lsp signature if changed + // otherwise take the old signature if possible + // otherwise the last one (in case there is less signatures than before) + let active_signature = old_popup + .as_ref() + .map(|popup| { + let old_lsp_sig = popup.contents().lsp_signature(); + let old_sig = popup + .contents() + .active_signature() + .min(signatures.len() - 1); + + if old_lsp_sig != lsp_signature { + lsp_signature.unwrap_or(old_sig) + } else { + old_sig + } + }) + .unwrap_or(lsp_signature.unwrap_or_default()); let contents = SignatureHelp::new( language.to_string(), Arc::clone(&editor.syn_loader), active_signature, + lsp_signature, signatures, ); @@ -286,11 +299,11 @@ fn signature_help_post_insert_char_hook( let (view, doc) = current!(cx.editor); // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow let Some(language_server) = doc - .language_servers_with_feature(LanguageServerFeature::SignatureHelp) - .next() - else { - return Ok(()); - }; + .language_servers_with_feature(LanguageServerFeature::SignatureHelp) + .next() + else { + return Ok(()); + }; let capabilities = language_server.capabilities(); diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index f793dd484..14397bb5c 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -448,14 +448,12 @@ impl Component for Completion { // --- // option.documentation - let (view, doc) = current!(cx.editor); - let language = doc.language_name().unwrap_or(""); - let text = doc.text().slice(..); - let cursor_pos = doc.selection(view.id).primary().cursor(text); - let coords = view - .screen_coords_at_pos(doc, text, cursor_pos) - .expect("cursor must be in view"); + let Some(coords) = cx.editor.cursor().0 else { + return; + }; let cursor_pos = coords.row as u16; + let doc = doc!(cx.editor); + let language = doc.language_name().unwrap_or(""); let markdowned = |lang: &str, detail: Option<&str>, doc: Option<&str>| { let md = match (detail, doc) { @@ -532,8 +530,8 @@ impl Component for Completion { surface.clear_with(doc_area, background); if cx.editor.popup_border() { - use tui::widgets::{Block, Borders, Widget}; - Widget::render(Block::default().borders(Borders::ALL), doc_area, surface); + use tui::widgets::{Block, Widget}; + Widget::render(Block::bordered(), doc_area, surface); } markdown_doc.render(doc_area, surface, cx); diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index bcbaa3519..79145ba04 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -12,26 +12,10 @@ use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; use helix_view::graphics::Rect; use helix_view::theme::Style; use helix_view::view::ViewPosition; -use helix_view::Document; -use helix_view::Theme; +use helix_view::{Document, Theme}; use tui::buffer::Buffer as Surface; -pub trait LineDecoration { - fn render_background(&mut self, _renderer: &mut TextRenderer, _pos: LinePos) {} - fn render_foreground( - &mut self, - _renderer: &mut TextRenderer, - _pos: LinePos, - _end_char_idx: usize, - ) { - } -} - -impl LineDecoration for F { - fn render_background(&mut self, renderer: &mut TextRenderer, pos: LinePos) { - self(renderer, pos) - } -} +use crate::ui::text_decorations::DecorationManager; #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum StyleIterKind { @@ -68,10 +52,7 @@ impl> Iterator for StyleIter<'_, H> { HighlightEvent::HighlightEnd => { self.active_highlights.pop(); } - HighlightEvent::Source { start, mut end } => { - if start == end { - continue; - } + HighlightEvent::Source { mut end, .. } => { let style = self .active_highlights .iter() @@ -98,15 +79,8 @@ pub struct LinePos { pub doc_line: usize, /// Vertical offset from the top of the inner view area pub visual_line: u16, - /// The first char index of this visual line. - /// Note that if the visual line is entirely filled by - /// a very long inline virtual text then this index will point - /// at the next (non-virtual) char after this visual line - pub start_char_idx: usize, } -pub type TranslatedPosition<'a> = (usize, Box); - #[allow(clippy::too_many_arguments)] pub fn render_document( surface: &mut Surface, @@ -117,87 +91,46 @@ pub fn render_document( syntax_highlight_iter: impl Iterator, overlay_highlight_iter: impl Iterator, theme: &Theme, - line_decoration: &mut [Box], - translated_positions: &mut [TranslatedPosition], + decorations: DecorationManager, ) { - let mut renderer = TextRenderer::new(surface, doc, theme, offset.horizontal_offset, viewport); + let mut renderer = TextRenderer::new( + surface, + doc, + theme, + Position::new(offset.vertical_offset, offset.horizontal_offset), + viewport, + ); render_text( &mut renderer, doc.text().slice(..), - offset, + offset.anchor, &doc.text_format(viewport.width, Some(theme)), doc_annotations, syntax_highlight_iter, overlay_highlight_iter, theme, - line_decoration, - translated_positions, + decorations, ) } -fn translate_positions( - char_pos: usize, - first_visible_char_idx: usize, - translated_positions: &mut [TranslatedPosition], - text_fmt: &TextFormat, - renderer: &mut TextRenderer, - pos: Position, -) { - // check if any positions translated on the fly (like cursor) has been reached - for (char_idx, callback) in &mut *translated_positions { - if *char_idx < char_pos && *char_idx >= first_visible_char_idx { - // by replacing the char_index with usize::MAX large number we ensure - // that the same position is only translated once - // text will never reach usize::MAX as rust memory allocations are limited - // to isize::MAX - *char_idx = usize::MAX; - - if text_fmt.soft_wrap { - callback(renderer, pos) - } else if pos.col >= renderer.col_offset - && pos.col - renderer.col_offset < renderer.viewport.width as usize - { - callback( - renderer, - Position { - row: pos.row, - col: pos.col - renderer.col_offset, - }, - ) - } - } - } -} - #[allow(clippy::too_many_arguments)] -pub fn render_text<'t>( +pub fn render_text( renderer: &mut TextRenderer, - text: RopeSlice<'t>, - offset: ViewPosition, + text: RopeSlice<'_>, + anchor: usize, text_fmt: &TextFormat, text_annotations: &TextAnnotations, syntax_highlight_iter: impl Iterator, overlay_highlight_iter: impl Iterator, theme: &Theme, - line_decorations: &mut [Box], - translated_positions: &mut [TranslatedPosition], + mut decorations: DecorationManager, ) { - let ( - Position { - row: mut row_off, .. - }, - mut char_pos, - ) = visual_offset_from_block( - text, - offset.anchor, - offset.anchor, - text_fmt, - text_annotations, - ); - row_off += offset.vertical_offset; + let row_off = visual_offset_from_block(text, anchor, anchor, text_fmt, text_annotations) + .0 + .row; - let (mut formatter, mut first_visible_char_idx) = - DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor); + let mut formatter = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, anchor); let mut syntax_styles = StyleIter { text_style: renderer.text_style, active_highlights: Vec::with_capacity(64), @@ -219,8 +152,8 @@ pub fn render_text<'t>( first_visual_line: false, doc_line: usize::MAX, visual_line: u16::MAX, - start_char_idx: usize::MAX, }; + let mut last_line_end = 0; let mut is_in_indent_area = true; let mut last_line_indent_level = 0; let mut syntax_style_span = syntax_styles @@ -229,133 +162,96 @@ pub fn render_text<'t>( let mut overlay_style_span = overlay_styles .next() .unwrap_or_else(|| (Style::default(), usize::MAX)); + let mut reached_view_top = false; loop { - // formattter.line_pos returns to line index of the next grapheme - // so it must be called before formatter.next - let doc_line = formatter.line_pos(); - let Some((grapheme, mut pos)) = formatter.next() else { - let mut last_pos = formatter.visual_pos(); - if last_pos.row >= row_off { - last_pos.col -= 1; - last_pos.row -= row_off; - // check if any positions translated on the fly (like cursor) are at the EOF - translate_positions( - char_pos + 1, - first_visible_char_idx, - translated_positions, - text_fmt, - renderer, - last_pos, - ); - } + let Some(mut grapheme) = formatter.next() else { break; }; // skip any graphemes on visual lines before the block start - if pos.row < row_off { - if char_pos >= syntax_style_span.1 { - syntax_style_span = if let Some(syntax_style_span) = syntax_styles.next() { - syntax_style_span - } else { - break; - } - } - if char_pos >= overlay_style_span.1 { - overlay_style_span = if let Some(overlay_style_span) = overlay_styles.next() { - overlay_style_span - } else { - break; - } - } - char_pos += grapheme.doc_chars(); - first_visible_char_idx = char_pos + 1; + if grapheme.visual_pos.row < row_off { continue; } - pos.row -= row_off; + grapheme.visual_pos.row -= row_off; + if !reached_view_top { + decorations.prepare_for_rendering(grapheme.char_idx); + reached_view_top = true; + } // if the end of the viewport is reached stop rendering - if pos.row as u16 >= renderer.viewport.height { + if grapheme.visual_pos.row as u16 >= renderer.viewport.height + renderer.offset.row as u16 { break; } // apply decorations before rendering a new line - if pos.row as u16 != last_line_pos.visual_line { - if pos.row > 0 { + if grapheme.visual_pos.row as u16 != last_line_pos.visual_line { + // we initiate doc_line with usize::MAX because no file + // can reach that size (memory allocations are limited to isize::MAX) + // initially there is no "previous" line (so doc_line is set to usize::MAX) + // in that case we don't need to draw indent guides/virtual text + if last_line_pos.doc_line != usize::MAX { + // draw indent guides for the last line renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); is_in_indent_area = true; - for line_decoration in &mut *line_decorations { - line_decoration.render_foreground(renderer, last_line_pos, char_pos); - } + decorations.render_virtual_lines(renderer, last_line_pos, last_line_end) } last_line_pos = LinePos { - first_visual_line: doc_line != last_line_pos.doc_line, - doc_line, - visual_line: pos.row as u16, - start_char_idx: char_pos, + first_visual_line: grapheme.line_idx != last_line_pos.doc_line, + doc_line: grapheme.line_idx, + visual_line: grapheme.visual_pos.row as u16, }; - for line_decoration in &mut *line_decorations { - line_decoration.render_background(renderer, last_line_pos); - } + decorations.decorate_line(renderer, last_line_pos); } // acquire the correct grapheme style - if char_pos >= syntax_style_span.1 { + while grapheme.char_idx >= syntax_style_span.1 { syntax_style_span = syntax_styles .next() .unwrap_or((Style::default(), usize::MAX)); } - if char_pos >= overlay_style_span.1 { + while grapheme.char_idx >= overlay_style_span.1 { overlay_style_span = overlay_styles .next() .unwrap_or((Style::default(), usize::MAX)); } - char_pos += grapheme.doc_chars(); - - // check if any positions translated on the fly (like cursor) has been reached - translate_positions( - char_pos, - first_visible_char_idx, - translated_positions, - text_fmt, - renderer, - pos, - ); - - let (syntax_style, overlay_style) = - if let GraphemeSource::VirtualText { highlight } = grapheme.source { - let mut style = renderer.text_style; - if let Some(highlight) = highlight { - style = style.patch(theme.highlight(highlight.0)) - } - (style, Style::default()) - } else { - (syntax_style_span.0, overlay_style_span.0) - }; - let is_virtual = grapheme.is_virtual(); - renderer.draw_grapheme( - grapheme.grapheme, + let grapheme_style = if let GraphemeSource::VirtualText { highlight } = grapheme.source { + let mut style = renderer.text_style; + if let Some(highlight) = highlight { + style = style.patch(theme.highlight(highlight.0)); + } + GraphemeStyle { + syntax_style: style, + overlay_style: Style::default(), + } + } else { GraphemeStyle { - syntax_style, - overlay_style, - }, - is_virtual, + syntax_style: syntax_style_span.0, + overlay_style: overlay_style_span.0, + } + }; + decorations.decorate_grapheme(renderer, &grapheme); + + let virt = grapheme.is_virtual(); + let grapheme_width = renderer.draw_grapheme( + grapheme.raw, + grapheme_style, + virt, &mut last_line_indent_level, &mut is_in_indent_area, - pos, + grapheme.visual_pos, ); + last_line_end = grapheme.visual_pos.col + grapheme_width; } renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); - for line_decoration in &mut *line_decorations { - line_decoration.render_foreground(renderer, last_line_pos, char_pos); - } + decorations.render_virtual_lines(renderer, last_line_pos, last_line_end) } #[derive(Debug)] pub struct TextRenderer<'a> { - pub surface: &'a mut Surface, + surface: &'a mut Surface, pub text_style: Style, pub whitespace_style: Style, pub indent_guide_char: String, @@ -369,8 +265,8 @@ pub struct TextRenderer<'a> { pub indent_width: u16, pub starting_indent: usize, pub draw_indent_guides: bool, - pub col_offset: usize, pub viewport: Rect, + pub offset: Position, } pub struct GraphemeStyle { @@ -383,7 +279,7 @@ impl<'a> TextRenderer<'a> { surface: &'a mut Surface, doc: &Document, theme: &Theme, - col_offset: usize, + offset: Position, viewport: Rect, ) -> TextRenderer<'a> { let editor_config = doc.config.load(); @@ -438,8 +334,8 @@ impl<'a> TextRenderer<'a> { virtual_tab, whitespace_style: theme.get("ui.virtual.whitespace"), indent_width, - starting_indent: col_offset / indent_width as usize - + (col_offset % indent_width as usize != 0) as usize + starting_indent: offset.col / indent_width as usize + + (offset.col % indent_width as usize != 0) as usize + editor_config.indent_guides.skip_levels as usize, indent_guide_style: text_style.patch( theme @@ -449,8 +345,46 @@ impl<'a> TextRenderer<'a> { text_style, draw_indent_guides: editor_config.indent_guides.render, viewport, - col_offset, + offset, + } + } + /// Draws a single `grapheme` at the current render position with a specified `style`. + pub fn draw_decoration_grapheme( + &mut self, + grapheme: Grapheme, + mut style: Style, + mut row: u16, + col: u16, + ) -> bool { + if (row as usize) < self.offset.row + || row >= self.viewport.height + || col >= self.viewport.width + { + return false; + } + row -= self.offset.row as u16; + // TODO is it correct to apply the whitspace style to all unicode white spaces? + if grapheme.is_whitespace() { + style = style.patch(self.whitespace_style); } + + let grapheme = match grapheme { + Grapheme::Tab { width } => { + let grapheme_tab_width = char_to_byte_idx(&self.virtual_tab, width); + &self.virtual_tab[..grapheme_tab_width] + } + Grapheme::Other { ref g } if g == "\u{00A0}" => " ", + Grapheme::Other { ref g } => g, + Grapheme::Newline => " ", + }; + + self.surface.set_string( + self.viewport.x + col, + self.viewport.y + row, + grapheme, + style, + ); + true } /// Draws a single `grapheme` at the current render position with a specified `style`. @@ -461,9 +395,13 @@ impl<'a> TextRenderer<'a> { is_virtual: bool, last_indent_level: &mut usize, is_in_indent_area: &mut bool, - position: Position, - ) { - let cut_off_start = self.col_offset.saturating_sub(position.col); + mut position: Position, + ) -> usize { + if position.row < self.offset.row { + return 0; + } + position.row -= self.offset.row; + let cut_off_start = self.offset.col.saturating_sub(position.col); let is_whitespace = grapheme.is_whitespace(); // TODO is it correct to apply the whitespace style to all unicode white spaces? @@ -495,12 +433,11 @@ impl<'a> TextRenderer<'a> { Grapheme::Newline => &self.newline, }; - let in_bounds = self.col_offset <= position.col - && position.col < self.viewport.width as usize + self.col_offset; + let in_bounds = self.column_in_bounds(position.col + width - 1); if in_bounds { self.surface.set_string( - self.viewport.x + (position.col - self.col_offset) as u16, + self.viewport.x + (position.col - self.offset.col) as u16, self.viewport.y + position.row as u16, grapheme, style, @@ -520,26 +457,33 @@ impl<'a> TextRenderer<'a> { *last_indent_level = position.col; *is_in_indent_area = false; } + + width + } + + pub fn column_in_bounds(&self, colum: usize) -> bool { + self.offset.col <= colum && colum < self.viewport.width as usize + self.offset.col } /// Overlay indentation guides ontop of a rendered line /// The indentation level is computed in `draw_lines`. /// Therefore this function must always be called afterwards. - pub fn draw_indent_guides(&mut self, indent_level: usize, row: u16) { - if !self.draw_indent_guides { + pub fn draw_indent_guides(&mut self, indent_level: usize, mut row: u16) { + if !self.draw_indent_guides || self.offset.row > row as usize { return; } + row -= self.offset.row as u16; // Don't draw indent guides outside of view let end_indent = min( indent_level, // Add indent_width - 1 to round up, since the first visible // indent might be a bit after offset.col - self.col_offset + self.viewport.width as usize + (self.indent_width as usize - 1), + self.offset.col + self.viewport.width as usize + (self.indent_width as usize - 1), ) / self.indent_width as usize; for i in self.starting_indent..end_indent { - let x = (self.viewport.x as usize + (i * self.indent_width as usize) - self.col_offset) + let x = (self.viewport.x as usize + (i * self.indent_width as usize) - self.offset.col) as u16; let y = self.viewport.y + row; debug_assert!(self.surface.in_bounds(x, y)); @@ -547,4 +491,62 @@ impl<'a> TextRenderer<'a> { .set_string(x, y, &self.indent_guide_char, self.indent_guide_style); } } + + pub fn set_string(&mut self, x: u16, y: u16, string: impl AsRef, style: Style) { + if (y as usize) < self.offset.row { + return; + } + self.surface + .set_string(x, y + self.viewport.y, string, style) + } + + pub fn set_stringn( + &mut self, + x: u16, + y: u16, + string: impl AsRef, + width: usize, + style: Style, + ) { + if (y as usize) < self.offset.row { + return; + } + self.surface + .set_stringn(x, y + self.viewport.y, string, width, style); + } + + /// Sets the style of an area **within the text viewport* this accounts + /// both for the renderers vertical offset and its viewport + pub fn set_style(&mut self, mut area: Rect, style: Style) { + area = area.clip_top(self.offset.row as u16); + area.y += self.viewport.y; + self.surface.set_style(area, style); + } + + /// Sets the style of an area **within the text viewport* this accounts + /// both for the renderers vertical offset and its viewport + #[allow(clippy::too_many_arguments)] + pub fn set_string_truncated( + &mut self, + x: u16, + y: u16, + string: &str, + width: usize, + style: impl Fn(usize) -> Style, // Map a grapheme's string offset to a style + ellipsis: bool, + truncate_start: bool, + ) -> (u16, u16) { + if (y as usize) < self.offset.row { + return (x, y); + } + self.surface.set_string_truncated( + x, + y + self.viewport.y, + string, + width, + style, + ellipsis, + truncate_start, + ) + } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 97f90f625..c151a7dd5 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -5,8 +5,10 @@ use crate::{ key, keymap::{KeymapResult, Keymaps}, ui::{ - document::{render_document, LinePos, TextRenderer, TranslatedPosition}, - Completion, ProgressSpinners, + document::{render_document, LinePos, TextRenderer}, + statusline, + text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics}, + Completion, CompletionItem, ProgressSpinners, }, }; @@ -20,6 +22,7 @@ use helix_core::{ visual_offset_from_block, Change, Position, Range, Selection, Transaction, }; use helix_view::{ + annotations::diagnostics::DiagnosticFilter, document::{Mode, SavePoint, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -31,9 +34,6 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; use tui::{buffer::Buffer as Surface, text::Span}; -use super::document::LineDecoration; -use super::{completion::CompletionItem, statusline}; - pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option, @@ -94,11 +94,10 @@ impl EditorView { let config = editor.config(); let text_annotations = view.text_annotations(doc, Some(theme)); - let mut line_decorations: Vec> = Vec::new(); - let mut translated_positions: Vec = Vec::new(); + let mut decorations = DecorationManager::default(); if is_focused && config.cursorline { - line_decorations.push(Self::cursorline_decorator(doc, view, theme)) + decorations.add_decoration(Self::cursorline(doc, view, theme)); } if is_focused && config.cursorcolumn { @@ -113,13 +112,10 @@ impl EditorView { if pos.doc_line != dap_line { return; } - renderer.surface.set_style( - Rect::new(inner.x, inner.y + pos.visual_line, inner.width, 1), - style, - ); + renderer.set_style(Rect::new(inner.x, pos.visual_line, inner.width, 1), style); }; - line_decorations.push(Box::new(line_decoration)); + decorations.add_decoration(line_decoration); } let syntax_highlights = @@ -176,22 +172,33 @@ impl EditorView { view.area, theme, is_focused & self.terminal_focused, - &mut line_decorations, + &mut decorations, ); } + let primary_cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); if is_focused { - let cursor = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - // set the cursor_cache to out of view in case the position is not found - editor.cursor_cache.set(Some(None)); - let update_cursor_cache = - |_: &mut TextRenderer, pos| editor.cursor_cache.set(Some(Some(pos))); - translated_positions.push((cursor, Box::new(update_cursor_cache))); + decorations.add_decoration(text_decorations::Cursor { + cache: &editor.cursor_cache, + primary_cursor, + }); } - + let width = view.inner_width(doc); + let config = doc.config.load(); + let enable_cursor_line = view + .diagnostics_handler + .show_cursorline_diagnostics(doc, view.id); + let inline_diagnostic_config = config.inline_diagnostics.prepare(width, enable_cursor_line); + decorations.add_decoration(InlineDiagnostics::new( + doc, + theme, + primary_cursor, + inline_diagnostic_config, + config.end_of_line_diagnostics, + )); render_document( surface, inner, @@ -201,8 +208,7 @@ impl EditorView { syntax_highlights, overlay_highlights, theme, - &mut line_decorations, - &mut translated_positions, + decorations, ); Self::render_rulers(editor, doc, view, inner, surface, theme); @@ -218,7 +224,11 @@ impl EditorView { } } - Self::render_diagnostics(doc, view, inner, surface, theme); + if config.inline_diagnostics.disabled() + && config.end_of_line_diagnostics == DiagnosticFilter::Disable + { + Self::render_diagnostics(doc, view, inner, surface, theme); + } let statusline_area = view .area @@ -637,7 +647,7 @@ impl EditorView { viewport: Rect, theme: &Theme, is_focused: bool, - line_decorations: &mut Vec>, + decoration_manager: &mut DecorationManager<'d>, ) { let text = doc.text().slice(..); let cursors: Rc<[_]> = doc @@ -663,7 +673,7 @@ impl EditorView { // TODO handle softwrap in gutters let selected = cursors.contains(&pos.doc_line); let x = viewport.x + offset; - let y = viewport.y + pos.visual_line; + let y = pos.visual_line; let gutter_style = match (selected, pos.first_visual_line) { (false, true) => gutter_style, @@ -675,11 +685,9 @@ impl EditorView { if let Some(style) = gutter(pos.doc_line, selected, pos.first_visual_line, &mut text) { - renderer - .surface - .set_stringn(x, y, &text, width, gutter_style.patch(style)); + renderer.set_stringn(x, y, &text, width, gutter_style.patch(style)); } else { - renderer.surface.set_style( + renderer.set_style( Rect { x, y, @@ -691,7 +699,7 @@ impl EditorView { } text.clear(); }; - line_decorations.push(Box::new(gutter_decoration)); + decoration_manager.add_decoration(gutter_decoration); offset += width as u16; } @@ -761,11 +769,7 @@ impl EditorView { } /// Apply the highlighting on the lines where a cursor is active - pub fn cursorline_decorator( - doc: &Document, - view: &View, - theme: &Theme, - ) -> Box { + pub fn cursorline(doc: &Document, view: &View, theme: &Theme) -> impl Decoration { let text = doc.text().slice(..); // TODO only highlight the visual line that contains the cursor instead of the full visual line let primary_line = doc.selection(view.id).primary().cursor_line(text); @@ -786,16 +790,14 @@ impl EditorView { let secondary_style = theme.get("ui.cursorline.secondary"); let viewport = view.area; - let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { - let area = Rect::new(viewport.x, viewport.y + pos.visual_line, viewport.width, 1); + move |renderer: &mut TextRenderer, pos: LinePos| { + let area = Rect::new(viewport.x, pos.visual_line, viewport.width, 1); if primary_line == pos.doc_line { - renderer.surface.set_style(area, primary_style); + renderer.set_style(area, primary_style); } else if secondary_lines.binary_search(&pos.doc_line).is_ok() { - renderer.surface.set_style(area, secondary_style); + renderer.set_style(area, secondary_style); } - }; - - Box::new(line_decoration) + } } /// Apply the highlighting on the columns where a cursor is active @@ -938,7 +940,11 @@ impl EditorView { // If the count is already started and the input is a number, always continue the count. (key!(i @ '0'..='9'), Some(count)) => { let i = i.to_digit(10).unwrap() as usize; - cxt.editor.count = NonZeroUsize::new(count.get() * 10 + i); + let count = count.get() * 10 + i; + if count > 100_000_000 { + return; + } + cxt.editor.count = NonZeroUsize::new(count); } // A non-zero digit will start the count if that number isn't used by a keymap. (key!(i @ '1'..='9'), None) if !self.keymaps.contains_key(mode, event) => { @@ -1451,7 +1457,7 @@ impl Component for EditorView { EventResult::Consumed(None) } Event::FocusLost => { - if context.editor.config().auto_save { + if context.editor.config().auto_save.focus_lost { if let Err(e) = commands::typed::write_all_impl(context, false, false) { context.editor.set_error(format!("{}", e)); } diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs index 651e5ca93..217cee6b3 100644 --- a/helix-term/src/ui/info.rs +++ b/helix-term/src/ui/info.rs @@ -3,7 +3,7 @@ use helix_view::graphics::{Margin, Rect}; use helix_view::info::Info; use tui::buffer::Buffer as Surface; use tui::text::Text; -use tui::widgets::{Block, Borders, Paragraph, Widget}; +use tui::widgets::{Block, Paragraph, Widget}; impl Component for Info { fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { @@ -23,13 +23,12 @@ impl Component for Info { )); surface.clear_with(area, popup_style); - let block = Block::default() + let block = Block::bordered() .title(self.title.as_str()) - .borders(Borders::ALL) .border_style(popup_style); let margin = Margin::horizontal(1); - let inner = block.inner(area).inner(&margin); + let inner = block.inner(area).inner(margin); block.render(area, surface); Paragraph::new(&Text::from(self.text.as_str())) diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index b82f7be29..b6491085b 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -27,6 +27,7 @@ pub struct SignatureHelp { language: String, config_loader: Arc>, active_signature: usize, + lsp_signature: Option, signatures: Vec, } @@ -37,12 +38,14 @@ impl SignatureHelp { language: String, config_loader: Arc>, active_signature: usize, + lsp_signature: Option, signatures: Vec, ) -> Self { Self { language, config_loader, active_signature, + lsp_signature, signatures, } } @@ -51,6 +54,10 @@ impl SignatureHelp { self.active_signature } + pub fn lsp_signature(&self) -> Option { + self.lsp_signature + } + pub fn visible_popup(compositor: &mut Compositor) -> Option<&mut Popup> { compositor.find_id::>(Self::ID) } @@ -119,7 +126,7 @@ impl Component for SignatureHelp { let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let sig_text_area = area.clip_top(1).with_height(sig_text_height); - let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area); + let sig_text_area = sig_text_area.inner(margin).intersection(surface.area); let sig_text_para = Paragraph::new(&sig_text).wrap(Wrap { trim: false }); sig_text_para.render(sig_text_area, surface); @@ -146,7 +153,7 @@ impl Component for SignatureHelp { let sig_doc_para = Paragraph::new(&sig_doc) .wrap(Wrap { trim: false }) .scroll((cx.scroll.unwrap_or_default() as u16, 0)); - sig_doc_para.render(sig_doc_area.inner(&margin), surface); + sig_doc_para.render(sig_doc_area.inner(margin), surface); } fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 81499d039..96614443f 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -351,7 +351,7 @@ impl Component for Markdown { .scroll((cx.scroll.unwrap_or_default() as u16, 0)); let margin = Margin::all(1); - par.render(area.inner(&margin), surface); + par.render(area.inner(margin), surface); } fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index c5006f958..c120d0b25 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,11 +1,11 @@ -use std::{borrow::Cow, cmp::Reverse, path::PathBuf}; +use std::{borrow::Cow, cmp::Reverse}; use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, }; use helix_core::fuzzy::MATCHER; -use nucleo::pattern::{Atom, AtomKind, CaseMatching}; +use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; use nucleo::{Config, Utf32Str}; use tui::{buffer::Buffer as Surface, widgets::Table}; @@ -31,18 +31,6 @@ pub trait Item: Sync + Send + 'static { } } -impl Item for PathBuf { - /// Root prefix to strip. - type Data = PathBuf; - - fn format(&self, root_path: &Self::Data) -> Row { - self.strip_prefix(root_path) - .unwrap_or(self) - .to_string_lossy() - .into() - } -} - pub type MenuCallback = Box, MenuEvent)>; pub struct Menu { @@ -92,7 +80,13 @@ impl Menu { pub fn score(&mut self, pattern: &str, incremental: bool) { let mut matcher = MATCHER.lock(); matcher.config = Config::DEFAULT; - let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false); + let pattern = Atom::new( + pattern, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + false, + ); let mut buf = Vec::new(); if incremental { self.matches.retain_mut(|(index, score)| { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 5211c2e27..10f4104ab 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -12,6 +12,7 @@ mod prompt; mod spinner; mod statusline; mod text; +mod text_decorations; use crate::compositor::Compositor; use crate::filter_picker_entry; @@ -21,7 +22,7 @@ pub use editor::EditorView; use helix_stdx::rope; pub use markdown::Markdown; pub use menu::Menu; -pub use picker::{DynamicPicker, FileLocation, Picker}; +pub use picker::{Column as PickerColumn, FileLocation, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -29,7 +30,7 @@ pub use text::Text; use helix_view::Editor; -use std::path::PathBuf; +use std::{error::Error, path::PathBuf}; pub fn prompt( cx: &mut crate::commands::Context, @@ -170,7 +171,9 @@ pub fn raw_regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker { +type FilePicker = Picker; + +pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time::Instant; @@ -217,7 +220,16 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker }); log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); - let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| { + let columns = [PickerColumn::new( + "path", + |item: &PathBuf, root: &PathBuf| { + item.strip_prefix(root) + .unwrap_or(item) + .to_string_lossy() + .into() + }, + )]; + let picker = Picker::new(columns, 0, [], root, move |cx, path: &PathBuf, action| { if let Err(e) = cx.editor.open(path, action) { let err = if let Some(err) = e.source() { format!("{}", err) @@ -227,7 +239,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker cx.editor.set_error(err); } }) - .with_preview(|_editor, path| Some((path.clone().into(), None))); + .with_preview(|_editor, path| Some((path.as_path().into(), None))); let injector = picker.injector(); let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30); @@ -364,14 +376,16 @@ pub mod completers { } pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec { - let Some(options) = doc!(editor) + let commands = doc!(editor) .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) - .find_map(|ls| ls.capabilities().execute_command_provider.as_ref()) - else { - return vec![]; - }; - - fuzzy_match(input, &options.commands, false) + .flat_map(|ls| { + ls.capabilities() + .execute_command_provider + .iter() + .flat_map(|options| options.commands.iter()) + }); + + fuzzy_match(input, commands, false) .into_iter() .map(|(name, _)| ((0..), name.to_owned().into())) .collect() diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index c2728888a..ffec0fd83 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,33 +1,40 @@ +mod handlers; +mod query; + use crate::{ alt, compositor::{self, Component, Compositor, Context, Event, EventResult}, - ctrl, - job::Callback, - key, shift, + ctrl, key, shift, ui::{ self, - document::{render_document, LineDecoration, LinePos, TextRenderer}, + document::{render_document, LinePos, TextRenderer}, + picker::query::PickerQuery, + text_decorations::DecorationManager, EditorView, }, }; -use futures_util::{future::BoxFuture, FutureExt}; -use nucleo::pattern::CaseMatching; -use nucleo::{Config, Nucleo, Utf32String}; +use futures_util::future::BoxFuture; +use helix_event::AsyncHook; +use nucleo::pattern::{CaseMatching, Normalization}; +use nucleo::{Config, Nucleo}; +use thiserror::Error; +use tokio::sync::mpsc::Sender; use tui::{ buffer::Buffer as Surface, layout::Constraint, text::{Span, Spans}, - widgets::{Block, BorderType, Borders, Cell, Table}, + widgets::{Block, BorderType, Cell, Row, Table}, }; use tui::widgets::Widget; use std::{ + borrow::Cow, collections::HashMap, io::Read, - path::PathBuf, + path::{Path, PathBuf}, sync::{ - atomic::{self, AtomicBool}, + atomic::{self, AtomicUsize}, Arc, }, }; @@ -36,7 +43,6 @@ use crate::ui::{Prompt, PromptEvent}; use helix_core::{ char_idx_at_visual_offset, fuzzy::MATCHER, movement::Direction, text_annotations::TextAnnotations, unicode::segmentation::UnicodeSegmentation, Position, - Syntax, }; use helix_view::{ editor::Action, @@ -46,45 +52,50 @@ use helix_view::{ Document, DocumentId, Editor, }; +use self::handlers::{DynamicQueryHandler, PreviewHighlightHandler}; + pub const ID: &str = "picker"; -use super::{menu::Item, overlay::Overlay}; pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; #[derive(PartialEq, Eq, Hash)] -pub enum PathOrId { +pub enum PathOrId<'a> { Id(DocumentId), - Path(PathBuf), + // See [PathOrId::from_path_buf]: this will eventually become `Path(&Path)`. + Path(Cow<'a, Path>), } -impl PathOrId { - fn get_canonicalized(self) -> Self { - use PathOrId::*; - match self { - Path(path) => Path(helix_stdx::path::canonicalize(path)), - Id(id) => Id(id), - } +impl<'a> PathOrId<'a> { + /// Creates a [PathOrId] from a PathBuf + /// + /// # Deprecated + /// The owned version of PathOrId will be removed in a future refactor + /// and replaced with `&'a Path`. See the caller of this function for + /// more details on its removal. + #[deprecated] + pub fn from_path_buf(path_buf: PathBuf) -> Self { + Self::Path(Cow::Owned(path_buf)) } } -impl From for PathOrId { - fn from(v: PathBuf) -> Self { - Self::Path(v) +impl<'a> From<&'a Path> for PathOrId<'a> { + fn from(path: &'a Path) -> Self { + Self::Path(Cow::Borrowed(path)) } } -impl From for PathOrId { +impl<'a> From for PathOrId<'a> { fn from(v: DocumentId) -> Self { Self::Id(v) } } -type FileCallback = Box Option>; +type FileCallback = Box Fn(&'a Editor, &'a T) -> Option>>; /// File path and range of lines (used to align and highlight lines) -pub type FileLocation = (PathOrId, Option<(usize, usize)>); +pub type FileLocation<'a> = (PathOrId<'a>, Option<(usize, usize)>); pub enum CachedPreview { Document(Box), @@ -123,62 +134,120 @@ impl Preview<'_, '_> { } } -fn item_to_nucleo(item: T, editor_data: &T::Data) -> Option<(T, Utf32String)> { - let row = item.format(editor_data); - let mut cells = row.cells.iter(); - let mut text = String::with_capacity(row.cell_text().map(|cell| cell.len()).sum()); - let cell = cells.next()?; - if let Some(cell) = cell.content.lines.first() { - for span in &cell.0 { - text.push_str(&span.content); - } - } - - for cell in cells { - text.push(' '); - if let Some(cell) = cell.content.lines.first() { - for span in &cell.0 { - text.push_str(&span.content); - } +fn inject_nucleo_item( + injector: &nucleo::Injector, + columns: &[Column], + item: T, + editor_data: &D, +) { + injector.push(item, |item, dst| { + for (column, text) in columns.iter().filter(|column| column.filter).zip(dst) { + *text = column.format_text(item, editor_data).into() } - } - Some((item, text.into())) + }); } -pub struct Injector { +pub struct Injector { dst: nucleo::Injector, - editor_data: Arc, - shutown: Arc, + columns: Arc<[Column]>, + editor_data: Arc, + version: usize, + picker_version: Arc, + /// A marker that requests a redraw when the injector drops. + /// This marker causes the "running" indicator to disappear when a background job + /// providing items is finished and drops. This could be wrapped in an [Arc] to ensure + /// that the redraw is only requested when all Injectors drop for a Picker (which removes + /// the "running" indicator) but the redraw handle is debounced so this is unnecessary. + _redraw: helix_event::RequestRedrawOnDrop, } -impl Clone for Injector { +impl Clone for Injector { fn clone(&self) -> Self { Injector { dst: self.dst.clone(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), - shutown: self.shutown.clone(), + version: self.version, + picker_version: self.picker_version.clone(), + _redraw: helix_event::RequestRedrawOnDrop, } } } +#[derive(Error, Debug)] +#[error("picker has been shut down")] pub struct InjectorShutdown; -impl Injector { +impl Injector { pub fn push(&self, item: T) -> Result<(), InjectorShutdown> { - if self.shutown.load(atomic::Ordering::Relaxed) { + if self.version != self.picker_version.load(atomic::Ordering::Relaxed) { return Err(InjectorShutdown); } - if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) { - self.dst.push(item, |dst| dst[0] = matcher_text); - } + inject_nucleo_item(&self.dst, &self.columns, item, &self.editor_data); Ok(()) } } -pub struct Picker { - editor_data: Arc, - shutdown: Arc, +type ColumnFormatFn = for<'a> fn(&'a T, &'a D) -> Cell<'a>; + +pub struct Column { + name: Arc, + format: ColumnFormatFn, + /// Whether the column should be passed to nucleo for matching and filtering. + /// `DynamicPicker` uses this so that the dynamic column (for example regex in + /// global search) is not used for filtering twice. + filter: bool, + hidden: bool, +} + +impl Column { + pub fn new(name: impl Into>, format: ColumnFormatFn) -> Self { + Self { + name: name.into(), + format, + filter: true, + hidden: false, + } + } + + /// A column which does not display any contents + pub fn hidden(name: impl Into>) -> Self { + let format = |_: &T, _: &D| unreachable!(); + + Self { + name: name.into(), + format, + filter: false, + hidden: true, + } + } + + pub fn without_filtering(mut self) -> Self { + self.filter = false; + self + } + + fn format<'a>(&self, item: &'a T, data: &'a D) -> Cell<'a> { + (self.format)(item, data) + } + + fn format_text<'a>(&self, item: &'a T, data: &'a D) -> Cow<'a, str> { + let text: String = self.format(item, data).content.into(); + text.into() + } +} + +/// Returns a new list of options to replace the contents of the picker +/// when called with the current picker query, +type DynQueryCallback = + fn(&str, &mut Editor, Arc, &Injector) -> BoxFuture<'static, anyhow::Result<()>>; + +pub struct Picker { + columns: Arc<[Column]>, + primary_column: usize, + editor_data: Arc, + version: Arc, matcher: Nucleo, /// Current height of the completions box @@ -186,7 +255,7 @@ pub struct Picker { cursor: u32, prompt: Prompt, - previous_pattern: String, + query: PickerQuery, /// Whether to show the preview panel (default true) show_preview: bool, @@ -197,67 +266,101 @@ pub struct Picker { pub truncate_start: bool, /// Caches paths to documents - preview_cache: HashMap, + preview_cache: HashMap, CachedPreview>, read_buffer: Vec, /// Given an item in the picker, return the file path and line number to display. file_fn: Option>, + /// An event handler for syntax highlighting the currently previewed file. + preview_highlight_handler: Sender>, + dynamic_query_handler: Option>>, } -impl Picker { - pub fn stream(editor_data: T::Data) -> (Nucleo, Injector) { +impl Picker { + pub fn stream( + columns: impl IntoIterator>, + editor_data: D, + ) -> (Nucleo, Injector) { + let columns: Arc<[_]> = columns.into_iter().collect(); + let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32; + assert!(matcher_columns > 0); let matcher = Nucleo::new( Config::DEFAULT, Arc::new(helix_event::request_redraw), None, - 1, + matcher_columns, ); let streamer = Injector { dst: matcher.injector(), + columns, editor_data: Arc::new(editor_data), - shutown: Arc::new(AtomicBool::new(false)), + version: 0, + picker_version: Arc::new(AtomicUsize::new(0)), + _redraw: helix_event::RequestRedrawOnDrop, }; (matcher, streamer) } - pub fn new( - options: Vec, - editor_data: T::Data, - callback_fn: impl Fn(&mut Context, &T, Action) + 'static, - ) -> Self { + pub fn new( + columns: C, + primary_column: usize, + options: O, + editor_data: D, + callback_fn: F, + ) -> Self + where + C: IntoIterator>, + O: IntoIterator, + F: Fn(&mut Context, &T, Action) + 'static, + { + let columns: Arc<[_]> = columns.into_iter().collect(); + let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32; + assert!(matcher_columns > 0); let matcher = Nucleo::new( Config::DEFAULT, Arc::new(helix_event::request_redraw), None, - 1, + matcher_columns, ); let injector = matcher.injector(); for item in options { - if let Some((item, matcher_text)) = item_to_nucleo(item, &editor_data) { - injector.push(item, |dst| dst[0] = matcher_text); - } + inject_nucleo_item(&injector, &columns, item, &editor_data); } Self::with( matcher, + columns, + primary_column, Arc::new(editor_data), - Arc::new(AtomicBool::new(false)), + Arc::new(AtomicUsize::new(0)), callback_fn, ) } pub fn with_stream( matcher: Nucleo, - injector: Injector, + primary_column: usize, + injector: Injector, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { - Self::with(matcher, injector.editor_data, injector.shutown, callback_fn) + Self::with( + matcher, + injector.columns, + primary_column, + injector.editor_data, + injector.picker_version, + callback_fn, + ) } fn with( matcher: Nucleo, - editor_data: Arc, - shutdown: Arc, + columns: Arc<[Column]>, + default_column: usize, + editor_data: Arc, + version: Arc, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { + assert!(!columns.is_empty()); + let prompt = Prompt::new( "".into(), None, @@ -265,29 +368,43 @@ impl Picker { |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + let widths = columns + .iter() + .map(|column| Constraint::Length(column.name.chars().count() as u16)) + .collect(); + + let query = PickerQuery::new(columns.iter().map(|col| &col.name).cloned(), default_column); + Self { + columns, + primary_column: default_column, matcher, editor_data, - shutdown, + version, cursor: 0, prompt, - previous_pattern: String::new(), + query, truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), completion_height: 0, - widths: Vec::new(), + widths, preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: None, + preview_highlight_handler: PreviewHighlightHandler::::default().spawn(), + dynamic_query_handler: None, } } - pub fn injector(&self) -> Injector { + pub fn injector(&self) -> Injector { Injector { dst: self.matcher.injector(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), - shutown: self.shutdown.clone(), + version: self.version.load(atomic::Ordering::Relaxed), + picker_version: self.version.clone(), + _redraw: helix_event::RequestRedrawOnDrop, } } @@ -298,7 +415,7 @@ impl Picker { pub fn with_preview( mut self, - preview_fn: impl Fn(&Editor, &T) -> Option + 'static, + preview_fn: impl for<'a> Fn(&'a Editor, &'a T) -> Option> + 'static, ) -> Self { self.file_fn = Some(Box::new(preview_fn)); // assumption: if we have a preview we are matching paths... If this is ever @@ -307,14 +424,20 @@ impl Picker { self } - pub fn set_options(&mut self, new_options: Vec) { - self.matcher.restart(false); - let injector = self.matcher.injector(); - for item in new_options { - if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) { - injector.push(item, |dst| dst[0] = matcher_text); - } - } + pub fn with_history_register(mut self, history_register: Option) -> Self { + self.prompt.with_history_register(history_register); + self + } + + pub fn with_dynamic_query( + mut self, + callback: DynQueryCallback, + debounce_ms: Option, + ) -> Self { + let handler = DynamicQueryHandler::new(callback, debounce_ms).spawn(); + helix_event::send_blocking(&handler, self.primary_query()); + self.dynamic_query_handler = Some(handler); + self } /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) @@ -367,52 +490,107 @@ impl Picker { .map(|item| item.data) } + fn primary_query(&self) -> Arc { + self.query + .get(&self.columns[self.primary_column].name) + .cloned() + .unwrap_or_else(|| "".into()) + } + + fn header_height(&self) -> u16 { + if self.columns.len() > 1 { + 1 + } else { + 0 + } + } + pub fn toggle_preview(&mut self) { self.show_preview = !self.show_preview; } fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) { - let pattern = self.prompt.line(); - // TODO: better track how the pattern has changed - if pattern != &self.previous_pattern { - self.matcher.pattern.reparse( - 0, - pattern, - CaseMatching::Smart, - pattern.starts_with(&self.previous_pattern), - ); - self.previous_pattern = pattern.clone(); - } + self.handle_prompt_change(); } EventResult::Consumed(None) } - fn current_file(&self, editor: &Editor) -> Option { - self.selection() - .and_then(|current| (self.file_fn.as_ref()?)(editor, current)) - .map(|(path_or_id, line)| (path_or_id.get_canonicalized(), line)) + fn handle_prompt_change(&mut self) { + // TODO: better track how the pattern has changed + let line = self.prompt.line(); + let old_query = self.query.parse(line); + if self.query == old_query { + return; + } + // If the query has meaningfully changed, reset the cursor to the top of the results. + self.cursor = 0; + // Have nucleo reparse each changed column. + for (i, column) in self + .columns + .iter() + .filter(|column| column.filter) + .enumerate() + { + let pattern = self + .query + .get(&column.name) + .map(|f| &**f) + .unwrap_or_default(); + let old_pattern = old_query + .get(&column.name) + .map(|f| &**f) + .unwrap_or_default(); + // Fastlane: most columns will remain unchanged after each edit. + if pattern == old_pattern { + continue; + } + let is_append = pattern.starts_with(old_pattern); + self.matcher.pattern.reparse( + i, + pattern, + CaseMatching::Smart, + Normalization::Smart, + is_append, + ); + } + // If this is a dynamic picker, notify the query hook that the primary + // query might have been updated. + if let Some(handler) = &self.dynamic_query_handler { + helix_event::send_blocking(handler, self.primary_query()); + } } - /// Get (cached) preview for a given path. If a document corresponding + /// Get (cached) preview for the currently selected item. If a document corresponding /// to the path is already open in the editor, it is used instead. fn get_preview<'picker, 'editor>( &'picker mut self, - path_or_id: PathOrId, editor: &'editor Editor, - ) -> Preview<'picker, 'editor> { + ) -> Option<(Preview<'picker, 'editor>, Option<(usize, usize)>)> { + let current = self.selection()?; + let (path_or_id, range) = (self.file_fn.as_ref()?)(editor, current)?; + match path_or_id { PathOrId::Path(path) => { - let path = &path; + let path = path.as_ref(); if let Some(doc) = editor.document_by_path(path) { - return Preview::EditorDocument(doc); + return Some((Preview::EditorDocument(doc), range)); } if self.preview_cache.contains_key(path) { - return Preview::Cached(&self.preview_cache[path]); + // NOTE: we use `HashMap::get_key_value` here instead of indexing so we can + // retrieve the `Arc` key. The `path` in scope here is a `&Path` and + // we can cheaply clone the key for the preview highlight handler. + let (path, preview) = self.preview_cache.get_key_value(path).unwrap(); + if matches!(preview, CachedPreview::Document(doc) if doc.language_config().is_none()) + { + helix_event::send_blocking(&self.preview_highlight_handler, path.clone()); + } + return Some((Preview::Cached(preview), range)); } - let data = std::fs::File::open(path).and_then(|file| { + let path: Arc = path.into(); + let data = std::fs::File::open(&path).and_then(|file| { let metadata = file.metadata()?; // Read up to 1kb to detect the content type let n = file.take(1024).read_to_end(&mut self.read_buffer)?; @@ -427,100 +605,29 @@ impl Picker { (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => { CachedPreview::LargeFile } - _ => Document::open(path, None, None, editor.config.clone()) - .map(|doc| CachedPreview::Document(Box::new(doc))) + _ => Document::open(&path, None, None, editor.config.clone()) + .map(|doc| { + // Asynchronously highlight the new document + helix_event::send_blocking( + &self.preview_highlight_handler, + path.clone(), + ); + CachedPreview::Document(Box::new(doc)) + }) .unwrap_or(CachedPreview::NotFound), }, ) .unwrap_or(CachedPreview::NotFound); - self.preview_cache.insert(path.to_owned(), preview); - Preview::Cached(&self.preview_cache[path]) + self.preview_cache.insert(path.clone(), preview); + Some((Preview::Cached(&self.preview_cache[&path]), range)) } PathOrId::Id(id) => { let doc = editor.documents.get(&id).unwrap(); - Preview::EditorDocument(doc) + Some((Preview::EditorDocument(doc), range)) } } } - fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { - let Some((current_file, _)) = self.current_file(cx.editor) else { - return EventResult::Consumed(None); - }; - - // Try to find a document in the cache - let doc = match ¤t_file { - PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id), - PathOrId::Path(path) => match self.preview_cache.get_mut(path) { - Some(CachedPreview::Document(ref mut doc)) => doc, - _ => return EventResult::Consumed(None), - }, - }; - - let mut callback: Option = None; - - // Then attempt to highlight it if it has no language set - if doc.language_config().is_none() { - if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader.load()) - { - doc.language = Some(language_config.clone()); - let text = doc.text().clone(); - let loader = cx.editor.syn_loader.clone(); - let job = tokio::task::spawn_blocking(move || { - let syntax = language_config - .highlight_config(&loader.load().scopes()) - .and_then(|highlight_config| { - Syntax::new(text.slice(..), highlight_config, loader) - }); - let callback = move |editor: &mut Editor, compositor: &mut Compositor| { - let Some(syntax) = syntax else { - log::info!("highlighting picker item failed"); - return; - }; - let picker = match compositor.find::>() { - Some(Overlay { content, .. }) => Some(content), - None => compositor - .find::>>() - .map(|overlay| &mut overlay.content.file_picker), - }; - let Some(picker) = picker else { - log::info!("picker closed before syntax highlighting finished"); - return; - }; - // Try to find a document in the cache - let doc = match current_file { - PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id), - PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) { - Some(CachedPreview::Document(ref mut doc)) => { - let diagnostics = Editor::doc_diagnostics( - &editor.language_servers, - &editor.diagnostics, - doc, - ); - doc.replace_diagnostics(diagnostics, &[], None); - doc - } - _ => return, - }, - }; - doc.syntax = Some(syntax); - }; - Callback::EditorCompositor(Box::new(callback)) - }); - let tmp: compositor::Callback = Box::new(move |_, ctx| { - ctx.jobs - .callback(job.map(|res| res.map_err(anyhow::Error::from))) - }); - callback = Some(Box::new(tmp)) - } - } - - // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now - // but it could be interesting in the future - - EventResult::Consumed(callback) - } - fn render_picker(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let status = self.matcher.tick(10); let snapshot = self.matcher.snapshot(); @@ -539,13 +646,12 @@ impl Picker { let background = cx.editor.theme.get("ui.background"); surface.clear_with(area, background); - // don't like this but the lifetime sucks - let block = Block::default().borders(Borders::ALL); + const BLOCK: Block<'_> = Block::bordered(); // calculate the inner area inside the box - let inner = block.inner(area); + let inner = BLOCK.inner(area); - block.render(area, surface); + BLOCK.render(area, surface); // -- Render the input bar: @@ -555,7 +661,11 @@ impl Picker { let count = format!( "{}{}/{}", - if status.running { "(running) " } else { "" }, + if status.running || self.matcher.active_injectors() > 0 { + "(running) " + } else { + "" + }, snapshot.matched_item_count(), snapshot.item_count(), ); @@ -579,7 +689,7 @@ impl Picker { // -- Render the contents: // subtract area of prompt from top let inner = inner.clip_top(2); - let rows = inner.height as u32; + let rows = inner.height.saturating_sub(self.header_height()) as u32; let offset = self.cursor - (self.cursor % std::cmp::max(1, rows)); let cursor = self.cursor.saturating_sub(offset); let end = offset @@ -593,83 +703,110 @@ impl Picker { } let options = snapshot.matched_items(offset..end).map(|item| { - snapshot.pattern().column_pattern(0).indices( - item.matcher_columns[0].slice(..), - &mut matcher, - &mut indices, - ); - indices.sort_unstable(); - indices.dedup(); - let mut row = item.data.format(&self.editor_data); - - let mut grapheme_idx = 0u32; - let mut indices = indices.drain(..); - let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX); - if self.widths.len() < row.cells.len() { - self.widths.resize(row.cells.len(), Constraint::Length(0)); - } let mut widths = self.widths.iter_mut(); - for cell in &mut row.cells { + let mut matcher_index = 0; + + Row::new(self.columns.iter().map(|column| { + if column.hidden { + return Cell::default(); + } + let Some(Constraint::Length(max_width)) = widths.next() else { unreachable!(); }; - - // merge index highlights on top of existing hightlights - let mut span_list = Vec::new(); - let mut current_span = String::new(); - let mut current_style = Style::default(); - let mut width = 0; - - let spans: &[Span] = cell.content.lines.first().map_or(&[], |it| it.0.as_slice()); - for span in spans { - // this looks like a bug on first glance, we are iterating - // graphemes but treating them as char indices. The reason that - // this is correct is that nucleo will only ever consider the first char - // of a grapheme (and discard the rest of the grapheme) so the indices - // returned by nucleo are essentially grapheme indecies - for grapheme in span.content.graphemes(true) { - let style = if grapheme_idx == next_highlight_idx { - next_highlight_idx = indices.next().unwrap_or(u32::MAX); - span.style.patch(highlight_style) - } else { - span.style - }; - if style != current_style { - if !current_span.is_empty() { - span_list.push(Span::styled(current_span, current_style)) + let mut cell = column.format(item.data, &self.editor_data); + let width = if column.filter { + snapshot.pattern().column_pattern(matcher_index).indices( + item.matcher_columns[matcher_index].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let mut indices = indices.drain(..); + let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX); + let mut span_list = Vec::new(); + let mut current_span = String::new(); + let mut current_style = Style::default(); + let mut grapheme_idx = 0u32; + let mut width = 0; + + let spans: &[Span] = + cell.content.lines.first().map_or(&[], |it| it.0.as_slice()); + for span in spans { + // this looks like a bug on first glance, we are iterating + // graphemes but treating them as char indices. The reason that + // this is correct is that nucleo will only ever consider the first char + // of a grapheme (and discard the rest of the grapheme) so the indices + // returned by nucleo are essentially grapheme indecies + for grapheme in span.content.graphemes(true) { + let style = if grapheme_idx == next_highlight_idx { + next_highlight_idx = indices.next().unwrap_or(u32::MAX); + span.style.patch(highlight_style) + } else { + span.style + }; + if style != current_style { + if !current_span.is_empty() { + span_list.push(Span::styled(current_span, current_style)) + } + current_span = String::new(); + current_style = style; } - current_span = String::new(); - current_style = style; + current_span.push_str(grapheme); + grapheme_idx += 1; } - current_span.push_str(grapheme); - grapheme_idx += 1; + width += span.width(); } - width += span.width(); - } - span_list.push(Span::styled(current_span, current_style)); + span_list.push(Span::styled(current_span, current_style)); + cell = Cell::from(Spans::from(span_list)); + matcher_index += 1; + width + } else { + cell.content + .lines + .first() + .map(|line| line.width()) + .unwrap_or_default() + }; + if width as u16 > *max_width { *max_width = width as u16; } - *cell = Cell::from(Spans::from(span_list)); - // spacer - if grapheme_idx == next_highlight_idx { - next_highlight_idx = indices.next().unwrap_or(u32::MAX); - } - grapheme_idx += 1; - } - - row + cell + })) }); - let table = Table::new(options) + let mut table = Table::new(options) .style(text_style) .highlight_style(selected) .highlight_symbol(" > ") .column_spacing(1) .widths(&self.widths); + // -- Header + if self.columns.len() > 1 { + let active_column = self.query.active_column(self.prompt.position()); + let header_style = cx.editor.theme.get("ui.picker.header"); + + table = table.header(Row::new(self.columns.iter().map(|column| { + if column.hidden { + Cell::default() + } else { + let style = if active_column.is_some_and(|name| Arc::ptr_eq(name, &column.name)) + { + cx.editor.theme.get("ui.picker.header.active") + } else { + header_style + }; + + Cell::from(Span::styled(Cow::from(&*column.name), style)) + } + }))); + } + use tui::widgets::TableState; table.render_table( @@ -690,18 +827,16 @@ impl Picker { let text = cx.editor.theme.get("ui.text"); surface.clear_with(area, background); - // don't like this but the lifetime sucks - let block = Block::default().borders(Borders::ALL); + const BLOCK: Block<'_> = Block::bordered(); // calculate the inner area inside the box - let inner = block.inner(area); + let inner = BLOCK.inner(area); // 1 column gap on either side let margin = Margin::horizontal(1); - let inner = inner.inner(&margin); - block.render(area, surface); + let inner = inner.inner(margin); + BLOCK.render(area, surface); - if let Some((path, range)) = self.current_file(cx.editor) { - let preview = self.get_preview(path, cx.editor); + if let Some((preview, range)) = self.get_preview(cx.editor) { let doc = match preview.document() { Some(doc) if range.map_or(true, |(start, end)| { @@ -761,7 +896,7 @@ impl Picker { } overlay_highlights = Box::new(helix_core::syntax::merge(overlay_highlights, spans)); } - let mut decorations: Vec> = Vec::new(); + let mut decorations = DecorationManager::default(); if let Some((start, end)) = range { let style = cx @@ -773,14 +908,14 @@ impl Picker { if (start..=end).contains(&pos.doc_line) { let area = Rect::new( renderer.viewport.x, - renderer.viewport.y + pos.visual_line, + pos.visual_line, renderer.viewport.width, 1, ); - renderer.surface.set_style(area, style) + renderer.set_style(area, style) } }; - decorations.push(Box::new(draw_highlight)) + decorations.add_decoration(draw_highlight); } render_document( @@ -793,14 +928,13 @@ impl Picker { syntax_highlights, overlay_highlights, &cx.editor.theme, - &mut decorations, - &mut [], + decorations, ); } } } -impl Component for Picker { +impl Component for Picker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // +---------+ +---------+ // |prompt | |preview | @@ -828,9 +962,6 @@ impl Component for Picker { } fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { - if let Event::IdleTimeout = event { - return self.handle_idle_timeout(ctx); - } // TODO: keybinds for scrolling preview let key_event = match event { @@ -854,7 +985,7 @@ impl Component for Picker { // be restarting the stream somehow once the picker gets // reopened instead (like for an FS crawl) that would also remove the // need for the special case above but that is pretty tricky - picker.shutdown.store(true, atomic::Ordering::Relaxed); + picker.version.fetch_add(1, atomic::Ordering::Relaxed); Box::new(|compositor: &mut Compositor, _ctx| { // remove the layer compositor.last_picker = compositor.pop(); @@ -863,9 +994,6 @@ impl Component for Picker { EventResult::Consumed(Some(callback)) }; - // So that idle timeout retriggers - ctx.editor.reset_idle_timer(); - match key_event { shift!(Tab) | key!(Up) | ctrl!('p') => { self.move_by(1, Direction::Backward); @@ -892,10 +1020,30 @@ impl Component for Picker { } } key!(Enter) => { - if let Some(option) = self.selection() { - (self.callback_fn)(ctx, option, Action::Replace); + // If the prompt has a history completion and is empty, use enter to accept + // that completion + if let Some(completion) = self + .prompt + .first_history_completion(ctx.editor) + .filter(|_| self.prompt.line().is_empty()) + { + self.prompt.set_line(completion.to_string(), ctx.editor); + self.handle_prompt_change(); + } else { + if let Some(option) = self.selection() { + (self.callback_fn)(ctx, option, Action::Replace); + } + if let Some(history_register) = self.prompt.history_register() { + if let Err(err) = ctx + .editor + .registers + .push(history_register, self.primary_query().to_string()) + { + ctx.editor.set_error(err.to_string()); + } + } + return close_fn(self); } - return close_fn(self); } ctrl!('s') => { if let Some(option) = self.selection() { @@ -921,7 +1069,7 @@ impl Component for Picker { } fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { - let block = Block::default().borders(Borders::ALL); + let block = Block::bordered(); // calculate the inner area inside the box let inner = block.inner(area); @@ -932,7 +1080,7 @@ impl Component for Picker { } fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> { - self.completion_height = height.saturating_sub(4); + self.completion_height = height.saturating_sub(4 + self.header_height()); Some((width, height)) } @@ -940,81 +1088,11 @@ impl Component for Picker { Some(ID) } } -impl Drop for Picker { +impl Drop for Picker { fn drop(&mut self) { // ensure we cancel any ongoing background threads streaming into the picker - self.shutdown.store(true, atomic::Ordering::Relaxed) + self.version.fetch_add(1, atomic::Ordering::Relaxed); } } type PickerCallback = Box; - -/// Returns a new list of options to replace the contents of the picker -/// when called with the current picker query, -pub type DynQueryCallback = - Box BoxFuture<'static, anyhow::Result>>>; - -/// A picker that updates its contents via a callback whenever the -/// query string changes. Useful for live grep, workspace symbols, etc. -pub struct DynamicPicker { - file_picker: Picker, - query_callback: DynQueryCallback, - query: String, -} - -impl DynamicPicker { - pub fn new(file_picker: Picker, query_callback: DynQueryCallback) -> Self { - Self { - file_picker, - query_callback, - query: String::new(), - } - } -} - -impl Component for DynamicPicker { - fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { - self.file_picker.render(area, surface, cx); - } - - fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { - let event_result = self.file_picker.handle_event(event, cx); - let current_query = self.file_picker.prompt.line(); - - if !matches!(event, Event::IdleTimeout) || self.query == *current_query { - return event_result; - } - - self.query.clone_from(current_query); - - let new_options = (self.query_callback)(current_query.to_owned(), cx.editor); - - cx.jobs.callback(async move { - let new_options = new_options.await?; - let callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { - // Wrapping of pickers in overlay is done outside the picker code, - // so this is fragile and will break if wrapped in some other widget. - let picker = match compositor.find_id::>>(ID) { - Some(overlay) => &mut overlay.content.file_picker, - None => return, - }; - picker.set_options(new_options); - editor.reset_idle_timer(); - })); - anyhow::Ok(callback) - }); - EventResult::Consumed(None) - } - - fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, CursorKind) { - self.file_picker.cursor(area, ctx) - } - - fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - self.file_picker.required_size(viewport) - } - - fn id(&self) -> Option<&'static str> { - Some(ID) - } -} diff --git a/helix-term/src/ui/picker/handlers.rs b/helix-term/src/ui/picker/handlers.rs new file mode 100644 index 000000000..4896ccbc6 --- /dev/null +++ b/helix-term/src/ui/picker/handlers.rs @@ -0,0 +1,182 @@ +use std::{ + path::Path, + sync::{atomic, Arc}, + time::Duration, +}; + +use helix_event::AsyncHook; +use tokio::time::Instant; + +use crate::{job, ui::overlay::Overlay}; + +use super::{CachedPreview, DynQueryCallback, Picker}; + +pub(super) struct PreviewHighlightHandler { + trigger: Option>, + phantom_data: std::marker::PhantomData<(T, D)>, +} + +impl Default for PreviewHighlightHandler { + fn default() -> Self { + Self { + trigger: None, + phantom_data: Default::default(), + } + } +} + +impl AsyncHook + for PreviewHighlightHandler +{ + type Event = Arc; + + fn handle_event( + &mut self, + path: Self::Event, + timeout: Option, + ) -> Option { + if self + .trigger + .as_ref() + .is_some_and(|trigger| trigger == &path) + { + // If the path hasn't changed, don't reset the debounce + timeout + } else { + self.trigger = Some(path); + Some(Instant::now() + Duration::from_millis(150)) + } + } + + fn finish_debounce(&mut self) { + let Some(path) = self.trigger.take() else { + return; + }; + + job::dispatch_blocking(move |editor, compositor| { + let Some(Overlay { + content: picker, .. + }) = compositor.find::>>() + else { + return; + }; + + let Some(CachedPreview::Document(ref mut doc)) = picker.preview_cache.get_mut(&path) + else { + return; + }; + + if doc.language_config().is_some() { + return; + } + + let Some(language_config) = doc.detect_language_config(&editor.syn_loader.load()) + else { + return; + }; + doc.language = Some(language_config.clone()); + let text = doc.text().clone(); + let loader = editor.syn_loader.clone(); + + tokio::task::spawn_blocking(move || { + let Some(syntax) = language_config + .highlight_config(&loader.load().scopes()) + .and_then(|highlight_config| { + helix_core::Syntax::new(text.slice(..), highlight_config, loader) + }) + else { + log::info!("highlighting picker item failed"); + return; + }; + + job::dispatch_blocking(move |editor, compositor| { + let Some(Overlay { + content: picker, .. + }) = compositor.find::>>() + else { + log::info!("picker closed before syntax highlighting finished"); + return; + }; + let Some(CachedPreview::Document(ref mut doc)) = + picker.preview_cache.get_mut(&path) + else { + return; + }; + let diagnostics = helix_view::Editor::doc_diagnostics( + &editor.language_servers, + &editor.diagnostics, + doc, + ); + doc.replace_diagnostics(diagnostics, &[], None); + doc.syntax = Some(syntax); + }); + }); + }); + } +} + +pub(super) struct DynamicQueryHandler { + callback: Arc>, + // Duration used as a debounce. + // Defaults to 100ms if not provided via `Picker::with_dynamic_query`. Callers may want to set + // this higher if the dynamic query is expensive - for example global search. + debounce: Duration, + last_query: Arc, + query: Option>, +} + +impl DynamicQueryHandler { + pub(super) fn new(callback: DynQueryCallback, duration_ms: Option) -> Self { + Self { + callback: Arc::new(callback), + debounce: Duration::from_millis(duration_ms.unwrap_or(100)), + last_query: "".into(), + query: None, + } + } +} + +impl AsyncHook for DynamicQueryHandler { + type Event = Arc; + + fn handle_event(&mut self, query: Self::Event, _timeout: Option) -> Option { + if query == self.last_query { + // If the search query reverts to the last one we requested, no need to + // make a new request. + self.query = None; + None + } else { + self.query = Some(query); + Some(Instant::now() + self.debounce) + } + } + + fn finish_debounce(&mut self) { + let Some(query) = self.query.take() else { + return; + }; + self.last_query = query.clone(); + let callback = self.callback.clone(); + + job::dispatch_blocking(move |editor, compositor| { + let Some(Overlay { + content: picker, .. + }) = compositor.find::>>() + else { + return; + }; + // Increment the version number to cancel any ongoing requests. + picker.version.fetch_add(1, atomic::Ordering::Relaxed); + picker.matcher.restart(false); + let injector = picker.injector(); + let get_options = (callback)(&query, editor, picker.editor_data.clone(), &injector); + tokio::spawn(async move { + if let Err(err) = get_options.await { + log::info!("Dynamic request failed: {err}"); + } + // NOTE: the Drop implementation of Injector will request a redraw when the + // injector falls out of scope here, clearing the "running" indicator. + }); + }) + } +} diff --git a/helix-term/src/ui/picker/query.rs b/helix-term/src/ui/picker/query.rs new file mode 100644 index 000000000..e433a11fa --- /dev/null +++ b/helix-term/src/ui/picker/query.rs @@ -0,0 +1,368 @@ +use std::{collections::HashMap, mem, ops::Range, sync::Arc}; + +#[derive(Debug)] +pub(super) struct PickerQuery { + /// The column names of the picker. + column_names: Box<[Arc]>, + /// The index of the primary column in `column_names`. + /// The primary column is selected by default unless another + /// field is specified explicitly with `%fieldname`. + primary_column: usize, + /// The mapping between column names and input in the query + /// for those columns. + inner: HashMap, Arc>, + /// The byte ranges of the input text which are used as input for each column. + /// This is calculated at parsing time for use in [Self::active_column]. + /// This Vec is naturally sorted in ascending order and ranges do not overlap. + column_ranges: Vec<(Range, Option>)>, +} + +impl PartialEq, Arc>> for PickerQuery { + fn eq(&self, other: &HashMap, Arc>) -> bool { + self.inner.eq(other) + } +} + +impl PickerQuery { + pub(super) fn new>>( + column_names: I, + primary_column: usize, + ) -> Self { + let column_names: Box<[_]> = column_names.collect(); + let inner = HashMap::with_capacity(column_names.len()); + let column_ranges = vec![(0..usize::MAX, Some(column_names[primary_column].clone()))]; + Self { + column_names, + primary_column, + inner, + column_ranges, + } + } + + pub(super) fn get(&self, column: &str) -> Option<&Arc> { + self.inner.get(column) + } + + pub(super) fn parse(&mut self, input: &str) -> HashMap, Arc> { + let mut fields: HashMap, String> = HashMap::new(); + let primary_field = &self.column_names[self.primary_column]; + let mut escaped = false; + let mut in_field = false; + let mut field = None; + let mut text = String::new(); + self.column_ranges.clear(); + self.column_ranges + .push((0..usize::MAX, Some(primary_field.clone()))); + + macro_rules! finish_field { + () => { + let key = field.take().unwrap_or(primary_field); + + if let Some(pattern) = fields.get_mut(key) { + pattern.push(' '); + pattern.push_str(text.trim()); + } else { + fields.insert(key.clone(), text.trim().to_string()); + } + text.clear(); + }; + } + + for (idx, ch) in input.char_indices() { + match ch { + // Backslash escaping + _ if escaped => { + // '%' is the only character that is special cased. + // You can escape it to prevent parsing the text that + // follows it as a field name. + if ch != '%' { + text.push('\\'); + } + text.push(ch); + escaped = false; + } + '\\' => escaped = !escaped, + '%' => { + if !text.is_empty() { + finish_field!(); + } + let (range, _field) = self + .column_ranges + .last_mut() + .expect("column_ranges is non-empty"); + range.end = idx; + in_field = true; + } + ' ' if in_field => { + text.clear(); + in_field = false; + } + _ if in_field => { + text.push(ch); + // Go over all columns and their indices, find all that starts with field key, + // select a column that fits key the most. + field = self + .column_names + .iter() + .filter(|col| col.starts_with(&text)) + // select "fittest" column + .min_by_key(|col| col.len()); + + // Update the column range for this column. + if let Some((_range, current_field)) = self + .column_ranges + .last_mut() + .filter(|(range, _)| range.end == usize::MAX) + { + *current_field = field.cloned(); + } else { + self.column_ranges.push((idx..usize::MAX, field.cloned())); + } + } + _ => text.push(ch), + } + } + + if !in_field && !text.is_empty() { + finish_field!(); + } + + let new_inner: HashMap<_, _> = fields + .into_iter() + .map(|(field, query)| (field, query.as_str().into())) + .collect(); + + mem::replace(&mut self.inner, new_inner) + } + + /// Finds the column which the cursor is 'within' in the last parse. + /// + /// The cursor is considered to be within a column when it is placed within any + /// of a column's text. See the `active_column_test` unit test below for examples. + /// + /// `cursor` is a byte index that represents the location of the prompt's cursor. + pub fn active_column(&self, cursor: usize) -> Option<&Arc> { + let point = self + .column_ranges + .partition_point(|(range, _field)| cursor > range.end); + + self.column_ranges + .get(point) + .filter(|(range, _field)| cursor >= range.start && cursor <= range.end) + .and_then(|(_range, field)| field.as_ref()) + } +} + +#[cfg(test)] +mod test { + use helix_core::hashmap; + + use super::*; + + #[test] + fn parse_query_test() { + let mut query = PickerQuery::new( + [ + "primary".into(), + "field1".into(), + "field2".into(), + "another".into(), + "anode".into(), + ] + .into_iter(), + 0, + ); + + // Basic field splitting + query.parse("hello world"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello world".into(), + ) + ); + query.parse("hello %field1 world %field2 !"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "world".into(), + "field2".into() => "!".into(), + ) + ); + query.parse("%field1 abc %field2 def xyz"); + assert_eq!( + query, + hashmap!( + "field1".into() => "abc".into(), + "field2".into() => "def xyz".into(), + ) + ); + + // Trailing space is trimmed + query.parse("hello "); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + ) + ); + + // Unknown fields are trimmed. + query.parse("hello %foo"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + ) + ); + + // Multiple words in a field + query.parse("hello %field1 a b c"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "a b c".into(), + ) + ); + + // Escaping + query.parse(r#"hello\ world"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"hello \%field1 world"#); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello %field1 world".into(), + ) + ); + query.parse(r#"%field1 hello\ world"#); + assert_eq!( + query, + hashmap!( + "field1".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"hello %field1 a\"b"#); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => r#"a\"b"#.into(), + ) + ); + query.parse(r#"%field1 hello\ world"#); + assert_eq!( + query, + hashmap!( + "field1".into() => r#"hello\ world"#.into(), + ) + ); + query.parse(r#"\bfoo\b"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"\bfoo\b"#.into(), + ) + ); + query.parse(r#"\\n"#); + assert_eq!( + query, + hashmap!( + "primary".into() => r#"\\n"#.into(), + ) + ); + + // Only the prefix of a field is required. + query.parse("hello %anot abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "another".into() => "abc".into(), + ) + ); + // The shortest matching the prefix is selected. + query.parse("hello %ano abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "anode".into() => "abc".into() + ) + ); + // Multiple uses of a column are concatenated with space separators. + query.parse("hello %field1 xyz %fie abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "xyz abc".into() + ) + ); + query.parse("hello %fie abc"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello".into(), + "field1".into() => "abc".into() + ) + ); + // The primary column can be explicitly qualified. + query.parse("hello %fie abc %prim world"); + assert_eq!( + query, + hashmap!( + "primary".into() => "hello world".into(), + "field1".into() => "abc".into() + ) + ); + } + + #[test] + fn active_column_test() { + fn active_column<'a>(query: &'a mut PickerQuery, input: &str) -> Option<&'a str> { + let cursor = input.find('|').expect("cursor must be indicated with '|'"); + let input = input.replace('|', ""); + query.parse(&input); + query.active_column(cursor).map(AsRef::as_ref) + } + + let mut query = PickerQuery::new( + ["primary".into(), "foo".into(), "bar".into()].into_iter(), + 0, + ); + + assert_eq!(active_column(&mut query, "|"), Some("primary")); + assert_eq!(active_column(&mut query, "hello| world"), Some("primary")); + assert_eq!(active_column(&mut query, "|%foo hello"), Some("primary")); + assert_eq!(active_column(&mut query, "%foo|"), Some("foo")); + assert_eq!(active_column(&mut query, "%|"), None); + assert_eq!(active_column(&mut query, "%baz|"), None); + assert_eq!(active_column(&mut query, "%quiz%|"), None); + assert_eq!(active_column(&mut query, "%foo hello| world"), Some("foo")); + assert_eq!(active_column(&mut query, "%foo hello world|"), Some("foo")); + assert_eq!(active_column(&mut query, "%foo| hello world"), Some("foo")); + assert_eq!(active_column(&mut query, "%|foo hello world"), Some("foo")); + assert_eq!(active_column(&mut query, "%f|oo hello world"), Some("foo")); + assert_eq!(active_column(&mut query, "hello %f|oo world"), Some("foo")); + assert_eq!( + active_column(&mut query, "hello %f|oo world %bar !"), + Some("foo") + ); + assert_eq!( + active_column(&mut query, "hello %foo wo|rld %bar !"), + Some("foo") + ); + assert_eq!( + active_column(&mut query, "hello %foo world %bar !|"), + Some("bar") + ); + } +} diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 5362bdc7f..2cefaf61b 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -5,7 +5,7 @@ use crate::{ }; use tui::{ buffer::Buffer as Surface, - widgets::{Block, Borders, Widget}, + widgets::{Block, Widget}, }; use helix_core::Position; @@ -323,8 +323,8 @@ impl Component for Popup { let mut inner = area; if render_borders { - inner = area.inner(&Margin::all(1)); - Widget::render(Block::default().borders(Borders::ALL), area, surface); + inner = area.inner(Margin::all(1)); + Widget::render(Block::bordered(), area, surface); } let border = usize::from(render_borders); diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index a6ee7f05d..6ba2fcb9e 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -2,12 +2,13 @@ use crate::compositor::{Component, Compositor, Context, Event, EventResult}; use crate::{alt, ctrl, key, shift, ui}; use arc_swap::ArcSwap; use helix_core::syntax; +use helix_view::document::Mode; use helix_view::input::KeyEvent; use helix_view::keyboard::KeyCode; use std::sync::Arc; use std::{borrow::Cow, ops::RangeFrom}; use tui::buffer::Buffer as Surface; -use tui::widgets::{Block, Borders, Widget}; +use tui::widgets::{Block, Widget}; use helix_core::{ unicode::segmentation::GraphemeCursor, unicode::width::UnicodeWidthStr, Position, @@ -91,12 +92,22 @@ impl Prompt { } } + /// Gets the byte index in the input representing the current cursor location. + #[inline] + pub(crate) fn position(&self) -> usize { + self.cursor + } + pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self.set_line(line, editor); + self + } + + pub fn set_line(&mut self, line: String, editor: &Editor) { let cursor = line.len(); self.line = line; self.cursor = cursor; self.recalculate_completion(editor); - self } pub fn with_language( @@ -112,6 +123,23 @@ impl Prompt { &self.line } + pub fn with_history_register(&mut self, history_register: Option) -> &mut Self { + self.history_register = history_register; + self + } + + pub(crate) fn history_register(&self) -> Option { + self.history_register + } + + pub(crate) fn first_history_completion<'a>( + &'a self, + editor: &'a Editor, + ) -> Option> { + self.history_register + .and_then(|reg| editor.registers.first(reg, editor)) + } + pub fn recalculate_completion(&mut self, editor: &Editor) { self.exit_selection(); self.completion = (self.completion_fn)(editor, &self.line); @@ -457,12 +485,11 @@ impl Prompt { let background = theme.get("ui.help"); surface.clear_with(area, background); - let block = Block::default() + let block = Block::bordered() // .title(self.title.as_str()) - .borders(Borders::ALL) .border_style(background); - let inner = block.inner(area).inner(&Margin::horizontal(1)); + let inner = block.inner(area).inner(Margin::horizontal(1)); block.render(area, surface); text.render(inner, surface, cx); @@ -476,10 +503,7 @@ impl Prompt { let line_area = area.clip_left(self.prompt.len() as u16).clip_top(line); if self.line.is_empty() { // Show the most recently entered value as a suggestion. - if let Some(suggestion) = self - .history_register - .and_then(|reg| cx.editor.registers.first(reg, cx.editor)) - { + if let Some(suggestion) = self.first_history_completion(cx.editor) { surface.set_string(line_area.x, line_area.y, suggestion, suggestion_color); } } else if let Some((language, loader)) = self.language.as_ref() { @@ -574,8 +598,7 @@ impl Component for Prompt { self.recalculate_completion(cx.editor); } else { let last_item = self - .history_register - .and_then(|reg| cx.editor.registers.first(reg, cx.editor)) + .first_history_completion(cx.editor) .map(|entry| entry.to_string()) .unwrap_or_else(|| String::from("")); @@ -663,7 +686,7 @@ impl Component for Prompt { self.render_prompt(area, surface, cx) } - fn cursor(&self, area: Rect, _editor: &Editor) -> (Option, CursorKind) { + fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { let line = area.height as usize - 1; ( Some(Position::new( @@ -672,7 +695,7 @@ impl Component for Prompt { + self.prompt.len() + UnicodeWidthStr::width(&self.line[..self.cursor]), )), - CursorKind::Block, + editor.config().cursor_shape.from_mode(Mode::Insert), ) } } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 8a87242f9..7437cbd07 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -4,6 +4,7 @@ use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, graphics::Rect, + theme::Style, Document, Editor, View, }; @@ -19,6 +20,7 @@ pub struct RenderContext<'a> { pub view: &'a View, pub focused: bool, pub spinners: &'a ProgressSpinners, + pub parts: RenderBuffer<'a>, } impl<'a> RenderContext<'a> { @@ -35,10 +37,18 @@ impl<'a> RenderContext<'a> { view, focused, spinners, + parts: RenderBuffer::default(), } } } +#[derive(Default)] +pub struct RenderBuffer<'a> { + pub left: Spans<'a>, + pub center: Spans<'a>, + pub right: Spans<'a>, +} + pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) { let base_style = if context.focused { context.editor.theme.get("ui.statusline") @@ -48,87 +58,85 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface surface.set_style(viewport.with_height(1), base_style); - let statusline = render_statusline(context, viewport.width as usize); + let write_left = |context: &mut RenderContext, text, style| { + append(&mut context.parts.left, text, &base_style, style) + }; + let write_center = |context: &mut RenderContext, text, style| { + append(&mut context.parts.center, text, &base_style, style) + }; + let write_right = |context: &mut RenderContext, text, style| { + append(&mut context.parts.right, text, &base_style, style) + }; + + // Left side of the status line. + + let config = context.editor.config(); + + let element_ids = &config.statusline.left; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_left)); surface.set_spans( viewport.x, viewport.y, - &statusline, - statusline.width() as u16, + &context.parts.left, + context.parts.left.width() as u16, ); -} -pub fn render_statusline<'a>(context: &mut RenderContext, width: usize) -> Spans<'a> { - let config = context.editor.config(); + // Right side of the status line. - let element_ids = &config.statusline.left; - let mut left = element_ids + let element_ids = &config.statusline.right; + element_ids .iter() .map(|element_id| get_render_function(*element_id)) - .flat_map(|render| render(context).0) - .collect::>(); + .for_each(|render| render(context, write_right)); + + surface.set_spans( + viewport.x + + viewport + .width + .saturating_sub(context.parts.right.width() as u16), + viewport.y, + &context.parts.right, + context.parts.right.width() as u16, + ); + + // Center of the status line. let element_ids = &config.statusline.center; - let mut center = element_ids + element_ids .iter() .map(|element_id| get_render_function(*element_id)) - .flat_map(|render| render(context).0) - .collect::>(); + .for_each(|render| render(context, write_center)); - let element_ids = &config.statusline.right; - let mut right = element_ids - .iter() - .map(|element_id| get_render_function(*element_id)) - .flat_map(|render| render(context).0) - .collect::>(); - - let left_area_width: usize = left.iter().map(|s| s.width()).sum(); - let center_area_width: usize = center.iter().map(|s| s.width()).sum(); - let right_area_width: usize = right.iter().map(|s| s.width()).sum(); - - let min_spacing_between_areas = 1usize; - let sides_space_required = left_area_width + right_area_width + min_spacing_between_areas; - let total_space_required = sides_space_required + center_area_width + min_spacing_between_areas; - - let mut statusline: Vec = vec![]; - - if center_area_width > 0 && total_space_required <= width { - // SAFETY: this subtraction cannot underflow because `left_area_width + center_area_width + right_area_width` - // is smaller than `total_space_required`, which is smaller than `width` in this branch. - let total_spacers = width - (left_area_width + center_area_width + right_area_width); - // This is how much padding space it would take on either side to align the center area to the middle. - let center_margin = (width - center_area_width) / 2; - let left_spacers = if left_area_width < center_margin && right_area_width < center_margin { - // Align the center area to the middle if there is enough space on both sides. - center_margin - left_area_width - } else { - // Otherwise split the available space evenly and use it as margin. - // The center element won't be aligned to the middle but it will be evenly - // spaced between the left and right areas. - total_spacers / 2 - }; - let right_spacers = total_spacers - left_spacers; - - statusline.append(&mut left); - statusline.push(" ".repeat(left_spacers).into()); - statusline.append(&mut center); - statusline.push(" ".repeat(right_spacers).into()); - statusline.append(&mut right); - } else if right_area_width > 0 && sides_space_required <= width { - let side_areas_width = left_area_width + right_area_width; - statusline.append(&mut left); - statusline.push(" ".repeat(width - side_areas_width).into()); - statusline.append(&mut right); - } else if left_area_width <= width { - statusline.append(&mut left); - } + // Width of the empty space between the left and center area and between the center and right area. + let spacing = 1u16; - statusline.into() + let edge_width = context.parts.left.width().max(context.parts.right.width()) as u16; + let center_max_width = viewport.width.saturating_sub(2 * edge_width + 2 * spacing); + let center_width = center_max_width.min(context.parts.center.width() as u16); + + surface.set_spans( + viewport.x + viewport.width / 2 - center_width / 2, + viewport.y, + &context.parts.center, + center_width, + ); +} + +fn append(buffer: &mut Spans, text: String, base_style: &Style, style: Option