diff --git a/Cargo.lock b/Cargo.lock index 237768b4..fb94d1e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3e1238132dc01f081e1cbb9dace14e5ef4c3a51ee244bd982275fb514605db" +checksum = "c4ab1b92798304eedc095b53942963240037c0516452cb11aeba709d420b2219" dependencies = [ "error-code", "str-buf", @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "grep-regex" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121553c9768c363839b92fc2d7cdbbad44a3b70e8d6e7b1b72b05c977527bd06" +checksum = "1345f8d33c89f2d5b081f2f2a41175adef9fd0bed2fea6a26c96c2deb027e58e" dependencies = [ "aho-corasick", "bstr", @@ -350,9 +350,9 @@ dependencies = [ [[package]] name = "grep-searcher" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdbde90ba52adc240d2deef7b6ad1f99f53142d074b771fe9b7bede6c4c23d" +checksum = "48852bd08f9b4eb3040ecb6d2f4ade224afe880a9a0909c5563cc59fa67932cc" dependencies = [ "bstr", "bytecount", @@ -644,9 +644,9 @@ checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "memmap2" -version = "0.3.1" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b6c2ebff6180198788f5db08d7ce3bc1d0b617176678831a7510825973e357" +checksum = "3a79b39c93a7a5a27eeaf9a23b5ff43f1b9e0ad6b1cdd441140ae53c35613fc7" dependencies = [ "libc", ] diff --git a/book/src/configuration.md b/book/src/configuration.md index cfa302f0..617071b6 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -38,7 +38,7 @@ hidden = false | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `cursorline` | Highlight all lines with a cursor. | `false` | -| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers`, note that `diagnostics` also includes other features like breakpoints | `["diagnostics", "line-numbers"]` | +| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `padding`, note that `diagnostics` also includes other features like breakpoints | `["diagnostics", "line-numbers", "padding"]` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | @@ -48,19 +48,52 @@ hidden = false | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | | `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | +### `[editor.statusline]` Section + +Allows configuring the statusline at the bottom of the editor. + +The configuration distinguishes between three areas of the status line: + +`[ ... ... LEFT ... ... | ... ... ... ... CENTER ... ... ... ... | ... ... RIGHT ... ... ]` + +Statusline elements can be defined as follows: + +```toml +[editor.statusline] +left = ["mode", "spinner"] +center = ["file-name"] +right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] +``` + +The following elements can be configured: + +| Key | Description | +| ------ | ----------- | +| `mode` | The current editor mode (`NOR`/`INS`/`SEL`) | +| `spinner` | A progress spinner indicating LSP activity | +| `file-name` | The path/name of the opened file | +| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | +| `file-line-ending` | The file line endings (CRLF or LF) | +| `file-type` | The type of the opened file | +| `diagnostics` | The number of warnings and/or errors | +| `selections` | The number of active selections | +| `position` | The cursor position | + ### `[editor.lsp]` Section -| Key | Description | Default | -| --- | ----------- | ------- | -| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | +| Key | Description | Default | +| --- | ----------- | ------- | +| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | +| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | +| `display-signature-help-docs` | Display docs under signature help popup | `true` | -[^1]: A progress spinner is always shown in the statusline beside the file path. +[^1]: By default, a progress spinner is shown in the statusline beside the file path. ### `[editor.cursor-shape]` Section Defines the shape of cursor in each mode. Note that due to limitations of the terminal environment, only the primary cursor can change shape. -Valid values for these options are `block`, `bar`, `underline`, or `none`. +Valid values for these options are `block`, `bar`, `underline`, or `hidden`. | Key | Description | Default | | --- | ----------- | ------- | diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 64cb32c3..21371c93 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -30,7 +30,7 @@ | git-diff | ✓ | | | | | git-ignore | ✓ | | | | | git-rebase | ✓ | | | | -| gleam | ✓ | ✓ | | | +| gleam | ✓ | ✓ | | `gleam` | | glsl | ✓ | ✓ | ✓ | | | go | ✓ | ✓ | ✓ | `gopls` | | gomod | ✓ | | | `gopls` | @@ -85,6 +85,7 @@ | rust | ✓ | ✓ | ✓ | `rust-analyzer` | | scala | ✓ | | ✓ | `metals` | | scheme | ✓ | | | | +| scss | ✓ | | | `vscode-css-language-server` | | solidity | ✓ | | | `solc` | | sql | ✓ | | | | | sshclientconfig | ✓ | | | | diff --git a/book/src/keymap.md b/book/src/keymap.md index aec876a6..c2f99edd 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -238,6 +238,7 @@ This layer is a kludge of mappings, mostly pickers. | ----- | ----------- | ------- | | `f` | Open file picker | `file_picker` | | `b` | Open buffer picker | `buffer_picker` | +| `j` | Open jumplist picker | `jumplist_picker` | | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | @@ -354,6 +355,7 @@ Keys to use within picker. Remapping currently not supported. | `Enter` | Open selected | | `Ctrl-s` | Open horizontally | | `Ctrl-v` | Open vertically | +| `Ctrl-t` | Toggle preview | | `Escape`, `Ctrl-c` | Close picker | # Prompt diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 00000000..0608a201 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,59 @@ +## Checklist + +Helix releases are versioned in the Calendar Versioning scheme: +`YY.0M(.MICRO)`, for example `22.05` for May of 2022. In these instructions +we'll use `` as a placeholder for the tag being published. + +* Merge the changelog PR +* Tag and push + * `git tag -s -m "" -a && git push` + * Make sure to switch to master and pull first +* Edit the `VERSION` file and change the date to the next planned release + * Releases are planned to happen every two months, so `22.05` would change to `22.07` +* Wait for the Release CI to finish + * It will automatically turn the git tag into a GitHub release when it uploads artifacts +* Edit the new release + * Use `` as the title + * Link to the changelog and release notes +* Merge the release notes PR +* Download the macos and linux binaries and update the `sha256`s in the [homebrew formula] + * Use `sha256sum` on the downloaded `.tar.xz` files to determine the hash +* Link to the release notes in this-week-in-rust + * [Example PR](https://github.com/rust-lang/this-week-in-rust/pull/3300) +* Post to reddit + * [Example post](https://www.reddit.com/r/rust/comments/uzp5ze/helix_editor_2205_released/) + +[homebrew formula]: https://github.com/helix-editor/homebrew-helix/blob/master/Formula/helix.rb + +## Changelog Curation + +The changelog is currently created manually by reading through commits in the +log since the last release. GitHub's compare view is a nice way to approach +this. For example when creating the 22.07 release notes, this compare link +may be used + +``` +https://github.com/helix-editor/helix/compare/22.05...master +``` + +Either side of the triple-dot may be replaced with an exact revision, so if +you wish to incrementally compile the changelog, you can tackle a weeks worth +or so, record the revision where you stopped, and use that as a starting point +next week: + +``` +https://github.com/helix-editor/helix/compare/7706a4a0d8b67b943c31d0c5f7b00d357b5d838d...master +``` + +A work-in-progress commit for a changelog might look like +[this example](https://github.com/helix-editor/helix/commit/831adfd4c709ca16b248799bfef19698d5175e55). + +Not every PR or commit needs a blurb in the changelog. Each release section +tends to have a blurb that links to a GitHub comparison between release +versions for convenience: + +> As usual, the following is a summary of each of the changes since the last +> release. For the full log, check out the git log. + +Typically, small changes like dependencies or documentation updates, refactors, +or meta changes like GitHub Actions work are left out. \ No newline at end of file diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 8d7520c3..9011f835 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -328,29 +328,15 @@ fn read_query(language: &str, filename: &str) -> String { let query = load_runtime_file(language, filename).unwrap_or_default(); - // TODO: the collect() is not ideal - let inherits = INHERITS_REGEX - .captures_iter(&query) - .flat_map(|captures| { + // replaces all "; inherits (,)*" with the queries of the given language(s) + INHERITS_REGEX + .replace_all(&query, |captures: ®ex::Captures| { captures[1] .split(',') - .map(str::to_owned) - .collect::>() + .map(|language| format!("\n{}\n", read_query(language, filename))) + .collect::() }) - .collect::>(); - - if inherits.is_empty() { - return query; - } - - let mut queries = inherits - .iter() - .map(|language| read_query(language, filename)) - .collect::>(); - - queries.push(query); - - queries.concat() + .to_string() } impl LanguageConfiguration { diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 9187a61e..f6cec6aa 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -322,6 +322,16 @@ impl Client { content_format: Some(vec![lsp::MarkupKind::Markdown]), ..Default::default() }), + signature_help: Some(lsp::SignatureHelpClientCapabilities { + signature_information: Some(lsp::SignatureInformationSettings { + documentation_format: Some(vec![lsp::MarkupKind::Markdown]), + parameter_information: Some(lsp::ParameterInformationSettings { + label_offset_support: Some(true), + }), + active_parameter_support: Some(true), + }), + ..Default::default() + }), rename: Some(lsp::RenameClientCapabilities { dynamic_registration: Some(false), prepare_support: Some(false), @@ -646,7 +656,12 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if signature help is not supported + capabilities.signature_help_provider.as_ref()?; + let params = lsp::SignatureHelpParams { text_document_position_params: lsp::TextDocumentPositionParams { text_document, @@ -657,7 +672,7 @@ impl Client { // lsp::SignatureHelpContext }; - self.call::(params) + Some(self.call::(params)) } pub fn text_document_hover( diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index c94e6502..b0e22896 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -63,8 +63,8 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } # ripgrep for global search -grep-regex = "0.1.9" -grep-searcher = "0.1.8" +grep-regex = "0.1.10" +grep-searcher = "0.1.10" # Remove once retain_mut lands in stable rust retain_mut = "0.1.7" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index f4f0876c..3ee5481f 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -151,10 +151,7 @@ impl Application { compositor.push(Box::new(overlayed(picker))); } else { let nr_of_files = args.files.len(); - editor.open(first, Action::VerticalSplit)?; - // Because the line above already opens the first file, we can - // simply skip opening it a second time by using .skip(1) here. - for (file, pos) in args.files.into_iter().skip(1) { + for (i, (file, pos)) in args.files.into_iter().enumerate() { if file.is_dir() { return Err(anyhow::anyhow!( "expected a path to file, found a directory. (to open a directory pass it as first argument)" @@ -166,6 +163,7 @@ impl Application { // option. If neither of those two arguments are passed // in, just load the files normally. let action = match args.split { + _ if i == 0 => Action::VerticalSplit, Some(Layout::Vertical) => Action::VerticalSplit, Some(Layout::Horizontal) => Action::HorizontalSplit, None => Action::Load, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6da30d04..73f799ee 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -264,6 +264,7 @@ impl MappableCommand { file_picker_in_current_directory, "Open file picker at current working directory", code_action, "Perform code action", buffer_picker, "Open buffer picker", + jumplist_picker, "Open jumplist picker", symbol_picker, "Open symbol picker", select_references_to_symbol_under_cursor, "Select symbol references", workspace_symbol_picker, "Open workspace symbol picker", @@ -718,6 +719,8 @@ fn kill_to_line_start(cx: &mut Context) { Range::new(head, anchor) }); delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } fn kill_to_line_end(cx: &mut Context) { @@ -737,6 +740,8 @@ fn kill_to_line_end(cx: &mut Context) { new_range }); delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } fn goto_first_nonwhitespace(cx: &mut Context) { @@ -2306,6 +2311,87 @@ fn buffer_picker(cx: &mut Context) { cx.push_layer(Box::new(overlayed(picker))); } +fn jumplist_picker(cx: &mut Context) { + struct JumpMeta { + id: DocumentId, + path: Option, + selection: Selection, + text: String, + is_current: bool, + } + + impl ui::menu::Item for JumpMeta { + type Data = (); + + fn label(&self, _data: &Self::Data) -> Spans { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; + + let mut flags = Vec::new(); + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + format!("{} {}{} {}", self.id, path, flag, self.text).into() + } + } + + let new_meta = |view: &View, doc_id: DocumentId, selection: Selection| { + let doc = &cx.editor.documents.get(&doc_id); + let text = doc.map_or("".into(), |d| { + selection + .fragments(d.text().slice(..)) + .map(Cow::into_owned) + .collect::>() + .join(" ") + }); + + JumpMeta { + id: doc_id, + path: doc.and_then(|d| d.path().cloned()), + selection, + text, + is_current: view.doc == doc_id, + } + }; + + let picker = FilePicker::new( + cx.editor + .tree + .views() + .flat_map(|(view, _)| { + view.jumps + .get() + .iter() + .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) + }) + .collect(), + (), + |cx, meta, action| { + cx.editor.switch(meta.id, action); + let (view, doc) = current!(cx.editor); + doc.set_selection(view.id, meta.selection.clone()); + }, + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + let line = meta.selection.primary().cursor_line(doc.text().slice(..)); + Some((meta.path.clone()?, Some((line, line)))) + }, + ); + cx.push_layer(Box::new(overlayed(picker))); +} + impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; @@ -2439,7 +2525,8 @@ async fn make_format_callback( Ok(call) } -enum Open { +#[derive(PartialEq)] +pub enum Open { Below, Above, } @@ -2837,6 +2924,9 @@ pub mod insert { use helix_lsp::lsp; // if ch matches signature_help char, trigger let doc = doc_mut!(cx.editor); + // The language_server!() macro is not used here since it will + // print an "LSP not active for current buffer" message on + // every keypress. let language_server = match doc.language_server() { Some(language_server) => language_server, None => return, @@ -2856,26 +2946,15 @@ pub mod insert { { // TODO: what if trigger is multiple chars long let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch)); + // lsp doesn't tell us when to close the signature help, so we request + // the help information again after common close triggers which should + // return None, which in turn closes the popup. + let close_triggers = &[')', ';', '.']; - if is_trigger { - super::signature_help(cx); + if is_trigger || close_triggers.contains(&ch) { + super::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } } - - // SignatureHelp { - // signatures: [ - // SignatureInformation { - // label: "fn open(&mut self, path: PathBuf, action: Action) -> Result", - // documentation: None, - // parameters: Some( - // [ParameterInformation { label: Simple("path: PathBuf"), documentation: None }, - // ParameterInformation { label: Simple("action: Action"), documentation: None }] - // ), - // active_parameter: Some(0) - // } - // ], - // active_signature: None, active_parameter: Some(0) - // } } // The default insert hook: simply insert the character @@ -2910,7 +2989,6 @@ pub mod insert { // this could also generically look at Transaction, but it's a bit annoying to look at // Operation instead of Change. for hook in &[language_server_completion, signature_help] { - // for hook in &[signature_help] { hook(cx, c); } } @@ -3018,14 +3096,18 @@ pub mod insert { pub fn delete_char_backward(cx: &mut Context) { let count = cx.count(); - let (view, doc) = current!(cx.editor); + let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); let indent_unit = doc.indent_unit(); let tab_size = doc.tab_width(); + let auto_pairs = doc.auto_pairs(cx.editor); let transaction = Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let pos = range.cursor(text); + if pos == 0 { + return (pos, pos, None); + } let line_start_pos = text.line_to_char(range.cursor_line(text)); // consider to delete by indent level if all characters before `pos` are indent units. let fragment = Cow::from(text.slice(line_start_pos..pos)); @@ -3073,15 +3155,39 @@ pub mod insert { (start, pos, None) // delete! } } else { - // delete char - ( - graphemes::nth_prev_grapheme_boundary(text, pos, count), - pos, - None, - ) + match ( + text.get_char(pos.saturating_sub(1)), + text.get_char(pos), + auto_pairs, + ) { + (Some(_x), Some(_y), Some(ap)) + if range.is_single_grapheme(text) + && ap.get(_x).is_some() + && ap.get(_x).unwrap().close == _y => + // delete both autopaired characters + { + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + graphemes::nth_next_grapheme_boundary(text, pos, count), + None, + ) + } + _ => + // delete 1 char + { + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + pos, + None, + ) + } + } } }); + let (view, doc) = current!(cx.editor); doc.apply(&transaction, view.id); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_char_forward(cx: &mut Context) { @@ -3098,6 +3204,8 @@ pub mod insert { ) }); doc.apply(&transaction, view.id); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_word_backward(cx: &mut Context) { @@ -3111,6 +3219,8 @@ pub mod insert { exclude_cursor(text, next, range) }); delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } pub fn delete_word_forward(cx: &mut Context) { @@ -3123,6 +3233,8 @@ pub mod insert { .clone() .transform(|range| movement::move_next_word_start(text, range, count)); delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } } @@ -4037,13 +4149,11 @@ 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 offset = view.offset; cx.editor.switch(id, action); // match the selection in the previous view let (view, doc) = current!(cx.editor); - view.offset = offset; doc.set_selection(view.id, selection); } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 630c47e1..1785a50c 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -6,18 +6,19 @@ use helix_lsp::{ }; use tui::text::{Span, Spans}; -use super::{align_view, push_jump, Align, Context, Editor}; +use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{path, Selection}; use helix_view::{editor::Action, theme::Style}; use crate::{ compositor::{self, Compositor}, - ui::{self, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent}, + ui::{ + self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent, + }, }; -use std::collections::BTreeMap; -use std::{borrow::Cow, path::PathBuf}; +use std::{borrow::Cow, collections::BTreeMap, path::PathBuf, sync::Arc}; /// Gets the language server that is attached to a document, and /// if it's not active displays a status message. Using this macro @@ -805,31 +806,116 @@ pub fn goto_reference(cx: &mut Context) { ); } +#[derive(PartialEq)] +pub enum SignatureHelpInvoked { + Manual, + Automatic, +} + pub fn signature_help(cx: &mut Context) { + signature_help_impl(cx, SignatureHelpInvoked::Manual) +} + +pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); + let was_manually_invoked = invoked == SignatureHelpInvoked::Manual; + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => { + // Do not show the message if signature help was invoked + // automatically on backspace, trigger characters, etc. + if was_manually_invoked { + cx.editor + .set_status("Language server not active for current buffer"); + } + return; + } + }; let offset_encoding = language_server.offset_encoding(); let pos = doc.position(view.id, offset_encoding); - let future = language_server.text_document_signature_help(doc.identifier(), pos, None); + let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { + Some(f) => f, + None => return, + }; cx.callback( future, - move |_editor, _compositor, response: Option| { - if let Some(signature_help) = response { - log::info!("{:?}", signature_help); - // signatures - // active_signature - // active_parameter - // render as: - - // signature - // ---------- - // doc - - // with active param highlighted + move |editor, compositor, response: Option| { + let config = &editor.config(); + + if !(config.lsp.auto_signature_help + || SignatureHelp::visible_popup(compositor).is_some() + || was_manually_invoked) + { + return; } + + let response = match response { + // According to the spec the response should be None if there + // are no signatures, but some servers don't follow this. + Some(s) if !s.signatures.is_empty() => s, + _ => { + compositor.remove(SignatureHelp::ID); + return; + } + }; + let doc = doc!(editor); + let language = doc + .language() + .and_then(|scope| scope.strip_prefix("source.")) + .unwrap_or(""); + + let signature = match response + .signatures + .get(response.active_signature.unwrap_or(0) as usize) + { + Some(s) => s, + None => return, + }; + let mut contents = SignatureHelp::new( + signature.label.clone(), + language.to_string(), + Arc::clone(&editor.syn_loader), + ); + + let signature_doc = if config.lsp.display_signature_help_docs { + signature.documentation.as_ref().map(|doc| match doc { + lsp::Documentation::String(s) => s.clone(), + lsp::Documentation::MarkupContent(markup) => markup.value.clone(), + }) + } else { + None + }; + + contents.set_signature_doc(signature_doc); + + let active_param_range = || -> Option<(usize, usize)> { + let param_idx = signature + .active_parameter + .or(response.active_parameter) + .unwrap_or(0) as usize; + let param = signature.parameters.as_ref()?.get(param_idx)?; + match ¶m.label { + lsp::ParameterLabel::Simple(string) => { + let start = signature.label.find(string.as_str())?; + Some((start, start + string.len())) + } + lsp::ParameterLabel::LabelOffsets([start, end]) => { + Some((*start as usize, *end as usize)) + } + } + }; + contents.set_active_param_range(active_param_range()); + + let old_popup = compositor.find_id::>(SignatureHelp::ID); + let popup = Popup::new(SignatureHelp::ID, contents) + .position(old_popup.and_then(|p| p.get_position())) + .position_bias(Open::Above) + .ignore_escape_key(true); + compositor.replace_or_push(SignatureHelp::ID, popup); }, ); } @@ -885,9 +971,21 @@ pub fn hover(cx: &mut Context) { } pub fn rename_symbol(cx: &mut Context) { - ui::prompt( + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + let primary_selection = doc.selection(view.id).primary(); + let prefill = if primary_selection.len() > 1 { + primary_selection + } else { + use helix_core::textobject::{textobject_word, TextObject}; + textobject_word(text, primary_selection, TextObject::Inside, 1, false) + } + .fragment(text) + .into(); + ui::prompt_with_input( cx, "rename-to:".into(), + prefill, None, ui::completers::none, move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 4e1ac0da..d6db117e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -413,12 +413,11 @@ fn set_line_ending( // Attempt to parse argument as a line ending. let line_ending = match arg { - // We check for CR first because it shares a common prefix with CRLF. - #[cfg(feature = "unicode-lines")] - arg if arg.starts_with("cr") => CR, arg if arg.starts_with("crlf") => Crlf, arg if arg.starts_with("lf") => LF, #[cfg(feature = "unicode-lines")] + arg if arg.starts_with("cr") => CR, + #[cfg(feature = "unicode-lines")] arg if arg.starts_with("ff") => FF, #[cfg(feature = "unicode-lines")] arg if arg.starts_with("nel") => Nel, @@ -1501,11 +1500,9 @@ fn run_shell_command( format!("```sh\n{}\n```", output), editor.syn_loader.clone(), ); - let mut popup = Popup::new("shell", contents); - popup.set_position(Some(helix_core::Position::new( - editor.cursor().0.unwrap_or_default().row, - 2, - ))); + let popup = Popup::new("shell", contents).position(Some( + helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), + )); compositor.replace_or_push("shell", popup); }); Ok(call) diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 61a3bfaf..5548e832 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -150,6 +150,14 @@ impl Compositor { self.layers.pop() } + pub fn remove(&mut self, id: &'static str) -> Option> { + let idx = self + .layers + .iter() + .position(|layer| layer.id() == Some(id))?; + Some(self.layers.remove(idx)) + } + pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool { // If it is a key event and a macro is being recorded, push the key event to the recording. if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) { diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 82418673..8aaac370 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -205,6 +205,7 @@ pub fn default() -> HashMap { "f" => file_picker, "F" => file_picker_in_current_directory, "b" => buffer_picker, + "j" => jumplist_picker, "s" => symbol_picker, "S" => workspace_symbol_picker, "g" => diagnostics_picker, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index a3637415..c1816db1 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -85,6 +85,8 @@ pub struct Completion { } impl Completion { + pub const ID: &'static str = "completion"; + pub fn new( editor: &Editor, items: Vec, @@ -214,7 +216,7 @@ impl Completion { } }; }); - let popup = Popup::new("completion", menu); + let popup = Popup::new(Self::ID, menu); let mut completion = Self { popup, start_offset, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 434b24bc..abd21b3c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1,13 +1,12 @@ use crate::{ commands, compositor::{Component, Context, EventResult}, - key, + job, key, keymap::{KeymapResult, Keymaps}, ui::{overlay::Overlay, Completion, Explorer, ProgressSpinners}, }; use helix_core::{ - coords_at_pos, encoding, graphemes::{ ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, }, @@ -17,7 +16,7 @@ use helix_core::{ LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ - document::{Mode, SCRATCH_BUFFER_NAME}, + document::Mode, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, input::KeyEvent, @@ -29,6 +28,9 @@ use std::borrow::Cow; use crossterm::event::{Event, MouseButton, MouseEvent, MouseEventKind}; use tui::buffer::Buffer as Surface; +use super::lsp::SignatureHelp; +use super::statusline; + pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option>, @@ -163,7 +165,11 @@ impl EditorView { .area .clip_top(view.area.height.saturating_sub(1)) .clip_bottom(1); // -1 from bottom to remove commandline - self.render_statusline(editor, doc, view, statusline_area, surface, is_focused); + + let mut context = + statusline::RenderContext::new(editor, doc, view, is_focused, &self.spinners); + + statusline::render(&mut context, statusline_area, surface); } pub fn render_rulers( @@ -417,7 +423,11 @@ impl EditorView { return; } - for i in 0..(indent_level / tab_width as u16) { + let starting_indent = (offset.col / tab_width) as u16; + // TODO: limit to a max indent level too. It doesn't cause visual artifacts but it would avoid some + // extra loops if the code is deeply nested. + + for i in starting_indent..(indent_level / tab_width as u16) { surface.set_string( viewport.x + (i * tab_width as u16) - offset.col as u16, viewport.y + line, @@ -612,7 +622,7 @@ impl EditorView { // avoid lots of small allocations by reusing a text buffer for each line let mut text = String::with_capacity(8); - for (constructor, width) in &view.gutters { + for (constructor, width) in view.gutters() { let gutter = constructor(editor, doc, view, theme, is_focused, *width); text.reserve(*width); // ensure there's enough space for the gutter for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { @@ -732,158 +742,6 @@ impl EditorView { } } - pub fn render_statusline( - &self, - editor: &Editor, - doc: &Document, - view: &View, - viewport: Rect, - surface: &mut Surface, - is_focused: bool, - ) { - use tui::text::{Span, Spans}; - - //------------------------------- - // Left side of the status line. - //------------------------------- - - let theme = &editor.theme; - let (mode, mode_style) = match doc.mode() { - Mode::Insert => (" INS ", theme.get("ui.statusline.insert")), - Mode::Select => (" SEL ", theme.get("ui.statusline.select")), - Mode::Normal => (" NOR ", theme.get("ui.statusline.normal")), - }; - let progress = doc - .language_server() - .and_then(|srv| { - self.spinners - .get(srv.id()) - .and_then(|spinner| spinner.frame()) - }) - .unwrap_or(""); - - let base_style = if is_focused { - theme.get("ui.statusline") - } else { - theme.get("ui.statusline.inactive") - }; - // statusline - surface.set_style(viewport.with_height(1), base_style); - if is_focused { - let color_modes = editor.config().color_modes; - surface.set_string( - viewport.x, - viewport.y, - mode, - if color_modes { mode_style } else { base_style }, - ); - } - surface.set_string(viewport.x + 5, viewport.y, progress, base_style); - - //------------------------------- - // Right side of the status line. - //------------------------------- - - let mut right_side_text = Spans::default(); - - // Compute the individual info strings and add them to `right_side_text`. - - // Diagnostics - let diags = doc.diagnostics().iter().fold((0, 0), |mut counts, diag| { - use helix_core::diagnostic::Severity; - match diag.severity { - Some(Severity::Warning) => counts.0 += 1, - Some(Severity::Error) | None => counts.1 += 1, - _ => {} - } - counts - }); - let (warnings, errors) = diags; - let warning_style = theme.get("warning"); - let error_style = theme.get("error"); - for i in 0..2 { - let (count, style) = match i { - 0 => (warnings, warning_style), - 1 => (errors, error_style), - _ => unreachable!(), - }; - if count == 0 { - continue; - } - let style = base_style.patch(style); - right_side_text.0.push(Span::styled("●", style)); - right_side_text - .0 - .push(Span::styled(format!(" {} ", count), base_style)); - } - - // Selections - let sels_count = doc.selection(view.id).len(); - right_side_text.0.push(Span::styled( - format!( - " {} sel{} ", - sels_count, - if sels_count == 1 { "" } else { "s" } - ), - base_style, - )); - - // Position - let pos = coords_at_pos( - doc.text().slice(..), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - ); - right_side_text.0.push(Span::styled( - format!(" {}:{} ", pos.row + 1, pos.col + 1), // Convert to 1-indexing. - base_style, - )); - - let enc = doc.encoding(); - if enc != encoding::UTF_8 { - right_side_text - .0 - .push(Span::styled(format!(" {} ", enc.name()), base_style)); - } - - // Render to the statusline. - surface.set_spans( - viewport.x - + viewport - .width - .saturating_sub(right_side_text.width() as u16), - viewport.y, - &right_side_text, - right_side_text.width() as u16, - ); - - //------------------------------- - // Middle / File path / Title - //------------------------------- - let title = { - let rel_path = doc.relative_path(); - let path = rel_path - .as_ref() - .map(|p| p.to_string_lossy()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); - format!("{}{}", path, if doc.is_modified() { "[+]" } else { "" }) - }; - - surface.set_string_truncated( - viewport.x + 8, // 8: 1 space + 3 char mode string + 1 space + 1 spinner + 1 space - viewport.y, - &title, - viewport - .width - .saturating_sub(6) - .saturating_sub(right_side_text.width() as u16 + 1) as usize, // "+ 1": a space between the title and the selection info - |_| base_style, - true, - true, - ); - } - /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was /// activated). Only KeymapResult::{NotFound, Cancelled} is returned @@ -1359,10 +1217,21 @@ impl Component for EditorView { _ => unimplemented!(), }; self.last_insert.1.clear(); + commands::signature_help_impl( + &mut cx, + commands::SignatureHelpInvoked::Automatic, + ); } (Mode::Insert, Mode::Normal) => { // if exiting insert mode, remove completion self.completion = None; + // TODO: Use an on_mode_change hook to remove signature help + context.jobs.callback(async { + let call: job::Callback = Box::new(|_editor, compositor| { + compositor.remove(SignatureHelp::ID); + }); + Ok(call) + }); } _ => (), } diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs new file mode 100644 index 00000000..f2854551 --- /dev/null +++ b/helix-term/src/ui/lsp.rs @@ -0,0 +1,133 @@ +use std::sync::Arc; + +use helix_core::syntax; +use helix_view::graphics::{Margin, Rect, Style}; +use tui::buffer::Buffer; +use tui::widgets::{BorderType, Paragraph, Widget, Wrap}; + +use crate::compositor::{Component, Compositor, Context}; + +use crate::ui::Markdown; + +use super::Popup; + +pub struct SignatureHelp { + signature: String, + signature_doc: Option, + /// Part of signature text + active_param_range: Option<(usize, usize)>, + + language: String, + config_loader: Arc, +} + +impl SignatureHelp { + pub const ID: &'static str = "signature-help"; + + pub fn new(signature: String, language: String, config_loader: Arc) -> Self { + Self { + signature, + signature_doc: None, + active_param_range: None, + language, + config_loader, + } + } + + pub fn set_signature_doc(&mut self, signature_doc: Option) { + self.signature_doc = signature_doc; + } + + pub fn set_active_param_range(&mut self, offset: Option<(usize, usize)>) { + self.active_param_range = offset; + } + + pub fn visible_popup(compositor: &mut Compositor) -> Option<&mut Popup> { + compositor.find_id::>(Self::ID) + } +} + +impl Component for SignatureHelp { + fn render(&mut self, area: Rect, surface: &mut Buffer, cx: &mut Context) { + let margin = Margin::horizontal(1); + + let active_param_span = self.active_param_range.map(|(start, end)| { + vec![( + cx.editor.theme.find_scope_index("ui.selection").unwrap(), + start..end, + )] + }); + + let sig_text = crate::ui::markdown::highlighted_code_block( + self.signature.clone(), + &self.language, + Some(&cx.editor.theme), + Arc::clone(&self.config_loader), + active_param_span, + ); + + let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); + let sig_text_area = area.clip_top(1).with_height(sig_text_height); + let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false }); + sig_text_para.render(sig_text_area.inner(&margin), surface); + + if self.signature_doc.is_none() { + return; + } + + let sep_style = Style::default(); + let borders = BorderType::line_symbols(BorderType::Plain); + for x in sig_text_area.left()..sig_text_area.right() { + if let Some(cell) = surface.get_mut(x, sig_text_area.bottom()) { + cell.set_symbol(borders.horizontal).set_style(sep_style); + } + } + + let sig_doc = match &self.signature_doc { + None => return, + Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)), + }; + let sig_doc = sig_doc.parse(Some(&cx.editor.theme)); + let sig_doc_area = area.clip_top(sig_text_area.height + 2); + let sig_doc_para = Paragraph::new(sig_doc) + .wrap(Wrap { trim: false }) + .scroll((cx.scroll.unwrap_or_default() as u16, 0)); + sig_doc_para.render(sig_doc_area.inner(&margin), surface); + } + + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + const PADDING: u16 = 2; + const SEPARATOR_HEIGHT: u16 = 1; + + if PADDING >= viewport.1 || PADDING >= viewport.0 { + return None; + } + let max_text_width = (viewport.0 - PADDING).min(120); + + let signature_text = crate::ui::markdown::highlighted_code_block( + self.signature.clone(), + &self.language, + None, + Arc::clone(&self.config_loader), + None, + ); + let (sig_width, sig_height) = + crate::ui::text::required_size(&signature_text, max_text_width); + + let (width, height) = match self.signature_doc { + Some(ref doc) => { + let doc_md = Markdown::new(doc.clone(), Arc::clone(&self.config_loader)); + let doc_text = doc_md.parse(None); + let (doc_width, doc_height) = + crate::ui::text::required_size(&doc_text, max_text_width); + ( + sig_width.max(doc_width), + sig_height + SEPARATOR_HEIGHT + doc_height, + ) + } + None => (sig_width, sig_height), + }; + + Some((width + PADDING, height + PADDING)) + } +} diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index a5c78c41..c53b3b66 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -144,7 +144,7 @@ impl Markdown { } } - fn parse(&self, theme: Option<&Theme>) -> tui::text::Text<'_> { + pub fn parse(&self, theme: Option<&Theme>) -> tui::text::Text<'_> { fn push_line<'a>(spans: &mut Vec>, lines: &mut Vec>) { let spans = std::mem::take(spans); if !spans.is_empty() { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index e8d59b7e..3ed3f8ae 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -2,13 +2,15 @@ mod completion; pub(crate) mod editor; mod explore; mod info; +pub mod lsp; mod markdown; pub mod menu; pub mod overlay; mod picker; -mod popup; +pub mod popup; mod prompt; mod spinner; +mod statusline; mod text; mod tree; @@ -37,7 +39,27 @@ pub fn prompt( completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, callback_fn: impl FnMut(&mut crate::compositor::Context, &str, PromptEvent) + 'static, ) { - let mut prompt = Prompt::new(prompt, history_register, completion_fn, callback_fn); + show_prompt( + cx, + Prompt::new(prompt, history_register, completion_fn, callback_fn), + ); +} + +pub fn prompt_with_input( + cx: &mut crate::commands::Context, + prompt: std::borrow::Cow<'static, str>, + input: String, + history_register: Option, + completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + callback_fn: impl FnMut(&mut crate::compositor::Context, &str, PromptEvent) + 'static, +) { + show_prompt( + cx, + Prompt::new(prompt, history_register, completion_fn, callback_fn).with_line(input), + ); +} + +fn show_prompt(cx: &mut crate::commands::Context, mut prompt: Prompt) { // Calculate initial completion prompt.recalculate_completion(cx.editor); cx.push_layer(Box::new(prompt)); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 375723e5..9707c81e 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -173,7 +173,7 @@ impl Component for FilePicker { // | | | | // +---------+ +---------+ - let render_preview = area.width > MIN_AREA_WIDTH_FOR_PREVIEW; + let render_preview = self.picker.show_preview && area.width > MIN_AREA_WIDTH_FOR_PREVIEW; // -- Render the frame: // clear area let background = cx.editor.theme.get("ui.background"); @@ -300,6 +300,8 @@ pub struct Picker { previous_pattern: String, /// Whether to truncate the start (default true) pub truncate_start: bool, + /// Whether to show the preview panel (default true) + show_preview: bool, callback_fn: Box, } @@ -327,6 +329,7 @@ impl Picker { prompt, previous_pattern: String::new(), truncate_start: true, + show_preview: true, callback_fn: Box::new(callback_fn), completion_height: 0, }; @@ -470,6 +473,10 @@ impl Picker { self.filters.sort_unstable(); // used for binary search later self.prompt.clear(cx); } + + pub fn toggle_preview(&mut self) { + self.show_preview = !self.show_preview; + } } // process: @@ -538,6 +545,9 @@ impl Component for Picker { ctrl!(' ') => { self.save_filter(cx); } + ctrl!('t') => { + self.toggle_preview(); + } _ => { if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) { // TODO: recalculate only if pattern changed diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index f5b79526..77ab2462 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -1,4 +1,5 @@ use crate::{ + commands::Open, compositor::{Callback, Component, Context, EventResult}, ctrl, key, }; @@ -17,8 +18,10 @@ pub struct Popup { margin: Margin, size: (u16, u16), child_size: (u16, u16), + position_bias: Open, scroll: usize, auto_close: bool, + ignore_escape_key: bool, id: &'static str, } @@ -29,15 +32,27 @@ impl Popup { position: None, margin: Margin::none(), size: (0, 0), + position_bias: Open::Below, child_size: (0, 0), scroll: 0, auto_close: false, + ignore_escape_key: false, id, } } - pub fn set_position(&mut self, pos: Option) { + pub fn position(mut self, pos: Option) -> Self { self.position = pos; + self + } + + pub fn get_position(&self) -> Option { + self.position + } + + pub fn position_bias(mut self, bias: Open) -> Self { + self.position_bias = bias; + self } pub fn margin(mut self, margin: Margin) -> Self { @@ -50,6 +65,18 @@ impl Popup { self } + /// Ignores an escape keypress event, letting the outer layer + /// (usually the editor) handle it. This is useful for popups + /// in insert mode like completion and signature help where + /// the popup is closed on the mode change from insert to normal + /// which is done with the escape key. Otherwise the popup consumes + /// the escape key event and closes it, and an additional escape + /// would be required to exit insert mode. + pub fn ignore_escape_key(mut self, ignore: bool) -> Self { + self.ignore_escape_key = ignore; + self + } + pub fn get_rel_position(&mut self, viewport: Rect, cx: &Context) -> (u16, u16) { let position = self .position @@ -68,13 +95,23 @@ impl Popup { rel_x = rel_x.saturating_sub((rel_x + width).saturating_sub(viewport.width)); } - // TODO: be able to specify orientation preference. We want above for most popups, below - // for menus/autocomplete. - if viewport.height > rel_y + height { - rel_y += 1 // position below point - } else { - rel_y = rel_y.saturating_sub(height) // position above point - } + let can_put_below = viewport.height > rel_y + height; + let can_put_above = rel_y.checked_sub(height).is_some(); + let final_pos = match self.position_bias { + Open::Below => match can_put_below { + true => Open::Below, + false => Open::Above, + }, + Open::Above => match can_put_above { + true => Open::Above, + false => Open::Below, + }, + }; + + rel_y = match final_pos { + Open::Above => rel_y.saturating_sub(height), + Open::Below => rel_y + 1, + }; (rel_x, rel_y) } @@ -112,9 +149,13 @@ impl Component for Popup { _ => return EventResult::Ignored(None), }; + if key!(Esc) == key.into() && self.ignore_escape_key { + return EventResult::Ignored(None); + } + let close_fn: Callback = Box::new(|compositor, _| { // remove the layer - compositor.pop(); + compositor.remove(self.id.as_ref()); }); match key.into() { diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 36ee62c3..6e7df907 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -84,6 +84,13 @@ impl Prompt { } } + pub fn with_line(mut self, line: String) -> Self { + let cursor = line.len(); + self.line = line; + self.cursor = cursor; + self + } + pub fn line(&self) -> &String { &self.line } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs new file mode 100644 index 00000000..85992c60 --- /dev/null +++ b/helix-term/src/ui/statusline.rs @@ -0,0 +1,336 @@ +use helix_core::{coords_at_pos, encoding}; +use helix_view::{ + document::{Mode, SCRATCH_BUFFER_NAME}, + graphics::Rect, + theme::Style, + Document, Editor, View, +}; + +use crate::ui::ProgressSpinners; + +use helix_view::editor::StatusLineElement as StatusLineElementID; +use tui::buffer::Buffer as Surface; +use tui::text::{Span, Spans}; + +pub struct RenderContext<'a> { + pub editor: &'a Editor, + pub doc: &'a Document, + pub view: &'a View, + pub focused: bool, + pub spinners: &'a ProgressSpinners, + pub parts: RenderBuffer<'a>, +} + +impl<'a> RenderContext<'a> { + pub fn new( + editor: &'a Editor, + doc: &'a Document, + view: &'a View, + focused: bool, + spinners: &'a ProgressSpinners, + ) -> Self { + RenderContext { + editor, + doc, + view, + focused, + spinners, + parts: RenderBuffer::default(), + } + } +} + +#[derive(Default)] +pub struct RenderBuffer<'a> { + pub left: Spans<'a>, + pub center: Spans<'a>, + pub right: Spans<'a>, +} + +pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) { + let base_style = if context.focused { + context.editor.theme.get("ui.statusline") + } else { + context.editor.theme.get("ui.statusline.inactive") + }; + + surface.set_style(viewport.with_height(1), base_style); + + let write_left = |context: &mut RenderContext, text, style| { + append(&mut context.parts.left, text, &base_style, style) + }; + let write_center = |context: &mut RenderContext, text, style| { + append(&mut context.parts.center, text, &base_style, style) + }; + let write_right = |context: &mut RenderContext, text, style| { + append(&mut context.parts.right, text, &base_style, style) + }; + + // Left side of the status line. + + let element_ids = &context.editor.config().statusline.left; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_left)); + + surface.set_spans( + viewport.x, + viewport.y, + &context.parts.left, + context.parts.left.width() as u16, + ); + + // Right side of the status line. + + let element_ids = &context.editor.config().statusline.right; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_right)); + + surface.set_spans( + viewport.x + + viewport + .width + .saturating_sub(context.parts.right.width() as u16), + viewport.y, + &context.parts.right, + context.parts.right.width() as u16, + ); + + // Center of the status line. + + let element_ids = &context.editor.config().statusline.center; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_center)); + + // Width of the empty space between the left and center area and between the center and right area. + let spacing = 1u16; + + let edge_width = context.parts.left.width().max(context.parts.right.width()) as u16; + let center_max_width = viewport.width.saturating_sub(2 * edge_width + 2 * spacing); + let center_width = center_max_width.min(context.parts.center.width() as u16); + + surface.set_spans( + viewport.x + viewport.width / 2 - center_width / 2, + viewport.y, + &context.parts.center, + center_width, + ); +} + +fn append(buffer: &mut Spans, text: String, base_style: &Style, style: Option