From c59f72f2376bc9809cb9b2ebbf7d0dcb4141fbb8 Mon Sep 17 00:00:00 2001 From: SoraTenshi Date: Mon, 15 Jul 2024 22:45:53 +0200 Subject: [PATCH] feat: implement sticky-context A lot more work has been put into this and those were 116 commits up to this point. I decided to squash all of them so that i will have an easier time rebasing in the future. --- book/src/editor.md | 21 + book/src/guides/context.md | 26 ++ helix-core/src/syntax.rs | 18 + helix-term/src/health.rs | 11 +- helix-term/src/ui/context.rs | 521 +++++++++++++++++++++++++ helix-term/src/ui/editor.rs | 73 +++- helix-term/src/ui/mod.rs | 1 + helix-view/src/editor.rs | 38 ++ runtime/queries/c-sharp/context.scm | 43 ++ runtime/queries/c/context.scm | 16 + runtime/queries/cpp/context.scm | 4 + runtime/queries/ecma/context.scm | 31 ++ runtime/queries/elixir/context.scm | 17 + runtime/queries/go/context.scm | 21 + runtime/queries/html/context.scm | 1 + runtime/queries/javascript/context.scm | 1 + runtime/queries/json/context.scm | 5 + runtime/queries/jsx/context.scm | 1 + runtime/queries/markdown/context.scm | 2 + runtime/queries/nix/context.scm | 5 + runtime/queries/python/context.scm | 15 + runtime/queries/rust/context.scm | 21 + runtime/queries/scala/context.scm | 41 ++ runtime/queries/toml/context.scm | 8 + runtime/queries/tsx/context.scm | 3 + runtime/queries/typescript/context.scm | 3 + runtime/queries/yaml/context.scm | 6 + runtime/queries/zig/context.scm | 17 + runtime/themes/tokyonight.toml | 4 +- xtask/src/querycheck.rs | 1 + 30 files changed, 955 insertions(+), 20 deletions(-) create mode 100644 book/src/guides/context.md create mode 100644 helix-term/src/ui/context.rs create mode 100644 runtime/queries/c-sharp/context.scm create mode 100644 runtime/queries/c/context.scm create mode 100644 runtime/queries/cpp/context.scm create mode 100644 runtime/queries/ecma/context.scm create mode 100644 runtime/queries/elixir/context.scm create mode 100644 runtime/queries/go/context.scm create mode 100644 runtime/queries/html/context.scm create mode 100644 runtime/queries/javascript/context.scm create mode 100644 runtime/queries/json/context.scm create mode 100644 runtime/queries/jsx/context.scm create mode 100644 runtime/queries/markdown/context.scm create mode 100644 runtime/queries/nix/context.scm create mode 100644 runtime/queries/python/context.scm create mode 100644 runtime/queries/rust/context.scm create mode 100644 runtime/queries/scala/context.scm create mode 100644 runtime/queries/toml/context.scm create mode 100644 runtime/queries/tsx/context.scm create mode 100644 runtime/queries/typescript/context.scm create mode 100644 runtime/queries/yaml/context.scm create mode 100644 runtime/queries/zig/context.scm diff --git a/book/src/editor.md b/book/src/editor.md index 82d5f8461..962e9bf26 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -17,6 +17,7 @@ - [`[editor.soft-wrap]` Section](#editorsoft-wrap-section) - [`[editor.smart-tab]` Section](#editorsmart-tab-section) - [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section) +- [`[editor.sticky-context]` Section](#editorsticky-context-section) ### `[editor]` Section @@ -433,3 +434,23 @@ end-of-line-diagnostics = "hint" [editor.inline-diagnostics] cursor-line = "warning" # show warnings and errors on the cursorline inline ``` + +### `[editor.sticky-context]` Section + +Option for sticky context, which is a feature that puts bigger blocks of code +e.g. Functions to the top of the viewport + +| Key | Description | Default | +| --- | --- | --- | +| `enable` | Display context of current line if outside the view | `false` | +| `indicator` | Display an additional line to indicate what part of the view is the sticky context | `false` | +| `max-lines` | The maximum number of lines to be shown as sticky context. 0 = 1/3 of the viewports height | `0` | + +Example: + +```toml +[editor.sticky-context] +enable = true +indicator = true +max-lines = 10 +``` diff --git a/book/src/guides/context.md b/book/src/guides/context.md new file mode 100644 index 000000000..a36bcb123 --- /dev/null +++ b/book/src/guides/context.md @@ -0,0 +1,26 @@ +# Adding context queries + +Helix uses tree-sitter to filter out specific scopes in which said scope may exceed the current +editor view, but which may be important for the developer to know. +These context require an accompanying tree-sitter grammar and a `context.scm` query file +to work properly. +Query files should be placed in `runtime/queries/{language}/context.scm` +when contributing to Helix. Note that to test the query files locally you should put +them under your local runtime directory (`~/.config/helix/runtime` on Linux for example). + +The following [captures][tree-sitter-captures] are recognized: + +| Capture Name | +| --- | +| `context` | +| `context.params` | + +[Example query files][context-examples] can be found in the helix GitHub repository. + +## Queries for the sticky-context feature + +All nodes that have a scope, should be captured with `context`, as an example a basic class. +The `context.params` is a capture for all the function parameters. + +[tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers#capturing-nodes +[context-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+filename%3Acontext.scm&type=Code&ref=advsearch&l=&l= diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 7be512f52..5e2866c3a 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -155,6 +155,10 @@ pub struct LanguageConfiguration { #[serde(skip_serializing_if = "Option::is_none")] pub debugger: Option, + /// The Grammar query for Sticky Context + #[serde(skip)] + pub(crate) context_query: OnceCell>, + /// Automatic insertion of pairs to parentheses, brackets, /// etc. Defaults to true. Optionally, this can be a list of 2-tuples /// to specify a list of characters to pair. This overrides the @@ -609,6 +613,11 @@ pub struct TextObjectQuery { pub query: Query, } +#[derive(Debug)] +pub struct ContextQuery { + pub query: Query, +} + #[derive(Debug)] pub enum CapturedNode<'a> { Single(Node<'a>), @@ -804,6 +813,15 @@ impl LanguageConfiguration { .as_ref() } + pub fn context_query(&self) -> Option<&ContextQuery> { + self.context_query + .get_or_init(|| { + self.load_query("context.scm") + .map(|query| ContextQuery { query }) + }) + .as_ref() + } + pub fn scope(&self) -> &str { &self.scope } diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 0bbb5735c..051583bbb 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -12,11 +12,17 @@ pub enum TsFeature { Highlight, TextObject, AutoIndent, + Context, } impl TsFeature { pub fn all() -> &'static [Self] { - &[Self::Highlight, Self::TextObject, Self::AutoIndent] + &[ + Self::Highlight, + Self::TextObject, + Self::AutoIndent, + Self::Context, + ] } pub fn runtime_filename(&self) -> &'static str { @@ -24,6 +30,7 @@ impl TsFeature { Self::Highlight => "highlights.scm", Self::TextObject => "textobjects.scm", Self::AutoIndent => "indents.scm", + Self::Context => "context.scm", } } @@ -32,6 +39,7 @@ impl TsFeature { Self::Highlight => "Syntax Highlighting", Self::TextObject => "Treesitter Textobjects", Self::AutoIndent => "Auto Indent", + Self::Context => "Sticky Context", } } @@ -40,6 +48,7 @@ impl TsFeature { Self::Highlight => "Highlight", Self::TextObject => "Textobject", Self::AutoIndent => "Indent", + Self::Context => "Context", } } } diff --git a/helix-term/src/ui/context.rs b/helix-term/src/ui/context.rs new file mode 100644 index 000000000..71f0fdcc7 --- /dev/null +++ b/helix-term/src/ui/context.rs @@ -0,0 +1,521 @@ +#[allow(deprecated)] +use helix_core::visual_coords_at_pos; + +use helix_core::{ + syntax::RopeProvider, + text_annotations::TextAnnotations, + tree_sitter::{Node, QueryCursor, QueryMatch}, + Position, +}; + +use helix_view::{editor::Config, graphics::Rect, Document, Theme, View, ViewId}; + +use tui::buffer::Buffer as Surface; + +use crate::ui::text_decorations::DecorationManager; + +use super::{ + document::{render_text, TextRenderer}, + EditorView, +}; + +#[derive(Debug, Default, Clone)] +pub struct StickyNode { + pub line: usize, + pub visual_line: u16, + pub byte_range: std::ops::Range, + pub indicator: Option, + pub anchor: usize, + pub has_context_end: bool, + pub view_id: ViewId, +} + +#[derive(Debug)] +struct StickyNodeContext { + /// The row on which the cursor is placed + pub visual_row: usize, + /// This marks the location of which we take possible out of range nodes + /// e.g. on follows-cursor: 'on', this would be the parent nodes, relative to the *cursor* + /// on follows-cursor: 'off', this would be the parent nodes, relative to the *topmost screen* + pub context_location: usize, + /// The topmost byte that is visible (and not hidden behind sticky nodes) + pub topmost_byte: usize, + /// The anchor of the view offset + pub anchor_line: usize, + /// The viewport + pub viewport: Rect, +} + +impl StickyNodeContext { + pub fn from_context( + last_nodes: Option<&Vec>, + doc: &Document, + view: &View, + config: &Config, + cursor_cache: Option<&Position>, + ) -> Option { + let Some(cursor_cache) = cursor_cache else { + return None; + }; + let text = doc.text().slice(..); + let viewport = view.inner_area(doc); + let cursor_byte = text.char_to_byte(doc.selection(view.id).primary().cursor(text)); + + let anchor_line = text.char_to_line(doc.view_offset(view.id).anchor); + let visual_cursor_row = cursor_cache.row; + + if visual_cursor_row == 0 { + return None; + } + + let top_first_byte = text + .try_line_to_byte(anchor_line + last_nodes.as_ref().map_or(0, |nodes| nodes.len())) + .ok()?; + + let last_scan_byte = if config.sticky_context.follow_cursor { + cursor_byte + } else { + top_first_byte + }; + Some(Self { + visual_row: visual_cursor_row, + context_location: last_scan_byte, + topmost_byte: top_first_byte, + anchor_line, + viewport, + }) + } +} + +/// Calculates the sticky nodes +pub fn calculate_sticky_nodes( + nodes: Option<&Vec>, + doc: &Document, + view: &View, + config: &Config, + cursor_cache: Option<&Position>, +) -> Option> { + let Some(mut context) = StickyNodeContext::from_context(nodes, doc, view, config, cursor_cache) + else { + return None; + }; + + let syntax = doc.syntax()?; + let tree = syntax.tree(); + let text = doc.text().slice(..); + + let mut cached_nodes = build_cached_nodes(doc, nodes, view, &mut context).unwrap_or_default(); + + if cached_nodes.iter().any(|node| node.view_id != view.id) { + cached_nodes.clear(); + } + + let start_byte_range = cached_nodes + .last() + .unwrap_or(&StickyNode::default()) + .byte_range + .clone(); + + let start_byte = if start_byte_range.start != tree.root_node().start_byte() { + start_byte_range.start + } else { + context.context_location + }; + + let mut result: Vec = Vec::new(); + let mut start_node = tree + .root_node() + .descendant_for_byte_range(start_byte, start_byte.saturating_sub(1)); + + // When the start_node is the root node... there's no point in searching further + if let Some(start_node) = start_node { + if start_node.byte_range() == tree.root_node().byte_range() { + return None; + } + } + + // Traverse to the parent node + while start_node + .unwrap_or_else(|| tree.root_node()) + .parent() + .unwrap_or_else(|| tree.root_node()) + .byte_range() + != tree.root_node().byte_range() + { + start_node = start_node.expect("parent exists").parent(); + } + + let context_nodes = doc + .language_config() + .and_then(|lang| lang.context_query())?; + + let start_index = context_nodes.query.capture_index_for_name("context")?; + let end_index = context_nodes + .query + .capture_index_for_name("context.params") + .unwrap_or(start_index); + + let mut cursor = QueryCursor::new(); + cursor.set_byte_range(start_byte_range.start..context.context_location); + + let query = &context_nodes.query; + // Collect the query, for further iteration + let query_matches = cursor.matches( + query, + start_node.unwrap_or_else(|| tree.root_node()), + RopeProvider(text), + ); + + for matched_node in query_matches { + // find @context.params nodes + let node_byte_range = get_context_paired_range( + &matched_node, + start_index, + end_index, + context.topmost_byte, + context.context_location, + ); + + for node in matched_node.nodes_for_capture_index(start_index) { + let mut last_node_add = 0; + if let Some(last_node) = result.last() { + if last_node.line == (node.start_position().row + 1) { + last_node_add += text + .line(text.byte_to_line(context.topmost_byte)) + .len_bytes(); + } + } + + if node_in_range( + node, + context.anchor_line, + node_byte_range.as_ref(), + context.context_location, + context.topmost_byte + last_node_add, + result.len(), + ) { + continue; + } + + result.push(StickyNode { + line: node.start_position().row, + visual_line: 0, + byte_range: node_byte_range + .as_ref() + .unwrap_or(&(node.start_byte()..node.end_byte())) + .clone(), + indicator: None, + anchor: doc.view_offset(view.id).anchor, + has_context_end: node_byte_range.is_some(), + view_id: view.id, + }); + } + } + // result should be filled by now + if result.is_empty() { + if !cached_nodes.is_empty() { + if config.sticky_context.indicator { + return Some(add_indicator(doc, &context.viewport, view, cached_nodes)); + } + + return Some(cached_nodes); + } + + return None; + } + + let mut res = { + cached_nodes.append(&mut result); + cached_nodes + }; + + // Order of commands is important here + res.sort_unstable_by(|lhs, rhs| lhs.line.cmp(&rhs.line)); + res.dedup_by(|lhs, rhs| lhs.line == rhs.line); + + // always cap the maximum amount of sticky contextes to 1/3 of the viewport + // unless configured otherwise + let max_lines = config.sticky_context.max_lines as u16; + let max_nodes_amount = max_lines.min(context.viewport.height / 3) as usize; + + let skip = res.len().saturating_sub(max_nodes_amount); + + res = res + .iter() + // only take the nodes until 1 / 3 of the viewport is reached or the maximum amount of sticky nodes + .skip(skip) + .enumerate() + .take_while(|(i, _)| { + *i + Into::::into(config.sticky_context.indicator) != context.visual_row + }) // also only nodes that don't overlap with the visual cursor position + .map(|(i, node)| { + let mut new_node = node.clone(); + new_node.visual_line = i as u16; + new_node + }) + .collect(); + + if config.sticky_context.indicator { + res = add_indicator(doc, &context.viewport, view, res); + } + + Some(res) +} + +fn build_cached_nodes( + doc: &Document, + nodes: Option<&Vec>, + view: &View, + context: &mut StickyNodeContext, +) -> Option> { + if let Some(nodes) = nodes { + if nodes.iter().any(|node| view.id != node.view_id) { + return None; + } + + // nothing has changed, so the cached result can be returned + if nodes + .iter() + .any(|node| doc.view_offset(view.id).anchor == node.anchor) + { + return Some(nodes.iter().take(context.visual_row).cloned().collect()); + } + + // Nodes are elligible for reuse + // While the cached nodes are outside our search-range, pop them, too + let valid_nodes: Vec = nodes + .iter() + .filter(|node| node.byte_range.contains(&context.topmost_byte)) + .cloned() + .collect(); + + return Some(valid_nodes); + } + + None +} + +fn get_context_paired_range( + query_match: &QueryMatch, + start_index: u32, + end_index: u32, + top_first_byte: usize, + last_scan_byte: usize, +) -> Option> { + // get all the captured @context.params nodes + let end_nodes = once_cell::unsync::Lazy::new(|| { + query_match + .nodes_for_capture_index(end_index) + .collect::>() + }); + + query_match + .nodes_for_capture_index(start_index) + .find_map(|context| { + let ctx_start_range = context.byte_range(); + + // filter all matches that are out of scope, based on follows-cursor + let start_range_contains_bytes = ctx_start_range.contains(&top_first_byte) + && ctx_start_range.contains(&last_scan_byte); + if !start_range_contains_bytes { + return None; + } + + let ctx_start_row = context.start_position().row; + let ctx_start_byte = ctx_start_range.start; + + end_nodes.iter().find_map(|it| { + let end = it.end_byte(); + // check whether or not @context.params nodes are on different lines + (ctx_start_row != it.end_position().row && ctx_start_range.contains(&end)) + .then_some(ctx_start_byte..end.saturating_sub(1)) + }) + }) +} + +/// Tests whether or not a given node is in a specific tree-sitter range +fn node_in_range( + node: Node, + anchor: usize, + node_byte_range: Option<&std::ops::Range>, + last_scan_byte: usize, + topmost_byte: usize, + result_len: usize, +) -> bool { + (!node.byte_range().contains(&last_scan_byte) || !node.byte_range().contains(&topmost_byte)) + && node.start_position().row != anchor + result_len + && node_byte_range.is_none() +} + +/// Adds an indicator line to the Sticky Context +fn add_indicator( + doc: &Document, + viewport: &Rect, + view: &View, + res: Vec, +) -> Vec { + let mut res = res; + let str = "─".repeat(viewport.width as usize); + res.push(StickyNode { + line: usize::MAX, + visual_line: res.len() as u16, + byte_range: 0..0, + indicator: Some(str), + anchor: doc.view_offset(view.id).anchor, + has_context_end: false, + view_id: view.id, + }); + + res +} + +/// Render the sticky context +pub fn render_sticky_context( + doc: &Document, + view: &View, + surface: &mut Surface, + context: Option<&Vec>, + theme: &Theme, +) { + let Some(context) = context else { + return; + }; + + let text = doc.text().slice(..); + let viewport = view.inner_area(doc); + + // backup (status line) shall always exist + let status_line_style = theme + .try_get("ui.statusline.context") + .expect("`ui.statusline.context` exists"); + + // define sticky context styles + let context_style = theme + .try_get("ui.sticky.context") + .unwrap_or(status_line_style); + let indicator_style = theme + .try_get("ui.sticky.indicator") + .unwrap_or(status_line_style); + + let mut context_area = viewport; + context_area.height = 1; + + const DOTS: &str = "..."; + + for node in context { + surface.clear_with(context_area, context_style); + + if let Some(indicator) = node.indicator.as_deref() { + // set the indicator + surface.set_stringn( + context_area.x, + context_area.y, + indicator, + indicator.len(), + indicator_style, + ); + continue; + } + + let node_start = text.byte_to_char(node.byte_range.start); + let first_node_line = text.line(text.char_to_line(node_start)); + + // subtract 1 to handle indexes + let mut first_node_line_end = first_node_line.len_chars().saturating_sub(1); + + // trim trailing whitespace / newline + first_node_line_end -= first_node_line + .chars() + .reversed() + .position(|c| !c.is_whitespace()) + .unwrap_or(0); + + #[allow(deprecated)] + let Position { + row: _, + col: first_node_line_end, + } = visual_coords_at_pos(first_node_line, first_node_line_end, doc.tab_width()); + + // get the highlighting of the basic capture + let syntax_highlights = EditorView::doc_syntax_highlights(doc, node_start, 1, theme); + let overlay_highlights = EditorView::empty_highlight_iter(doc, node_start, 1); + + let mut offset_area = context_area; + + // Limit scope of borrowed surface + { + let mut renderer = + TextRenderer::new(surface, doc, theme, Position::new(0, 0), context_area); + + // create the formatting for the basic node render + let mut formatting = doc.text_format(context_area.width, Some(theme)); + formatting.soft_wrap = false; + + render_text( + &mut renderer, + text, + node_start, + &formatting, + &TextAnnotations::default(), + syntax_highlights, + overlay_highlights, + theme, + DecorationManager::default(), + ); + offset_area.x += first_node_line_end as u16; + } + + if node.has_context_end { + let node_end = text.byte_to_char(node.byte_range.end); + let end_node_line = text.line(text.char_to_line(node_end)); + let whitespace_offset = end_node_line + .chars() + .position(|c| !c.is_whitespace()) + .unwrap_or(0); + + #[allow(deprecated)] + let Position { + col: end_vis_offset, + row: _, + } = visual_coords_at_pos(end_node_line, whitespace_offset, doc.tab_width()); + + surface.set_stringn( + offset_area.x, + offset_area.y, + DOTS, + DOTS.len(), + theme.get("keyword.operator"), + ); + offset_area.x += DOTS.len() as u16; + + let mut renderer = TextRenderer::new( + surface, + doc, + theme, + Position::new(0, end_vis_offset), + offset_area, + ); + + let syntax_highlights = EditorView::doc_syntax_highlights(doc, node_end, 1, theme); + let overlay_highlights = EditorView::empty_highlight_iter(doc, node_end, 1); + + let mut formatting = doc.text_format(offset_area.width, Some(theme)); + formatting.soft_wrap = false; + + render_text( + &mut renderer, + text, + node_end, + &formatting, + &TextAnnotations::default(), + syntax_highlights, + overlay_highlights, + theme, + DecorationManager::default(), + ); + } + + // next node + context_area.y += 1; + } +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f7541fe25..5b5effe78 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -34,6 +34,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; use tui::{buffer::Buffer as Surface, text::Span}; +use super::context::{self, StickyNode}; + pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option, @@ -41,6 +43,7 @@ pub struct EditorView { pub(crate) last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, spinners: ProgressSpinners, + sticky_nodes: Option>, /// Tracks if the terminal window is focused by reaction to terminal focus events terminal_focused: bool, } @@ -71,6 +74,7 @@ impl EditorView { last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), + sticky_nodes: None, terminal_focused: true, } } @@ -80,7 +84,7 @@ impl EditorView { } pub fn render_view( - &self, + &mut self, editor: &Editor, doc: &Document, view: &View, @@ -165,29 +169,31 @@ impl EditorView { } } + let primary_cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + + if is_focused { + decorations.add_decoration(text_decorations::Cursor { + cache: &editor.cursor_cache, + primary_cursor, + }); + } + let gutter_overflow = view.gutter_offset(doc) == 0; if !gutter_overflow { Self::render_gutter( editor, doc, + self.sticky_nodes.clone(), view, - view.area, theme, is_focused & self.terminal_focused, &mut decorations, ); } - let primary_cursor = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - if is_focused { - decorations.add_decoration(text_decorations::Cursor { - cache: &editor.cursor_cache, - primary_cursor, - }); - } let width = view.inner_width(doc); let config = doc.config.load(); let enable_cursor_line = view @@ -201,6 +207,7 @@ impl EditorView { inline_diagnostic_config, config.end_of_line_diagnostics, )); + render_document( surface, inner, @@ -212,6 +219,19 @@ impl EditorView { theme, decorations, ); + + if config.sticky_context.enable { + self.sticky_nodes = context::calculate_sticky_nodes( + self.sticky_nodes.as_ref(), + doc, + view, + &config, + editor.cursor_cache.get(view, doc).as_ref(), + ); + + context::render_sticky_context(doc, view, surface, self.sticky_nodes.as_ref(), theme); + } + Self::render_rulers(editor, doc, view, inner, surface, theme); // if we're not at the edge of the screen, draw a right border @@ -489,7 +509,6 @@ impl EditorView { let base_primary_cursor_scope = theme .find_scope_index("ui.cursor.primary") .unwrap_or(base_cursor_scope); - let cursor_scope = match mode { Mode::Insert => theme.find_scope_index_exact("ui.cursor.insert"), Mode::Select => theme.find_scope_index_exact("ui.cursor.select"), @@ -647,8 +666,8 @@ impl EditorView { pub fn render_gutter<'d>( editor: &'d Editor, doc: &'d Document, + context: Option>, view: &View, - viewport: Rect, theme: &Theme, is_focused: bool, decoration_manager: &mut DecorationManager<'d>, @@ -661,18 +680,24 @@ impl EditorView { .collect(); let mut offset = 0; + let viewport = view.area; let gutter_style = theme.get("ui.gutter"); let gutter_selected_style = theme.get("ui.gutter.selected"); let gutter_style_virtual = theme.get("ui.gutter.virtual"); let gutter_selected_style_virtual = theme.get("ui.gutter.selected.virtual"); + let context_rc = Rc::new(context); + for gutter_type in view.gutters() { let mut gutter = gutter_type.style(editor, doc, view, theme, is_focused); let width = gutter_type.width(view, doc); // avoid lots of small allocations by reusing a text buffer for each line - let mut text = String::with_capacity(width); + let mut text_to_draw = String::with_capacity(width); let cursors = cursors.clone(); + + let context_instance = context_rc.clone(); + let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { // TODO handle softwrap in gutters let selected = cursors.contains(&pos.doc_line); @@ -686,10 +711,22 @@ impl EditorView { (true, false) => gutter_selected_style_virtual, }; + let mut doc_line = pos.doc_line; + if let Some(current_context) = context_instance + .as_ref() + .as_ref() + .and_then(|c| c.iter().find(|n| n.visual_line == pos.visual_line)) + { + doc_line = match current_context.indicator { + Some(_) => return, + None => current_context.line, + }; + } + if let Some(style) = - gutter(pos.doc_line, selected, pos.first_visual_line, &mut text) + gutter(doc_line, selected, pos.first_visual_line, &mut text_to_draw) { - renderer.set_stringn(x, y, &text, width, gutter_style.patch(style)); + renderer.set_stringn(x, y, &text_to_draw, width, gutter_style.patch(style)); } else { renderer.set_style( Rect { @@ -701,7 +738,7 @@ impl EditorView { gutter_style, ); } - text.clear(); + text_to_draw.clear(); }; decoration_manager.add_decoration(gutter_decoration); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 6a3e198c1..8b8f43d91 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,4 +1,5 @@ mod completion; +mod context; mod document; pub(crate) mod editor; mod info; diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 1708b3b4e..a531ca67f 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -325,6 +325,8 @@ pub struct Config { pub soft_wrap: SoftWrap, /// Workspace specific lsp ceiling dirs pub workspace_lsp_roots: Vec, + /// Contextual information on top of the viewport + pub sticky_context: StickyContextConfig, /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`. pub default_line_ending: LineEndingConfig, /// Whether to automatically insert a trailing line-ending on write if missing. Defaults to `true`. @@ -363,6 +365,41 @@ impl Default for SmartTabConfig { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct StickyContextConfig { + /// Display context of current top view if it is outside the view. Default to off + pub enable: bool, + + /// Display an indicator whether to indicate if the sticky context is active + /// Eventually making this a string so that it is configurable. + /// Default to off + pub indicator: bool, + + /// The max amount of lines to be displayed. (including indicator!) + /// The viewport is taken into account when changing this value. + /// So if the configured amount is more than the viewport height, it will be capped to a max + /// of the complete viewport height. + /// + /// Default: 10, which means that it is a fixed size based on the viewport + pub max_lines: u8, + + /// Whether or not the Sticky context shall also depend on the cursor position + /// Default to off + pub follow_cursor: bool, +} + +impl Default for StickyContextConfig { + fn default() -> Self { + StickyContextConfig { + enable: false, + indicator: false, + max_lines: 10, + follow_cursor: false, + } + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct TerminalConfig { @@ -971,6 +1008,7 @@ impl Default for Config { text_width: 80, completion_replace: false, workspace_lsp_roots: Vec::new(), + sticky_context: StickyContextConfig::default(), default_line_ending: LineEndingConfig::default(), insert_final_newline: true, smart_tab: Some(SmartTabConfig::default()), diff --git a/runtime/queries/c-sharp/context.scm b/runtime/queries/c-sharp/context.scm new file mode 100644 index 000000000..f0e85348b --- /dev/null +++ b/runtime/queries/c-sharp/context.scm @@ -0,0 +1,43 @@ +; Credits to nvim-treesitter-context + +(interface_declaration) @context + +(class_declaration) @context + +(enum_declaration) @context + +(struct_declaration) @context + +(record_declaration) @context + +(namespace_declaration) @context + +(constructor_declaration + (parameter_list) @context.params + ) @context + +(destructor_declaration + (parameter_list) @context.params + ) @context + +(method_declaration + (parameter_list) @context.params + ) @context + +(switch_statement) @context + +(for_statement) @context + +(if_statement) @context + +([ + (do_statement) + (while_statement) +] @context) + +(try_statement) @context + +(catch_clause) @context + +(finally_clause) @context + diff --git a/runtime/queries/c/context.scm b/runtime/queries/c/context.scm new file mode 100644 index 000000000..608d5b094 --- /dev/null +++ b/runtime/queries/c/context.scm @@ -0,0 +1,16 @@ +; Credits to nvim-treesitter/nvim-treesitter-context + +(function_definition + (_ parameters: (_) @context.params) +) @context + +[ + (preproc_if) + (preproc_ifdef) + (for_statement) + (if_statement) + (while_statement) + (do_statement) + (struct_specifier) + (enum_specifier) +] @context diff --git a/runtime/queries/cpp/context.scm b/runtime/queries/cpp/context.scm new file mode 100644 index 000000000..91c737763 --- /dev/null +++ b/runtime/queries/cpp/context.scm @@ -0,0 +1,4 @@ +; Credits to nvim-treesitter-context + +; inherits c +(class_specifier) @context diff --git a/runtime/queries/ecma/context.scm b/runtime/queries/ecma/context.scm new file mode 100644 index 000000000..c4bb59989 --- /dev/null +++ b/runtime/queries/ecma/context.scm @@ -0,0 +1,31 @@ +(arrow_function + (formal_parameters) @context.params +) @context + +(function_declaration + (formal_parameters) @context.params +) @context + +(function + (formal_parameters) @context.params +) @context + +(generator_function_declaration + (formal_parameters) @context.params +) @context + +[ + (call_expression) + (class_declaration) + (else_clause) + (for_statement) + ; (interface_declaration) ; not usable in javascript + (lexical_declaration) + (method_definition) + (object) + (pair) + (while_statement) + (switch_statement) + (switch_case) +] @context + diff --git a/runtime/queries/elixir/context.scm b/runtime/queries/elixir/context.scm new file mode 100644 index 000000000..0a6b0bc2e --- /dev/null +++ b/runtime/queries/elixir/context.scm @@ -0,0 +1,17 @@ +; Credits to nvim-treesitter/nvim-treesitter-context +(binary_operator + left: (_) + right: (_) @context) + +(pair + key: (_) + value: (_) @context) + +((unary_operator + operand: (call + target: (identifier) + (arguments (_)))) @_op (#lua-match? @_op "@[%w_]+")) @context + +(stab_clause) @context + +(call) @context diff --git a/runtime/queries/go/context.scm b/runtime/queries/go/context.scm new file mode 100644 index 000000000..c41531998 --- /dev/null +++ b/runtime/queries/go/context.scm @@ -0,0 +1,21 @@ +; Credits to nvim-treesitter/nvim-treesitter-context + +(function_declaration + (parameter_list) @context.params +) @context + +(method_declaration + (parameter_list) @context.params +) @context + +(for_statement + (_)) @context + +[ + (const_declaration) + (for_statement) + (if_statement) + (import_declaration) + (type_declaration) + (var_declaration) +] @context diff --git a/runtime/queries/html/context.scm b/runtime/queries/html/context.scm new file mode 100644 index 000000000..f612ace9e --- /dev/null +++ b/runtime/queries/html/context.scm @@ -0,0 +1 @@ +(element) @context diff --git a/runtime/queries/javascript/context.scm b/runtime/queries/javascript/context.scm new file mode 100644 index 000000000..1f0508b5a --- /dev/null +++ b/runtime/queries/javascript/context.scm @@ -0,0 +1 @@ +; inherits ecma diff --git a/runtime/queries/json/context.scm b/runtime/queries/json/context.scm new file mode 100644 index 000000000..2b0f30f23 --- /dev/null +++ b/runtime/queries/json/context.scm @@ -0,0 +1,5 @@ +; Credits to nvim-treesitter/nvim-treesitter-context +[ + (object) + (pair) +] @context diff --git a/runtime/queries/jsx/context.scm b/runtime/queries/jsx/context.scm new file mode 100644 index 000000000..1f0508b5a --- /dev/null +++ b/runtime/queries/jsx/context.scm @@ -0,0 +1 @@ +; inherits ecma diff --git a/runtime/queries/markdown/context.scm b/runtime/queries/markdown/context.scm new file mode 100644 index 000000000..e1cc92fe4 --- /dev/null +++ b/runtime/queries/markdown/context.scm @@ -0,0 +1,2 @@ +; Credits to nvim-treesitter/nvim-treesitter-context +(section) @context diff --git a/runtime/queries/nix/context.scm b/runtime/queries/nix/context.scm new file mode 100644 index 000000000..6f5ba3a99 --- /dev/null +++ b/runtime/queries/nix/context.scm @@ -0,0 +1,5 @@ +[ + (attrset_expression) + (binding) + (list_expression) +] @context diff --git a/runtime/queries/python/context.scm b/runtime/queries/python/context.scm new file mode 100644 index 000000000..b1e20a9d3 --- /dev/null +++ b/runtime/queries/python/context.scm @@ -0,0 +1,15 @@ +(function_definition + _ parameters: (_) @context.params +) @context + +(class_definition) @context + +[ + (if_statement) + (for_statement) + (while_statement) + (with_statement) + (try_statement) + (match_statement) + (case_clause) +] @context diff --git a/runtime/queries/rust/context.scm b/runtime/queries/rust/context.scm new file mode 100644 index 000000000..04c2f1549 --- /dev/null +++ b/runtime/queries/rust/context.scm @@ -0,0 +1,21 @@ +(function_item + (parameters) @context.params +) @context + +(mod_item + body: (_)) @context + +[ + (if_expression) + (else_clause) + (match_expression) + (match_arm) + (for_expression) + (while_expression) + (loop_expression) + (closure_expression) + (impl_item) + (trait_item) + (struct_item) + (enum_item) +] @context diff --git a/runtime/queries/scala/context.scm b/runtime/queries/scala/context.scm new file mode 100644 index 000000000..54e904553 --- /dev/null +++ b/runtime/queries/scala/context.scm @@ -0,0 +1,41 @@ +(object_definition) @context + +(class_definition + class_parameters: (_) @context.params +) @context + +(trait_definition + class_parameters: (_) @context.params +) @context + +(enum_definition + class_parameters: (_) @context.params +) @context + +(given_definition + parameters: (_) @context.params +) @context + +(extension_definition + parameters: (_) @context.params +) @context + +(function_definition + parameters: (_) @context.params +) @context + +(if_expression + alternative: (_) @context +) @context + +[ + (call_expression) + (case_clause) + (catch_clause) + (lambda_expression) + (match_expression) + (try_expression) + (while_expression) +] @context + + diff --git a/runtime/queries/toml/context.scm b/runtime/queries/toml/context.scm new file mode 100644 index 000000000..feceb3925 --- /dev/null +++ b/runtime/queries/toml/context.scm @@ -0,0 +1,8 @@ +; Credits to nvim-treesitter/nvim-treesitter-context +[ + (table) + (table_array_element) + (inline_table) + (array) + (pair) +] @context diff --git a/runtime/queries/tsx/context.scm b/runtime/queries/tsx/context.scm new file mode 100644 index 000000000..6bdac3610 --- /dev/null +++ b/runtime/queries/tsx/context.scm @@ -0,0 +1,3 @@ +; inherits ecma + +(interface_declaration) @context diff --git a/runtime/queries/typescript/context.scm b/runtime/queries/typescript/context.scm new file mode 100644 index 000000000..6bdac3610 --- /dev/null +++ b/runtime/queries/typescript/context.scm @@ -0,0 +1,3 @@ +; inherits ecma + +(interface_declaration) @context diff --git a/runtime/queries/yaml/context.scm b/runtime/queries/yaml/context.scm new file mode 100644 index 000000000..df6fdf3ff --- /dev/null +++ b/runtime/queries/yaml/context.scm @@ -0,0 +1,6 @@ +; Credits to nvim-treesitter/nvim-treesitter-context +[ + (block_mapping) + (block_mapping_pair) + (block_sequence_item) +] @context diff --git a/runtime/queries/zig/context.scm b/runtime/queries/zig/context.scm new file mode 100644 index 000000000..1d5fff13a --- /dev/null +++ b/runtime/queries/zig/context.scm @@ -0,0 +1,17 @@ +(Decl + (FnProto (ParamDeclList) @context.params) +) @context + +[ + (ContainerDecl) + (ForStatement) + (IfStatement) + (InitList) + (LabeledStatement) + (LoopStatement) + (SwitchCase) + (SwitchExpr) + (SwitchProng) + (TestDecl) + (WhileStatement) +] @context diff --git a/runtime/themes/tokyonight.toml b/runtime/themes/tokyonight.toml index 4e53e03b8..960a1457f 100644 --- a/runtime/themes/tokyonight.toml +++ b/runtime/themes/tokyonight.toml @@ -85,7 +85,9 @@ hint = { fg = "hint" } "ui.statusline.normal" = { bg = "blue", fg = "bg", modifiers = ["bold"] } "ui.statusline.insert" = { bg = "light-green", fg = "bg", modifiers = ["bold"] } "ui.statusline.select" = { bg = "magenta", fg = "bg", modifiers = ["bold"] } -"ui.text" = { bg = "bg", fg = "fg" } +"ui.sticky.context" = { bg = "fg-gutter" } +"ui.sticky.indicator" = { fg = "comment", bg = "fg-gutter" } +"ui.text" = { fg = "fg" } "ui.text.focus" = { bg = "bg-focus" } "ui.text.inactive" = { fg = "comment", modifiers = ["italic"] } "ui.text.info" = { bg = "bg-menu", fg = "fg" } diff --git a/xtask/src/querycheck.rs b/xtask/src/querycheck.rs index a27f85e63..0ec340c48 100644 --- a/xtask/src/querycheck.rs +++ b/xtask/src/querycheck.rs @@ -11,6 +11,7 @@ pub fn query_check() -> Result<(), DynError> { "injections.scm", "textobjects.scm", "indents.scm", + "context.scm", ]; for language in lang_config().language {