diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ce9ed62ea2db..b16fa428af293 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,8 +67,9 @@ jobs: - name: Run cargo test uses: actions-rs/cargo@v1 with: + use-cross: ${{ matrix.cross }} command: test - args: --release --locked + args: --release --locked --target ${{ matrix.target }} - name: Build release binary uses: actions-rs/cargo@v1 diff --git a/.gitmodules b/.gitmodules index 87acea60741c8..e750198aee566 100644 --- a/.gitmodules +++ b/.gitmodules @@ -94,3 +94,11 @@ path = helix-syntax/languages/tree-sitter-latex url = https://github.com/latex-lsp/tree-sitter-latex shallow = true +[submodule "helix-syntax/languages/tree-sitter-ledger"] + path = helix-syntax/languages/tree-sitter-ledger + url = https://github.com/cbarrete/tree-sitter-ledger + shallow = true +[submodule "helix-syntax/languages/tree-sitter-protobuf"] + path = helix-syntax/languages/tree-sitter-protobuf + url = https://github.com/yusdacra/tree-sitter-protobuf.git + shallow = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e83240f8a63c..03f5730764fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,48 @@ -# 0.3.0 + +# 0.4.1 (2021-08-14) + +A minor release that includes: +- A fix for rendering glitches that would occur after editing with multiple selections. +- CI fix for grammars not being cross-compiled for aarch64 + +# 0.4.0 (2021-08-13) + +Two months have passed, so this is another big release. A big thank you to all +the contributors and package maintainers! + +Helix has popped up in [Arch, Manjaro, Nix, MacPorts and Parabola and Termux repositories](https://repology.org/project/helix/versions)! + +A [large scale refactor](https://github.com/helix-editor/helix/pull/376) landed that allows us to support zero width (empty) +selections in the future as well as resolves many bugs and edge cases. + +- Multi-key remapping! Key binds now support much more complex usecases ([#454](https://github.com/helix-editor/helix/pull/454)) +- Pending keys are shown in the statusline ([#515](https://github.com/helix-editor/helix/pull/515)) +- Object selection / textobjects. `mi(` to select text inside parentheses ([#385](https://github.com/helix-editor/helix/pull/385)) +- Autoinfo: `whichkey`-like popups which show available sub-mode shortcuts ([#316](https://github.com/helix-editor/helix/pull/316)) +- Added WORD movements (W/B/E) ([#390](https://github.com/helix-editor/helix/pull/390)) +- Vertical selections (repeat selection above/below) ([#462](https://github.com/helix-editor/helix/pull/462)) +- Selection rotation via `(` and `)` ([66a90130](https://github.com/helix-editor/helix/commit/66a90130a5f99d769e9f6034025297f78ecaa3ec)) +- Selection contents rotation via `Alt-(` and `Alt-)` ([02cba2a](https://github.com/helix-editor/helix/commit/02cba2a7f403f48eccb18100fb751f7b42373dba)) +- Completion behavior improvements ([f917b5a4](https://github.com/helix-editor/helix/commit/f917b5a441ff3ae582358b6939ffbf889f4aa530), [627b899](https://github.com/helix-editor/helix/commit/627b89931576f7af86166ae8d5cbc55537877473)) +- Fixed a language server crash ([385a6b5a](https://github.com/helix-editor/helix/commit/385a6b5a1adddfc26e917982641530e1a7c7aa81)) +- Case change commands (`` ` ``, `~`, ````) ([#441](https://github.com/helix-editor/helix/pull/441)) +- File pickers (including goto) now provide a preview! ([#534](https://github.com/helix-editor/helix/pull/534)) +- Injection query support. Rust macro calls and embedded languages are now properly highlighted ([#430](https://github.com/helix-editor/helix/pull/430)) +- Formatting is now asynchronous, and the async job infrastructure has been improved ([#285](https://github.com/helix-editor/helix/pull/285)) +- Grammars are now compiled as separate shared libraries and loaded on-demand at runtime ([#432](https://github.com/helix-editor/helix/pull/432)) +- Code action support ([#478](https://github.com/helix-editor/helix/pull/478)) +- Mouse support ([#509](https://github.com/helix-editor/helix/pull/509), [#548](https://github.com/helix-editor/helix/pull/548)) +- Native Windows clipboard support ([#373](https://github.com/helix-editor/helix/pull/373)) +- Themes can now use color palettes ([#393](https://github.com/helix-editor/helix/pull/393)) +- `:reload` command ([#374](https://github.com/helix-editor/helix/pull/374)) +- Ctrl-z to suspend ([#464](https://github.com/helix-editor/helix/pull/464)) +- Language servers can now be configured with a custom JSON config ([#460](https://github.com/helix-editor/helix/pull/460)) +- Comment toggling now uses a language specific comment token ([#463](https://github.com/helix-editor/helix/pull/463)) +- Julia support ([#413](https://github.com/helix-editor/helix/pull/413)) +- Java support ([#448](https://github.com/helix-editor/helix/pull/448)) +- Prompts have an (in-memory) history ([63e54e30](https://github.com/helix-editor/helix/commit/63e54e30a74bb0d1d782877ddbbcf95f2817d061)) + +# 0.3.0 (2021-06-27) Another big release. diff --git a/Cargo.lock b/Cargo.lock index 5a5fcf4b44865..9303aa1205bd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486" +checksum = "28ae2b3dec75a406790005a200b1bd89785afc02517a00ca99ecfe093ee9e6cf" [[package]] name = "arc-swap" @@ -31,9 +31,9 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bstr" @@ -62,12 +62,6 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.0" @@ -76,11 +70,11 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chardetng" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81a81b0d8f8ee23417182818b4f06312c5f535c2b04eef1773f7c24bbdf8c500" +checksum = "36a5a2ca47925d19fb6835f53b3e70dec0d25659211c8ee5cc784f1fd6838f9c" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "encoding_rs", "memchr", ] @@ -114,7 +108,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "lazy_static", ] @@ -150,7 +144,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "dirs-sys-next", ] @@ -177,7 +171,7 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -196,7 +190,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016b04fd1e94fb833d432634245c9bb61cf1c7409668a0e7d4c3ab00c5172dec" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "dirs-next", "thiserror", ] @@ -288,7 +282,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "wasi", ] @@ -308,7 +302,7 @@ dependencies = [ [[package]] name = "helix-core" -version = "0.3.0" +version = "0.4.1" dependencies = [ "arc-swap", "etcetera", @@ -330,7 +324,7 @@ dependencies = [ [[package]] name = "helix-lsp" -version = "0.3.0" +version = "0.4.1" dependencies = [ "anyhow", "futures-executor", @@ -348,7 +342,7 @@ dependencies = [ [[package]] name = "helix-syntax" -version = "0.3.0" +version = "0.4.1" dependencies = [ "anyhow", "cc", @@ -359,7 +353,7 @@ dependencies = [ [[package]] name = "helix-term" -version = "0.3.0" +version = "0.4.1" dependencies = [ "anyhow", "chrono", @@ -386,7 +380,7 @@ dependencies = [ [[package]] name = "helix-tui" -version = "0.3.0" +version = "0.4.1" dependencies = [ "bitflags", "cassowary", @@ -399,7 +393,7 @@ dependencies = [ [[package]] name = "helix-view" -version = "0.3.0" +version = "0.4.1" dependencies = [ "anyhow", "bitflags", @@ -465,7 +459,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee0328b1209d157ef001c94dd85b4f8f64139adb0eac2659f4b08382b2f474d" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -495,9 +489,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.98" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" +checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" [[package]] name = "libloading" @@ -505,7 +499,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f84d96438c15fcd6c3f244c8fce01d1e2b9c6b5623e9c711dc9286d8fc92d6a" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "winapi", ] @@ -524,7 +518,7 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -548,15 +542,15 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "matches" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "memchr" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "mio" @@ -647,7 +641,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "instant", "libc", "redox_syscall", @@ -675,9 +669,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" dependencies = [ "unicode-xid", ] @@ -731,9 +725,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ "bitflags", ] @@ -887,9 +881,9 @@ checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" [[package]] name = "slab" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" +checksum = "c307a32c1c5c437f38c7fd45d753050587732ba8628319fbdf12a7e289ccc590" [[package]] name = "slotmap" @@ -914,9 +908,9 @@ checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" [[package]] name = "syn" -version = "1.0.73" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" +checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" dependencies = [ "proc-macro2", "quote", @@ -974,9 +968,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac2e1d4bd0f75279cfd5a076e0d578bbf02c22b7c39e766c437dd49b3ec43e0" +checksum = "848a1e1181b9f6753b5e96a092749e29b11d19ede67dfbbd6c7dc7e0f49b5338" dependencies = [ "tinyvec_macros", ] @@ -989,9 +983,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b7b349f11a7047e6d1276853e612d152f5e8a352c61917887cc2169e2366b4c" +checksum = "01cf844b23c6131f624accf65ce0e4e9956a8bb329400ea5bcc26ae3a5c20b0b" dependencies = [ "autocfg", "bytes", @@ -1059,12 +1053,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" -dependencies = [ - "matches", -] +checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" [[package]] name = "unicode-general-category" diff --git a/README.md b/README.md index e0af744c06038..199caee633f28 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ myself agreeing with most of kakoune's design decisions. For more information, see the [website](https://helix-editor.com) or [documentation](https://docs.helix-editor.com/). -All shortcuts/keymaps can be found [in the documentation on the website](https://docs.helix-editor.com/keymap.html) +All shortcuts/keymaps can be found [in the documentation on the website](https://docs.helix-editor.com/keymap.html). + +[Troubleshooting](https://github.com/helix-editor/helix/wiki/Troubleshooting) # Features diff --git a/TODO.md b/TODO.md index 2354bef97365c..f6a9ef09fb5e0 100644 --- a/TODO.md +++ b/TODO.md @@ -22,19 +22,12 @@ as you type completion! - [ ] = for auto indent line/selection - [ ] :x for closing buffers - - [ ] repeat selection -- [] jump to alt buffer - - [ ] lsp: signature help -- [ ] lsp: code actions -- [ ] lsp: formatting - [ ] search: smart case by default: insensitive unless upper detected -- [ ] move Compositor into tui - 2 - [ ] macro recording - [ ] extend selection (treesitter select parent node) (replaces viw, vi(, va( etc ) diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 5dea311283ce6..3fa8e0676705f 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -2,6 +2,7 @@ - [Installation](./install.md) - [Usage](./usage.md) +- [Migrating from Vim](./from-vim.md) - [Configuration](./configuration.md) - [Themes](./themes.md) - [Keymap](./keymap.md) diff --git a/book/src/configuration.md b/book/src/configuration.md index 5ca2391103cfd..00dfbbd836e8c 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -1,6 +1,9 @@ # Configuration -To override global configuration parameters create a `config.toml` file located in your config directory (i.e `~/.config/helix/config.toml`). +To override global configuration parameters, create a `config.toml` file located in your config directory: + +* Linux and Mac: `~/.config/helix/config.toml` +* Windows: `%AppData%\helix\config.toml` ## LSP diff --git a/book/src/from-vim.md b/book/src/from-vim.md new file mode 100644 index 0000000000000..8e9bbac39362f --- /dev/null +++ b/book/src/from-vim.md @@ -0,0 +1,10 @@ +# Migrating from Vim + +Helix's editing model is strongly inspired from vim and kakoune, and a notable +difference from vim (and the most striking similarity to kakoune) is that Helix +follows the `selection → action` model. This means that the whatever you are +going to act on (a word, a paragraph, a line, etc) is selected first and the +action itself (delete, change, yank, etc) comes second. A cursor is simply a +single width selection. + +> TODO: Mention texobjects, surround, registers diff --git a/book/src/keymap.md b/book/src/keymap.md index 156e694d02597..4eb8563693d97 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -59,6 +59,7 @@ | `y` | Yank selection | | `p` | Paste after selection | | `P` | Paste before selection | +| `"` `` | Select a register to yank to or paste from | | `>` | Indent selection | | `<` | Unindent selection | | `=` | Format selection | diff --git a/book/src/remapping.md b/book/src/remapping.md index b4f4005bf1110..3f25e364d7634 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -13,10 +13,12 @@ this: [keys.normal] a = "move_char_left" # Maps the 'a' key to the move_char_left command w = "move_line_up" # Maps the 'w' key move_line_up -C-S-esc = "extend_line" # Maps Control-Shift-Escape to extend_line +"C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line +g = { a = "code_action" } # Maps `ga` to show possible code actions [keys.insert] -A-x = "normal_mode" # Maps Alt-X to enter normal mode +"A-x" = "normal_mode" # Maps Alt-X to enter normal mode +j = { k = "normal_mode" } # Maps `jk` to exit insert mode ``` Control, Shift and Alt modifiers are encoded respectively with the prefixes diff --git a/book/src/usage.md b/book/src/usage.md index 0458071a541a2..9ee8634c67946 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -2,6 +2,28 @@ (Currently not fully documented, see the [keymappings](./keymap.md) list for more.) +## Registers + +Vim-like registers can be used to yank and store text to be pasted later. Usage is similar, with `"` being used to select a register: + +- `"ay` - Yank the current selection to register `a`. +- `"op` - Paste the text in register `o` after the selection. + +If there is a selected register 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. + +### Special Registers + +| Register character | Contains | +| --- | --- | +| `/` | Last search | +| `:` | Last executed command | +| `"` | Last yanked text | + +> There is no special register for copying to system clipboard, instead special commands and keybindings are provided. See the [keymap](keymap.md#space-mode) for the specifics. + ## Surround Functionality similar to [vim-surround](https://github.com/tpope/vim-surround) is built into diff --git a/flake.lock b/flake.lock index 4aec1594da6d0..054a515443212 100644 --- a/flake.lock +++ b/flake.lock @@ -40,11 +40,11 @@ "rustOverlay": "rustOverlay" }, "locked": { - "lastModified": 1627940369, - "narHash": "sha256-KtY837WKsX9B/pIKFDKzN0wl1t3et1JZjMjGa7SAZxI=", + "lastModified": 1628489367, + "narHash": "sha256-ADYKHf8aPo1qTw1J+eqVprnEbH8lES0yZamD/yM7RAM=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "fac8518469e226db4805ff80788979c847b0c322", + "rev": "0dc8383aae5f791a48e34120edb04670b947dc0b", "type": "github" }, "original": { @@ -55,11 +55,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1627391865, - "narHash": "sha256-tPoWBO9Nzu3wuX37WcnctzL6LoDCErJLnfLGqqmXCm4=", + "lastModified": 1628465643, + "narHash": "sha256-QSNw9bDq9uGUniQQtakRuw4m21Jxugm23SXLVgEV4DM=", "owner": "nixos", "repo": "nixpkgs", - "rev": "8ecc61c91a596df7d3293603a9c2384190c1b89a", + "rev": "6ef4f522d63f22b40004319778761040d3197390", "type": "github" }, "original": { @@ -79,11 +79,11 @@ "rustOverlay": { "flake": false, "locked": { - "lastModified": 1627870491, - "narHash": "sha256-0Myg04QOIcTN1RhgfRNx0i/iCRyVyf/Z6rJxZUmot5k=", + "lastModified": 1628475192, + "narHash": "sha256-A32shcfPMCll7psCS0OBxVCkA+PKfeWvmU4y9lgNZzU=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "71d825269cfaa30605d058bd92381be9af87b0be", + "rev": "56a8ddb827cbe7a914be88f4a52998a5f93ff468", "type": "github" }, "original": { diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 4316dc2c49f12..8c83816cf16bf 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-core" -version = "0.3.0" +version = "0.4.1" authors = ["Blaž Hrastnik "] edition = "2018" license = "MPL-2.0" @@ -13,7 +13,7 @@ include = ["src/**/*", "README.md"] [features] [dependencies] -helix-syntax = { version = "0.3", path = "../helix-syntax" } +helix-syntax = { version = "0.4", path = "../helix-syntax" } ropey = "1.3" smallvec = "1.4" diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index d9569acd763f1..9b901e9b128c9 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -84,6 +84,7 @@ fn handle_open( match next { Some(ch) if !close_before.contains(ch) => { + offs += 1; // TODO: else return (use default handler that inserts open) (pos, pos, Some(Tendril::from_char(open))) } diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index d272dd68db9c7..f5f36acac8a29 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -313,6 +313,26 @@ pub fn suggested_indent_for_pos( } } +pub fn get_scopes(syntax: Option<&Syntax>, text: RopeSlice, pos: usize) -> Vec<&'static str> { + let mut scopes = Vec::new(); + if let Some(syntax) = syntax { + let byte_start = text.char_to_byte(pos); + let node = match get_highest_syntax_node_at_bytepos(syntax, byte_start) { + Some(node) => node, + None => return scopes, + }; + + scopes.push(node.kind()); + + while let Some(parent) = node.parent() { + scopes.push(parent.kind()) + } + } + + scopes.reverse(); + scopes +} + #[cfg(test)] mod test { use super::*; diff --git a/helix-core/src/match_brackets.rs b/helix-core/src/match_brackets.rs index f3d9e845e7aee..a4b2fb9c8503f 100644 --- a/helix-core/src/match_brackets.rs +++ b/helix-core/src/match_brackets.rs @@ -26,7 +26,7 @@ pub fn find(syntax: &Syntax, doc: &Rope, pos: usize) -> Option { let len = doc.len_bytes(); let start_byte = node.start_byte(); - let end_byte = node.end_byte() - 1; // it's end exclusive + let end_byte = node.end_byte().saturating_sub(1); // it's end exclusive if start_byte >= len || end_byte >= len { return None; } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 60d44976f2a79..4bceb73b31fe8 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -656,8 +656,10 @@ impl LanguageLayer { let edits = Self::generate_edits(old_source.slice(..), changeset); // Notify the tree about all the changes - for edit in edits { - self.tree.as_mut().unwrap().edit(&edit); + for edit in edits.iter().rev() { + // apply the edits in reverse. If we applied them in order then edit 1 would disrupt + // the positioning of edit 2 + self.tree.as_mut().unwrap().edit(edit); } self.parse( diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index ef9feb947b232..2d4a16c6de584 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-lsp" -version = "0.3.0" +version = "0.4.1" authors = ["Blaž Hrastnik "] edition = "2018" license = "MPL-2.0" @@ -12,7 +12,7 @@ homepage = "https://helix-editor.com" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -helix-core = { version = "0.3", path = "../helix-core" } +helix-core = { version = "0.4", path = "../helix-core" } anyhow = "1.0" futures-executor = "0.3" diff --git a/helix-syntax/Cargo.toml b/helix-syntax/Cargo.toml index 7ad244886fe45..73eda4720ed0b 100644 --- a/helix-syntax/Cargo.toml +++ b/helix-syntax/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-syntax" -version = "0.3.0" +version = "0.4.1" authors = ["Blaž Hrastnik "] edition = "2018" license = "MPL-2.0" diff --git a/helix-syntax/build.rs b/helix-syntax/build.rs index 02c4bc0ac999e..75f8c970b8f87 100644 --- a/helix-syntax/build.rs +++ b/helix-syntax/build.rs @@ -77,7 +77,8 @@ fn build_library(src_path: &Path, language: &str) -> Result<()> { command .args(&["/nologo", "/LD", "/I"]) .arg(header_path) - .arg("/Od"); + .arg("/Od") + .arg("/utf-8"); if let Some(scanner_path) = scanner_path.as_ref() { command.arg(scanner_path); } @@ -105,6 +106,9 @@ fn build_library(src_path: &Path, language: &str) -> Result<()> { } } command.arg("-xc").arg(parser_path); + if cfg!(all(unix, not(target_os = "macos"))) { + command.arg("-Wl,-z,relro,-z,now"); + } } let output = command diff --git a/helix-syntax/languages/tree-sitter-ledger b/helix-syntax/languages/tree-sitter-ledger new file mode 160000 index 0000000000000..72319504776f1 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-ledger @@ -0,0 +1 @@ +Subproject commit 72319504776f14193472a6ad14abec0af0225cbe diff --git a/helix-syntax/languages/tree-sitter-protobuf b/helix-syntax/languages/tree-sitter-protobuf new file mode 160000 index 0000000000000..3eb3da67280d8 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-protobuf @@ -0,0 +1 @@ +Subproject commit 3eb3da67280d8fc32d644c484d05a6ae7f7e4b8f diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 0e2baae37e106..b42daff6cae14 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-term" -version = "0.3.0" +version = "0.4.1" description = "A post-modern text editor." authors = ["Blaž Hrastnik "] edition = "2018" @@ -21,9 +21,9 @@ name = "hx" path = "src/main.rs" [dependencies] -helix-core = { version = "0.3", path = "../helix-core" } -helix-view = { version = "0.3", path = "../helix-view" } -helix-lsp = { version = "0.3", path = "../helix-lsp" } +helix-core = { version = "0.4", path = "../helix-core" } +helix-view = { version = "0.4", path = "../helix-view" } +helix-lsp = { version = "0.4", path = "../helix-lsp" } anyhow = "1" once_cell = "1.8" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 9cd9ee7e4fd9d..3d59c33a73330 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -527,7 +527,9 @@ impl Application { self.event_loop().await; - self.editor.close_language_servers(None).await?; + if self.editor.close_language_servers(None).await.is_err() { + log::error!("Timed out waiting for language servers to shutdown"); + }; self.restore_term()?; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7b2041cd3db48..58641e829df06 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -12,8 +12,8 @@ use helix_core::{ }; use helix_view::{ - document::Mode, editor::Action, input::KeyEvent, keyboard::KeyCode, view::View, Document, - DocumentId, Editor, ViewId, + clipboard::ClipboardType, document::Mode, editor::Action, input::KeyEvent, keyboard::KeyCode, + view::View, Document, DocumentId, Editor, ViewId, }; use anyhow::{anyhow, bail, Context as _}; @@ -27,11 +27,11 @@ use movement::Movement; use crate::{ compositor::{self, Component, Compositor}, - ui::{self, Picker, Popup, Prompt, PromptEvent}, + ui::{self, FilePicker, Picker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Job, Jobs}; -use futures_util::{FutureExt, TryFutureExt}; +use futures_util::FutureExt; use std::num::NonZeroUsize; use std::{fmt, future::Future}; @@ -110,13 +110,15 @@ fn align_view(doc: &Document, view: &mut View, align: Align) { .cursor(doc.text().slice(..)); let line = doc.text().char_to_line(pos); + let height = view.inner_area().height as usize; + let relative = match align { - Align::Center => view.area.height as usize / 2, + Align::Center => height / 2, Align::Top => 0, - Align::Bottom => view.area.height as usize, + Align::Bottom => height, }; - view.first_line = line.saturating_sub(relative); + view.offset.row = line.saturating_sub(relative); } /// A command is composed of a static name, and a function that takes the current state plus a count, @@ -256,12 +258,17 @@ impl Command { yank, "Yank selection", yank_joined_to_clipboard, "Join and yank selections to clipboard", yank_main_selection_to_clipboard, "Yank main selection to clipboard", + yank_joined_to_primary_clipboard, "Join and yank selections to primary clipboard", + yank_main_selection_to_primary_clipboard, "Yank main selection to primary clipboard", replace_with_yanked, "Replace with yanked text", replace_selections_with_clipboard, "Replace selections by clipboard content", + replace_selections_with_primary_clipboard, "Replace selections by primary clipboard content", paste_after, "Paste after selection", paste_before, "Paste before selection", paste_clipboard_after, "Paste clipboard after selections", paste_clipboard_before, "Paste clipboard before selections", + paste_primary_clipboard_after, "Paste primary clipboard after selections", + paste_primary_clipboard_before, "Paste primary clipboard before selections", indent, "Indent selection", unindent, "Unindent selection", format_selections, "Format selection", @@ -453,17 +460,18 @@ fn goto_first_nonwhitespace(cx: &mut Context) { fn goto_window(cx: &mut Context, align: Align) { let (view, doc) = current!(cx.editor); - let scrolloff = cx - .editor - .config - .scrolloff - .min(view.area.height as usize / 2); // TODO: user pref + let height = view.inner_area().height as usize; + + // - 1 so we have at least one gap in the middle. + // a height of 6 with padding of 3 on each side will keep shifting the view back and forth + // as we type + let scrolloff = cx.editor.config.scrolloff.min(height.saturating_sub(1) / 2); let last_line = view.last_line(doc); let line = match align { - Align::Top => (view.first_line + scrolloff), - Align::Center => (view.first_line + (view.area.height as usize / 2)), + Align::Top => (view.offset.row + scrolloff), + Align::Center => (view.offset.row + (height / 2)), Align::Bottom => last_line.saturating_sub(scrolloff), } .min(last_line.saturating_sub(scrolloff)); @@ -876,34 +884,31 @@ fn switch_to_lowercase(cx: &mut Context) { doc.append_changes_to_history(view.id); } -fn scroll(cx: &mut Context, offset: usize, direction: Direction) { +pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { use Direction::*; let (view, doc) = current!(cx.editor); - let cursor = coords_at_pos( - doc.text().slice(..), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - ); - let doc_last_line = doc.text().len_lines() - 1; + + let range = doc.selection(view.id).primary(); + let text = doc.text().slice(..); + + let cursor = coords_at_pos(text, range.cursor(text)); + let doc_last_line = doc.text().len_lines().saturating_sub(1); let last_line = view.last_line(doc); - if direction == Backward && view.first_line == 0 + if direction == Backward && view.offset.row == 0 || direction == Forward && last_line == doc_last_line { return; } - let scrolloff = cx - .editor - .config - .scrolloff - .min(view.area.height as usize / 2); // TODO: user pref + let height = view.inner_area().height; + + let scrolloff = cx.editor.config.scrolloff.min(height as usize / 2); - view.first_line = match direction { - Forward => view.first_line + offset, - Backward => view.first_line.saturating_sub(offset), + view.offset.row = match direction { + Forward => view.offset.row + offset, + Backward => view.offset.row.saturating_sub(offset), } .min(doc_last_line); @@ -913,37 +918,42 @@ fn scroll(cx: &mut Context, offset: usize, direction: Direction) { // clamp into viewport let line = cursor .row - .max(view.first_line + scrolloff) + .max(view.offset.row + scrolloff) .min(last_line.saturating_sub(scrolloff)); - let text = doc.text().slice(..); - let pos = pos_at_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end + let head = pos_at_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end + + let anchor = if doc.mode == Mode::Select { + range.anchor + } else { + head + }; // TODO: only manipulate main selection - doc.set_selection(view.id, Selection::point(pos)); + doc.set_selection(view.id, Selection::single(anchor, head)); } fn page_up(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.area.height as usize; + let offset = view.inner_area().height as usize; scroll(cx, offset, Direction::Backward); } fn page_down(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.area.height as usize; + let offset = view.inner_area().height as usize; scroll(cx, offset, Direction::Forward); } fn half_page_up(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.area.height as usize / 2; + let offset = view.inner_area().height as usize / 2; scroll(cx, offset, Direction::Backward); } fn half_page_down(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.area.height as usize / 2; + let offset = view.inner_area().height as usize / 2; scroll(cx, offset, Direction::Forward); } @@ -1153,7 +1163,7 @@ fn search(cx: &mut Context) { move |view, doc, registers, regex| { search_impl(doc, view, &contents, ®ex, false); // TODO: only store on enter (accept), not update - registers.write('\\', vec![regex.as_str().to_string()]); + registers.write('/', vec![regex.as_str().to_string()]); }, ); @@ -1163,7 +1173,7 @@ fn search(cx: &mut Context) { fn search_next_impl(cx: &mut Context, extend: bool) { let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; - if let Some(query) = registers.read('\\') { + if let Some(query) = registers.read('/') { let query = query.first().unwrap(); let contents = doc.text().slice(..).to_string(); let regex = Regex::new(query).unwrap(); @@ -1184,7 +1194,7 @@ fn search_selection(cx: &mut Context) { let contents = doc.text().slice(..); let query = doc.selection(view.id).primary().fragment(contents); let regex = regex::escape(&query); - cx.editor.registers.write('\\', vec![regex]); + cx.editor.registers.write('/', vec![regex]); search_next(cx); } @@ -1386,7 +1396,7 @@ mod cmd { fn write_impl>( cx: &mut compositor::Context, path: Option

