mirror of https://github.com/helix-editor/helix
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
531 lines
16 KiB
Rust
531 lines
16 KiB
Rust
#[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, view::ViewPosition, Document, Theme, View, ViewId,
|
|
};
|
|
|
|
use tui::buffer::Buffer as Surface;
|
|
|
|
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<usize>,
|
|
pub indicator: Option<String>,
|
|
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<StickyNode>>,
|
|
doc: &Document,
|
|
view: &View,
|
|
config: &Config,
|
|
cursor_cache: &Option<Option<Position>>,
|
|
) -> Option<Self> {
|
|
let Some(cursor_cache) = cursor_cache else {
|
|
return None;
|
|
};
|
|
let cursor_cache = cursor_cache.as_ref()?;
|
|
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(view.offset.anchor);
|
|
let visual_cursor_row = cursor_cache.row;
|
|
|
|
if visual_cursor_row == 0 {
|
|
return None;
|
|
}
|
|
|
|
let top_first_byte =
|
|
text.line_to_byte(anchor_line + last_nodes.as_ref().map_or(0, |nodes| nodes.len()));
|
|
|
|
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<StickyNode>>,
|
|
doc: &Document,
|
|
view: &View,
|
|
config: &Config,
|
|
cursor_cache: &Option<Option<Position>>,
|
|
) -> Option<Vec<StickyNode>> {
|
|
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(nodes, view, &mut context, text).unwrap_or(Vec::new());
|
|
|
|
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<StickyNode> = 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()
|
|
+ 1;
|
|
}
|
|
}
|
|
|
|
if node_in_range(
|
|
node,
|
|
context.anchor_line,
|
|
&node_byte_range.clone(),
|
|
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: view.offset.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(&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::<usize>::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(&context.viewport, view, res);
|
|
}
|
|
|
|
Some(res)
|
|
}
|
|
|
|
fn build_cached_nodes(
|
|
nodes: &Option<Vec<StickyNode>>,
|
|
view: &View,
|
|
context: &mut StickyNodeContext,
|
|
text: helix_core::RopeSlice<'_>,
|
|
) -> Option<Vec<StickyNode>> {
|
|
// nothing has changed, so the cached result can be returned
|
|
if let Some(nodes) = nodes {
|
|
if nodes
|
|
.iter()
|
|
.any(|node| view.offset.anchor == node.anchor && view.id == node.view_id)
|
|
{
|
|
return Some(nodes.iter().take(context.visual_row).cloned().collect());
|
|
}
|
|
|
|
let mut cached_nodes = nodes.to_vec();
|
|
// Pop the last node + indicator node (if it exists)
|
|
if let Some(popped) = cached_nodes.pop() {
|
|
if popped.indicator.is_some() {
|
|
_ = cached_nodes.pop();
|
|
}
|
|
}
|
|
|
|
// While the cached nodes are outside our search-range, pop them, too
|
|
while cached_nodes
|
|
.last()
|
|
.is_some_and(|node| node.byte_range.start >= context.topmost_byte)
|
|
{
|
|
let Some(popped) = cached_nodes.pop() else {
|
|
break;
|
|
};
|
|
|
|
context.topmost_byte = context.topmost_byte.saturating_sub(
|
|
text.line(text.try_byte_to_line(popped.byte_range.start).ok()?)
|
|
.len_bytes(),
|
|
);
|
|
}
|
|
|
|
return Some(cached_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<std::ops::Range<usize>> {
|
|
// get all the captured @context.params nodes
|
|
let end_nodes = once_cell::unsync::Lazy::new(|| {
|
|
query_match
|
|
.nodes_for_capture_index(end_index)
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
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<usize>>,
|
|
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(viewport: &Rect, view: &View, res: Vec<StickyNode>) -> Vec<StickyNode> {
|
|
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: view.offset.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<StickyNode>>,
|
|
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, 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,
|
|
ViewPosition {
|
|
anchor: node_start,
|
|
..ViewPosition::default()
|
|
},
|
|
&formatting,
|
|
&TextAnnotations::default(),
|
|
syntax_highlights,
|
|
overlay_highlights,
|
|
theme,
|
|
&mut [],
|
|
&mut [],
|
|
);
|
|
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, 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,
|
|
ViewPosition {
|
|
anchor: node_end,
|
|
..ViewPosition::default()
|
|
},
|
|
&formatting,
|
|
&TextAnnotations::default(),
|
|
syntax_highlights,
|
|
overlay_highlights,
|
|
theme,
|
|
&mut [],
|
|
&mut [],
|
|
);
|
|
}
|
|
|
|
// next node
|
|
context_area.y += 1;
|
|
}
|
|
}
|