Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/ui/editor.rs')
| -rw-r--r-- | helix-term/src/ui/editor.rs | 1763 |
1 files changed, 715 insertions, 1048 deletions
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107..967c5193 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1,60 +1,54 @@ use crate::{ - commands::{self, OnKeyCallback, OnKeyCallbackKind}, - compositor::{Component, Context, Event, EventResult}, - events::{OnModeSwitch, PostCommand}, - handlers::completion::CompletionItem, + commands, + compositor::{Component, Context, EventResult}, key, - keymap::{KeymapResult, Keymaps}, - ui::{ - document::{render_document, LinePos, TextRenderer}, - statusline, - text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics}, - Completion, ProgressSpinners, - }, + keymap::{KeymapResult, KeymapResultKind, Keymaps}, + ui::{Completion, ProgressSpinners}, }; use helix_core::{ - diagnostic::NumberOrString, - graphemes::{next_grapheme_boundary, prev_grapheme_boundary}, + coords_at_pos, encoding, + graphemes::{ + ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, + }, movement::Direction, - syntax::{self, OverlayHighlights}, - text_annotations::TextAnnotations, + syntax::{self, HighlightEvent}, + unicode::segmentation::UnicodeSegmentation, unicode::width::UnicodeWidthStr, - visual_offset_from_block, Change, Position, Range, Selection, Transaction, + LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ - annotations::diagnostics::DiagnosticFilter, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, - graphics::{Color, CursorKind, Modifier, Rect, Style}, - input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, + graphics::{CursorKind, Modifier, Rect, Style}, + input::KeyEvent, keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc}; +use std::borrow::Cow; -use tui::{buffer::Buffer as Surface, text::Span}; +use crossterm::event::{Event, MouseButton, MouseEvent, MouseEventKind}; +use tui::buffer::{Buffer as Surface, SurfaceExt}; pub struct EditorView { pub keymaps: Keymaps, - on_next_key: Option<(OnKeyCallback, OnKeyCallbackKind)>, - pseudo_pending: Vec<KeyEvent>, - pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>), + on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>, + last_insert: (commands::MappableCommand, Vec<InsertEvent>), pub(crate) completion: Option<Completion>, spinners: ProgressSpinners, - /// Tracks if the terminal window is focused by reaction to terminal focus events - terminal_focused: bool, } #[derive(Debug, Clone)] pub enum InsertEvent { Key(KeyEvent), - CompletionApply { - trigger_offset: usize, - changes: Vec<Change>, - }, + CompletionApply(CompleteAction), TriggerCompletion, - RequestCompletion, +} + +impl Default for EditorView { + fn default() -> Self { + Self::new(Keymaps::default()) + } } impl EditorView { @@ -62,11 +56,9 @@ impl EditorView { Self { keymaps, on_next_key: None, - pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), - terminal_focused: true, } } @@ -83,427 +75,199 @@ impl EditorView { surface: &mut Surface, is_focused: bool, ) { - let inner = view.inner_area(doc); + let inner = view.inner_area(); let area = view.area; let theme = &editor.theme; - let config = editor.config(); - let loader = editor.syn_loader.load(); - - let view_offset = doc.view_offset(view.id); - - let text_annotations = view.text_annotations(doc, Some(theme)); - let mut decorations = DecorationManager::default(); - - if is_focused && config.cursorline { - decorations.add_decoration(Self::cursorline(doc, view, theme)); - } - - if is_focused && config.cursorcolumn { - Self::highlight_cursorcolumn(doc, view, surface, theme, inner, &text_annotations); - } - - // Set DAP highlights, if needed. - if let Some(frame) = editor.current_stack_frame() { - let dap_line = frame.line.saturating_sub(1); - let style = theme.get("ui.highlight.frameline"); - let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { - if pos.doc_line != dap_line { - return; - } - renderer.set_style(Rect::new(inner.x, pos.visual_line, inner.width, 1), style); - }; - - decorations.add_decoration(line_decoration); - } - - let syntax_highlighter = - Self::doc_syntax_highlighter(doc, view_offset.anchor, inner.height, &loader); - let mut overlays = Vec::new(); - - overlays.push(Self::overlay_syntax_highlights( - doc, - view_offset.anchor, - inner.height, - &text_annotations, - )); - - if doc - .language_config() - .and_then(|config| config.rainbow_brackets) - .unwrap_or(config.rainbow_brackets) - { - if let Some(overlay) = - Self::doc_rainbow_highlights(doc, view_offset.anchor, inner.height, theme, &loader) - { - overlays.push(overlay); - } - } - Self::doc_diagnostics_highlights_into(doc, theme, &mut overlays); - - if is_focused { - if let Some(tabstops) = Self::tabstop_highlights(doc, theme) { - overlays.push(tabstops); + // DAP: Highlight current stack frame position + let stack_frame = editor.debugger.as_ref().and_then(|debugger| { + if let (Some(frame), Some(thread_id)) = (debugger.active_frame, debugger.thread_id) { + debugger + .stack_frames + .get(&thread_id) + .and_then(|bt| bt.get(frame)) + } else { + None } - overlays.push(Self::doc_selection_highlights( - editor.mode(), - doc, - view, - theme, - &config.cursor_shape, - self.terminal_focused, - )); - if let Some(overlay) = Self::highlight_focused_view_elements(view, doc, theme) { - overlays.push(overlay); + }); + if let Some(frame) = stack_frame { + if doc.path().is_some() + && frame + .source + .as_ref() + .and_then(|source| source.path.as_ref()) + == doc.path() + { + let line = frame.line - 1; // convert to 0-indexing + if line >= view.offset.row && line < view.offset.row + area.height as usize { + surface.set_style( + Rect::new( + area.x, + area.y + (line - view.offset.row) as u16, + area.width, + 1, + ), + theme.get("ui.highlight"), + ); + } } } - let gutter_overflow = view.gutter_offset(doc) == 0; - if !gutter_overflow { - Self::render_gutter( - editor, - doc, - view, - view.area, - theme, - is_focused & self.terminal_focused, - &mut decorations, - ); - } + let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); + let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused { + Box::new(syntax::merge( + highlights, + Self::doc_selection_highlights(doc, view, theme, &editor.config.cursor_shape), + )) + } else { + Box::new(highlights) + }; - Self::render_rulers(editor, doc, view, inner, surface, theme); + Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights); + Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); - 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, - }); + Self::render_focused_view_elements(view, doc, inner, theme, surface); } - let width = view.inner_width(doc); - let config = doc.config.load(); - let enable_cursor_line = view - .diagnostics_handler - .show_cursorline_diagnostics(doc, view.id); - let inline_diagnostic_config = config.inline_diagnostics.prepare(width, enable_cursor_line); - decorations.add_decoration(InlineDiagnostics::new( - doc, - theme, - primary_cursor, - inline_diagnostic_config, - config.end_of_line_diagnostics, - )); - render_document( - surface, - inner, - doc, - view_offset, - &text_annotations, - syntax_highlighter, - overlays, - theme, - decorations, - ); // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { let x = area.right(); let border_style = theme.get("ui.window"); for y in area.top()..area.bottom() { - surface[(x, y)] + surface + .get_mut(x, y) .set_symbol(tui::symbols::line::VERTICAL) //.set_symbol(" ") .set_style(border_style); } } - if config.inline_diagnostics.disabled() - && config.end_of_line_diagnostics == DiagnosticFilter::Disable - { - Self::render_diagnostics(doc, view, inner, surface, theme); - } + self.render_diagnostics(doc, view, inner, surface, theme); let statusline_area = view .area .clip_top(view.area.height.saturating_sub(1)) .clip_bottom(1); // -1 from bottom to remove commandline - - let mut context = - statusline::RenderContext::new(editor, doc, view, is_focused, &self.spinners); - - statusline::render(&mut context, statusline_area, surface); + self.render_statusline(doc, view, statusline_area, surface, theme, is_focused); } - pub fn render_rulers( - editor: &Editor, - doc: &Document, - view: &View, - viewport: Rect, - surface: &mut Surface, - theme: &Theme, - ) { - let editor_rulers = &editor.config().rulers; - let ruler_theme = theme - .try_get("ui.virtual.ruler") - .unwrap_or_else(|| Style::default().bg(Color::Red)); - - let rulers = doc - .language_config() - .and_then(|config| config.rulers.as_ref()) - .unwrap_or(editor_rulers); - - let view_offset = doc.view_offset(view.id); - - rulers - .iter() - // View might be horizontally scrolled, convert from absolute distance - // from the 1st column to relative distance from left of viewport - .filter_map(|ruler| ruler.checked_sub(1 + view_offset.horizontal_offset as u16)) - .filter(|ruler| ruler < &viewport.width) - .map(|ruler| viewport.clip_left(ruler).with_width(1)) - .for_each(|area| surface.set_style(area, ruler_theme)) - } - - fn viewport_byte_range( - text: helix_core::RopeSlice, - row: usize, - height: u16, - ) -> std::ops::Range<usize> { - // Calculate viewport byte ranges: - // Saturating subs to make it inclusive zero indexing. - let last_line = text.len_lines().saturating_sub(1); - let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); - let start = text.line_to_byte(row.min(last_line)); - let end = text.line_to_byte(last_visible_line + 1); - - start..end - } - - /// Get the syntax highlighter for a document in a view represented by the first line + /// Get syntax highlights for a document in a view represented by the first line /// and column (`offset`) and the last line. This is done instead of using a view /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) - pub fn doc_syntax_highlighter<'editor>( - doc: &'editor Document, - anchor: usize, + pub fn doc_syntax_highlights<'doc>( + doc: &'doc Document, + offset: Position, height: u16, - loader: &'editor syntax::Loader, - ) -> Option<syntax::Highlighter<'editor>> { - let syntax = doc.syntax()?; + _theme: &Theme, + ) -> Box<dyn Iterator<Item = HighlightEvent> + 'doc> { let text = doc.text().slice(..); - let row = text.char_to_line(anchor.min(text.len_chars())); - let range = Self::viewport_byte_range(text, row, height); - let range = range.start as u32..range.end as u32; - - let highlighter = syntax.highlighter(text, loader, range); - Some(highlighter) - } + let last_line = std::cmp::min( + // Saturating subs to make it inclusive zero indexing. + (offset.row + height as usize).saturating_sub(1), + doc.text().len_lines().saturating_sub(1), + ); - pub fn overlay_syntax_highlights( - doc: &Document, - anchor: usize, - height: u16, - text_annotations: &TextAnnotations, - ) -> OverlayHighlights { - let text = doc.text().slice(..); - let row = text.char_to_line(anchor.min(text.len_chars())); + let range = { + // calculate viewport byte ranges + let start = text.line_to_byte(offset.row); + let end = text.line_to_byte(last_line + 1); - let mut range = Self::viewport_byte_range(text, row, height); - range = text.byte_to_char(range.start)..text.byte_to_char(range.end); + start..end + }; - text_annotations.collect_overlay_highlights(range) - } + match doc.syntax() { + Some(syntax) => { + let iter = syntax + // TODO: range doesn't actually restrict source, just highlight range + .highlight_iter(text.slice(..), Some(range), None) + .map(|event| event.unwrap()) + .map(move |event| match event { + // convert byte offsets to char offset + HighlightEvent::Source { start, end } => { + let start = + text.byte_to_char(ensure_grapheme_boundary_next_byte(text, start)); + let end = + text.byte_to_char(ensure_grapheme_boundary_next_byte(text, end)); + HighlightEvent::Source { start, end } + } + event => event, + }); - pub fn doc_rainbow_highlights( - doc: &Document, - anchor: usize, - height: u16, - theme: &Theme, - loader: &syntax::Loader, - ) -> Option<OverlayHighlights> { - let syntax = doc.syntax()?; - let text = doc.text().slice(..); - let row = text.char_to_line(anchor.min(text.len_chars())); - let visible_range = Self::viewport_byte_range(text, row, height); - let start = syntax::child_for_byte_range( - &syntax.tree().root_node(), - visible_range.start as u32..visible_range.end as u32, - ) - .map_or(visible_range.start as u32, |node| node.start_byte()); - let range = start..visible_range.end as u32; - - Some(syntax.rainbow_highlights(text, theme.rainbow_length(), loader, range)) + Box::new(iter) + } + None => Box::new( + [HighlightEvent::Source { + start: text.byte_to_char(range.start), + end: text.byte_to_char(range.end), + }] + .into_iter(), + ), + } } /// Get highlight spans for document diagnostics - pub fn doc_diagnostics_highlights_into( + pub fn doc_diagnostics_highlights( doc: &Document, theme: &Theme, - overlay_highlights: &mut Vec<OverlayHighlights>, - ) { - use helix_core::diagnostic::{DiagnosticTag, Range, Severity}; - let get_scope_of = |scope| { - theme - .find_highlight_exact(scope) - // get one of the themes below as fallback values - .or_else(|| theme.find_highlight_exact("diagnostic")) - .or_else(|| theme.find_highlight_exact("ui.cursor")) - .or_else(|| theme.find_highlight_exact("ui.selection")) - .expect( - "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", - ) - }; - - // Diagnostic tags - let unnecessary = theme.find_highlight_exact("diagnostic.unnecessary"); - let deprecated = theme.find_highlight_exact("diagnostic.deprecated"); - - let mut default_vec = Vec::new(); - let mut info_vec = Vec::new(); - let mut hint_vec = Vec::new(); - let mut warning_vec = Vec::new(); - let mut error_vec = Vec::new(); - let mut unnecessary_vec = Vec::new(); - let mut deprecated_vec = Vec::new(); - - let push_diagnostic = |vec: &mut Vec<ops::Range<usize>>, range: Range| { - // If any diagnostic overlaps ranges with the prior diagnostic, - // merge the two together. Otherwise push a new span. - match vec.last_mut() { - Some(existing_range) if range.start <= existing_range.end => { - // This branch merges overlapping diagnostics, assuming that the current - // diagnostic starts on range.start or later. If this assertion fails, - // we will discard some part of `diagnostic`. This implies that - // `doc.diagnostics()` is not sorted by `diagnostic.range`. - debug_assert!(existing_range.start <= range.start); - existing_range.end = range.end.max(existing_range.end) - } - _ => vec.push(range.start..range.end), - } - }; - - for diagnostic in doc.diagnostics() { - // Separate diagnostics into different Vecs by severity. - let vec = match diagnostic.severity { - Some(Severity::Info) => &mut info_vec, - Some(Severity::Hint) => &mut hint_vec, - Some(Severity::Warning) => &mut warning_vec, - Some(Severity::Error) => &mut error_vec, - _ => &mut default_vec, - }; + ) -> Vec<(usize, std::ops::Range<usize>)> { + let diagnostic_scope = theme + .find_scope_index("diagnostic") + .or_else(|| theme.find_scope_index("ui.cursor")) + .or_else(|| theme.find_scope_index("ui.selection")) + .expect( + "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", + ); - // If the diagnostic has tags and a non-warning/error severity, skip rendering - // the diagnostic as info/hint/default and only render it as unnecessary/deprecated - // instead. For warning/error diagnostics, render both the severity highlight and - // the tag highlight. - if diagnostic.tags.is_empty() - || matches!( - diagnostic.severity, - Some(Severity::Warning | Severity::Error) + doc.diagnostics() + .iter() + .map(|diagnostic| { + ( + diagnostic_scope, + diagnostic.range.start..diagnostic.range.end, ) - { - push_diagnostic(vec, diagnostic.range); - } - - for tag in &diagnostic.tags { - match tag { - DiagnosticTag::Unnecessary => { - if unnecessary.is_some() { - push_diagnostic(&mut unnecessary_vec, diagnostic.range) - } - } - DiagnosticTag::Deprecated => { - if deprecated.is_some() { - push_diagnostic(&mut deprecated_vec, diagnostic.range) - } - } - } - } - } - - overlay_highlights.push(OverlayHighlights::Homogeneous { - highlight: get_scope_of("diagnostic"), - ranges: default_vec, - }); - if let Some(highlight) = unnecessary { - overlay_highlights.push(OverlayHighlights::Homogeneous { - highlight, - ranges: unnecessary_vec, - }); - } - if let Some(highlight) = deprecated { - overlay_highlights.push(OverlayHighlights::Homogeneous { - highlight, - ranges: deprecated_vec, - }); - } - overlay_highlights.extend([ - OverlayHighlights::Homogeneous { - highlight: get_scope_of("diagnostic.info"), - ranges: info_vec, - }, - OverlayHighlights::Homogeneous { - highlight: get_scope_of("diagnostic.hint"), - ranges: hint_vec, - }, - OverlayHighlights::Homogeneous { - highlight: get_scope_of("diagnostic.warning"), - ranges: warning_vec, - }, - OverlayHighlights::Homogeneous { - highlight: get_scope_of("diagnostic.error"), - ranges: error_vec, - }, - ]); + }) + .collect() } /// Get highlight spans for selections in a document view. pub fn doc_selection_highlights( - mode: Mode, doc: &Document, view: &View, theme: &Theme, cursor_shape_config: &CursorShapeConfig, - is_terminal_focused: bool, - ) -> OverlayHighlights { + ) -> Vec<(usize, std::ops::Range<usize>)> { let text = doc.text().slice(..); let selection = doc.selection(view.id); let primary_idx = selection.primary_index(); + let mode = doc.mode(); let cursorkind = cursor_shape_config.from_mode(mode); let cursor_is_block = cursorkind == CursorKind::Block; let selection_scope = theme - .find_highlight_exact("ui.selection") + .find_scope_index("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); - let primary_selection_scope = theme - .find_highlight_exact("ui.selection.primary") - .unwrap_or(selection_scope); - let base_cursor_scope = theme - .find_highlight_exact("ui.cursor") + .find_scope_index("ui.cursor") .unwrap_or(selection_scope); - let base_primary_cursor_scope = theme - .find_highlight("ui.cursor.primary") - .unwrap_or(base_cursor_scope); let cursor_scope = match mode { - Mode::Insert => theme.find_highlight_exact("ui.cursor.insert"), - Mode::Select => theme.find_highlight_exact("ui.cursor.select"), - Mode::Normal => theme.find_highlight_exact("ui.cursor.normal"), + Mode::Insert => theme.find_scope_index("ui.cursor.insert"), + Mode::Select => theme.find_scope_index("ui.cursor.select"), + Mode::Normal => Some(base_cursor_scope), } .unwrap_or(base_cursor_scope); - let primary_cursor_scope = match mode { - Mode::Insert => theme.find_highlight_exact("ui.cursor.primary.insert"), - Mode::Select => theme.find_highlight_exact("ui.cursor.primary.select"), - Mode::Normal => theme.find_highlight_exact("ui.cursor.primary.normal"), - } - .unwrap_or(base_primary_cursor_scope); + let primary_cursor_scope = theme + .find_scope_index("ui.cursor.primary") + .unwrap_or(cursor_scope); + let primary_selection_scope = theme + .find_scope_index("ui.selection.primary") + .unwrap_or(selection_scope); - let mut spans = Vec::new(); + let mut spans: Vec<(usize, std::ops::Range<usize>)> = Vec::new(); for (i, range) in selection.iter().enumerate() { let selection_is_primary = i == primary_idx; let (cursor_scope, selection_scope) = if selection_is_primary { @@ -514,7 +278,7 @@ impl EditorView { // Special-case: cursor at end of the rope. if range.head == range.anchor && range.head == text.len_chars() { - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + if !selection_is_primary || cursor_is_block { // Bar and underline cursors are drawn by the terminal // BUG: If the editor area loses focus while having a bar or // underline cursor (eg. when a regex prompt has focus) then @@ -529,132 +293,175 @@ impl EditorView { if range.head > range.anchor { // Standard case. let cursor_start = prev_grapheme_boundary(text, range.head); - // non block cursors look like they exclude the cursor - let selection_end = - if selection_is_primary && !cursor_is_block && mode != Mode::Insert { - range.head - } else { - cursor_start - }; - spans.push((selection_scope, range.anchor..selection_end)); - // add block cursors - // skip primary cursor if terminal is unfocused - terminal cursor is used in that case - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + spans.push((selection_scope, range.anchor..cursor_start)); + if !selection_is_primary || cursor_is_block { spans.push((cursor_scope, cursor_start..range.head)); } } else { // Reverse case. let cursor_end = next_grapheme_boundary(text, range.head); - // add block cursors - // skip primary cursor if terminal is unfocused - terminal cursor is used in that case - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + if !selection_is_primary || cursor_is_block { spans.push((cursor_scope, range.head..cursor_end)); } - // non block cursors look like they exclude the cursor - let selection_start = if selection_is_primary - && !cursor_is_block - && !(mode == Mode::Insert && cursor_end == range.anchor) - { - range.head - } else { - cursor_end - }; - spans.push((selection_scope, selection_start..range.anchor)); + spans.push((selection_scope, cursor_end..range.anchor)); } } - OverlayHighlights::Heterogenous { highlights: spans } + spans } - /// Render brace match, etc (meant for the focused view only) - pub fn highlight_focused_view_elements( - view: &View, + pub fn render_text_highlights<H: Iterator<Item = HighlightEvent>>( doc: &Document, + offset: Position, + viewport: Rect, + surface: &mut Surface, theme: &Theme, - ) -> Option<OverlayHighlights> { - // Highlight matching braces - let syntax = doc.syntax()?; - let highlight = theme.find_highlight_exact("ui.cursor.match")?; + highlights: H, + ) { let text = doc.text().slice(..); - let pos = doc.selection(view.id).primary().cursor(text); - let pos = helix_core::match_brackets::find_matching_bracket(syntax, text, pos)?; - Some(OverlayHighlights::single(highlight, pos..pos + 1)) - } - pub fn tabstop_highlights(doc: &Document, theme: &Theme) -> Option<OverlayHighlights> { - let snippet = doc.active_snippet.as_ref()?; - let highlight = theme.find_highlight_exact("tabstop")?; - let mut ranges = Vec::new(); - for tabstop in snippet.tabstops() { - ranges.extend(tabstop.ranges.iter().map(|range| range.start..range.end)); - } - Some(OverlayHighlights::Homogeneous { highlight, ranges }) - } + let mut spans = Vec::new(); + let mut visual_x = 0u16; + let mut line = 0u16; + let tab_width = doc.tab_width(); + let tab = " ".repeat(tab_width); - /// Render bufferline at the top - pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) { - let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer - surface.clear_with( - viewport, - editor - .theme - .try_get("ui.bufferline.background") - .unwrap_or_else(|| editor.theme.get("ui.statusline")), - ); + let text_style = theme.get("ui.text"); - let bufferline_active = editor - .theme - .try_get("ui.bufferline.active") - .unwrap_or_else(|| editor.theme.get("ui.statusline.active")); - - let bufferline_inactive = editor - .theme - .try_get("ui.bufferline") - .unwrap_or_else(|| editor.theme.get("ui.statusline.inactive")); - - let mut x = viewport.x; - let current_doc = view!(editor).doc; - - for doc in editor.documents() { - let fname = doc - .path() - .unwrap_or(&scratch) - .file_name() - .unwrap_or_default() - .to_str() - .unwrap_or_default(); - - let style = if current_doc == doc.id() { - bufferline_active - } else { - bufferline_inactive - }; + // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch + // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). + let text = text.slice(..); - let text = format!(" {}{} ", fname, if doc.is_modified() { "[+]" } else { "" }); - let used_width = viewport.x.saturating_sub(x); - let rem_width = surface.area.width.saturating_sub(used_width); + 'outer: for event in highlights { + match event { + HighlightEvent::HighlightStart(span) => { + spans.push(span); + } + HighlightEvent::HighlightEnd => { + spans.pop(); + } + HighlightEvent::Source { start, end } => { + // `unwrap_or_else` part is for off-the-end indices of + // the rope, to allow cursor highlighting at the end + // of the rope. + let text = text.get_slice(start..end).unwrap_or_else(|| " ".into()); + + use helix_core::graphemes::{grapheme_width, RopeGraphemes}; + + for grapheme in RopeGraphemes::new(text) { + let out_of_bounds = visual_x < offset.col as u16 + || visual_x >= viewport.width + offset.col as u16; + + if LineEnding::from_rope_slice(&grapheme).is_some() { + if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + + // we still want to render an empty cell with the style + surface.set_string( + viewport.x + visual_x - offset.col as u16, + viewport.y + line, + " ", + style, + ); + } + + visual_x = 0; + line += 1; - x = surface - .set_stringn(x, viewport.y, text, rem_width as usize, style) - .0; + // TODO: with proper iter this shouldn't be necessary + if line >= viewport.height { + break 'outer; + } + } else { + let grapheme = Cow::from(grapheme); + + let (grapheme, width) = if grapheme == "\t" { + // make sure we display tab as appropriate amount of spaces + let visual_tab_width = tab_width - (visual_x as usize % tab_width); + (&tab[..visual_tab_width], visual_tab_width) + } else { + // Cow will prevent allocations if span contained in a single slice + // which should really be the majority case + let width = grapheme_width(&grapheme); + (grapheme.as_ref(), width) + }; - if x >= surface.area.right() { - break; + if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + + // if we're offscreen just keep going until we hit a new line + surface.set_string( + viewport.x + visual_x - offset.col as u16, + viewport.y + line, + grapheme, + style, + ); + } + + visual_x = visual_x.saturating_add(width as u16); + } + } + } } } } - pub fn render_gutter<'d>( - editor: &'d Editor, - doc: &'d Document, + /// Render brace match, etc (meant for the focused view only) + pub fn render_focused_view_elements( view: &View, + doc: &Document, viewport: Rect, theme: &Theme, + surface: &mut Surface, + ) { + // Highlight matching braces + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + use helix_core::match_brackets; + let pos = doc.selection(view.id).primary().cursor(text); + + let pos = match_brackets::find_matching_bracket(syntax, doc.text(), pos) + .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); + + if let Some(pos) = pos { + // ensure col is on screen + if (pos.col as u16) < viewport.width + view.offset.col as u16 + && pos.col >= view.offset.col + { + let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { + Style::default() + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::DIM) + }); + + surface + .get_mut(viewport.x + pos.col as u16, viewport.y + pos.row as u16) + .set_style(style); + } + } + } + } + + pub fn render_gutter( + editor: &Editor, + doc: &Document, + view: &View, + viewport: Rect, + surface: &mut Surface, + theme: &Theme, is_focused: bool, - decoration_manager: &mut DecorationManager<'d>, ) { let text = doc.text().slice(..); - let cursors: Rc<[_]> = doc + let last_line = view.last_line(doc); + + // it's used inside an iterator so the collect isn't needless: + // https://github.com/rust-lang/rust-clippy/issues/6164 + #[allow(clippy::needless_collect)] + let cursors: Vec<_> = doc .selection(view.id) .iter() .map(|range| range.cursor_line(text)) @@ -663,53 +470,34 @@ impl EditorView { let mut offset = 0; 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"); - - 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 cursors = cursors.clone(); - let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { - // TODO handle softwrap in gutters - let selected = cursors.contains(&pos.doc_line); - let x = viewport.x + offset; - let y = pos.visual_line; - - let gutter_style = match (selected, pos.first_visual_line) { - (false, true) => gutter_style, - (true, true) => gutter_selected_style, - (false, false) => gutter_style_virtual, - (true, false) => gutter_selected_style_virtual, - }; - if let Some(style) = - gutter(pos.doc_line, selected, pos.first_visual_line, &mut text) - { - renderer.set_stringn(x, y, &text, width, gutter_style.patch(style)); - } else { - renderer.set_style( - Rect { - x, - y, - width: width as u16, - height: 1, - }, - gutter_style, + // 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() { + 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() { + let selected = cursors.contains(&line); + + if let Some(style) = gutter(line, selected, &mut text) { + surface.set_stringn( + viewport.x + offset, + viewport.y + i as u16, + &text, + *width, + gutter_style.patch(style), ); } text.clear(); - }; - decoration_manager.add_decoration(gutter_decoration); + } - offset += width as u16; + offset += *width as u16; } } pub fn render_diagnostics( + &self, doc: &Document, view: &View, viewport: Rect, @@ -738,30 +526,20 @@ impl EditorView { let hint = theme.get("hint"); let mut lines = Vec::new(); - let background_style = theme.get("ui.background"); for diagnostic in diagnostics { - let style = Style::reset() - .patch(background_style) - .patch(match diagnostic.severity { + let text = Text::styled( + &diagnostic.message, + match diagnostic.severity { Some(Severity::Error) => error, Some(Severity::Warning) | None => warning, Some(Severity::Info) => info, Some(Severity::Hint) => hint, - }); - let text = Text::styled(&diagnostic.message, style); + }, + ); lines.extend(text.lines); - let code = diagnostic.code.as_ref().map(|x| match x { - NumberOrString::Number(n) => format!("({n})"), - NumberOrString::String(s) => format!("({s})"), - }); - if let Some(code) = code { - let span = Span::styled(code, style); - lines.push(span.into()); - } } - let text = Text::from(lines); - let paragraph = Paragraph::new(&text) + let paragraph = Paragraph::new(lines) .alignment(Alignment::Right) .wrap(Wrap { trim: true }); let width = 100.min(viewport.width); @@ -772,95 +550,154 @@ impl EditorView { ); } - /// Apply the highlighting on the lines where a cursor is active - pub fn cursorline(doc: &Document, view: &View, theme: &Theme) -> impl Decoration { - let text = doc.text().slice(..); - // TODO only highlight the visual line that contains the cursor instead of the full visual line - let primary_line = doc.selection(view.id).primary().cursor_line(text); - - // The secondary_lines do contain the primary_line, it doesn't matter - // as the else-if clause in the loop later won't test for the - // secondary_lines if primary_line == line. - // It's used inside a loop so the collect isn't needless: - // https://github.com/rust-lang/rust-clippy/issues/6164 - #[allow(clippy::needless_collect)] - let secondary_lines: Vec<_> = doc - .selection(view.id) - .iter() - .map(|range| range.cursor_line(text)) - .collect(); - - let primary_style = theme.get("ui.cursorline.primary"); - let secondary_style = theme.get("ui.cursorline.secondary"); - let viewport = view.area; - - move |renderer: &mut TextRenderer, pos: LinePos| { - let area = Rect::new(viewport.x, pos.visual_line, viewport.width, 1); - if primary_line == pos.doc_line { - renderer.set_style(area, primary_style); - } else if secondary_lines.binary_search(&pos.doc_line).is_ok() { - renderer.set_style(area, secondary_style); - } - } - } - - /// Apply the highlighting on the columns where a cursor is active - pub fn highlight_cursorcolumn( + pub fn render_statusline( + &self, doc: &Document, view: &View, + viewport: Rect, surface: &mut Surface, theme: &Theme, - viewport: Rect, - text_annotations: &TextAnnotations, + is_focused: bool, ) { - let text = doc.text().slice(..); + use tui::text::{Span, Spans}; - // Manual fallback behaviour: - // ui.cursorcolumn.{p/s} -> ui.cursorcolumn -> ui.cursorline.{p/s} - let primary_style = theme - .try_get_exact("ui.cursorcolumn.primary") - .or_else(|| theme.try_get_exact("ui.cursorcolumn")) - .unwrap_or_else(|| theme.get("ui.cursorline.primary")); - let secondary_style = theme - .try_get_exact("ui.cursorcolumn.secondary") - .or_else(|| theme.try_get_exact("ui.cursorcolumn")) - .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); + //------------------------------- + // Left side of the status line. + //------------------------------- - let inner_area = view.inner_area(doc); + let mode = match doc.mode() { + Mode::Insert => "INS", + Mode::Select => "SEL", + Mode::Normal => "NOR", + }; + let progress = doc + .language_server() + .and_then(|srv| { + self.spinners + .get(srv.id()) + .and_then(|spinner| spinner.frame()) + }) + .unwrap_or(""); - let selection = doc.selection(view.id); - let view_offset = doc.view_offset(view.id); - let primary = selection.primary(); - let text_format = doc.text_format(viewport.width, None); - for range in selection.iter() { - let is_primary = primary == *range; - let cursor = range.cursor(text); - - let Position { col, .. } = - visual_offset_from_block(text, cursor, cursor, &text_format, text_annotations).0; - - // if the cursor is horizontally in the view - if col >= view_offset.horizontal_offset - && inner_area.width > (col - view_offset.horizontal_offset) as u16 - { - let area = Rect::new( - inner_area.x + (col - view_offset.horizontal_offset) as u16, - view.area.y, - 1, - view.area.height, - ); - if is_primary { - surface.set_style(area, primary_style) - } else { - surface.set_style(area, secondary_style) - } + 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 { + surface.set_string(viewport.x + 1, viewport.y, mode, 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 + /// activated). Only KeymapResultKind::{NotFound, Cancelled} is returned /// otherwise. fn handle_keymap_event( &mut self, @@ -868,68 +705,38 @@ impl EditorView { cxt: &mut commands::Context, event: KeyEvent, ) -> Option<KeymapResult> { - let mut last_mode = mode; - self.pseudo_pending.extend(self.keymaps.pending()); - let key_result = self.keymaps.get(mode, event); - cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); - - let mut execute_command = |command: &commands::MappableCommand| { - command.execute(cxt); - helix_event::dispatch(PostCommand { command, cx: cxt }); - - let current_mode = cxt.editor.mode(); - if current_mode != last_mode { - helix_event::dispatch(OnModeSwitch { - old_mode: last_mode, - new_mode: current_mode, - cx: cxt, - }); - - // HAXX: if we just entered insert mode from normal, clear key buf - // and record the command that got us into this mode. - if current_mode == Mode::Insert { - // how we entered insert mode is important, and we should track that so - // we can repeat the side effect. - self.last_insert.0 = command.clone(); - self.last_insert.1.clear(); - } - } - - last_mode = current_mode; - }; - - match &key_result { - KeymapResult::Matched(command) => { - execute_command(command); - } - KeymapResult::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), - KeymapResult::MatchedSequence(commands) => { + cxt.editor.autoinfo = None; + let key_result = self.keymaps.get_mut(&mode).unwrap().get(event); + cxt.editor.autoinfo = key_result.sticky.map(|node| node.infobox()); + + match &key_result.kind { + KeymapResultKind::Matched(command) => command.execute(cxt), + KeymapResultKind::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), + KeymapResultKind::MatchedSequence(commands) => { for command in commands { - execute_command(command); + command.execute(cxt); } } - KeymapResult::NotFound | KeymapResult::Cancelled(_) => return Some(key_result), + KeymapResultKind::NotFound | KeymapResultKind::Cancelled(_) => return Some(key_result), } None } fn insert_mode(&mut self, cx: &mut commands::Context, event: KeyEvent) { if let Some(keyresult) = self.handle_keymap_event(Mode::Insert, cx, event) { - match keyresult { - KeymapResult::NotFound => { - if !self.on_next_key(OnKeyCallbackKind::Fallback, cx, event) { - if let Some(ch) = event.char() { - commands::insert::insert_char(cx, ch) - } + match keyresult.kind { + KeymapResultKind::NotFound => { + if let Some(ch) = event.char() { + commands::insert::insert_char(cx, ch) } } - KeymapResult::Cancelled(pending) => { + KeymapResultKind::Cancelled(pending) => { for ev in pending { match ev.char() { Some(ch) => commands::insert::insert_char(cx, ch), None => { - if let KeymapResult::Matched(command) = - self.keymaps.get(Mode::Insert, ev) + if let KeymapResultKind::Matched(command) = + self.keymaps.get_mut(&Mode::Insert).unwrap().get(ev).kind { command.execute(cx); } @@ -943,68 +750,46 @@ impl EditorView { } fn command_mode(&mut self, mode: Mode, cxt: &mut commands::Context, event: KeyEvent) { - match (event, cxt.editor.count) { - // If the count is already started and the input is a number, always continue the count. - (key!(i @ '0'..='9'), Some(count)) => { - let i = i.to_digit(10).unwrap() as usize; - let count = count.get() * 10 + i; - if count > 100_000_000 { - return; - } - cxt.editor.count = NonZeroUsize::new(count); - } - // A non-zero digit will start the count if that number isn't used by a keymap. - (key!(i @ '1'..='9'), None) if !self.keymaps.contains_key(mode, event) => { + match event { + // count handling + key!(i @ '0'..='9') => { let i = i.to_digit(10).unwrap() as usize; - cxt.editor.count = NonZeroUsize::new(i); + cxt.editor.count = + std::num::NonZeroUsize::new(cxt.editor.count.map_or(i, |c| c.get() * 10 + i)); } // special handling for repeat operator - (key!('.'), _) if self.keymaps.pending().is_empty() => { - for _ in 0..cxt.editor.count.map_or(1, NonZeroUsize::into) { - // first execute whatever put us into insert mode - self.last_insert.0.execute(cxt); - let mut last_savepoint = None; - let mut last_request_savepoint = None; - // then replay the inputs - for key in self.last_insert.1.clone() { - match key { - InsertEvent::Key(key) => self.insert_mode(cxt, key), - InsertEvent::CompletionApply { - trigger_offset, - changes, - } => { - let (view, doc) = current!(cxt.editor); - - if let Some(last_savepoint) = last_savepoint.as_deref() { - doc.restore(view, last_savepoint, true); - } - - let text = doc.text().slice(..); - let cursor = doc.selection(view.id).primary().cursor(text); - - let shift_position = |pos: usize| -> usize { - (pos + cursor).saturating_sub(trigger_offset) - }; - - let tx = Transaction::change( - doc.text(), - changes.iter().cloned().map(|(start, end, t)| { - (shift_position(start), shift_position(end), t) - }), - ); - doc.apply(&tx, view.id); - } - InsertEvent::TriggerCompletion => { - last_savepoint = take(&mut last_request_savepoint); - } - InsertEvent::RequestCompletion => { - let (view, doc) = current!(cxt.editor); - last_request_savepoint = Some(doc.savepoint(view)); - } + key!('.') if self.keymaps.pending().is_empty() => { + // first execute whatever put us into insert mode + self.last_insert.0.execute(cxt); + // then replay the inputs + for key in self.last_insert.1.clone() { + match key { + InsertEvent::Key(key) => self.insert_mode(cxt, key), + InsertEvent::CompletionApply(compl) => { + let (view, doc) = current!(cxt.editor); + + doc.restore(view.id); + + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + let shift_position = + |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; + + let tx = Transaction::change( + doc.text(), + compl.changes.iter().cloned().map(|(start, end, t)| { + (shift_position(start), shift_position(end), t) + }), + ); + doc.apply(&tx, view.id); + } + InsertEvent::TriggerCompletion => { + let (_, doc) = current!(cxt.editor); + doc.savepoint(); } } } - cxt.editor.count = None; } _ => { // set the count @@ -1016,193 +801,127 @@ impl EditorView { // set the register cxt.register = cxt.editor.selected_register.take(); - let res = self.handle_keymap_event(mode, cxt, event); - if matches!(&res, Some(KeymapResult::NotFound)) { - self.on_next_key(OnKeyCallbackKind::Fallback, cxt, event); - } + self.handle_keymap_event(mode, cxt, event); if self.keymaps.pending().is_empty() { cxt.editor.count = None - } else { - cxt.editor.selected_register = cxt.register.take(); } } } } - #[allow(clippy::too_many_arguments)] pub fn set_completion( &mut self, editor: &mut Editor, - items: Vec<CompletionItem>, + items: Vec<helix_lsp::lsp::CompletionItem>, + offset_encoding: helix_lsp::OffsetEncoding, + start_offset: usize, trigger_offset: usize, size: Rect, - ) -> Option<Rect> { - let mut completion = Completion::new(editor, items, trigger_offset); + ) { + let mut completion = + Completion::new(editor, items, offset_encoding, start_offset, trigger_offset); if completion.is_empty() { // skip if we got no completion results - return None; + return; } - let area = completion.area(size, editor); - editor.last_completion = Some(CompleteAction::Triggered); + // Immediately initialize a savepoint + doc_mut!(editor).savepoint(); + + editor.last_completion = None; self.last_insert.1.push(InsertEvent::TriggerCompletion); // TODO : propagate required size on resize to completion too + completion.required_size((size.width, size.height)); self.completion = Some(completion); - Some(area) } - pub fn clear_completion(&mut self, editor: &mut Editor) -> Option<OnKeyCallback> { + pub fn clear_completion(&mut self, editor: &mut Editor) { self.completion = None; - let mut on_next_key: Option<OnKeyCallback> = None; - editor.handlers.completions.request_controller.restart(); - editor.handlers.completions.active_completions.clear(); - if let Some(last_completion) = editor.last_completion.take() { - match last_completion { - CompleteAction::Triggered => (), - CompleteAction::Applied { - trigger_offset, - changes, - placeholder, - } => { - self.last_insert.1.push(InsertEvent::CompletionApply { - trigger_offset, - changes, - }); - on_next_key = placeholder.then_some(Box::new(|cx, key| { - if let Some(c) = key.char() { - let (view, doc) = current!(cx.editor); - if let Some(snippet) = &doc.active_snippet { - doc.apply(&snippet.delete_placeholder(doc.text()), view.id); - } - commands::insert::insert_char(cx, c); - } - })) - } - CompleteAction::Selected { savepoint } => { - let (view, doc) = current!(editor); - doc.restore(view, &savepoint, false); - } - } - } - on_next_key + + // Clear any savepoints + let doc = doc_mut!(editor); + doc.savepoint = None; + editor.clear_idle_timer(); // don't retrigger } - pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { - commands::compute_inlay_hints_for_all_views(cx.editor, cx.jobs); + pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { + if self.completion.is_some() + || !cx.editor.config.auto_completion + || doc!(cx.editor).mode != Mode::Insert + { + return EventResult::Ignored(None); + } + + let mut cx = commands::Context { + register: None, + editor: cx.editor, + jobs: cx.jobs, + count: None, + callback: None, + on_next_key_callback: None, + }; + crate::commands::insert::idle_completion(&mut cx); - EventResult::Ignored(None) + EventResult::Consumed(None) } } impl EditorView { - /// must be called whenever the editor processed input that - /// is not a `KeyEvent`. In these cases any pending keys/on next - /// key callbacks must be canceled. - fn handle_non_key_input(&mut self, cxt: &mut commands::Context) { - cxt.editor.status_msg = None; - cxt.editor.reset_idle_timer(); - // HACKS: create a fake key event that will never trigger any actual map - // and therefore simply acts as "dismiss" - let null_key_event = KeyEvent { - code: KeyCode::Null, - modifiers: KeyModifiers::empty(), - }; - // dismiss any pending keys - if let Some((on_next_key, _)) = self.on_next_key.take() { - on_next_key(cxt, null_key_event); - } - self.handle_keymap_event(cxt.editor.mode, cxt, null_key_event); - self.pseudo_pending.clear(); - } - fn handle_mouse_event( &mut self, - event: &MouseEvent, + event: MouseEvent, cxt: &mut commands::Context, ) -> EventResult { - if event.kind != MouseEventKind::Moved { - self.handle_non_key_input(cxt) - } - - let config = cxt.editor.config(); - let MouseEvent { - kind, - row, - column, - modifiers, - .. - } = *event; - - let pos_and_view = |editor: &Editor, row, column, ignore_virtual_text| { - editor.tree.views().find_map(|(view, _focus)| { - view.pos_at_screen_coords( - &editor.documents[&view.doc], - row, - column, - ignore_virtual_text, - ) - .map(|pos| (pos, view.id)) - }) - }; - - let gutter_coords_and_view = |editor: &Editor, row, column| { - editor.tree.views().find_map(|(view, _focus)| { - view.gutter_coords_at_screen_coords(row, column) - .map(|coords| (coords, view.id)) - }) - }; - - match kind { - MouseEventKind::Down(MouseButton::Left) => { + match event { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + row, + column, + modifiers, + .. + } => { let editor = &mut cxt.editor; - if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) { - editor.focus(view_id); + let result = editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&editor.documents[&view.doc], row, column) + .map(|pos| (pos, view.id)) + }); - let prev_view_id = view!(editor).id; - let doc = doc_mut!(editor, &view!(editor, view_id).doc); + if let Some((pos, view_id)) = result { + let doc = editor.document_mut(editor.tree.get(view_id).doc).unwrap(); - if modifiers == KeyModifiers::ALT { + if modifiers == crossterm::event::KeyModifiers::ALT { let selection = doc.selection(view_id).clone(); doc.set_selection(view_id, selection.push(Range::point(pos))); - } else if editor.mode == Mode::Select { - // Discards non-primary selections for consistent UX with normal mode - let primary = doc.selection(view_id).primary().put_cursor( - doc.text().slice(..), - pos, - true, - ); - editor.mouse_down_range = Some(primary); - doc.set_selection(view_id, Selection::single(primary.anchor, primary.head)); } else { doc.set_selection(view_id, Selection::point(pos)); } - if view_id != prev_view_id { - self.clear_completion(editor); - } - - editor.ensure_cursor_in_view(view_id); + editor.tree.focus = view_id; return EventResult::Consumed(None); } - if let Some((coords, view_id)) = gutter_coords_and_view(editor, row, column) { - editor.focus(view_id); + let result = editor.tree.views().find_map(|(view, _focus)| { + view.gutter_coords_at_screen_coords(row, column) + .map(|coords| (coords, view.id)) + }); + + if let Some((coords, view_id)) = result { + editor.tree.focus = view_id; - let (view, doc) = current!(cxt.editor); + let view = editor.tree.get(view_id); + let doc = editor.documents.get_mut(&view.doc).unwrap(); let path = match doc.path() { Some(path) => path.clone(), None => return EventResult::Ignored(None), }; - if let Some(char_idx) = - view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) - { - let line = doc.text().char_to_line(char_idx); + let line = coords.row + view.offset.row; + if line < doc.text().len_lines() { commands::dap_toggle_breakpoint_impl(cxt, path, line); return EventResult::Consumed(None); } @@ -1211,24 +930,32 @@ impl EditorView { EventResult::Ignored(None) } - MouseEventKind::Drag(MouseButton::Left) => { + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + row, + column, + .. + } => { let (view, doc) = current!(cxt.editor); - let pos = match view.pos_at_screen_coords(doc, row, column, true) { + let pos = match view.pos_at_screen_coords(doc, row, column) { Some(pos) => pos, None => return EventResult::Ignored(None), }; let mut selection = doc.selection(view.id).clone(); let primary = selection.primary_mut(); - *primary = primary.put_cursor(doc.text().slice(..), pos, true); + *primary = Range::new(primary.anchor, pos); doc.set_selection(view.id, selection); - let view_id = view.id; - cxt.editor.ensure_cursor_in_view(view_id); EventResult::Consumed(None) } - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + MouseEvent { + kind: MouseEventKind::ScrollUp | MouseEventKind::ScrollDown, + row, + column, + .. + } => { let current_view = cxt.editor.tree.focus; let direction = match event.kind { @@ -1237,94 +964,105 @@ impl EditorView { _ => unreachable!(), }; - match pos_and_view(cxt.editor, row, column, false) { - Some((_, view_id)) => cxt.editor.tree.focus = view_id, + let result = cxt.editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&cxt.editor.documents[&view.doc], row, column) + .map(|_| view.id) + }); + + match result { + Some(view_id) => cxt.editor.tree.focus = view_id, None => return EventResult::Ignored(None), } - let offset = config.scroll_lines.unsigned_abs(); - commands::scroll(cxt, offset, direction, false); + let offset = cxt.editor.config.scroll_lines.abs() as usize; + commands::scroll(cxt, offset, direction); cxt.editor.tree.focus = current_view; - cxt.editor.ensure_cursor_in_view(current_view); EventResult::Consumed(None) } - MouseEventKind::Up(MouseButton::Left) => { - if !config.middle_click_paste { + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + .. + } => { + if !cxt.editor.config.middle_click_paste { return EventResult::Ignored(None); } let (view, doc) = current!(cxt.editor); + let range = doc.selection(view.id).primary(); - let should_yank = match cxt.editor.mouse_down_range.take() { - Some(down_range) => doc.selection(view.id).primary() != down_range, - None => { - // This should not happen under normal cases. We fall back to the original - // behavior of yanking on non-single-char selections. - doc.selection(view.id) - .primary() - .slice(doc.text().slice(..)) - .len_chars() - > 1 - } - }; - - if should_yank { - commands::MappableCommand::yank_main_selection_to_primary_clipboard - .execute(cxt); - EventResult::Consumed(None) - } else { - EventResult::Ignored(None) + if range.to() - range.from() <= 1 { + return EventResult::Ignored(None); } + + commands::MappableCommand::yank_main_selection_to_primary_clipboard.execute(cxt); + + EventResult::Consumed(None) } - MouseEventKind::Up(MouseButton::Right) => { - if let Some((pos, view_id)) = gutter_coords_and_view(cxt.editor, row, column) { - cxt.editor.focus(view_id); + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + row, + column, + modifiers, + .. + } => { + let result = cxt.editor.tree.views().find_map(|(view, _focus)| { + view.gutter_coords_at_screen_coords(row, column) + .map(|coords| (coords, view.id)) + }); - if let Some((pos, _)) = pos_and_view(cxt.editor, row, column, true) { - doc_mut!(cxt.editor).set_selection(view_id, Selection::point(pos)); - } else { - let (view, doc) = current!(cxt.editor); + if let Some((coords, view_id)) = result { + cxt.editor.tree.focus = view_id; - if let Some(pos) = view.pos_at_visual_coords(doc, pos.row as u16, 0, true) { - doc.set_selection(view_id, Selection::point(pos)); - match modifiers { - KeyModifiers::ALT => { - commands::MappableCommand::dap_edit_log.execute(cxt) - } - _ => commands::MappableCommand::dap_edit_condition.execute(cxt), - }; + let view = cxt.editor.tree.get(view_id); + let doc = cxt.editor.documents.get_mut(&view.doc).unwrap(); + let line = coords.row + view.offset.row; + if let Ok(pos) = doc.text().try_line_to_char(line) { + doc.set_selection(view_id, Selection::point(pos)); + if modifiers == crossterm::event::KeyModifiers::ALT { + commands::MappableCommand::dap_edit_log.execute(cxt); + } else { + commands::MappableCommand::dap_edit_condition.execute(cxt); } - } - cxt.editor.ensure_cursor_in_view(view_id); - return EventResult::Consumed(None); + return EventResult::Consumed(None); + } } EventResult::Ignored(None) } - MouseEventKind::Up(MouseButton::Middle) => { + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Middle), + row, + column, + modifiers, + .. + } => { let editor = &mut cxt.editor; - if !config.middle_click_paste { + if !editor.config.middle_click_paste { return EventResult::Ignored(None); } - if modifiers == KeyModifiers::ALT { + if modifiers == crossterm::event::KeyModifiers::ALT { commands::MappableCommand::replace_selections_with_primary_clipboard .execute(cxt); return EventResult::Consumed(None); } - if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) { - let doc = doc_mut!(editor, &view!(editor, view_id).doc); + let result = editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords(&editor.documents[&view.doc], row, column) + .map(|pos| (pos, view.id)) + }); + + if let Some((pos, view_id)) = result { + let doc = editor.document_mut(editor.tree.get(view_id).doc).unwrap(); doc.set_selection(view_id, Selection::point(pos)); - cxt.editor.focus(view_id); + editor.tree.focus = view_id; commands::MappableCommand::paste_primary_clipboard_before.execute(cxt); - return EventResult::Consumed(None); } @@ -1334,125 +1072,85 @@ impl EditorView { _ => EventResult::Ignored(None), } } - fn on_next_key( - &mut self, - kind: OnKeyCallbackKind, - ctx: &mut commands::Context, - event: KeyEvent, - ) -> bool { - if let Some((on_next_key, kind_)) = self.on_next_key.take() { - if kind == kind_ { - on_next_key(ctx, event); - true - } else { - self.on_next_key = Some((on_next_key, kind_)); - false - } - } else { - false - } - } } impl Component for EditorView { fn handle_event( &mut self, - event: &Event, + event: Event, context: &mut crate::compositor::Context, ) -> EventResult { let mut cx = commands::Context { editor: context.editor, count: None, register: None, - callback: Vec::new(), + callback: None, on_next_key_callback: None, jobs: context.jobs, }; match event { - Event::Paste(contents) => { - self.handle_non_key_input(&mut cx); - cx.count = cx.editor.count; - commands::paste_bracketed_value(&mut cx, contents.clone()); - cx.editor.count = None; - - let config = cx.editor.config(); - let mode = cx.editor.mode(); - let (view, doc) = current!(cx.editor); - view.ensure_cursor_in_view(doc, config.scrolloff); - - // Store a history state if not in insert mode. Otherwise wait till we exit insert - // to include any edits to the paste in the history state. - if mode != Mode::Insert { - doc.append_changes_to_history(view); - } - - EventResult::Consumed(None) - } Event::Resize(_width, _height) => { // Ignore this event, we handle resizing just before rendering to screen. // Handling it here but not re-rendering will cause flashing EventResult::Consumed(None) } - Event::Key(mut key) => { + Event::Key(key) => { cx.editor.reset_idle_timer(); + let mut key = KeyEvent::from(key); canonicalize_key(&mut key); // clear status cx.editor.status_msg = None; - let mode = cx.editor.mode(); + let doc = doc!(cx.editor); + let mode = doc.mode(); - if !self.on_next_key(OnKeyCallbackKind::PseudoPending, &mut cx, key) { + if let Some(on_next_key) = self.on_next_key.take() { + // if there's a command waiting input, do that first + on_next_key(&mut cx, key); + } else { match mode { Mode::Insert => { // let completion swallow the event if necessary let mut consumed = false; if let Some(completion) = &mut self.completion { - let res = { - // use a fake context here - let mut cx = Context { - editor: cx.editor, - jobs: cx.jobs, - scroll: None, - }; - - if let EventResult::Consumed(callback) = - completion.handle_event(event, &mut cx) - { - consumed = true; - Some(callback) - } else if let EventResult::Consumed(callback) = - completion.handle_event(&Event::Key(key!(Enter)), &mut cx) - { - Some(callback) - } else { - None - } + // use a fake context here + let mut cx = Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, }; + let res = completion.handle_event(event, &mut cx); + + if let EventResult::Consumed(callback) = res { + consumed = true; - if let Some(callback) = res { if callback.is_some() { // assume close_fn - if let Some(cb) = self.clear_completion(cx.editor) { - if consumed { - cx.on_next_key_callback = - Some((cb, OnKeyCallbackKind::Fallback)) - } else { - self.on_next_key = - Some((cb, OnKeyCallbackKind::Fallback)); - } - } + self.clear_completion(cx.editor); } } } // if completion didn't take the event, we pass it onto commands if !consumed { + if let Some(compl) = cx.editor.last_completion.take() { + self.last_insert.1.push(InsertEvent::CompletionApply(compl)); + } + self.insert_mode(&mut cx, key); // record last_insert key self.last_insert.1.push(InsertEvent::Key(key)); + + // lastly we recalculate completion + if let Some(completion) = &mut self.completion { + completion.update(&mut cx); + if completion.is_empty() { + self.clear_completion(cx.editor); + } + } } } mode => self.command_mode(mode, &mut cx, key), @@ -1460,13 +1158,8 @@ impl Component for EditorView { } self.on_next_key = cx.on_next_key_callback.take(); - match self.on_next_key { - Some((_, OnKeyCallbackKind::PseudoPending)) => self.pseudo_pending.push(key), - _ => self.pseudo_pending.clear(), - } - // appease borrowck - let callbacks = take(&mut cx.callback); + let callback = cx.callback.take(); // if the command consumed the last view, skip the render. // on the next loop cycle the Application will then terminate. @@ -1474,86 +1167,62 @@ impl Component for EditorView { return EventResult::Ignored(None); } - let config = cx.editor.config(); - let mode = cx.editor.mode(); let (view, doc) = current!(cx.editor); - - view.ensure_cursor_in_view(doc, config.scrolloff); + view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); // Store a history state if not in insert mode. This also takes care of - // committing changes when leaving insert mode. - if mode != Mode::Insert { - doc.append_changes_to_history(view); + // commiting changes when leaving insert mode. + if doc.mode() != Mode::Insert { + doc.append_changes_to_history(view.id); } - let callback = if callbacks.is_empty() { - None - } else { - let callback: crate::compositor::Callback = Box::new(move |compositor, cx| { - for callback in callbacks { - callback(compositor, cx) - } - }); - Some(callback) - }; - EventResult::Consumed(callback) - } + // mode transitions + match (mode, doc.mode()) { + (Mode::Normal, Mode::Insert) => { + // HAXX: if we just entered insert mode from normal, clear key buf + // and record the command that got us into this mode. - Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), - Event::IdleTimeout => self.handle_idle_timeout(&mut cx), - Event::FocusGained => { - self.terminal_focused = true; - EventResult::Consumed(None) - } - Event::FocusLost => { - if context.editor.config().auto_save.focus_lost { - let options = commands::WriteAllOptions { - force: false, - write_scratch: false, - auto_format: false, - }; - if let Err(e) = commands::typed::write_all_impl(context, options) { - context.editor.set_error(format!("{}", e)); + // how we entered insert mode is important, and we should track that so + // we can repeat the side effect. + + self.last_insert.0 = + match self.keymaps.get_mut(&mode).unwrap().get(key).kind { + KeymapResultKind::Matched(command) => command, + // FIXME: insert mode can only be entered through single KeyCodes + _ => unimplemented!(), + }; + self.last_insert.1.clear(); + } + (Mode::Insert, Mode::Normal) => { + // if exiting insert mode, remove completion + self.completion = None; } + _ => (), } - self.terminal_focused = false; - EventResult::Consumed(None) + + EventResult::Consumed(callback) } + + Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), } } fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // clear with background color - surface.set_style(area, cx.editor.theme.get("ui.background")); - let config = cx.editor.config(); - - // check if bufferline should be rendered - use helix_view::editor::BufferLine; - let use_bufferline = match config.bufferline { - BufferLine::Always => true, - BufferLine::Multiple if cx.editor.documents.len() > 1 => true, - _ => false, - }; - - // -1 for commandline and -1 for bufferline - let mut editor_area = area.clip_bottom(1); - if use_bufferline { - editor_area = editor_area.clip_top(1); - } + let bg = cx.editor.theme.get("ui.background"); + surface.add_change(termwiz::surface::Change::ClearScreen( + bg.bg.expect("no bg color set!").into(), + )); // if the terminal size suddenly changed, we need to trigger a resize - cx.editor.resize(editor_area); - - if use_bufferline { - Self::render_bufferline(cx.editor, area.with_height(1), surface); - } + cx.editor.resize(area.clip_bottom(1)); // -1 from bottom for commandline for (view, is_focused) in cx.editor.tree.views() { let doc = cx.editor.document(view.doc).unwrap(); self.render_view(cx.editor, doc, view, area, surface, is_focused); } - if config.auto_info { + if cx.editor.config.auto_info { if let Some(mut info) = cx.editor.autoinfo.take() { info.render(area, surface, cx); cx.editor.autoinfo = Some(info) @@ -1587,10 +1256,15 @@ impl Component for EditorView { disp.push_str(&count.to_string()) } for key in self.keymaps.pending() { - disp.push_str(&key.key_sequence_format()); + let s = key.to_string(); + if s.graphemes(true).count() > 1 { + disp.push_str(&format!("<{}>", s)); + } else { + disp.push_str(&s); + } } - for key in &self.pseudo_pending { - disp.push_str(&key.key_sequence_format()); + if let Some(pseudo_pending) = &cx.editor.pseudo_pending { + disp.push_str(pseudo_pending.as_str()) } let style = cx.editor.theme.get("ui.text"); let macro_width = if cx.editor.macro_recording.is_some() { @@ -1626,15 +1300,8 @@ impl Component for EditorView { fn cursor(&self, _area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) { match editor.cursor() { - // all block cursors are drawn manually - (pos, CursorKind::Block) => { - if self.terminal_focused { - (pos, CursorKind::Hidden) - } else { - // use terminal cursor when terminal loses focus - (pos, CursorKind::Underline) - } - } + // All block cursors are drawn manually + (pos, CursorKind::Block) => (pos, CursorKind::Hidden), cursor => cursor, } } |