, - ) -> Result>, anyhow::Error> { + ) -> anyhow::Result<()> { let jobs = &mut cx.jobs; let (_, doc) = current!(cx.editor); @@ -1407,7 +1417,9 @@ mod cmd { jobs.callback(callback); shared }); - Ok(tokio::spawn(doc.format_and_save(fmt))) + let future = doc.format_and_save(fmt); + cx.jobs.add(Job::new(future).wait_before_exiting()); + Ok(()) } fn write( @@ -1415,11 +1427,7 @@ mod cmd { args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - let handle = write_impl(cx, args.first())?; - cx.jobs - .add(Job::new(handle.unwrap_or_else(|e| Err(e.into()))).wait_before_exiting()); - - Ok(()) + write_impl(cx, args.first()) } fn new_file( @@ -1510,18 +1518,22 @@ mod cmd { return Ok(()); } + let arg = args + .get(0) + .context("argument missing")? + .to_ascii_lowercase(); + // Attempt to parse argument as a line ending. - let line_ending = match args.get(0) { + let line_ending = match arg { // We check for CR first because it shares a common prefix with CRLF. - Some(arg) if "cr".starts_with(&arg.to_lowercase()) => Some(CR), - Some(arg) if "crlf".starts_with(&arg.to_lowercase()) => Some(Crlf), - Some(arg) if "lf".starts_with(&arg.to_lowercase()) => Some(LF), - Some(arg) if "ff".starts_with(&arg.to_lowercase()) => Some(FF), - Some(arg) if "nel".starts_with(&arg.to_lowercase()) => Some(Nel), - _ => None, + arg if arg.starts_with("cr") => CR, + arg if arg.starts_with("crlf") => Crlf, + arg if arg.starts_with("lf") => LF, + arg if arg.starts_with("ff") => FF, + arg if arg.starts_with("nel") => Nel, + _ => bail!("invalid line ending"), }; - let line_ending = line_ending.context("invalid line ending")?; doc_mut!(cx.editor).line_ending = line_ending; Ok(()) } @@ -1562,8 +1574,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - let handle = write_impl(cx, args.first())?; - let _ = helix_lsp::block_on(handle)?; + write_impl(cx, args.first())?; quit(cx, &[], event) } @@ -1572,8 +1583,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - let handle = write_impl(cx, args.first())?; - let _ = helix_lsp::block_on(handle)?; + write_impl(cx, args.first())?; force_quit(cx, &[], event) } @@ -1600,7 +1610,7 @@ mod cmd { } fn write_all_impl( - editor: &mut Editor, + cx: &mut compositor::Context, _args: &[&str], _event: PromptEvent, quit: bool, @@ -1609,25 +1619,26 @@ mod cmd { let mut errors = String::new(); // save all documents - for (_, doc) in &mut editor.documents { + for (_, doc) in &mut cx.editor.documents { if doc.path().is_none() { errors.push_str("cannot write a buffer without a filename\n"); continue; } // TODO: handle error. - let _ = helix_lsp::block_on(tokio::spawn(doc.save())); + let handle = doc.save(); + cx.jobs.add(Job::new(handle).wait_before_exiting()); } if quit { if !force { - buffers_remaining_impl(editor)?; + buffers_remaining_impl(cx.editor)?; } // close all views - let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); + let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { - editor.close(view_id, false); + cx.editor.close(view_id, false); } } @@ -1639,7 +1650,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - write_all_impl(&mut cx.editor, args, event, false, false) + write_all_impl(cx, args, event, false, false) } fn write_all_quit( @@ -1647,7 +1658,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - write_all_impl(&mut cx.editor, args, event, true, false) + write_all_impl(cx, args, event, true, false) } fn force_write_all_quit( @@ -1655,7 +1666,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - write_all_impl(&mut cx.editor, args, event, true, true) + write_all_impl(cx, args, event, true, true) } fn quit_all_impl( @@ -1707,7 +1718,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(&mut cx.editor) + yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard) } fn yank_joined_to_clipboard( @@ -1720,7 +1731,28 @@ mod cmd { .first() .copied() .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator) + yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Clipboard) + } + + fn yank_main_selection_to_primary_clipboard( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection) + } + + fn yank_joined_to_primary_clipboard( + cx: &mut compositor::Context, + args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let (_, doc) = current!(cx.editor); + let separator = args + .first() + .copied() + .unwrap_or_else(|| doc.line_ending.as_str()); + yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Selection) } fn paste_clipboard_after( @@ -1728,7 +1760,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After) + paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) } fn paste_clipboard_before( @@ -1736,17 +1768,32 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After) + paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) } - fn replace_selections_with_clipboard( + fn paste_primary_clipboard_after( cx: &mut compositor::Context, _args: &[&str], _event: PromptEvent, + ) -> anyhow::Result<()> { + paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + } + + fn paste_primary_clipboard_before( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + } + + fn replace_selections_with_clipboard_impl( + cx: &mut compositor::Context, + clipboard_type: ClipboardType, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); - match cx.editor.clipboard_provider.get_contents() { + match cx.editor.clipboard_provider.get_contents(clipboard_type) { Ok(contents) => { let selection = doc.selection(view.id); let transaction = @@ -1762,13 +1809,29 @@ mod cmd { } } + fn replace_selections_with_clipboard( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) + } + + fn replace_selections_with_primary_clipboard( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) + } + fn show_clipboard_provider( cx: &mut compositor::Context, _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor - .set_status(cx.editor.clipboard_provider.name().into()); + .set_status(cx.editor.clipboard_provider.name().to_string()); Ok(()) } @@ -1780,7 +1843,7 @@ mod cmd { let dir = args.first().context("target directory not provided")?; if let Err(e) = std::env::set_current_dir(dir) { - bail!("Couldn't change the current working directory: {:?}", e); + bail!("Couldn't change the current working directory: {}", e); } let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; @@ -1828,6 +1891,20 @@ mod cmd { doc.reload(view.id) } + fn tree_sitter_scopes( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let pos = doc.selection(view.id).primary().cursor(text); + let scopes = indent::get_scopes(doc.syntax(), text, pos); + cx.editor.set_status(format!("scopes: {:?}", &scopes)); + Ok(()) + } + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -1969,6 +2046,20 @@ mod cmd { fun: yank_joined_to_clipboard, completer: None, }, + TypableCommand { + name: "primary-clipboard-yank", + alias: None, + doc: "Yank main selection into system primary clipboard.", + fun: yank_main_selection_to_primary_clipboard, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-yank-join", + alias: None, + doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. + fun: yank_joined_to_primary_clipboard, + completer: None, + }, TypableCommand { name: "clipboard-paste-after", alias: None, @@ -1990,6 +2081,27 @@ mod cmd { fun: replace_selections_with_clipboard, completer: None, }, + TypableCommand { + name: "primary-clipboard-paste-after", + alias: None, + doc: "Paste primary clipboard after selections.", + fun: paste_primary_clipboard_after, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-before", + alias: None, + doc: "Paste primary clipboard before selections.", + fun: paste_primary_clipboard_before, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-replace", + alias: None, + doc: "Replace selections with content of system primary clipboard.", + fun: replace_selections_with_primary_clipboard, + completer: None, + }, TypableCommand { name: "show-clipboard-provider", alias: None, @@ -2024,6 +2136,13 @@ mod cmd { doc: "Discard changes and reload from the source file.", fun: reload, completer: None, + }, + TypableCommand { + name: "tree-sitter-scopes", + alias: None, + doc: "Display tree sitter scopes, primarily for theming and development.", + fun: tree_sitter_scopes, + completer: None, } ]; @@ -2122,7 +2241,7 @@ fn file_picker(cx: &mut Context) { fn buffer_picker(cx: &mut Context) { let current = view!(cx.editor).doc; - let picker = Picker::new( + let picker = FilePicker::new( cx.editor .documents .iter() @@ -2144,6 +2263,15 @@ fn buffer_picker(cx: &mut Context) { |editor: &mut Editor, (id, _path): &(DocumentId, Option), _action| { editor.switch(*id, Action::Replace); }, + |editor, (id, path)| { + let doc = &editor.documents.get(*id)?; + let &view_id = doc.selections().keys().next()?; + let line = doc + .selection(view_id) + .primary() + .cursor_line(doc.text().slice(..)); + Some((path.clone()?, Some(line))) + }, ); cx.push_layer(Box::new(picker)); } @@ -2197,7 +2325,7 @@ fn symbol_picker(cx: &mut Context) { } }; - let picker = Picker::new( + let picker = FilePicker::new( symbols, |symbol| (&symbol.name).into(), move |editor: &mut Editor, symbol, _action| { @@ -2207,10 +2335,17 @@ fn symbol_picker(cx: &mut Context) { if let Some(range) = lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) { - doc.set_selection(view.id, Selection::single(range.to(), range.from())); + // we flip the range so that the cursor sits on the start of the symbol + // (for example start of the function). + doc.set_selection(view.id, Selection::single(range.head, range.anchor)); align_view(doc, view, Align::Center); } }, + move |_editor, symbol| { + let path = symbol.location.uri.to_file_path().unwrap(); + let line = Some(symbol.location.range.start.line as usize); + Some((path, line)) + }, ); compositor.push(Box::new(picker)) } @@ -2242,6 +2377,7 @@ pub fn code_action(cx: &mut Context) { response: Option| { if let Some(actions) = response { let picker = Picker::new( + true, actions, |action| match action { lsp::CodeActionOrCommand::CodeAction(action) => { @@ -2565,7 +2701,10 @@ fn select_mode(cx: &mut Context) { } fn exit_select_mode(cx: &mut Context) { - doc_mut!(cx.editor).mode = Mode::Normal; + let doc = doc_mut!(cx.editor); + if doc.mode == Mode::Select { + doc.mode = Mode::Normal; + } } fn goto_impl( @@ -2610,7 +2749,7 @@ fn goto_impl( editor.set_error("No definition found.".to_string()); } _locations => { - let picker = ui::Picker::new( + let picker = FilePicker::new( locations, move |location| { let file: Cow<'_, str> = (location.uri.scheme() == "file") @@ -2635,6 +2774,11 @@ fn goto_impl( move |editor: &mut Editor, location, action| { jump_to(editor, location, offset_encoding, action) }, + |_editor, location| { + let path = location.uri.to_file_path().unwrap(); + let line = Some(location.range.start.line as usize); + Some((path, line)) + }, ); compositor.push(Box::new(picker)); } @@ -3208,7 +3352,11 @@ fn yank(cx: &mut Context) { exit_select_mode(cx); } -fn yank_joined_to_clipboard_impl(editor: &mut Editor, separator: &str) -> anyhow::Result<()> { +fn yank_joined_to_clipboard_impl( + editor: &mut Editor, + separator: &str, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { let (view, doc) = current!(editor); let text = doc.text().slice(..); @@ -3227,7 +3375,7 @@ fn yank_joined_to_clipboard_impl(editor: &mut Editor, separator: &str) -> anyhow editor .clipboard_provider - .set_contents(joined) + .set_contents(joined, clipboard_type) .context("Couldn't set system clipboard content")?; editor.set_status(msg); @@ -3237,18 +3385,28 @@ fn yank_joined_to_clipboard_impl(editor: &mut Editor, separator: &str) -> anyhow fn yank_joined_to_clipboard(cx: &mut Context) { let line_ending = current!(cx.editor).1.line_ending; - let _ = yank_joined_to_clipboard_impl(&mut cx.editor, line_ending.as_str()); + let _ = yank_joined_to_clipboard_impl( + &mut cx.editor, + line_ending.as_str(), + ClipboardType::Clipboard, + ); exit_select_mode(cx); } -fn yank_main_selection_to_clipboard_impl(editor: &mut Editor) -> anyhow::Result<()> { +fn yank_main_selection_to_clipboard_impl( + editor: &mut Editor, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { let (view, doc) = current!(editor); let text = doc.text().slice(..); let value = doc.selection(view.id).primary().fragment(text); - if let Err(e) = editor.clipboard_provider.set_contents(value.into_owned()) { - bail!("Couldn't set system clipboard content: {:?}", e); + if let Err(e) = editor + .clipboard_provider + .set_contents(value.into_owned(), clipboard_type) + { + bail!("Couldn't set system clipboard content: {}", e); } editor.set_status("yanked main selection to system clipboard".to_owned()); @@ -3256,7 +3414,20 @@ fn yank_main_selection_to_clipboard_impl(editor: &mut Editor) -> anyhow::Result< } fn yank_main_selection_to_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor); + let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); +} + +fn yank_joined_to_primary_clipboard(cx: &mut Context) { + let line_ending = current!(cx.editor).1.line_ending; + let _ = yank_joined_to_clipboard_impl( + &mut cx.editor, + line_ending.as_str(), + ClipboardType::Selection, + ); +} + +fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { + let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection); exit_select_mode(cx); } @@ -3309,12 +3480,16 @@ fn paste_impl( Some(transaction) } -fn paste_clipboard_impl(editor: &mut Editor, action: Paste) -> anyhow::Result<()> { +fn paste_clipboard_impl( + editor: &mut Editor, + action: Paste, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { let (view, doc) = current!(editor); match editor .clipboard_provider - .get_contents() + .get_contents(clipboard_type) .map(|contents| paste_impl(&[contents], doc, view, action)) { Ok(Some(transaction)) => { @@ -3328,11 +3503,19 @@ fn paste_clipboard_impl(editor: &mut Editor, action: Paste) -> anyhow::Result<() } fn paste_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::After); + let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard); } fn paste_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before); + let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Clipboard); +} + +fn paste_primary_clipboard_after(cx: &mut Context) { + let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection); +} + +fn paste_primary_clipboard_before(cx: &mut Context) { + let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Selection); } fn replace_with_yanked(cx: &mut Context) { @@ -3357,10 +3540,13 @@ fn replace_with_yanked(cx: &mut Context) { } } -fn replace_selections_with_clipboard_impl(editor: &mut Editor) -> anyhow::Result<()> { +fn replace_selections_with_clipboard_impl( + editor: &mut Editor, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { let (view, doc) = current!(editor); - match editor.clipboard_provider.get_contents() { + match editor.clipboard_provider.get_contents(clipboard_type) { Ok(contents) => { let selection = doc.selection(view.id); let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { @@ -3376,7 +3562,11 @@ fn replace_selections_with_clipboard_impl(editor: &mut Editor) -> anyhow::Result } fn replace_selections_with_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(&mut cx.editor); + let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); +} + +fn replace_selections_with_primary_clipboard(cx: &mut Context) { + let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Selection); } fn paste_after(cx: &mut Context) { @@ -3590,8 +3780,7 @@ fn keep_primary_selection(cx: &mut Context) { let (view, doc) = current!(cx.editor); let range = doc.selection(view.id).primary(); - let selection = Selection::single(range.anchor, range.head); - doc.set_selection(view.id, selection); + doc.set_selection(view.id, Selection::single(range.anchor, range.head)); } fn completion(cx: &mut Context) { @@ -3747,6 +3936,7 @@ fn toggle_comments(cx: &mut Context) { doc.apply(&transaction, view.id); doc.append_changes_to_history(view.id); + exit_select_mode(cx); } fn rotate_selections(cx: &mut Context, direction: Direction) { @@ -3881,13 +4071,13 @@ fn split(cx: &mut Context, action: Action) { let (view, doc) = current!(cx.editor); let id = doc.id(); let selection = doc.selection(view.id).clone(); - let first_line = view.first_line; + let offset = view.offset; cx.editor.switch(id, action); // match the selection in the previous view let (view, doc) = current!(cx.editor); - view.first_line = first_line; + view.offset = offset; doc.set_selection(view.id, selection); } @@ -3930,15 +4120,13 @@ fn align_view_bottom(cx: &mut Context) { fn align_view_middle(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let pos = coords_at_pos(doc.text().slice(..), pos); + let text = doc.text().slice(..); + let pos = doc.selection(view.id).primary().cursor(text); + let pos = coords_at_pos(text, pos); - view.first_col = pos.col.saturating_sub( - ((view.area.width as usize).saturating_sub(crate::ui::editor::GUTTER_OFFSET as usize)) / 2, - ); + view.offset.col = pos + .col + .saturating_sub((view.inner_area().width as usize) / 2); } fn scroll_up(cx: &mut Context) { @@ -4019,11 +4207,11 @@ fn surround_replace(cx: &mut Context) { let transaction = Transaction::change( doc.text(), change_pos.iter().enumerate().map(|(i, &pos)| { - if i % 2 == 0 { - (pos, pos + 1, Some(Tendril::from_char(open))) - } else { - (pos.saturating_sub(1), pos, Some(Tendril::from_char(close))) - } + ( + pos, + pos + 1, + Some(Tendril::from_char(if i % 2 == 0 { open } else { close })), + ) }), ); doc.apply(&transaction, view.id); diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 628c4e13c8f6e..36e54eded1fbc 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -46,7 +46,7 @@ pub trait Component: Any + AnyComponent { } /// Render the component onto the provided surface. - fn render(&self, area: Rect, frame: &mut Surface, ctx: &mut Context); + fn render(&mut self, area: Rect, frame: &mut Surface, ctx: &mut Context); /// Get cursor position and cursor kind. fn cursor(&self, _area: Rect, _ctx: &Editor) -> (Option, CursorKind) { @@ -152,8 +152,8 @@ impl Compositor { let area = *surface.area(); - for layer in &self.layers { - layer.render(area, surface, cx) + for layer in &mut self.layers { + layer.render(area, surface, cx); } let (pos, kind) = self.cursor(area, cx.editor); diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 3d541e01bfc58..29b9559765844 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -145,7 +145,7 @@ impl From for Info { .map(|(desc, keys)| (desc.strip_prefix(&prefix).unwrap(), keys)) .collect(); } - Info::key(node.name(), body) + Info::new(node.name(), body) } } diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 2725d53debd67..906577643ffef 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -5,7 +5,7 @@ use tui::buffer::Buffer as Surface; use std::borrow::Cow; use helix_core::Transaction; -use helix_view::{graphics::Rect, Editor}; +use helix_view::{graphics::Rect, Document, Editor, View}; use crate::commands; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; @@ -14,6 +14,10 @@ use helix_lsp::{lsp, util}; use lsp::CompletionItem; impl menu::Item for CompletionItem { + fn sort_text(&self) -> &str { + self.filter_text.as_ref().unwrap_or(&self.label).as_str() + } + fn filter_text(&self) -> &str { self.filter_text.as_ref().unwrap_or(&self.label).as_str() } @@ -77,15 +81,47 @@ impl Completion { ) -> Self { // let items: Vec = Vec::new(); let menu = Menu::new(items, move |editor: &mut Editor, item, event| { + fn item_to_transaction( + doc: &Document, + view: &View, + item: &CompletionItem, + offset_encoding: helix_lsp::OffsetEncoding, + ) -> Transaction { + if let Some(edit) = &item.text_edit { + let edit = match edit { + lsp::CompletionTextEdit::Edit(edit) => edit.clone(), + lsp::CompletionTextEdit::InsertAndReplace(item) => { + unimplemented!("completion: insert_and_replace {:?}", item) + } + }; + util::generate_transaction_from_edits( + doc.text(), + vec![edit], + offset_encoding, // TODO: should probably transcode in Client + ) + } else { + let text = item.insert_text.as_ref().unwrap_or(&item.label); + let cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + Transaction::change( + doc.text(), + vec![(cursor, cursor, Some(text.as_str().into()))].into_iter(), + ) + } + } + match event { PromptEvent::Abort => {} - PromptEvent::Validate => { + PromptEvent::Update => { let (view, doc) = current!(editor); // always present here let item = item.unwrap(); // if more text was entered, remove it + // TODO: ideally to undo we should keep the last completion tx revert, and map it over new changes let cursor = doc .selection(view.id) .primary() @@ -98,30 +134,30 @@ impl Completion { doc.apply(&remove, view.id); } - let transaction = if let Some(edit) = &item.text_edit { - let edit = match edit { - lsp::CompletionTextEdit::Edit(edit) => edit.clone(), - lsp::CompletionTextEdit::InsertAndReplace(item) => { - unimplemented!("completion: insert_and_replace {:?}", item) - } - }; - util::generate_transaction_from_edits( - doc.text(), - vec![edit], - offset_encoding, // TODO: should probably transcode in Client - ) - } else { - let text = item.insert_text.as_ref().unwrap_or(&item.label); - let cursor = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - Transaction::change( + let transaction = item_to_transaction(doc, view, item, offset_encoding); + doc.apply(&transaction, view.id); + } + PromptEvent::Validate => { + let (view, doc) = current!(editor); + + // always present here + let item = item.unwrap(); + + // if more text was entered, remove it + // TODO: ideally to undo we should keep the last completion tx revert, and map it over new changes + let cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + if trigger_offset < cursor { + let remove = Transaction::change( doc.text(), - vec![(cursor, cursor, Some(text.as_str().into()))].into_iter(), - ) - }; + vec![(trigger_offset, cursor, None)].into_iter(), + ); + doc.apply(&remove, view.id); + } + let transaction = item_to_transaction(doc, view, item, offset_encoding); doc.apply(&transaction, view.id); if let Some(additional_edits) = &item.additional_text_edits { @@ -136,7 +172,6 @@ impl Completion { } } } - _ => (), }; }); let popup = Popup::new(menu); @@ -206,7 +241,7 @@ impl Component for Completion { self.popup.required_size(viewport) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { self.popup.render(area, surface, cx); // if we have a selection, render a markdown popup on top/below with info @@ -226,9 +261,9 @@ impl Component for Completion { .primary() .cursor(doc.text().slice(..)); let cursor_pos = (helix_core::coords_at_pos(doc.text().slice(..), cursor_pos).row - - view.first_line) as u16; + - view.offset.row) as u16; - let doc = match &option.documentation { + let mut doc = match &option.documentation { Some(lsp::Documentation::String(contents)) | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::PlainText, @@ -279,7 +314,7 @@ impl Component for Completion { let half = area.height / 2; let height = 15.min(half); // we want to make sure the cursor is visible (not hidden behind the documentation) - let y = if cursor_pos + view.area.y + let y = if cursor_pos + area.y >= (cx.editor.tree.area().height - height - 2/* statusline + commandline */) { 0 diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index ffe755e6065fe..4da8bfd557c4d 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -9,6 +9,7 @@ use crate::{ use helix_core::{ coords_at_pos, graphemes::{ensure_grapheme_boundary_next, next_grapheme_boundary, prev_grapheme_boundary}, + movement::Direction, syntax::{self, HighlightEvent}, unicode::segmentation::UnicodeSegmentation, unicode::width::UnicodeWidthStr, @@ -16,6 +17,7 @@ use helix_core::{ }; use helix_view::{ document::Mode, + editor::LineNumber, graphics::{CursorKind, Modifier, Rect, Style}, info::Info, input::KeyEvent, @@ -33,11 +35,9 @@ pub struct EditorView { last_insert: (commands::Command, Vec), completion: Option, spinners: ProgressSpinners, - pub autoinfo: Option, + autoinfo: Option, } -pub const GUTTER_OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter - impl Default for EditorView { fn default() -> Self { Self::new(Keymaps::default()) @@ -70,15 +70,28 @@ impl EditorView { theme: &Theme, is_focused: bool, loader: &syntax::Loader, + config: &helix_view::editor::Config, ) { - let area = Rect::new( - view.area.x + GUTTER_OFFSET, - view.area.y, - view.area.width - GUTTER_OFFSET, - view.area.height.saturating_sub(1), - ); // - 1 for statusline + let inner = view.inner_area(); + let area = view.area; - self.render_buffer(doc, view, area, surface, theme, is_focused, loader); + let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme, loader); + let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let highlights: Box> = if is_focused { + Box::new(syntax::merge( + highlights, + Self::doc_selection_highlights(doc, view, theme), + )) + } else { + Box::new(highlights) + }; + + Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights); + Self::render_gutter(doc, view, view.area, surface, theme, is_focused, config); + + if is_focused { + Self::render_focused_view_elements(view, doc, inner, theme, surface); + } // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { @@ -93,42 +106,43 @@ impl EditorView { } } - self.render_diagnostics(doc, view, area, surface, theme, is_focused); + self.render_diagnostics(doc, view, inner, surface, theme); - let area = Rect::new( - view.area.x, - view.area.y + view.area.height.saturating_sub(1), - view.area.width, - 1, - ); - self.render_statusline(doc, view, area, surface, theme, is_focused); + let statusline_area = view + .area + .clip_top(view.area.height.saturating_sub(1)) + .clip_bottom(1); // -1 from bottom to remove commandline + self.render_statusline(doc, view, statusline_area, surface, theme, is_focused); } + /// Get syntax highlights for a document in a view represented by the first line + /// and column (`offset`) and the last line. This is done instead of using a view + /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) #[allow(clippy::too_many_arguments)] - pub fn render_buffer( - &self, - doc: &Document, - view: &View, - viewport: Rect, - surface: &mut Surface, + pub fn doc_syntax_highlights<'doc>( + doc: &'doc Document, + offset: Position, + height: u16, theme: &Theme, - is_focused: bool, loader: &syntax::Loader, - ) { + ) -> Box + 'doc> { let text = doc.text().slice(..); - - let last_line = view.last_line(doc); + let last_line = std::cmp::min( + // Saturating subs to make it inclusive zero indexing. + (offset.row + height as usize).saturating_sub(1), + doc.text().len_lines().saturating_sub(1), + ); let range = { // calculate viewport byte ranges - let start = text.line_to_byte(view.first_line); + let start = text.line_to_byte(offset.row); let end = text.line_to_byte(last_line + 1); start..end }; // TODO: range doesn't actually restrict source, just highlight range - let highlights: Vec<_> = match doc.syntax() { + let highlights = match doc.syntax() { Some(syntax) => { let scopes = theme.scopes(); syntax @@ -150,20 +164,16 @@ impl EditorView { Some(config_ref) }) }) + .map(|event| event.unwrap()) .collect() // TODO: we collect here to avoid holding the lock, fix later } - None => vec![Ok(HighlightEvent::Source { + None => vec![HighlightEvent::Source { start: range.start, end: range.end, - })], - }; - let mut spans = Vec::new(); - let mut visual_x = 0u16; - let mut line = 0u16; - let tab_width = doc.tab_width(); - let tab = " ".repeat(tab_width); - - let highlights = highlights.into_iter().map(|event| match event.unwrap() { + }], + } + .into_iter() + .map(move |event| match event { // convert byte offsets to char offset HighlightEvent::Source { start, end } => { let start = ensure_grapheme_boundary_next(text, text.byte_to_char(start)); @@ -173,13 +183,46 @@ impl EditorView { event => event, }); - let selections = doc.selection(view.id); - let primary_idx = selections.primary_index(); + Box::new(highlights) + } + + /// Get highlight spans for document diagnostics + pub fn doc_diagnostics_highlights( + doc: &Document, + theme: &Theme, + ) -> Vec<(usize, std::ops::Range)> { + let diagnostic_scope = theme + .find_scope_index("diagnostic") + .or_else(|| theme.find_scope_index("ui.cursor")) + .or_else(|| theme.find_scope_index("ui.selection")) + .expect( + "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", + ); + + doc.diagnostics() + .iter() + .map(|diagnostic| { + ( + diagnostic_scope, + diagnostic.range.start..diagnostic.range.end, + ) + }) + .collect() + } + + /// Get highlight spans for selections in a document view. + pub fn doc_selection_highlights( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Vec<(usize, std::ops::Range)> { + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let primary_idx = selection.primary_index(); let selection_scope = theme .find_scope_index("ui.selection") - .expect("no selection scope found!"); - + .expect("could not find `ui.selection` scope in the theme!"); let base_cursor_scope = theme .find_scope_index("ui.cursor") .unwrap_or(selection_scope); @@ -191,64 +234,61 @@ impl EditorView { } .unwrap_or(base_cursor_scope); - let highlights: Box> = if is_focused { - // TODO: primary + insert mode patching: - // (ui.cursor.primary).patch(mode).unwrap_or(cursor) - let primary_cursor_scope = theme - .find_scope_index("ui.cursor.primary") - .unwrap_or(cursor_scope); - let primary_selection_scope = theme - .find_scope_index("ui.selection.primary") - .unwrap_or(selection_scope); - - // inject selections as highlight scopes - let mut spans: Vec<(usize, std::ops::Range)> = Vec::new(); - for (i, range) in selections.iter().enumerate() { - let (cursor_scope, selection_scope) = if i == primary_idx { - (primary_cursor_scope, primary_selection_scope) - } else { - (cursor_scope, selection_scope) - }; + let primary_cursor_scope = theme + .find_scope_index("ui.cursor.primary") + .unwrap_or(cursor_scope); + let primary_selection_scope = theme + .find_scope_index("ui.selection.primary") + .unwrap_or(selection_scope); - // Special-case: cursor at end of the rope. - if range.head == range.anchor && range.head == text.len_chars() { - spans.push((cursor_scope, range.head..range.head + 1)); - continue; - } + let mut spans: Vec<(usize, std::ops::Range)> = Vec::new(); + for (i, range) in selection.iter().enumerate() { + let (cursor_scope, selection_scope) = if i == primary_idx { + (primary_cursor_scope, primary_selection_scope) + } else { + (cursor_scope, selection_scope) + }; - let range = range.min_width_1(text); - if range.head > range.anchor { - // Standard case. - let cursor_start = prev_grapheme_boundary(text, range.head); - spans.push((selection_scope, range.anchor..cursor_start)); - spans.push((cursor_scope, cursor_start..range.head)); - } else { - // Reverse case. - let cursor_end = next_grapheme_boundary(text, range.head); - spans.push((cursor_scope, range.head..cursor_end)); - spans.push((selection_scope, cursor_end..range.anchor)); - } + // Special-case: cursor at end of the rope. + if range.head == range.anchor && range.head == text.len_chars() { + spans.push((cursor_scope, range.head..range.head + 1)); + continue; } - Box::new(syntax::merge(highlights, spans)) - } else { - Box::new(highlights) - }; + let range = range.min_width_1(text); + if range.head > range.anchor { + // Standard case. + let cursor_start = prev_grapheme_boundary(text, range.head); + spans.push((selection_scope, range.anchor..cursor_start)); + spans.push((cursor_scope, cursor_start..range.head)); + } else { + // Reverse case. + let cursor_end = next_grapheme_boundary(text, range.head); + spans.push((cursor_scope, range.head..cursor_end)); + spans.push((selection_scope, cursor_end..range.anchor)); + } + } - // diagnostic injection - let diagnostic_scope = theme.find_scope_index("diagnostic").unwrap_or(cursor_scope); - let highlights = Box::new(syntax::merge( - highlights, - doc.diagnostics() - .iter() - .map(|diagnostic| { - ( - diagnostic_scope, - diagnostic.range.start..diagnostic.range.end, - ) - }) - .collect(), - )); + spans + } + + pub fn render_text_highlights>( + doc: &Document, + offset: Position, + viewport: Rect, + surface: &mut Surface, + theme: &Theme, + highlights: H, + ) { + let text = doc.text().slice(..); + + let mut spans = Vec::new(); + let mut visual_x = 0u16; + let mut line = 0u16; + let tab_width = doc.tab_width(); + let tab = " ".repeat(tab_width); + + let text_style = theme.get("ui.text"); 'outer: for event in highlights { match event { @@ -266,20 +306,20 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; - let style = spans.iter().fold(theme.get("ui.text"), |acc, span| { + let style = spans.iter().fold(text_style, |acc, span| { let style = theme.get(theme.scopes()[span.0].as_str()); acc.patch(style) }); for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = visual_x < view.first_col as u16 - || visual_x >= viewport.width + view.first_col as u16; + let out_of_bounds = visual_x < offset.col as u16 + || visual_x >= viewport.width + offset.col as u16; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { // we still want to render an empty cell with the style surface.set_string( - viewport.x + visual_x - view.first_col as u16, + viewport.x + visual_x - offset.col as u16, viewport.y + line, " ", style, @@ -309,7 +349,7 @@ impl EditorView { if !out_of_bounds { // if we're offscreen just keep going until we hit a new line surface.set_string( - viewport.x + visual_x - view.first_col as u16, + viewport.x + visual_x - offset.col as u16, viewport.y + line, grapheme, style, @@ -322,24 +362,87 @@ impl EditorView { } } } + } - // render gutters + /// Render brace match, etc (meant for the focused view only) + pub fn render_focused_view_elements( + view: &View, + doc: &Document, + viewport: Rect, + theme: &Theme, + surface: &mut Surface, + ) { + // Highlight matching braces + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + use helix_core::match_brackets; + let pos = doc.selection(view.id).primary().cursor(text); + + let pos = match_brackets::find(syntax, doc.text(), pos) + .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); + + if let Some(pos) = pos { + // ensure col is on screen + if (pos.col as u16) < viewport.width + view.offset.col as u16 + && pos.col >= view.offset.col + { + let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { + Style::default() + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::DIM) + }); - let linenr: Style = theme.get("ui.linenr"); - let warning: Style = theme.get("warning"); - let error: Style = theme.get("error"); - let info: Style = theme.get("info"); - let hint: Style = theme.get("hint"); + surface + .get_mut(viewport.x + pos.col as u16, viewport.y + pos.row as u16) + .set_style(style); + } + } + } + } + + #[allow(clippy::too_many_arguments)] + pub fn render_gutter( + doc: &Document, + view: &View, + viewport: Rect, + surface: &mut Surface, + theme: &Theme, + is_focused: bool, + config: &helix_view::editor::Config, + ) { + let text = doc.text().slice(..); + let last_line = view.last_line(doc); + + let linenr = theme.get("ui.linenr"); + let linenr_select: Style = theme.try_get("ui.linenr.selected").unwrap_or(linenr); + + let warning = theme.get("warning"); + let error = theme.get("error"); + let info = theme.get("info"); + let hint = theme.get("hint"); // Whether to draw the line number for the last line of the // document or not. We only draw it if it's not an empty line. let draw_last = text.line_to_byte(last_line) < text.len_bytes(); - for (i, line) in (view.first_line..(last_line + 1)).enumerate() { + let current_line = doc + .text() + .char_to_line(doc.selection(view.id).primary().anchor); + + // it's used inside an iterator so the collect isn't needless: + // https://github.com/rust-lang/rust-clippy/issues/6164 + #[allow(clippy::needless_collect)] + let cursors: Vec<_> = doc + .selection(view.id) + .iter() + .map(|range| range.cursor_line(text)) + .collect(); + + for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { use helix_core::diagnostic::Severity; if let Some(diagnostic) = doc.diagnostics().iter().find(|d| d.line == line) { surface.set_stringn( - viewport.x - GUTTER_OFFSET, + viewport.x, viewport.y + i as u16, "●", 1, @@ -352,95 +455,29 @@ impl EditorView { ); } - // Line numbers having selections are rendered - // differently, further below. - let line_number_text = if line == last_line && !draw_last { + let selected = cursors.contains(&line); + + let text = if line == last_line && !draw_last { " ~".into() } else { - format!("{:>5}", line + 1) + let line = match config.line_number { + LineNumber::Absolute => line + 1, + LineNumber::Relative => abs_diff(current_line, line), + }; + format!("{:>5}", line) }; surface.set_stringn( - viewport.x + 1 - GUTTER_OFFSET, + viewport.x + 1, viewport.y + i as u16, - line_number_text, + text, 5, - linenr, + if selected && is_focused { + linenr_select + } else { + linenr + }, ); } - - // render selections and selected linenr(s) - let linenr_select: Style = theme - .try_get("ui.linenr.selected") - .unwrap_or_else(|| theme.get("ui.linenr")); - - if is_focused { - let screen = { - let start = text.line_to_char(view.first_line); - let end = text.line_to_char(last_line + 1) + 1; // +1 for cursor at end of text. - Range::new(start, end) - }; - - let selection = doc.selection(view.id); - - for selection in selection.iter().filter(|range| range.overlaps(&screen)) { - let head = view.screen_coords_at_pos( - doc, - text, - if selection.head > selection.anchor { - selection.head - 1 - } else { - selection.head - }, - ); - if let Some(head) = head { - // Draw line number for selected lines. - let line_number = view.first_line + head.row; - let line_number_text = if line_number == last_line && !draw_last { - " ~".into() - } else { - format!("{:>5}", line_number + 1) - }; - surface.set_stringn( - viewport.x + 1 - GUTTER_OFFSET, - viewport.y + head.row as u16, - line_number_text, - 5, - linenr_select, - ); - - // TODO: set cursor position for IME - if let Some(syntax) = doc.syntax() { - use helix_core::match_brackets; - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let pos = match_brackets::find(syntax, doc.text(), pos) - .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); - - if let Some(pos) = pos { - // ensure col is on screen - if (pos.col as u16) < viewport.width + view.first_col as u16 - && pos.col >= view.first_col - { - let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { - Style::default() - .add_modifier(Modifier::REVERSED) - .add_modifier(Modifier::DIM) - }); - - surface - .get_mut( - viewport.x + pos.col as u16, - viewport.y + pos.row as u16, - ) - .set_style(style); - } - } - } - } - } - } } pub fn render_diagnostics( @@ -450,7 +487,6 @@ impl EditorView { viewport: Rect, surface: &mut Surface, theme: &Theme, - _is_focused: bool, ) { use helix_core::diagnostic::Severity; use tui::{ @@ -468,10 +504,10 @@ impl EditorView { diagnostic.range.start <= cursor && diagnostic.range.end >= cursor }); - let warning: Style = theme.get("warning"); - let error: Style = theme.get("error"); - let info: Style = theme.get("info"); - let hint: Style = theme.get("hint"); + let warning = theme.get("warning"); + let error = theme.get("error"); + let info = theme.get("info"); + let hint = theme.get("hint"); // Vec::with_capacity(diagnostics.len()); // rough estimate let mut lines = Vec::new(); @@ -492,12 +528,7 @@ impl EditorView { let width = 80.min(viewport.width); let height = 15.min(viewport.height); paragraph.render( - Rect::new( - viewport.right() - width, - viewport.y as u16 + 1, - width, - height, - ), + Rect::new(viewport.right() - width, viewport.y + 1, width, height), surface, ); } @@ -536,7 +567,7 @@ impl EditorView { theme.get("ui.statusline.inactive") }; // statusline - surface.set_style(Rect::new(viewport.x, viewport.y, viewport.width, 1), style); + surface.set_style(viewport.with_height(1), style); if is_focused { surface.set_string(viewport.x + 1, viewport.y, mode, style); } @@ -694,33 +725,182 @@ impl EditorView { } } +impl EditorView { + fn handle_mouse_event( + &mut self, + event: MouseEvent, + cxt: &mut commands::Context, + ) -> EventResult { + match event { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + row, + column, + modifiers, + .. + } => { + let editor = &mut cxt.editor; + + let result = editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&editor.documents[view.doc], row, column) + .map(|pos| (pos, view.id)) + }); + + if let Some((pos, view_id)) = result { + let doc = &mut editor.documents[editor.tree.get(view_id).doc]; + + if modifiers == crossterm::event::KeyModifiers::ALT { + let selection = doc.selection(view_id).clone(); + doc.set_selection(view_id, selection.push(Range::point(pos))); + } else { + doc.set_selection(view_id, Selection::point(pos)); + } + + editor.tree.focus = view_id; + + return EventResult::Consumed(None); + } + + EventResult::Ignored + } + + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + row, + column, + .. + } => { + let (view, doc) = current!(cxt.editor); + + let pos = match view.pos_at_screen_coords(doc, row, column) { + Some(pos) => pos, + None => return EventResult::Ignored, + }; + + let mut selection = doc.selection(view.id).clone(); + let primary = selection.primary_mut(); + *primary = Range::new(primary.anchor, pos); + doc.set_selection(view.id, selection); + EventResult::Consumed(None) + } + + MouseEvent { + kind: MouseEventKind::ScrollUp | MouseEventKind::ScrollDown, + row, + column, + .. + } => { + let current_view = cxt.editor.tree.focus; + + let direction = match event.kind { + MouseEventKind::ScrollUp => Direction::Backward, + MouseEventKind::ScrollDown => Direction::Forward, + _ => unreachable!(), + }; + + let result = cxt.editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&cxt.editor.documents[view.doc], row, column) + .map(|_| view.id) + }); + + match result { + Some(view_id) => cxt.editor.tree.focus = view_id, + None => return EventResult::Ignored, + } + + let offset = cxt.editor.config.scroll_lines.abs() as usize; + commands::scroll(cxt, offset, direction); + + cxt.editor.tree.focus = current_view; + + EventResult::Consumed(None) + } + + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + .. + } => { + if !cxt.editor.config.middle_click_paste { + return EventResult::Ignored; + } + + let (view, doc) = current!(cxt.editor); + let range = doc.selection(view.id).primary(); + + if range.to() - range.from() <= 1 { + return EventResult::Ignored; + } + + commands::Command::yank_main_selection_to_primary_clipboard.execute(cxt); + + EventResult::Consumed(None) + } + + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Middle), + row, + column, + modifiers, + .. + } => { + let editor = &mut cxt.editor; + if !editor.config.middle_click_paste { + return EventResult::Ignored; + } + + if modifiers == crossterm::event::KeyModifiers::ALT { + commands::Command::replace_selections_with_primary_clipboard.execute(cxt); + + return EventResult::Consumed(None); + } + + let result = editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&editor.documents[view.doc], row, column) + .map(|pos| (pos, view.id)) + }); + + if let Some((pos, view_id)) = result { + let doc = &mut editor.documents[editor.tree.get(view_id).doc]; + doc.set_selection(view_id, Selection::point(pos)); + editor.tree.focus = view_id; + commands::Command::paste_primary_clipboard_before.execute(cxt); + return EventResult::Consumed(None); + } + + EventResult::Ignored + } + + _ => EventResult::Ignored, + } + } +} + impl Component for EditorView { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { + let mut cxt = commands::Context { + selected_register: helix_view::RegisterSelection::default(), + editor: &mut cx.editor, + count: None, + callback: None, + on_next_key_callback: None, + jobs: cx.jobs, + }; + match event { - Event::Resize(width, height) => { - // HAXX: offset the render area height by 1 to account for prompt/commandline - cx.editor - .resize(Rect::new(0, 0, width, height.saturating_sub(1))); + Event::Resize(_width, _height) => { + // Ignore this event, we handle resizing just before rendering to screen. + // Handling it here but not re-rendering will cause flashing EventResult::Consumed(None) } Event::Key(key) => { let mut key = KeyEvent::from(key); canonicalize_key(&mut key); // clear status - cx.editor.status_msg = None; + cxt.editor.status_msg = None; - let (_, doc) = current!(cx.editor); + let (_, doc) = current!(cxt.editor); let mode = doc.mode(); - let mut cxt = commands::Context { - selected_register: helix_view::RegisterSelection::default(), - editor: &mut cx.editor, - count: None, - callback: None, - on_next_key_callback: None, - jobs: cx.jobs, - }; - if let Some(on_next_key) = self.on_next_key.take() { // if there's a command waiting input, do that first on_next_key(&mut cxt, key); @@ -774,12 +954,12 @@ impl Component for EditorView { // if the command consumed the last view, skip the render. // on the next loop cycle the Application will then terminate. - if cx.editor.should_close() { + if cxt.editor.should_close() { return EventResult::Ignored; } - let (view, doc) = current!(cx.editor); - view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); + let (view, doc) = current!(cxt.editor); + view.ensure_cursor_in_view(doc, cxt.editor.config.scrolloff); // mode transitions match (mode, doc.mode()) { @@ -806,68 +986,17 @@ impl Component for EditorView { EventResult::Consumed(callback) } - Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - row, - column, - modifiers, - .. - }) => { - let editor = &mut cx.editor; - - let result = editor.tree.views().find_map(|(view, _focus)| { - view.pos_at_screen_coords(&editor.documents[view.doc], row, column) - .map(|pos| (pos, view.id)) - }); - - if let Some((pos, view_id)) = result { - let doc = &mut editor.documents[editor.tree.get(view_id).doc]; - if modifiers == crossterm::event::KeyModifiers::ALT { - let selection = doc.selection(view_id).clone(); - doc.set_selection(view_id, selection.push(Range::point(pos))); - } else { - doc.set_selection(view_id, Selection::point(pos)); - } - - editor.tree.focus = view_id; - - return EventResult::Consumed(None); - } - - EventResult::Ignored - } - - Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - row, - column, - .. - }) => { - let (view, doc) = current!(cx.editor); - - let pos = match view.pos_at_screen_coords(doc, row, column) { - Some(pos) => pos, - None => return EventResult::Ignored, - }; - - let mut selection = doc.selection(view.id).clone(); - let primary = selection.primary_mut(); - *primary = Range::new(primary.anchor, pos); - doc.set_selection(view.id, selection); - EventResult::Consumed(None) - } - Event::Mouse(_) => EventResult::Ignored, + Event::Mouse(event) => self.handle_mouse_event(event, &mut cxt), } } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // clear with background color surface.set_style(area, cx.editor.theme.get("ui.background")); // if the terminal size suddenly changed, we need to trigger a resize - cx.editor - .resize(Rect::new(area.x, area.y, area.width, area.height - 1)); // - 1 to account for commandline + cx.editor.resize(area.clip_bottom(1)); // -1 from bottom for commandline for (view, is_focused) in cx.editor.tree.views() { let doc = cx.editor.document(view.doc).unwrap(); @@ -880,10 +1009,11 @@ impl Component for EditorView { &cx.editor.theme, is_focused, loader, + &cx.editor.config, ); } - if let Some(ref info) = self.autoinfo { + if let Some(ref mut info) = self.autoinfo { info.render(area, surface, cx); } @@ -930,7 +1060,7 @@ impl Component for EditorView { ); } - if let Some(completion) = &self.completion { + if let Some(completion) = self.completion.as_mut() { completion.render(area, surface, cx); } } @@ -953,3 +1083,12 @@ fn canonicalize_key(key: &mut KeyEvent) { key.modifiers.remove(KeyModifiers::SHIFT) } } + +#[inline] +fn abs_diff(a: usize, b: usize) -> usize { + if a > b { + a - b + } else { + b - a + } +} diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs index 6e810b86617c1..75d978daf59a4 100644 --- a/helix-term/src/ui/info.rs +++ b/helix-term/src/ui/info.rs @@ -1,38 +1,41 @@ use crate::compositor::{Component, Context}; -use helix_view::graphics::Rect; +use helix_view::graphics::{Margin, Rect}; use helix_view::info::Info; use tui::buffer::Buffer as Surface; -use tui::widgets::{Block, Borders, Widget}; +use tui::widgets::{Block, Borders, Paragraph, Widget}; impl Component for Info { - fn render(&self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { - let style = cx.editor.theme.get("ui.popup"); + fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { + let text_style = cx.editor.theme.get("ui.text.focus"); + let popup_style = text_style.patch(cx.editor.theme.get("ui.popup")); // Calculate the area of the terminal to modify. Because we want to // render at the bottom right, we use the viewport's width and height // which evaluate to the most bottom right coordinate. - let (width, height) = (self.width + 2, self.height + 2); + let width = self.width + 2 + 2; // +2 for border, +2 for margin + let height = self.height + 2; // +2 for border let area = viewport.intersection(Rect::new( viewport.width.saturating_sub(width), - viewport.height.saturating_sub(height + 2), + viewport.height.saturating_sub(height + 2), // +2 for statusline width, height, )); - surface.clear_with(area, style); + surface.clear_with(area, popup_style); let block = Block::default() .title(self.title.as_str()) .borders(Borders::ALL) - .border_style(style); - let inner = block.inner(area); + .border_style(popup_style); + + let margin = Margin { + vertical: 0, + horizontal: 1, + }; + let inner = block.inner(area).inner(&margin); block.render(area, surface); - // Only write as many lines as there are rows available. - for (y, line) in (inner.y..) - .zip(self.text.lines()) - .take(inner.height as usize) - { - surface.set_string(inner.x, y, line, style); - } + Paragraph::new(self.text.as_str()) + .style(text_style) + .render(inner, surface); } } diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 6c79ca67160d5..28542cdcc22ce 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -13,7 +13,7 @@ use helix_core::{ Rope, }; use helix_view::{ - graphics::{Color, Rect, Style}, + graphics::{Color, Margin, Rect, Style}, Theme, }; @@ -198,7 +198,7 @@ fn parse<'a>( Text::from(lines) } impl Component for Markdown { - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; let text = parse(&self.contents, Some(&cx.editor.theme), &self.config_loader); @@ -207,8 +207,11 @@ impl Component for Markdown { .wrap(Wrap { trim: false }) .scroll((cx.scroll.unwrap_or_default() as u16, 0)); - let area = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); - par.render(area, surface); + let margin = Margin { + vertical: 1, + horizontal: 1, + }; + 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 1e1c5427282da..a56cf19b27ed9 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -11,7 +11,7 @@ use helix_view::{graphics::Rect, Editor}; use tui::layout::Constraint; pub trait Item { - // TODO: sort_text + fn sort_text(&self) -> &str; fn filter_text(&self) -> &str; fn label(&self) -> &str; @@ -64,24 +64,21 @@ impl Menu { let Self { ref mut matcher, ref mut matches, + ref options, .. } = *self; // reuse the matches allocation matches.clear(); - matches.extend( - self.options - .iter() - .enumerate() - .filter_map(|(index, option)| { - let text = option.filter_text(); - // TODO: using fuzzy_indices could give us the char idx for match highlighting - matcher - .fuzzy_match(text, pattern) - .map(|score| (index, score)) - }), - ); - matches.sort_unstable_by_key(|(_, score)| -score); + matches.extend(options.iter().enumerate().filter_map(|(index, option)| { + let text = option.filter_text(); + // TODO: using fuzzy_indices could give us the char idx for match highlighting + matcher + .fuzzy_match(text, pattern) + .map(|score| (index, score)) + })); + // matches.sort_unstable_by_key(|(_, score)| -score); + matches.sort_unstable_by_key(|(index, _score)| options[*index].sort_text()); // reset cursor position self.cursor = None; @@ -223,8 +220,6 @@ impl Component for Menu { EventResult::Ignored } - // TODO: completion sorting - fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { let n = self .options @@ -263,7 +258,7 @@ impl Component for Menu { // TODO: required size should re-trigger when we filter items so we can draw a smaller menu - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let style = cx.editor.theme.get("ui.text"); let selected = cx.editor.theme.get("ui.menu.selected"); @@ -309,14 +304,6 @@ impl Component for Menu { }, ); - // // TODO: set bg for the whole row if selected - // if line == self.cursor { - // surface.set_style( - // Rect::new(area.x, area.y + i as u16, area.width - 1, 1), - // selected, - // ) - // } - for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() { let is_marked = i >= scroll_line && i < scroll_line + scroll_height; diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index f68ad0a74578d..390f1a66ed15b 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -13,7 +13,7 @@ pub use completion::Completion; pub use editor::EditorView; pub use markdown::Markdown; pub use menu::Menu; -pub use picker::Picker; +pub use picker::{FilePicker, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -73,29 +73,26 @@ pub fn regex_prompt( ) } -pub fn file_picker(root: PathBuf) -> Picker { +pub fn file_picker(root: PathBuf) -> FilePicker { use ignore::Walk; use std::time; - let files = Walk::new(root.clone()).filter_map(|entry| match entry { - Ok(entry) => { - // filter dirs, but we might need special handling for symlinks! - if !entry.file_type().map_or(false, |entry| entry.is_dir()) { - let time = if let Ok(metadata) = entry.metadata() { - metadata - .accessed() - .or_else(|_| metadata.modified()) - .or_else(|_| metadata.created()) - .unwrap_or(time::UNIX_EPOCH) - } else { - time::UNIX_EPOCH - }; - - Some((entry.into_path(), time)) - } else { - None - } + let files = Walk::new(&root).filter_map(|entry| { + let entry = entry.ok()?; + // Path::is_dir() traverses symlinks, so we use it over DirEntry::is_dir + if entry.path().is_dir() { + // Will give a false positive if metadata cannot be read (eg. permission error) + return None; } - Err(_err) => None, + + let time = entry.metadata().map_or(time::UNIX_EPOCH, |metadata| { + metadata + .accessed() + .or_else(|_| metadata.modified()) + .or_else(|_| metadata.created()) + .unwrap_or(time::UNIX_EPOCH) + }); + + Some((entry.into_path(), time)) }); let mut files: Vec<_> = if root.join(".git").is_dir() { @@ -109,7 +106,7 @@ pub fn file_picker(root: PathBuf) -> Picker { let files = files.into_iter().map(|(path, _)| path).collect(); - Picker::new( + FilePicker::new( files, move |path: &PathBuf| { // format_fn @@ -124,6 +121,7 @@ pub fn file_picker(root: PathBuf) -> Picker { .open(path.into(), action) .expect("editor.open failed"); }, + |_editor, path| Some((path.clone(), None)), ) } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 0b67cd9c6399a..ecf9d528c736c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,4 +1,7 @@ -use crate::compositor::{Component, Compositor, Context, EventResult}; +use crate::{ + compositor::{Component, Compositor, Context, EventResult}, + ui::EditorView, +}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use tui::{ buffer::Buffer as Surface, @@ -7,17 +10,156 @@ use tui::{ use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; +use tui::widgets::Widget; -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashMap, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; use helix_core::Position; use helix_view::{ + document::canonicalize_path, editor::Action, - graphics::{Color, CursorKind, Rect, Style}, - Editor, + graphics::{Color, CursorKind, Margin, Rect, Style}, + Document, Editor, }; +pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80; + +/// File path and line number (used to align and highlight a line) +type FileLocation = (PathBuf, Option); + +pub struct FilePicker { + picker: Picker, + /// Caches paths to documents + preview_cache: HashMap, + /// Given an item in the picker, return the file path and line number to display. + file_fn: Box Option>, +} + +impl FilePicker { + pub fn new( + options: Vec, + format_fn: impl Fn(&T) -> Cow + 'static, + callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, + preview_fn: impl Fn(&Editor, &T) -> Option + 'static, + ) -> Self { + Self { + picker: Picker::new(false, options, format_fn, callback_fn), + preview_cache: HashMap::new(), + file_fn: Box::new(preview_fn), + } + } + + fn current_file(&self, editor: &Editor) -> Option { + self.picker + .selection() + .and_then(|current| (self.file_fn)(editor, current)) + .and_then(|(path, line)| canonicalize_path(&path).ok().zip(Some(line))) + } + + fn calculate_preview(&mut self, editor: &Editor) { + if let Some((path, _line)) = self.current_file(editor) { + if !self.preview_cache.contains_key(&path) && editor.document_by_path(&path).is_none() { + // TODO: enable syntax highlighting; blocked by async rendering + let doc = Document::open(&path, None, Some(&editor.theme), None).unwrap(); + self.preview_cache.insert(path, doc); + } + } + } +} + +impl Component for FilePicker { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + // +---------+ +---------+ + // |prompt | |preview | + // +---------+ | | + // |picker | | | + // | | | | + // +---------+ +---------+ + self.calculate_preview(cx.editor); + let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW; + let area = inner_rect(area); + // -- Render the frame: + // clear area + let background = cx.editor.theme.get("ui.background"); + surface.clear_with(area, background); + + let picker_width = if render_preview { + area.width / 2 + } else { + area.width + }; + + let picker_area = area.with_width(picker_width); + self.picker.render(picker_area, surface, cx); + + if !render_preview { + return; + } + + let preview_area = area.clip_left(picker_width); + + // don't like this but the lifetime sucks + let block = Block::default().borders(Borders::ALL); + + // calculate the inner area inside the box + let inner = block.inner(preview_area); + // 1 column gap on either side + let margin = Margin { + vertical: 1, + horizontal: 0, + }; + let inner = inner.inner(&margin); + + block.render(preview_area, surface); + + if let Some((doc, line)) = self.current_file(cx.editor).and_then(|(path, line)| { + cx.editor + .document_by_path(&path) + .or_else(|| self.preview_cache.get(&path)) + .zip(Some(line)) + }) { + // align to middle + let first_line = line.unwrap_or(0).saturating_sub(inner.height as usize / 2); + let offset = Position::new(first_line, 0); + + let highlights = EditorView::doc_syntax_highlights( + doc, + offset, + area.height, + &cx.editor.theme, + &cx.editor.syn_loader, + ); + EditorView::render_text_highlights( + doc, + offset, + inner, + surface, + &cx.editor.theme, + highlights, + ); + + // highlight the line + if let Some(line) = line { + for x in inner.left()..inner.right() { + surface + .get_mut(x, inner.y + line.saturating_sub(first_line) as u16) + .set_style(cx.editor.theme.get("ui.selection")); + } + } + } + } + + fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult { + // TODO: keybinds for scrolling preview + self.picker.handle_event(event, ctx) + } + + fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, CursorKind) { + self.picker.cursor(area, ctx) + } +} + pub struct Picker { options: Vec, // filter: String, @@ -30,6 +172,8 @@ pub struct Picker { cursor: usize, // pattern: String, prompt: Prompt, + /// Whether to render in the middle of the area + render_centered: bool, format_fn: Box Cow>, callback_fn: Box, @@ -37,6 +181,7 @@ pub struct Picker { impl Picker { pub fn new( + render_centered: bool, options: Vec, format_fn: impl Fn(&T) -> Cow + 'static, callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, @@ -57,6 +202,7 @@ impl Picker { filters: Vec::new(), cursor: 0, prompt, + render_centered, format_fn: Box::new(format_fn), callback_fn: Box::new(callback_fn), }; @@ -139,15 +285,11 @@ impl Picker { // - score all the names in relation to input fn inner_rect(area: Rect) -> Rect { - let padding_vertical = area.height * 20 / 100; - let padding_horizontal = area.width * 20 / 100; - - Rect::new( - area.x + padding_horizontal, - area.y + padding_vertical, - area.width - padding_horizontal * 2, - area.height - padding_vertical * 2, - ) + let margin = Margin { + vertical: area.height * 10 / 100, + horizontal: area.width * 10 / 100, + }; + area.inner(&margin) } impl Component for Picker { @@ -174,7 +316,9 @@ impl Component for Picker { | KeyEvent { code: KeyCode::Char('p'), modifiers: KeyModifiers::CONTROL, - } => self.move_up(), + } => { + self.move_up(); + } KeyEvent { code: KeyCode::Down, .. @@ -185,7 +329,9 @@ impl Component for Picker { | KeyEvent { code: KeyCode::Char('n'), modifiers: KeyModifiers::CONTROL, - } => self.move_down(), + } => { + self.move_down(); + } KeyEvent { code: KeyCode::Esc, .. } @@ -239,16 +385,20 @@ impl Component for Picker { EventResult::Consumed(None) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { - let area = inner_rect(area); + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + let area = if self.render_centered { + inner_rect(area) + } else { + area + }; - // -- Render the frame: + let text_style = cx.editor.theme.get("ui.text"); + // -- Render the frame: // clear area let background = cx.editor.theme.get("ui.background"); surface.clear_with(area, background); - use tui::widgets::Widget; // don't like this but the lifetime sucks let block = Block::default().borders(Borders::ALL); @@ -259,25 +409,36 @@ impl Component for Picker { // -- Render the input bar: - let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1); + let area = inner.clip_left(1).with_height(1); + + let count = format!("{}/{}", self.matches.len(), self.options.len()); + surface.set_stringn( + (area.x + area.width).saturating_sub(count.len() as u16 + 1), + area.y, + &count, + (count.len()).min(area.width as usize), + text_style, + ); + self.prompt.render(area, surface, cx); // -- Separator - let style = Style::default().fg(Color::Rgb(90, 89, 119)); - let symbols = BorderType::line_symbols(BorderType::Plain); + let sep_style = Style::default().fg(Color::Rgb(90, 89, 119)); + let borders = BorderType::line_symbols(BorderType::Plain); for x in inner.left()..inner.right() { surface .get_mut(x, inner.y + 1) - .set_symbol(symbols.horizontal) - .set_style(style); + .set_symbol(borders.horizontal) + .set_style(sep_style); } // -- Render the contents: + // subtract area of prompt from top and current item marker " > " from left + let inner = inner.clip_top(2).clip_left(3); - let style = cx.editor.theme.get("ui.text"); - let selected = Style::default().fg(Color::Rgb(255, 255, 255)); + let selected = cx.editor.theme.get("ui.text.focus"); - let rows = inner.height - 2; // -1 for search bar + let rows = inner.height; let offset = self.cursor / (rows as usize) * (rows as usize); let files = self.matches.iter().skip(offset).map(|(index, _score)| { @@ -286,18 +447,18 @@ impl Component for Picker { for (i, (_index, option)) in files.take(rows as usize).enumerate() { if i == (self.cursor - offset) { - surface.set_string(inner.x + 1, inner.y + 2 + i as u16, ">", selected); + surface.set_string(inner.x - 2, inner.y + i as u16, ">", selected); } surface.set_string_truncated( - inner.x + 3, - inner.y + 2 + i as u16, + inner.x, + inner.y + i as u16, (self.format_fn)(option), - (inner.width as usize).saturating_sub(3), // account for the " > " + inner.width as usize, if i == (self.cursor - offset) { selected } else { - style + text_style }, true, ); @@ -312,7 +473,7 @@ impl Component for Picker { let inner = block.inner(area); // prompt area - let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1); + let area = inner.clip_left(1).with_height(1); self.prompt.cursor(area, editor) } diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 29ffb4ad5daf0..e126c84506cfa 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -105,13 +105,12 @@ impl Component for Popup { Some(self.size) } - fn render(&self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { cx.scroll = Some(self.scroll); let position = self .position - .or_else(|| cx.editor.cursor().0) - .unwrap_or_default(); + .get_or_insert_with(|| cx.editor.cursor().0.unwrap_or_default()); let (width, height) = self.size; diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 57daef3a689d8..19986b5cbfcfa 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -284,7 +284,8 @@ const BASE_WIDTH: u16 = 30; impl Prompt { pub fn render_prompt(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { let theme = &cx.editor.theme; - let text_color = theme.get("ui.text.focus"); + let prompt_color = theme.get("ui.text"); + let completion_color = theme.get("ui.statusline"); let selected_color = theme.get("ui.menu.selected"); // completion @@ -326,15 +327,13 @@ impl Prompt { let mut row = 0; let mut col = 0; - // TODO: paginate for (i, (_range, completion)) in self.completion.iter().enumerate().skip(offset).take(items) { let color = if Some(i) == self.selection { - // Style::default().bg(Color::Rgb(104, 60, 232)) selected_color // TODO: just invert bg } else { - text_color + completion_color }; surface.set_stringn( area.x + col * (1 + col_width), @@ -352,7 +351,7 @@ impl Prompt { } if let Some(doc) = (self.doc_fn)(&self.line) { - let text = ui::Text::new(doc.to_string()); + let mut text = ui::Text::new(doc.to_string()); let viewport = area; let area = viewport.intersection(Rect::new( @@ -377,12 +376,12 @@ impl Prompt { let line = area.height - 1; // render buffer text - surface.set_string(area.x, area.y + line, &self.prompt, text_color); + surface.set_string(area.x, area.y + line, &self.prompt, prompt_color); surface.set_string( area.x + self.prompt.len() as u16, area.y + line, &self.line, - text_color, + prompt_color, ); } } @@ -546,7 +545,7 @@ impl Component for Prompt { EventResult::Consumed(None) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { self.render_prompt(area, surface, cx) } diff --git a/helix-term/src/ui/text.rs b/helix-term/src/ui/text.rs index 249cf89ed01ad..65a75a4af2d5c 100644 --- a/helix-term/src/ui/text.rs +++ b/helix-term/src/ui/text.rs @@ -13,7 +13,7 @@ impl Text { } } impl Component for Text { - fn render(&self, area: Rect, surface: &mut Surface, _cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, _cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; let contents = tui::text::Text::from(self.contents.clone()); diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index 33a9427af72e3..5f22b7c8696f1 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-tui" -version = "0.3.0" +version = "0.4.1" authors = ["Blaž Hrastnik "] description = """ A library to build rich terminal user interfaces or dashboards @@ -16,10 +16,10 @@ include = ["src/**/*", "README.md"] default = ["crossterm"] [dependencies] -bitflags = "1.0" +bitflags = "1.3" cassowary = "0.3" unicode-segmentation = "1.8" crossterm = { version = "0.20", optional = true } serde = { version = "1", "optional" = true, features = ["derive"]} -helix-view = { version = "0.3", path = "../helix-view", features = ["term"] } -helix-core = { version = "0.3", path = "../helix-core" } +helix-view = { version = "0.4", path = "../helix-view", features = ["term"] } +helix-core = { version = "0.4", path = "../helix-core" } diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 3617506fc1280..29cfe047e874e 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-view" -version = "0.3.0" +version = "0.4.1" authors = ["Blaž Hrastnik "] edition = "2018" license = "MPL-2.0" @@ -14,10 +14,10 @@ default = [] term = ["crossterm"] [dependencies] -bitflags = "1.0" +bitflags = "1.3" anyhow = "1" -helix-core = { version = "0.3", path = "../helix-core" } -helix-lsp = { version = "0.3", path = "../helix-lsp"} +helix-core = { version = "0.4", path = "../helix-core" } +helix-lsp = { version = "0.4", path = "../helix-lsp"} crossterm = { version = "0.20", optional = true } # Conversion traits diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs index 401c0459c009e..a11224ace74e4 100644 --- a/helix-view/src/clipboard.rs +++ b/helix-view/src/clipboard.rs @@ -3,10 +3,15 @@ use anyhow::Result; use std::borrow::Cow; +pub enum ClipboardType { + Clipboard, + Selection, +} + pub trait ClipboardProvider: std::fmt::Debug { fn name(&self) -> Cow; - fn get_contents(&self) -> Result; - fn set_contents(&self, contents: String) -> Result<()>; + fn get_contents(&self, clipboard_type: ClipboardType) -> Result; + fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()>; } macro_rules! command_provider { @@ -20,6 +25,33 @@ macro_rules! command_provider { prg: $set_prg, args: &[ $( $set_arg ),* ], }, + get_primary_cmd: None, + set_primary_cmd: None, + }) + }}; + + (paste => $get_prg:literal $( , $get_arg:literal )* ; + copy => $set_prg:literal $( , $set_arg:literal )* ; + primary_paste => $pr_get_prg:literal $( , $pr_get_arg:literal )* ; + primary_copy => $pr_set_prg:literal $( , $pr_set_arg:literal )* ; + ) => {{ + Box::new(provider::CommandProvider { + get_cmd: provider::CommandConfig { + prg: $get_prg, + args: &[ $( $get_arg ),* ], + }, + set_cmd: provider::CommandConfig { + prg: $set_prg, + args: &[ $( $set_arg ),* ], + }, + get_primary_cmd: Some(provider::CommandConfig { + prg: $pr_get_prg, + args: &[ $( $pr_get_arg ),* ], + }), + set_primary_cmd: Some(provider::CommandConfig { + prg: $pr_set_prg, + args: &[ $( $pr_set_arg ),* ], + }), }) }}; } @@ -37,18 +69,24 @@ pub fn get_clipboard_provider() -> Box { command_provider! { paste => "wl-paste", "--no-newline"; copy => "wl-copy", "--type", "text/plain"; + primary_paste => "wl-paste", "-p", "--no-newline"; + primary_copy => "wl-copy", "-p", "--type", "text/plain"; } } else if env_var_is_set("DISPLAY") && exists("xclip") { command_provider! { paste => "xclip", "-o", "-selection", "clipboard"; copy => "xclip", "-i", "-selection", "clipboard"; + primary_paste => "xclip", "-o"; + primary_copy => "xclip", "-i"; } } else if env_var_is_set("DISPLAY") && exists("xsel") && is_exit_success("xsel", &["-o", "-b"]) { // FIXME: check performance of is_exit_success command_provider! { paste => "xsel", "-o", "-b"; - copy => "xsel", "--nodetach", "-i", "-b"; + copy => "xsel", "-i", "-b"; + primary_paste => "xsel", "-o"; + primary_copy => "xsel", "-i"; } } else if exists("lemonade") { command_provider! { @@ -78,10 +116,10 @@ pub fn get_clipboard_provider() -> Box { } } else { #[cfg(target_os = "windows")] - return Box::new(provider::WindowsProvider); + return Box::new(provider::WindowsProvider::new()); #[cfg(not(target_os = "windows"))] - return Box::new(provider::NopProvider); + return Box::new(provider::NopProvider::new()); } } @@ -103,30 +141,64 @@ fn is_exit_success(program: &str, args: &[&str]) -> bool { } mod provider { - use super::ClipboardProvider; + use super::{ClipboardProvider, ClipboardType}; use anyhow::{bail, Context as _, Result}; use std::borrow::Cow; #[derive(Debug)] - pub struct NopProvider; + pub struct NopProvider { + buf: String, + primary_buf: String, + } + + impl NopProvider { + #[allow(dead_code)] + // Only dead_code on Windows. + pub fn new() -> Self { + Self { + buf: String::new(), + primary_buf: String::new(), + } + } + } impl ClipboardProvider for NopProvider { fn name(&self) -> Cow { Cow::Borrowed("none") } - fn get_contents(&self) -> Result { - Ok(String::new()) + fn get_contents(&self, clipboard_type: ClipboardType) -> Result { + let value = match clipboard_type { + ClipboardType::Clipboard => self.buf.clone(), + ClipboardType::Selection => self.primary_buf.clone(), + }; + + Ok(value) } - fn set_contents(&self, _: String) -> Result<()> { + fn set_contents(&mut self, content: String, clipboard_type: ClipboardType) -> Result<()> { + match clipboard_type { + ClipboardType::Clipboard => self.buf = content, + ClipboardType::Selection => self.primary_buf = content, + } Ok(()) } } #[cfg(target_os = "windows")] #[derive(Debug)] - pub struct WindowsProvider; + pub struct WindowsProvider { + selection_buf: String, + } + + #[cfg(target_os = "windows")] + impl WindowsProvider { + pub fn new() -> Self { + Self { + selection_buf: String::new(), + } + } + } #[cfg(target_os = "windows")] impl ClipboardProvider for WindowsProvider { @@ -134,13 +206,23 @@ mod provider { Cow::Borrowed("clipboard-win") } - fn get_contents(&self) -> Result { - let contents = clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?; - Ok(contents) + fn get_contents(&self, clipboard_type: ClipboardType) -> Result { + match clipboard_type { + ClipboardType::Clipboard => { + let contents = clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?; + Ok(contents) + } + ClipboardType::Selection => Ok(String::new()), + } } - fn set_contents(&self, contents: String) -> Result<()> { - clipboard_win::set_clipboard(clipboard_win::formats::Unicode, contents)?; + fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()> { + match clipboard_type { + ClipboardType::Clipboard => { + clipboard_win::set_clipboard(clipboard_win::formats::Unicode, contents)?; + } + ClipboardType::Selection => {} + }; Ok(()) } } @@ -192,6 +274,8 @@ mod provider { pub struct CommandProvider { pub get_cmd: CommandConfig, pub set_cmd: CommandConfig, + pub get_primary_cmd: Option, + pub set_primary_cmd: Option, } impl ClipboardProvider for CommandProvider { @@ -203,16 +287,34 @@ mod provider { } } - fn get_contents(&self) -> Result { - let output = self - .get_cmd - .execute(None, true)? - .context("output is missing")?; - Ok(output) + fn get_contents(&self, clipboard_type: ClipboardType) -> Result { + match clipboard_type { + ClipboardType::Clipboard => Ok(self + .get_cmd + .execute(None, true)? + .context("output is missing")?), + ClipboardType::Selection => { + if let Some(cmd) = &self.get_primary_cmd { + return cmd.execute(None, true)?.context("output is missing"); + } + + Ok(String::new()) + } + } } - fn set_contents(&self, value: String) -> Result<()> { - self.set_cmd.execute(Some(&value), false).map(|_| ()) + fn set_contents(&mut self, value: String, clipboard_type: ClipboardType) -> Result<()> { + let cmd = match clipboard_type { + ClipboardType::Clipboard => &self.set_cmd, + ClipboardType::Selection => { + if let Some(cmd) = &self.set_primary_cmd { + cmd + } else { + return Ok(()); + } + } + }; + cmd.execute(Some(&value), false).map(|_| ()) } } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 99faebec37cca..ff0c8bf47b383 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -20,6 +20,7 @@ use helix_lsp::util::LspFormatting; use crate::{DocumentId, Theme, ViewId}; +/// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] @@ -432,14 +433,15 @@ impl Document { /// Create a new document from `path`. Encoding is auto-detected, but it can be manually /// overwritten with the `encoding` parameter. pub fn open( - path: PathBuf, + path: &Path, encoding: Option<&'static encoding_rs::Encoding>, theme: Option<&Theme>, config_loader: Option<&syntax::Loader>, ) -> Result { + // Open the file if it exists, otherwise assume it is a new file (and thus empty). let (rope, encoding) = if path.exists() { let mut file = - std::fs::File::open(&path).context(format!("unable to open {:?}", path))?; + std::fs::File::open(path).context(format!("unable to open {:?}", path))?; from_reader(&mut file, encoding)? } else { let encoding = encoding.unwrap_or(encoding_rs::UTF_8); @@ -449,7 +451,7 @@ impl Document { let mut doc = Self::from(rope, Some(encoding)); // set the path and try detecting the language - doc.set_path(&path)?; + doc.set_path(path)?; if let Some(loader) = config_loader { doc.detect_language(theme, loader); } @@ -564,6 +566,7 @@ impl Document { } } + /// Detect the programming language based on the file type. pub fn detect_language(&mut self, theme: Option<&Theme>, config_loader: &syntax::Loader) { if let Some(path) = &self.path { let language_config = config_loader.language_config_for_file_name(path); @@ -571,6 +574,10 @@ impl Document { } } + /// Detect the indentation used in the file, or otherwise defaults to the language indentation + /// configured in `languages.toml`, with a fallback back to 2 space indentation if it isn't + /// specified. Line ending is likewise auto-detected, and will fallback to the default OS + /// line ending. pub fn detect_indent_and_line_ending(&mut self) { self.indent_style = auto_detect_indent_style(&self.text).unwrap_or_else(|| { IndentStyle::from_str( @@ -596,6 +603,9 @@ impl Document { let mut file = std::fs::File::open(path.unwrap())?; let (rope, ..) = from_reader(&mut file, Some(encoding))?; + // Calculate the difference between the buffer and source text, and apply it. + // This is not considered a modification of the contents of the file regardless + // of the encoding. let transaction = helix_core::diff::compare_ropes(self.text(), &rope); self.apply(&transaction, view_id); self.append_changes_to_history(view_id); @@ -630,6 +640,8 @@ impl Document { Ok(()) } + /// Set the programming language for the file and load associated data (e.g. highlighting) + /// if it exists. pub fn set_language( &mut self, theme: Option<&Theme>, @@ -650,6 +662,8 @@ impl Document { }; } + /// Set the programming language for the file if you know the name (scope) but don't have the + /// [`syntax::LanguageConfiguration`] for it. pub fn set_language2( &mut self, scope: &str, @@ -661,16 +675,19 @@ impl Document { self.set_language(theme, language_config); } + /// Set the LSP. pub fn set_language_server(&mut self, language_server: Option>) { self.language_server = language_server; } + /// Select text within the [`Document`]. pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) { // TODO: use a transaction? self.selections .insert(view_id, selection.ensure_invariants(self.text().slice(..))); } + /// Apply a [`Transaction`] to the [`Document`] to change its text. fn apply_impl(&mut self, transaction: &Transaction, view_id: ViewId) -> bool { let old_doc = self.text().clone(); @@ -733,6 +750,7 @@ impl Document { success } + /// Apply a [`Transaction`] to the [`Document`] to change its text. pub fn apply(&mut self, transaction: &Transaction, view_id: ViewId) -> bool { // store the state just before any changes are made. This allows us to undo to the // state just before a transaction was applied. @@ -754,6 +772,7 @@ impl Document { success } + /// Undo the last modification to the [`Document`]. pub fn undo(&mut self, view_id: ViewId) { let mut history = self.history.take(); let success = if let Some(transaction) = history.undo() { @@ -769,6 +788,7 @@ impl Document { } } + /// Redo the last modification to the [`Document`]. pub fn redo(&mut self, view_id: ViewId) { let mut history = self.history.take(); let success = if let Some(transaction) = history.redo() { @@ -784,6 +804,7 @@ impl Document { } } + /// Undo modifications to the [`Document`] according to `uk`. pub fn earlier(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) { let txns = self.history.get_mut().earlier(uk); for txn in txns { @@ -791,6 +812,7 @@ impl Document { } } + /// Redo modifications to the [`Document`] according to `uk`. pub fn later(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) { let txns = self.history.get_mut().later(uk); for txn in txns { @@ -823,6 +845,7 @@ impl Document { self.id } + /// If there are unsaved modifications. pub fn is_modified(&self) -> bool { let history = self.history.take(); let current_revision = history.current_revision(); @@ -830,6 +853,7 @@ impl Document { current_revision != self.last_saved_revision || !self.changes.is_empty() } + /// Save modifications to history, and so [`Self::is_modified`] will return false. pub fn reset_modified(&mut self) { let history = self.history.take(); let current_revision = history.current_revision(); @@ -837,6 +861,7 @@ impl Document { self.last_saved_revision = current_revision; } + /// Current editing mode for the [`Document`]. pub fn mode(&self) -> Mode { self.mode } @@ -848,6 +873,7 @@ impl Document { .map(|language| language.scope.as_str()) } + /// Corresponding [`LanguageConfiguration`]. pub fn language_config(&self) -> Option<&LanguageConfiguration> { self.language.as_deref() } @@ -890,6 +916,7 @@ impl Document { self.path.as_ref() } + /// File path as a URL. pub fn url(&self) -> Option { self.path().map(|path| Url::from_file_path(path).unwrap()) } @@ -904,6 +931,10 @@ impl Document { &self.selections[&view_id] } + pub fn selections(&self) -> &HashMap { + &self.selections + } + pub fn relative_path(&self) -> Option { let cwdir = std::env::current_dir().expect("couldn't determine current directory"); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index db2d74ff46559..d61cd042cee28 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -7,7 +7,11 @@ use crate::{ }; use futures_util::future; -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; use slotmap::SlotMap; @@ -21,26 +25,45 @@ use helix_core::Position; use serde::Deserialize; #[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", default)] pub struct Config { /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. pub scrolloff: usize, + /// Number of lines to scroll at once. Defaults to 3 + pub scroll_lines: isize, /// Mouse support. Defaults to true. pub mouse: bool, /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. pub shell: Vec, + /// Line number mode. + pub line_number: LineNumber, + /// Middle click paste support. Defaults to true + pub middle_click_paste: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LineNumber { + /// Show absolute line number + Absolute, + + /// Show relative line number to the primary cursor + Relative, } impl Default for Config { fn default() -> Self { Self { scrolloff: 5, + scroll_lines: 3, mouse: true, shell: if cfg!(windows) { vec!["cmd".to_owned(), "/C".to_owned()] } else { vec!["sh".to_owned(), "-c".to_owned()] }, + line_number: LineNumber::Absolute, + middle_click_paste: true, } } } @@ -164,7 +187,7 @@ impl Editor { view.jumps.push(jump); view.last_accessed_doc = Some(view.doc); view.doc = id; - view.first_line = 0; + view.offset = Position::default(); let (view, doc) = current!(self); @@ -178,7 +201,7 @@ impl Editor { .primary() .cursor(doc.text().slice(..)); let line = doc.text().char_to_line(pos); - view.first_line = line.saturating_sub(view.area.height as usize / 2); + view.offset.row = line.saturating_sub(view.inner_area().height as usize / 2); return; } @@ -223,7 +246,7 @@ impl Editor { let id = if let Some(id) = id { id } else { - let mut doc = Document::open(path, None, Some(&self.theme), Some(&self.syn_loader))?; + let mut doc = Document::open(&path, None, Some(&self.theme), Some(&self.syn_loader))?; // try to find a language server based on the language name let language_server = doc @@ -317,13 +340,17 @@ impl Editor { self.documents.iter_mut().map(|(_id, doc)| doc) } + pub fn document_by_path>(&self, path: P) -> Option<&Document> { + self.documents() + .find(|doc| doc.path().map(|p| p == path.as_ref()).unwrap_or(false)) + } + // pub fn current_document(&self) -> Document { // let id = self.view().doc; // let doc = &mut editor.documents[id]; // } pub fn cursor(&self) -> (Option, CursorKind) { - const OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter let view = view!(self); let doc = &self.documents[view.doc]; let cursor = doc @@ -331,8 +358,9 @@ impl Editor { .primary() .cursor(doc.text().slice(..)); if let Some(mut pos) = view.screen_coords_at_pos(doc, doc.text().slice(..), cursor) { - pos.col += view.area.x as usize + OFFSET as usize; - pos.row += view.area.y as usize; + let inner = view.inner_area(); + pos.col += inner.x as usize; + pos.row += inner.y as usize; (Some(pos), CursorKind::Hidden) } else { (None, CursorKind::Hidden) diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index ed530533a190f..66013ee519a81 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -1,5 +1,8 @@ use bitflags::bitflags; -use std::cmp::{max, min}; +use std::{ + cmp::{max, min}, + str::FromStr, +}; #[derive(Debug, Clone, Copy, PartialEq)] /// UNSTABLE @@ -21,7 +24,7 @@ pub struct Margin { } /// A simple rectangle used in the computation of the layout and to give widgets an hint about the -/// area they are supposed to render to. +/// area they are supposed to render to. (x, y) = (0, 0) is at the top left corner of the screen. #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub struct Rect { pub x: u16, @@ -89,6 +92,57 @@ impl Rect { self.y.saturating_add(self.height) } + // Returns a new Rect with width reduced from the left side. + // This changes the `x` coordinate and clamps it to the right + // edge of the original Rect. + pub fn clip_left(self, width: u16) -> Rect { + let width = std::cmp::min(width, self.width); + Rect { + x: self.x.saturating_add(width), + width: self.width.saturating_sub(width), + ..self + } + } + + // Returns a new Rect with width reduced from the right side. + // This does _not_ change the `x` coordinate. + pub fn clip_right(self, width: u16) -> Rect { + Rect { + width: self.width.saturating_sub(width), + ..self + } + } + + // Returns a new Rect with height reduced from the top. + // This changes the `y` coordinate and clamps it to the bottom + // edge of the original Rect. + pub fn clip_top(self, height: u16) -> Rect { + let height = std::cmp::min(height, self.height); + Rect { + y: self.y.saturating_add(height), + height: self.height.saturating_sub(height), + ..self + } + } + + // Returns a new Rect with height reduced from the bottom. + // This does _not_ change the `y` coordinate. + pub fn clip_bottom(self, height: u16) -> Rect { + Rect { + height: self.height.saturating_sub(height), + ..self + } + } + + pub fn with_height(self, height: u16) -> Rect { + // new height may make area > u16::max_value, so use new() + Self::new(self.x, self.y, self.width, height) + } + + pub fn with_width(self, width: u16) -> Rect { + Self::new(self.x, self.y, width, self.height) + } + pub fn inner(self, margin: &Margin) -> Rect { if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical { Rect::default() @@ -237,6 +291,25 @@ bitflags! { } } +impl FromStr for Modifier { + type Err = &'static str; + + fn from_str(modifier: &str) -> Result { + match modifier { + "bold" => Ok(Self::BOLD), + "dim" => Ok(Self::DIM), + "italic" => Ok(Self::ITALIC), + "underlined" => Ok(Self::UNDERLINED), + "slow_blink" => Ok(Self::SLOW_BLINK), + "rapid_blink" => Ok(Self::RAPID_BLINK), + "reversed" => Ok(Self::REVERSED), + "hidden" => Ok(Self::HIDDEN), + "crossed_out" => Ok(Self::CROSSED_OUT), + _ => Err("Invalid modifier"), + } + } +} + /// Style let you control the main characteristics of the displayed elements. /// /// ```rust @@ -473,6 +546,40 @@ mod tests { assert_eq!(rect.height, 100); } + #[test] + fn test_rect_chop_from_left() { + let rect = Rect::new(0, 0, 20, 30); + assert_eq!(Rect::new(10, 0, 10, 30), rect.clip_left(10)); + assert_eq!( + Rect::new(20, 0, 0, 30), + rect.clip_left(40), + "x should be clamped to original width if new width is bigger" + ); + } + + #[test] + fn test_rect_chop_from_right() { + let rect = Rect::new(0, 0, 20, 30); + assert_eq!(Rect::new(0, 0, 10, 30), rect.clip_right(10)); + } + + #[test] + fn test_rect_chop_from_top() { + let rect = Rect::new(0, 0, 20, 30); + assert_eq!(Rect::new(0, 10, 20, 20), rect.clip_top(10)); + assert_eq!( + Rect::new(0, 30, 20, 0), + rect.clip_top(50), + "y should be clamped to original height if new height is bigger" + ); + } + + #[test] + fn test_rect_chop_from_bottom() { + let rect = Rect::new(0, 0, 20, 30); + assert_eq!(Rect::new(0, 0, 20, 20), rect.clip_bottom(10)); + } + fn styles() -> Vec