#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))] // Adding this file level gate as some of the code around editability is not used in WASM yet. use crate::code::editor::line_iterator::LineIterator; use crate::code_review::CodeReviewTelemetryEvent; use num_traits::SaturatingSub; use rangemap::{RangeMap, RangeSet}; use std::future::Future; use std::ops::Range; use std::path::Path; use std::rc::Rc; use std::sync::Arc; use std::{cmp, mem}; use galaxy_core::platform::SessionPlatform; use galaxy_core::send_telemetry_from_ctx; use galaxy_core::ui::theme::Fill; use galaxy_editor::content::anchor::Anchor; use galaxy_editor::content::edit::EditDelta; use galaxy_editor::content::find::{SearchConfig, SearchResults}; use galaxy_editor::content::selection_model::BufferSelectionModel; use galaxy_editor::content::version::BufferVersion; use galaxy_editor::multiline::{AnyMultilineString, MultilineString, LF}; use galaxy_editor::render::model::{AutoScrollMode, LineCount, StyleUpdateAction}; use galaxy_editor::selection::TextDirection; use galaxyui::units::{IntoPixels, Pixels}; use crate::util::link_detection::get_word_range_at_offset; use crate::{ appearance::Appearance, editor::InteractionState, notebooks::editor::model::word_unit, themes::theme::AnsiColorIdentifier, }; use ai::diff_validation::DiffDelta; use itertools::Itertools; use languages::{language_by_filename, language_by_name, Language}; use line_ending::LineEnding; use string_offset::CharOffset; use syntax_tree::{ColorMap, DecorationStateEvent, SyntaxTreeState}; use vec1::{vec1, Vec1}; use vim::vim::{ BracketChar, CharacterMotion, Direction, FindCharMotion, FirstNonWhitespaceMotion, InsertPosition, LineMotion, MotionType, TextObjectInclusion, TextObjectType, VimOperator, VimTextObject, WordBound, WordMotion, WordType, }; use vim::{ find_next_paragraph_end, find_previous_paragraph_start, vim_a_block, vim_a_paragraph, vim_a_quote, vim_a_word, vim_find_char_on_line, vim_find_matching_bracket, vim_inner_block, vim_inner_paragraph, vim_inner_quote, vim_inner_word, vim_word_iterator_from_offset, }; use galaxy_core::semantic_selection::SemanticSelection; use galaxy_editor::content::buffer::{ShouldAutoscroll, VimInsertPoint}; use galaxy_editor::{ content::{ buffer::{ AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin, InitialBufferState, SelectionOffsets, ToBufferCharOffset, ToBufferPoint, }, hidden_lines_model::HiddenLinesModel, text::{BufferBlockStyle, IndentBehavior, IndentUnit}, }, decoration::DecorationLayer, editor::TextDecoration, model::{CoreEditorModel, PlainTextEditorModel}, render::model::{ BlockItem, Decoration, LineDecoration, RenderEvent, RenderLineLocation, RenderState, RichTextStyles, UpdateDecorationAfterLayout, WidthSetting, }, selection::{SelectionMode, SelectionModel, TextUnit}, }; use galaxyui::elements::{ AnchorPair, OffsetPositioning, OffsetType, PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor, }; use galaxyui::text::{point::Point, TextBuffer}; use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; use super::super::DiffResult; use super::comments::{EditorCommentsModel, PendingComment, PendingCommentEvent}; use super::diff::{ add_inline_overlay_color, DiffModel, DiffModelEvent, DiffStatus, RenderableDiffHunk, }; use super::line::EditorLineLocation; use crate::code_review::comments::{CommentId, CommentOrigin, LineDiffContent}; /// An opaque handle to a stable line in the editor content, suitable for scroll /// position preservation. Contains an internal anchor that tracks through /// buffer edits so the line position remains accurate even after insertions /// or deletions elsewhere in the file. /// /// Obtain via [`CodeEditorModel::line_at_vertical_offset`]; resolve back to a /// pixel offset via [`CodeEditorModel::line_top`]. #[derive(Debug, Clone)] pub struct StableEditorLine(StableEditorLineInner); #[derive(Debug, Clone)] enum StableEditorLineInner { /// Scroll position is on a line in the current buffer. CurrentLine { /// Anchored at the start of the line; tracks through buffer edits. anchor: Anchor, }, /// Scroll position is on a removed/deleted line shown in the diff. RemovedLine { /// Anchored at the start of the attachment line; tracks through /// buffer edits. anchor: Anchor, temp_index: usize, }, } /// Enum used for vim movements to the start and end of lines pub enum LineBound { Start, End, } #[derive(Debug, Clone, PartialEq)] pub enum DiffNavigationState { /// Diff hunks are collapsed and not visible Collapsed, /// All diff hunks are expanded but none is specifically focused Expanded, /// All diff hunks are expanded and one specific hunk is focused Focused(usize), } enum IndentMode { NewlineAbove, NewlineBelow, LinewiseChange, Enter, } struct IndentResult { /// The indentation to insert before the cursor insert_before_cursor: String, /// Optionally, text to insert after the cursor (e.g. in the case /// where we hit enter between brackets) insert_after_cursor: Option, } /// Case transformation kinds used by vim toggle case. pub enum CaseTransform { Toggle, Uppercase, Lowercase, } impl CaseTransform { fn apply_to(&self, input: String) -> String { match self { CaseTransform::Toggle => input .chars() .map(|c| { if c.is_lowercase() { c.to_uppercase().next().unwrap_or(c) } else if c.is_uppercase() { c.to_lowercase().next().unwrap_or(c) } else { c } }) .collect(), CaseTransform::Uppercase => input.to_uppercase(), CaseTransform::Lowercase => input.to_lowercase(), } } } /// A hoverable link with an optional on-click handler. pub struct HoverableLink { range: Range, #[allow(clippy::type_complexity)] on_click: Option>, } impl HoverableLink { pub fn new(range: Range) -> Self { Self { range, on_click: None, } } pub fn with_on_click(mut self, on_click: Box) -> Self { self.on_click = Some(on_click); self } pub fn range(&self) -> &Range { &self.range } pub fn trigger_on_click(&self, ctx: &mut AppContext) { if let Some(on_click) = &self.on_click { on_click(ctx); } } } pub enum CodeEditorModelEvent { /// Emitted when diff decorations are updated (line highlights, removed lines, etc.) DiffUpdated, /// Emitted when syntax highlighting decorations are updated SyntaxHighlightingUpdated, ContentChanged { origin: EditOrigin, }, SelectionChanged, UnifiedDiffComputed(Rc), ViewportUpdated(BufferVersion), InteractionStateChanged, DelayedRenderingFlushed, /// Emitted when the render state layout has been updated. /// Consumers can use this to invalidate cached heights. LayoutInvalidated, #[cfg(windows)] WindowsCtrlC { /// True if the `ctrl-c` action was used to copy an active selection. copied_selection: bool, }, } /// Triggers to delay rendering until a certain event. #[derive(Clone, Copy)] enum DelayRenderingTrigger { SyntaxHighlighting(BufferVersion), DiffUpdate(BufferVersion), } struct DelayRendering { edits: Vec<(EditDelta, BufferVersion)>, should_autoscroll: ShouldAutoscroll, block_until: DelayRenderingTrigger, } impl DelayRendering { fn new(trigger: DelayRenderingTrigger) -> Self { Self { edits: Vec::new(), should_autoscroll: ShouldAutoscroll::No, block_until: trigger, } } fn should_render_for_syntax_highlight(&self, buffer_version: BufferVersion) -> bool { match self.block_until { DelayRenderingTrigger::DiffUpdate(_) => false, DelayRenderingTrigger::SyntaxHighlighting(version) => buffer_version >= version, } } fn should_render_for_diff_update(&self, buffer_version: BufferVersion) -> bool { match self.block_until { DelayRenderingTrigger::DiffUpdate(version) => buffer_version >= version, DelayRenderingTrigger::SyntaxHighlighting(_) => false, } } fn flush_render(self, model: &CodeEditorModel, ctx: &mut ModelContext) { model.render_state.update(ctx, move |render_state, _| { let should_autoscroll = self.should_autoscroll; for (delta, content_version) in self.edits { render_state.add_pending_edit(delta.clone(), content_version); } match should_autoscroll { ShouldAutoscroll::Yes => render_state.request_autoscroll(), ShouldAutoscroll::VerticalOnly => render_state.request_vertical_autoscroll(), ShouldAutoscroll::No => (), } }); // Refresh the diff state now that the pending render state has been flushed. if model.diff_nav_is_active() { model.refresh_diff_state(ctx); } ctx.emit(CodeEditorModelEvent::DelayedRenderingFlushed); } /// Consume the delay rendering state without flushing edits to the render state. /// Use when a full layout rebuild will follow that supersedes the pending edits. /// Still emits `DelayedRenderingFlushed` so downstream listeners (e.g. CodeReviewView) /// are notified. fn skip(self, ctx: &mut ModelContext) { ctx.emit(CodeEditorModelEvent::DelayedRenderingFlushed); } } pub struct CodeEditorModel { render_state: ModelHandle, content: ModelHandle, selection_model: ModelHandle, diff: ModelHandle, selection: ModelHandle, syntax_tree: ModelHandle, comments: ModelHandle, hidden_lines: ModelHandle, /// The current state of diff navigation (collapsed, expanded, or focused on a specific hunk) diff_navigation_state: DiffNavigationState, interaction_state: InteractionState, /// Only applies to scenarios where current line highlighting is possible. /// For example, current line highlighting will always be disabled during diff navigation. show_current_line_highlights: bool, /// Delay rendering of content updates until a certain trigger. delay_rendering: Option, /// Stores the selection "tails" when entering Vim visual mode so we can derive /// visual selections that may differ from the cursor positions. vim_visual_tails: Vec, hovered_symbol_range: Option, /// Automatically hide lines outside of the active diff with X context lines. hide_lines_outside_of_active_diff: Option, /// Whether this editor was configured to use lazy layout. lazy_layout_enabled: bool, /// Whether the editor has completed at least one layout cycle. lazy_layout_initialized: bool, /// Whether syntax parsing should be bootstrapped from the latest full buffer content. pending_syntax_tree_bootstrap: bool, } impl CodeEditorModel { pub fn new( text_styles: RichTextStyles, session_platform: Option, lazy_layout: bool, buffer: Option>, // Whether the editor is using an underlying shared buffer. ctx: &mut ModelContext, ) -> Self { let content = buffer.unwrap_or_else(|| { ctx.add_model(|_| { Buffer::new(Box::new(|block_style, _| match block_style { // Use 4 spaces as the default indentation unit. BufferBlockStyle::PlainText => IndentBehavior::TabIndent(IndentUnit::Space(4)), _ => IndentBehavior::Ignore, })) }) }); content.update(ctx, |buffer, _| { buffer.set_session_platform(session_platform); }); ctx.subscribe_to_model(&content, |me, event, ctx| { me.handle_content_model_event(event, ctx); }); let selection_model = ctx.add_model(|_ctx| BufferSelectionModel::new(content.clone())); let color_map = Self::syntax_highlighting_color_map(ctx); let buffer_version = content.as_ref(ctx).buffer_version(); let buffer_handle = content.downgrade(); let syntax_tree = ctx.add_model(|_ctx| SyntaxTreeState::new(buffer_handle, buffer_version, color_map)); ctx.subscribe_to_model(&syntax_tree, |me, event, ctx| { me.handle_syntax_tree_model_event(event, ctx); }); let diff = ctx.add_model(|_ctx| DiffModel::new()); ctx.subscribe_to_model(&diff, |me, event, ctx| { me.handle_diff_model_event(event, ctx); }); let hidden_lines = ctx.add_model(|_| HiddenLinesModel::new(content.clone(), selection_model.clone())); let render_state = ctx.add_model(|ctx| { RenderState::new(text_styles, lazy_layout, Some(hidden_lines.clone()), ctx) .with_width_setting(WidthSetting::InfiniteWidth) }); ctx.subscribe_to_model(&render_state, |me, event, ctx| { me.handle_render_state_model_event(event, ctx); }); let selection = ctx.add_model(|ctx| { SelectionModel::new( content.clone(), render_state.clone(), selection_model.clone(), Some(hidden_lines.clone()), ctx, ) .with_disable_hidden_navigation() }); let comments = ctx.add_model(|_| EditorCommentsModel { pending_comment: PendingComment::Closed, }); Self { render_state, diff, content, selection_model, selection, syntax_tree, comments, hidden_lines, diff_navigation_state: DiffNavigationState::Collapsed, interaction_state: InteractionState::Editable, show_current_line_highlights: true, delay_rendering: None, vim_visual_tails: vec![], hovered_symbol_range: None, hide_lines_outside_of_active_diff: None, lazy_layout_enabled: lazy_layout, lazy_layout_initialized: false, pending_syntax_tree_bootstrap: false, } } fn should_defer_syntax_tree_parsing(&self) -> bool { self.lazy_layout_enabled && !self.lazy_layout_initialized } fn mark_lazy_layout_initialized(&mut self, ctx: &mut ModelContext) { if self.lazy_layout_enabled { self.lazy_layout_initialized = true; } self.maybe_bootstrap_syntax_tree(ctx); } fn maybe_bootstrap_syntax_tree(&mut self, ctx: &mut ModelContext) { if !self.pending_syntax_tree_bootstrap || !self.lazy_layout_initialized { return; } if !self.syntax_tree.as_ref(ctx).has_supported_highlighting() { return; } let content = self.content.as_ref(ctx); let buffer_version = content.buffer_version(); let buffer_snapshot = content.buffer_snapshot(); self.syntax_tree.update(ctx, move |syntax_tree, ctx| { syntax_tree.update_internal_state_with_delta(&[], buffer_version, buffer_snapshot, ctx); }); self.pending_syntax_tree_bootstrap = false; } fn handle_render_state_model_event( &mut self, event: &RenderEvent, ctx: &mut ModelContext, ) { match event { RenderEvent::PendingEditsFlushed => { self.mark_lazy_layout_initialized(ctx); } RenderEvent::ViewportUpdated(version) => { self.mark_lazy_layout_initialized(ctx); if let Some(version) = version { ctx.emit(CodeEditorModelEvent::ViewportUpdated(*version)); } } RenderEvent::LayoutUpdated => { ctx.emit(CodeEditorModelEvent::LayoutInvalidated); } RenderEvent::NeedsResize => {} } } /// Set hide_lines_outside_of_active_diff. This will automatically set a delay rendering trigger to wait /// for the next diff to be computed. pub fn hide_lines_outside_of_active_diff( &mut self, context_lines: usize, ctx: &mut ModelContext, ) { let buffer_version = self.buffer_version(ctx); self.hide_lines_outside_of_active_diff = Some(context_lines); self.delay_rendering = Some(DelayRendering::new(DelayRenderingTrigger::DiffUpdate( buffer_version, ))); } /// We need to set the diff model base to the normalized version of the text. This is because the internal text /// representation of the content used for syntax tree highlighting and text rendering uses standard LF. pub fn set_base(&self, base: &str, recompute_diff: bool, ctx: &mut ModelContext) { let normalized_text = MultilineString::::apply(base); self.diff .update(ctx, |diff, _ctx| diff.set_base(normalized_text)); if recompute_diff { let buffer_version = self.buffer_version(ctx); let content = self.content().as_ref(ctx).text(); self.diff.update(ctx, move |diff, ctx| { diff.compute_diff(content, true, buffer_version, ctx) }); } } pub fn positioning(&self, ctx: &AppContext) -> OffsetPositioning { let selection_position = self .render_state() .as_ref(ctx) .saved_positions() .text_selection_id(); OffsetPositioning::from_axes( PositioningAxis::relative_to_stack_child( &selection_position, PositionedElementOffsetBounds::ParentByPosition, OffsetType::Pixel(0.), AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left), ) .with_conditional_anchor(), PositioningAxis::relative_to_stack_child( &selection_position, PositionedElementOffsetBounds::ParentByPosition, OffsetType::Pixel(0.), AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top), ) .with_conditional_anchor(), ) } pub fn diff(&self) -> &ModelHandle { &self.diff } pub fn hovered_symbol_range(&self) -> Option<&HoverableLink> { self.hovered_symbol_range.as_ref() } pub fn set_hovered_symbol_range(&mut self, range: Option) -> bool { if self.hovered_symbol_range.is_none() && range.is_none() { return false; } self.hovered_symbol_range = range; true } pub fn maybe_click_on_hovered_link(&self, offset: &CharOffset, ctx: &mut ModelContext) { if let Some(link) = self.hovered_symbol_range() { if link.range().contains(offset) { link.trigger_on_click(ctx); } } } pub fn max_character_offset(&self, ctx: &AppContext) -> CharOffset { self.content().as_ref(ctx).max_charoffset() } /// When diff navigation is active we expand all diffs. When diff navigation is inactive all diffs /// are collapsed. pub fn diff_nav_is_active(&self) -> bool { !matches!(self.diff_navigation_state, DiffNavigationState::Collapsed) } /// Allows programmatic selection updates pub fn selection(&self) -> &ModelHandle { &self.selection } pub fn comments(&self) -> &ModelHandle { &self.comments } // Set the following line ranges to be hidden in the editor. pub fn set_hidden_lines( &mut self, ranges: RangeSet, ctx: &mut ModelContext, ) { self.hidden_lines.update(ctx, |model, ctx| { model.set_hidden_lines(ranges, ctx); }); } pub fn hidden_ranges(&self, ctx: &AppContext) -> RangeSet { self.hidden_lines.as_ref(ctx).hidden_ranges_at_latest(ctx) } // Set the following hidden line ranges to be visible. This is no-op if the lines are already visible. pub fn set_visible_line_range( &mut self, range: Range, ctx: &mut ModelContext, ) { let version = self.content().as_ref(ctx).buffer_version(); // Re-render since the hidden range has updated. if let Some(delta) = self .hidden_lines .update(ctx, |model, ctx| model.set_visible_line_range(range, ctx)) { self.render_state.update(ctx, |render_state, _ctx| { render_state.add_pending_edit(delta, version); }); } } /// Helper fn to set selections easily for vim features pub fn vim_set_selections( &mut self, selections: Vec1, autoscroll: AutoScrollBehavior, ctx: &mut ModelContext, ) { self.selection.update(ctx, |selection, ctx| { selection.update_selection( BufferSelectAction::SetSelectionOffsets { selections }, autoscroll, ctx, ); }); } /// Set selections but save goal columns. /// update_selection typically overwrites goal columns; this fn will save and restore them. pub fn vim_set_selections_preserving_goal_xs( &mut self, selections: Vec1, autoscroll: AutoScrollBehavior, ctx: &mut ModelContext, ) { self.selection.update(ctx, |selection, ctx| { let saved_goal_xs = selection.goal_xs.clone(); selection.update_selection( BufferSelectAction::SetSelectionOffsets { selections }, autoscroll, ctx, ); selection.goal_xs = saved_goal_xs; }); } pub fn focused_diff_index(&self) -> Option { match self.diff_navigation_state { DiffNavigationState::Focused(index) => Some(index), _ => None, } } pub fn diff_navigation_state(&self) -> &DiffNavigationState { &self.diff_navigation_state } pub fn nav_diff_up(&mut self, ctx: &mut ModelContext) { let selected_index = match self.diff_navigation_state { DiffNavigationState::Focused(index) => index, _ => return, }; let total = self.diff().as_ref(ctx).diff_hunk_count(); // Cycle down to the end if navigating up at index 0. let new_index = if selected_index == 0 { total.saturating_sub(1) } else { selected_index - 1 }; self.diff_navigation_state = DiffNavigationState::Focused(new_index); self.refresh_diff_state(ctx); } pub fn nav_diff_down(&mut self, ctx: &mut ModelContext) { let selected_index = match self.diff_navigation_state { DiffNavigationState::Focused(index) => index, _ => return, }; let total = self.diff().as_ref(ctx).diff_hunk_count(); // Cycle down to the end if navigating up at index 0. let new_index = if selected_index == total.saturating_sub(1) { 0 } else { selected_index + 1 }; self.diff_navigation_state = DiffNavigationState::Focused(new_index); self.refresh_diff_state(ctx); } pub fn revert_diff_index(&mut self, ctx: &mut ModelContext) { let total = self.diff().as_ref(ctx).diff_hunk_count(); let active_index = match self.diff_navigation_state { DiffNavigationState::Focused(index) => index, _ => return, }; self.reverse_diff_by_index(active_index, ctx); if active_index + 1 == total { self.diff_navigation_state = DiffNavigationState::Focused(active_index.saturating_sub(1)); } self.refresh_diff_state(ctx); } /// For a diff hunk index, update the buffer with the reverse action that undo-es the diff. pub fn reverse_diff_by_index(&mut self, index: usize, ctx: &mut ModelContext) { let Some((replace_range, text)) = self .diff .as_ref(ctx) .reverse_action_by_diff_hunk_index(index) else { return; }; let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { // TODO(CLD-558). let buffer = content.buffer(); let start = Point::new(replace_range.start as u32 + 1, 0).to_buffer_char_offset(buffer); let end = Point::new(replace_range.end as u32 + 1, 0).to_buffer_char_offset(buffer); let edit = Vec1::new((text, start..end)); content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edit }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); } /// Returns the string content of the active buffer using its inferred line ending mode. pub fn content_string(&self, ctx: &AppContext) -> AnyMultilineString { self.content().as_ref(ctx).text_with_line_ending() } /// Returns the content of a line given the location of the line, /// along with the number of added and removed lines. /// For Current and Collapsed lines, retrieves from the buffer. /// For Removed lines, retrieves from the diff base content. /// Prepends '+' for added/modified lines and '-' for removed lines. pub fn get_diff_content_for_line( &self, line: &EditorLineLocation, ctx: &AppContext, ) -> LineDiffContent { match line { EditorLineLocation::Collapsed { .. } | EditorLineLocation::Current { .. } => { let buffer = self.buffer().as_ref(ctx); let (modified, mut content) = if let Some(line_number) = line.line_number() { // TODO(CLD-558) Buffer lines are 1-indexed. let start_offset = Point::new(line_number.as_u32() + 1, 0).to_buffer_char_offset(buffer); let end_offset = Point::new(line_number.as_u32() + 2, 0).to_buffer_char_offset(buffer); let modified = self.diff.as_ref(ctx).is_line_added_or_changed(&line_number); ( modified, buffer.text_in_range(start_offset..end_offset).into_string(), ) } else { (false, String::new()) }; // Prepend '+' for modified lines if modified { content = format!("+{content}"); } LineDiffContent { content, lines_added: if modified { LineCount::from(1) } else { LineCount::from(0) }, lines_removed: LineCount::from(0), } } EditorLineLocation::Removed { .. } => { let mut content = self .diff .as_ref(ctx) .deleted_line_content(line) .unwrap_or_default(); // Prepend '-' for removed lines content = format!("-{content}"); LineDiffContent { content, lines_added: LineCount::from(0), lines_removed: LineCount::from(1), } } } } /// Find the 0-based index of the temporary block containing the given /// content-space vertical offset within its diff hunk. /// /// Uses the render state's sumtree cursor for an O(log n) seek plus /// an O(k) backward walk (k = position within hunk). Called only on /// scroll-settle (debounced), not per-frame. pub fn removed_line_hunk_index(&self, target_offset: Pixels, ctx: &AppContext) -> usize { let render_state = self.render_state.as_ref(ctx); render_state .content() .temp_block_hunk_index_at_height(target_offset.as_f32() as f64) .unwrap_or(0) } /// Given a content-space vertical offset, identifies which line (current or /// removed) is at that position. Returns the stable line identifier /// (including an internal anchor for edit-tracking) and the intra-line /// pixel offset from the top of that line. /// /// Returns `None` if the offset is beyond the content height or the block /// lookup fails. pub fn line_at_vertical_offset( &self, offset: Pixels, ctx: &mut ModelContext, ) -> Option<(StableEditorLine, Pixels)> { // Phase 1: Gather all information from immutable borrows (render state, // buffer, diff model). Extract values and drop borrows before phase 2. // `removed_info` is `Some(temp_index)` for removed lines, `None` for // current-buffer lines. let (anchor_char_offset, removed_info, intra_line_offset) = { let render_state = self.render_state.as_ref(ctx); if offset >= render_state.height() { return None; } let content = render_state.content(); let positioned_block = content.block_at_height(offset.as_f32() as f64)?; let block_top = positioned_block.start_y_offset; let intra_line_offset = (offset - block_top).max(Pixels::zero()); match positioned_block.item { BlockItem::TemporaryBlock { .. } => { let line_number = positioned_block.start_line; let temp_index = self.removed_line_hunk_index(offset, ctx); let buffer = self.content.as_ref(ctx); let char_offset = Point::new(line_number.as_usize() as u32, 0).to_buffer_char_offset(buffer); (char_offset, Some(temp_index), intra_line_offset) } BlockItem::Paragraph(_) | BlockItem::TextBlock { .. } | BlockItem::RunnableCodeBlock { .. } | BlockItem::MermaidDiagram { .. } | BlockItem::TaskList { .. } | BlockItem::UnorderedList { .. } | BlockItem::OrderedList { .. } | BlockItem::Header { .. } | BlockItem::Embedded(_) | BlockItem::HorizontalRule(_) | BlockItem::Image { .. } | BlockItem::Table(_) | BlockItem::TrailingNewLine(_) | BlockItem::Hidden(_) => { (positioned_block.start_char_offset, None, intra_line_offset) } } }; // Phase 2: Create anchor (requires mutable borrow). let anchor = self .selection_model .update(ctx, |sel, ctx| sel.anchor(anchor_char_offset, ctx)); let inner = match removed_info { Some(temp_index) => StableEditorLineInner::RemovedLine { anchor, temp_index }, None => StableEditorLineInner::CurrentLine { anchor }, }; Some((StableEditorLine(inner), intra_line_offset)) } /// Returns the content-space vertical offset of the top of the given line. /// The internal anchor is resolved to obtain the current line position, /// so this remains accurate after buffer edits. /// /// Returns `None` if the line no longer exists in the render model. pub fn line_top(&self, line: &StableEditorLine, ctx: &AppContext) -> Option { let selection_model = self.selection_model.as_ref(ctx); let render_location = match &line.0 { StableEditorLineInner::CurrentLine { anchor } => { let line_number = LineCount::from(selection_model.line_number_from_anchor(anchor, ctx)?); RenderLineLocation::Current(line_number) } StableEditorLineInner::RemovedLine { anchor, temp_index } => { let line_number = LineCount::from(selection_model.line_number_from_anchor(anchor, ctx)?); RenderLineLocation::Temporary { at_line: line_number, index_from_at_line: *temp_index, } } }; let render_state = self.render_state.as_ref(ctx); // vertical_offset_at_render_location returns viewport-relative offset // (subtracts scroll_top). Add scroll_top back to get content-space. let viewport_offset = render_state.vertical_offset_at_render_location(render_location)?; Some(viewport_offset + render_state.viewport().scroll_top()) } pub fn buffer(&self) -> &ModelHandle { &self.content } /// Returns the character at the given offset in the buffer, if it exists. pub fn char_at(&self, offset: CharOffset, ctx: &AppContext) -> Option { self.content.as_ref(ctx).char_at(offset) } pub fn buffer_selection_model(&self) -> &ModelHandle { &self.selection_model } /// Apply a vector of diffs on the current buffer without changing the active selection. pub fn apply_diffs(&mut self, diffs: Vec, ctx: &mut ModelContext) { let insertion = { let buffer = self.content().as_ref(ctx); diffs .into_iter() .map(|diff| { let line_start = diff.replacement_line_range.start as u32; let line_end = diff.replacement_line_range.end as u32; let start = Point::new(line_start, 0).to_buffer_char_offset(buffer); let end = Point::new(line_end, 0).to_buffer_char_offset(buffer); // If the replacement range spans the entire buffer and the buffer is empty, special // case to ensure we are actually replacing the initial block marker // // Internally, an empty buffer has an initial plaintext block marker (a buffer with no markers is considered invalid). // If we try to replace range (0..0), we'll end up replacing everything except // the trailing newline produced by this marker, which will cause us to inadvertently keep an extra newline // in the buffer. let (start, end) = if buffer.is_empty() && diff.replacement_line_range.start == 0 && diff.replacement_line_range.end == 0 { (CharOffset::from(0), CharOffset::from(1)) } else { (start, end) }; let mut formatted = diff.insertion.clone(); if !formatted.is_empty() && !formatted.ends_with("\n") // Don't add a newline if this is content in a brand new file. && !self.content().as_ref(ctx).is_empty() { // Make sure the formatted text ends with a linebreak. formatted.push('\n'); } (formatted, start..end) }) .collect_vec() }; let Ok(edit) = Vec1::try_from_vec(insertion) else { return; }; let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edit }, EditOrigin::SystemEdit, selection_model, ctx, ); content.buffer().reset_undo_stack(); }, ctx, ) } /// Refresh the diff nav state. When diff navigation is active we expand all diffs, so we show /// their removed and added lines. When diff navigation is inactive we don't show any line /// decorations for the diffs in the editor. The [`super::element::EditorWrapper`] may still /// display indicators for collapsed diff hunks in its `GutterElement`. fn refresh_diff_state(&self, ctx: &mut ModelContext) { let mut all_diffs_removed_lines = Vec::new(); let mut all_diffs_line_decorations = Vec::new(); let mut all_diffs_text_decorations = Vec::new(); match self.diff_navigation_state { DiffNavigationState::Collapsed => { // No diff decorations when collapsed } DiffNavigationState::Expanded | DiffNavigationState::Focused(_) => { let focused_diff_index = self.focused_diff_index(); // Show all diffs expanded, with the focused one getting special highlighting let Some(base) = self.diff.as_ref(ctx).base() else { return; }; let mut line_iterator = LineIterator::new(base.lines()); for diff_index in 0..self.diff().as_ref(ctx).diff_hunk_count() { let appearance = Appearance::as_ref(ctx); let should_highlight = match focused_diff_index { Some(focused_index) => diff_index == focused_index, None => true, }; let (mut removed_lines, mut line_decoration, mut inline_decorations) = match self.diff.as_ref(ctx).renderable_diff_hunk_by_index( diff_index, &mut line_iterator, appearance, ) { Some(RenderableDiffHunk::Add { line_decoration }) => { (Vec::new(), Some(line_decoration), None::>) } Some(RenderableDiffHunk::Deletion { removed_lines }) => { (removed_lines, None, None::>) } Some(RenderableDiffHunk::Replace { line_decoration, inline_highlights, mut removed_lines, }) => { // Highlight the focused diff hunk OR only 1-line replacements if no diff is focused. let should_highlight_changes = match focused_diff_index { Some(focused_index) => diff_index == focused_index, None => { let new_lines = (line_decoration.end - line_decoration.start) .as_usize(); new_lines == 1 && removed_lines.len() == 1 } }; let highlight_text = if should_highlight_changes { let buffer = self.content.as_ref(ctx); let highlight_text = inline_highlights .into_iter() .map(|(row_start, inline)| { let start_char = Point::new((row_start + 1) as u32, 0) .to_buffer_char_offset(buffer); Decoration { start: start_char + inline.start - 1, end: start_char + inline.end - 1, background: Some(Fill::Solid( add_inline_overlay_color(appearance), )), dashed_underline: None, } }) .collect_vec(); Some(highlight_text) } else { for removed_line in &mut removed_lines { removed_line.inline_text_decorations.clear(); } None }; (removed_lines, Some(line_decoration), highlight_text) } None => (Vec::new(), None, None::>), }; // Fade decorations for non-focused diffs if !should_highlight { let background_color = Fill::Solid(Appearance::as_ref(ctx).theme().background().into_solid()); if let Some(line_decoration) = line_decoration.as_mut() { line_decoration.overlay = line_decoration .overlay .fade_into_background(&background_color); }; // Do not render inline decorations when diff is not focused. if let Some(inline_decorations) = inline_decorations.as_mut() { inline_decorations.clear(); } for removed_line in &mut removed_lines { removed_line.line_decoration = removed_line.line_decoration.map(|line_decoration| { line_decoration.fade_into_background(&background_color) }); removed_line.inline_text_decorations.clear(); } } if let Some(line_decoration) = line_decoration { all_diffs_line_decorations.push(line_decoration); } all_diffs_removed_lines.extend(removed_lines); if let Some(inline_decorations) = inline_decorations { all_diffs_text_decorations.extend(inline_decorations); } } } } // If there is no diff navigation, we update the `RenderState` to have no temporary blocks // or decorations. Other events can add decorations, e.g. the active cursor line highlight. self.render_state.update(ctx, |render_state, _| { render_state.add_temporary_blocks(all_diffs_removed_lines); render_state.set_decorations_after_layout(UpdateDecorationAfterLayout::LineAndText { line: all_diffs_line_decorations, text: all_diffs_text_decorations, }); }); } /// Returns `true` if the diff navigation was toggled on. Returns `false` if the diff navigation /// toggled off. pub fn toggle_diff_nav( &mut self, line_range: Option>, ctx: &mut ModelContext, ) -> bool { if self.diff_nav_is_active() { self.diff_navigation_state = DiffNavigationState::Collapsed; self.refresh_diff_state(ctx); return false; } let index = if let Some(range) = line_range { self.diff() .as_ref(ctx) .diff_hunk_count_before_line(range.start.as_usize()) } else { 0 }; self.diff_navigation_state = DiffNavigationState::Focused(index); self.refresh_diff_state(ctx); ctx.emit(CodeEditorModelEvent::DiffUpdated); true } /// Expands all diff hunks without focusing any specific diff hunk. /// All diff hunks will be shown expanded with normal highlighting. pub fn expand_diffs(&mut self, ctx: &mut ModelContext) { self.diff_navigation_state = DiffNavigationState::Expanded; self.refresh_diff_state(ctx); ctx.emit(CodeEditorModelEvent::DiffUpdated); } pub fn diff_status(&self, app: &AppContext) -> DiffStatus { self.diff.as_ref(app).diff_status().clone() } /// Set the language of the syntax map based on the file path. pub fn set_language_with_path(&mut self, path: &Path, ctx: &mut ModelContext) { let language = language_by_filename(path); if let Some(language) = language { self.set_language(language, ctx); } } pub fn set_language_with_name(&mut self, name: &str, ctx: &mut ModelContext) { let language = language_by_name(name); if let Some(language) = language { self.set_language(language, ctx); } } fn set_language(&mut self, language: Arc, ctx: &mut ModelContext) { let unit = language.indent_unit; self.content.update(ctx, |buffer, _ctx| { buffer.set_tab_indentation(Box::new(move |block_style, _| match block_style { BufferBlockStyle::PlainText => IndentBehavior::TabIndent(unit), _ => IndentBehavior::Ignore, })) }); self.syntax_tree.update(ctx, |syntax_tree, _ctx| { syntax_tree.set_language(language); }); self.maybe_bootstrap_syntax_tree(ctx); } /// Rebuilds the layout and bootstraps the syntax tree for an editor that was created /// with an existing buffer. This is more efficient than calling `reset()` because it /// directly triggers the syntax tree parsing without recreating the entire buffer state. pub fn rebuild_layout_with_syntax_highlighting(&mut self, ctx: &mut ModelContext) { let content = self.content.as_ref(ctx); let delta = content.invalidate_layout(); let buffer_version = content.buffer_version(); if self.should_defer_syntax_tree_parsing() { self.pending_syntax_tree_bootstrap = true; } else { let buffer_snapshot = content.buffer_snapshot(); let precise_deltas = delta.precise_deltas.clone(); // Update syntax tree with the delta to trigger syntax tree parsing self.syntax_tree.update(ctx, move |syntax_tree, ctx| { syntax_tree.update_internal_state_with_delta( &precise_deltas, buffer_version, buffer_snapshot, ctx, ) }); } if let Some(delay_rendering) = &mut self.delay_rendering { delay_rendering.edits.push((delta.clone(), buffer_version)); } else { // Update render state to rebuild the layout self.render_state.update(ctx, move |render_state, _| { render_state.add_pending_edit(delta, buffer_version); }); if self.diff_nav_is_active() { self.refresh_diff_state(ctx); } } } /// Rebuild layout and make sure the temporary blocks for diff state has the right styling + anchored /// at the right lines after the rebuild. This should be used over the default implementation of rebuild_layout. fn rebuild_layout_and_refresh_diff(&self, ctx: &mut ModelContext) { self.rebuild_layout(ctx); if self.diff_nav_is_active() { self.refresh_diff_state(ctx); } } fn syntax_highlighting_color_map(ctx: &mut ModelContext) -> ColorMap { let appearance = Appearance::as_ref(ctx); let terminal_color = appearance.theme().terminal_colors().normal; // TODO: This mapping is not finalized. We still need to double check with design. ColorMap { keyword_color: AnsiColorIdentifier::Magenta .to_ansi_color(&terminal_color) .into(), function_color: AnsiColorIdentifier::Blue .to_ansi_color(&terminal_color) .into(), string_color: AnsiColorIdentifier::Green .to_ansi_color(&terminal_color) .into(), type_color: AnsiColorIdentifier::Red .to_ansi_color(&terminal_color) .into(), number_color: AnsiColorIdentifier::Green .to_ansi_color(&terminal_color) .into(), comment_color: AnsiColorIdentifier::Yellow .to_ansi_color(&terminal_color) .into(), property_color: AnsiColorIdentifier::Cyan .to_ansi_color(&terminal_color) .into(), tag_color: AnsiColorIdentifier::Red .to_ansi_color(&terminal_color) .into(), } } pub fn text_decoration_for_ranges<'a>( &'a self, ranges: RangeSet, render_buffer_version: Option, ctx: &'a AppContext, ) -> TextDecoration<'a> { let theme = Appearance::as_ref(ctx).theme(); let underline_color = theme.accent().into_solid(); let base_color_map = self.syntax_tree .as_ref(ctx) .highlights_in_ranges(ranges, render_buffer_version, ctx); let underline_range = self .hovered_symbol_range .as_ref() .map(|link| RangeMap::from_iter([(link.range.clone(), underline_color)])); TextDecoration { base_color_map, override_color_map: underline_range.clone(), underline_range, } } /// Re-calculate the hidden range given the active diff state. fn calculate_hidden_lines(&mut self, ctx: &mut ModelContext) { if let Some(context_line) = self.hide_lines_outside_of_active_diff { let line_count = self.line_count(ctx); // Calculate the visible line ranges (with context) let mut visible_ranges: RangeSet = RangeSet::new(); // Add ranges for diffs for range in self.diff().as_ref(ctx).modified_lines() { // Convert 1-indexed line ranges to 0-indexed let start_line = range.start.saturating_sub(1); let end_line = range.end.saturating_sub(1); let context_start = start_line.saturating_sub(context_line); let context_end = end_line + context_line; if context_start < context_end { visible_ranges.insert(context_start.into()..context_end.into()); } } // Calculate hidden ranges as the complement of visible ranges let all_lines: Range = galaxy_editor::content::text::LineCount::from(0) ..galaxy_editor::content::text::LineCount::from(line_count); // Find gaps in the visible ranges let hidden_ranges = visible_ranges .gaps(&all_lines) .collect::>(); self.set_hidden_lines(hidden_ranges, ctx); } } fn handle_diff_model_event(&mut self, event: &DiffModelEvent, ctx: &mut ModelContext) { match event { DiffModelEvent::DiffUpdated { version, should_recalculate_hidden_lines, } => { // If we are hiding lines based on active diffs, there are 3 steps here once the diff is computed: // 1) If we should, recalculate hidden lines based on the updated diff state. // 2) Flush any delayed rendering based on diff update trigger. // 3) If hidden lines are recalculated, rebuild the current layout. if *should_recalculate_hidden_lines { self.calculate_hidden_lines(ctx); } // Do not refresh diff state if there is an active delayed rendering. We should wait until the delayed rendering // is flushed so we could insert temporary blocks based on the accurate line ranges. if self.delay_rendering.is_none() && self.diff_nav_is_active() { self.refresh_diff_state(ctx); } let will_rebuild_layout = *should_recalculate_hidden_lines && self.hide_lines_outside_of_active_diff.is_some(); if self .delay_rendering .as_ref() .map(|delay_rendering| delay_rendering.should_render_for_diff_update(*version)) .unwrap_or(false) { let delay_rendering = self.delay_rendering.take().expect("Checked above"); if will_rebuild_layout { // Full rebuild will supersede pending edits — skip the expensive render state update. delay_rendering.skip(ctx); } else { delay_rendering.flush_render(self, ctx); } } // This could be optimized to not rebuild the entire layout and only the part of the hidden ranges that are changed. // It is challenging tho because we will need to somehow expand and calculate style blocks based on past buffer versions. // // Realistically, the impact of rebuilding layout should be minimal given 1) it is only triggered on the first edit within // the hidden range 2) we are not re-rendering hidden sections. if will_rebuild_layout { self.rebuild_layout_and_refresh_diff(ctx); } ctx.emit(CodeEditorModelEvent::DiffUpdated); } DiffModelEvent::UnifiedDiffComputed(unified_diff) => { ctx.emit(CodeEditorModelEvent::UnifiedDiffComputed( unified_diff.clone(), )); } } } fn handle_syntax_tree_model_event( &mut self, event: &DecorationStateEvent, ctx: &mut ModelContext, ) { match event { DecorationStateEvent::DecorationUpdated { version } => { // Once highlighting completes for a content version matching or after the delay rendering block, // we flush the delay rendering state to diff and rendering model. if self .delay_rendering .as_ref() .map(|delay_rendering| { delay_rendering.should_render_for_syntax_highlight(*version) }) .unwrap_or(false) { let delay_rendering = self.delay_rendering.take().expect("Checked above"); delay_rendering.flush_render(self, ctx); } ctx.emit(CodeEditorModelEvent::SyntaxHighlightingUpdated); } } } fn handle_content_model_event(&mut self, event: &BufferEvent, ctx: &mut ModelContext) { match event { BufferEvent::ContentChanged { delta, origin, should_autoscroll, buffer_version, selection_model_id, } => { let buffer = self.content().as_ref(ctx); let content = buffer.text(); if self.should_defer_syntax_tree_parsing() { self.pending_syntax_tree_bootstrap = true; } else { let buffer_snapshot = buffer.buffer_snapshot(); self.syntax_tree.update(ctx, move |syntax_tree, ctx| { syntax_tree.update_internal_state_with_delta( &delta.precise_deltas, *buffer_version, buffer_snapshot, ctx, ) }); } let is_from_external_source = *selection_model_id != Some(self.selection_model.id()); let mut should_recalculate_hidden_lines = false; // If the edit is from an external source, we need to 1) materialize hidden range offsets for rendering the edit // 2) if the edit overlaps with a hidden range, re-calculate what the new hidden range should be. if is_from_external_source { let edit_range = &delta.old_offset; should_recalculate_hidden_lines = self.hidden_lines.update(ctx, |hidden_lines, ctx| { if !hidden_lines.has_offsets_for_version(*buffer_version) { hidden_lines.materialize_hidden_range_offsets(*buffer_version, ctx); } hidden_lines.range_intersects_with_hidden_range_at_version( edit_range, *buffer_version, ) }); } if should_recalculate_hidden_lines { if let Some(delay_rendering) = &mut self.delay_rendering { delay_rendering.block_until = DelayRenderingTrigger::DiffUpdate(*buffer_version); } else { self.delay_rendering = Some(DelayRendering::new( DelayRenderingTrigger::DiffUpdate(*buffer_version), )); } } self.diff.update(ctx, move |diff, ctx| { diff.compute_diff( content, should_recalculate_hidden_lines, *buffer_version, ctx, ) }); // If we are delaying rendering, push these updates to the delay rendering state. Otherwise, flush them to diff and rendering model. if let Some(delay_rendering) = &mut self.delay_rendering { delay_rendering.edits.push((delta.clone(), *buffer_version)); delay_rendering.should_autoscroll = *should_autoscroll; } else { self.render_state.update(ctx, move |render_state, _| { render_state.add_pending_edit(delta.clone(), *buffer_version); match should_autoscroll { ShouldAutoscroll::Yes => render_state.request_autoscroll(), ShouldAutoscroll::VerticalOnly => { render_state.request_vertical_autoscroll() } ShouldAutoscroll::No => (), } }); } ctx.emit(CodeEditorModelEvent::ContentChanged { origin: *origin }); } BufferEvent::ContentReplaced { buffer_version } => { // Skip delay rendering for test environments. They expect rendering to happen synchronously if cfg!(test) { return; } // On content replacement with active hidden ranges, we should always recalculate hidden lines and delay rendering // since all anchors will all be invalidated. if self.hide_lines_outside_of_active_diff.is_some() { let content = self.content().as_ref(ctx).text(); self.diff.update(ctx, move |diff, ctx| { diff.compute_diff(content, true, *buffer_version, ctx) }); if self.delay_rendering.is_none() { self.delay_rendering = Some(DelayRendering::new( DelayRenderingTrigger::DiffUpdate(*buffer_version), )); } } else if self.should_defer_syntax_tree_parsing() { self.pending_syntax_tree_bootstrap = true; } else if self.delay_rendering.is_none() && self.syntax_tree.as_ref(ctx).has_supported_highlighting() { self.delay_rendering = Some(DelayRendering::new( DelayRenderingTrigger::SyntaxHighlighting(*buffer_version), )); } } BufferEvent::SelectionChanged { should_autoscroll, buffer_version, .. } => { let content = self.content.as_ref(ctx); let mut selections = content.to_rendered_selection_set(self.selection_model.clone(), ctx); let mut all_selections_entirely_visible = true; for selection in selections.iter_mut() { let range = if selection.head <= selection.tail { selection.head..selection.tail } else { selection.tail..selection.head }; if self .hidden_lines .as_ref(ctx) .contains_hidden_section(&range, ctx) { all_selections_entirely_visible = false; } selection.head -= CharOffset::from(1); selection.tail -= CharOffset::from(1); } if !all_selections_entirely_visible && self.interaction_state == InteractionState::Editable { self.set_interaction_state(InteractionState::EditableWithInvalidSelection, ctx); } else if all_selections_entirely_visible && self.interaction_state == InteractionState::EditableWithInvalidSelection { self.set_interaction_state(InteractionState::Editable, ctx); } self.render_state.update(ctx, move |render_state, _| { render_state.update_selection(selections, *buffer_version); match should_autoscroll { AutoScrollBehavior::Selection => render_state.request_autoscroll(), AutoScrollBehavior::Override(mode) => { render_state.request_autoscroll_to(mode.clone()) } AutoScrollBehavior::None => (), } }); self.update_cursor_line_highlights(ctx); ctx.emit(CodeEditorModelEvent::SelectionChanged); } // Handled by selection model. BufferEvent::AnchorUpdated { .. } => (), } } /// Update the line highlights for the current cursor positions. fn update_cursor_line_highlights(&self, ctx: &mut ModelContext) { let selection_model = self.selection_model.as_ref(ctx); let overlay = Appearance::as_ref(ctx).theme().surface_2(); let highlight_line = if self.diff_nav_is_active() { // We don't show current line highlights during diff navigation so we don't need // to update the `RenderState`. This lets us keep the line decorations we set // for the expanded diff hunks as well. None } else if selection_model.all_single_cursors() && self.show_current_line_highlights { // When diff is not expanded, the only source of line decoration is highlights // from the active cursor, e.g. the current line highlight. Some( selection_model .selected_lines(ctx) .into_iter() .map(|line| { LineDecoration::new( // TODO(CLD-558) LineCount::from(line - 1), LineCount::from(line), overlay, ) }) .collect(), ) } else { // Don't show current line highlights either when text is selected or when // current line highlighting is disabled. Some(vec![]) }; if let Some(highlight_line) = highlight_line { self.render_state.update(ctx, move |render_state, _| { render_state.set_decorations_after_layout(UpdateDecorationAfterLayout::Line( highlight_line, )); }); } } /// Handle a theme or font change, applying the new rich text styles to the editor. /// This will also set the syntax color map and cursor line highlight based on the new theme. pub fn handle_appearance_or_font_change( &self, new_styles: RichTextStyles, ctx: &mut ModelContext, ) { let style_update = self.render_state.update(ctx, |render_state, _| { render_state.update_styles(new_styles) }); match style_update { StyleUpdateAction::Relayout => { self.rebuild_layout_and_refresh_diff(ctx); } StyleUpdateAction::None => return, StyleUpdateAction::Repaint => (), }; // Rebuild the color map for syntax highlighting as the theme may have changed. self.set_color_map(ctx); self.update_cursor_line_highlights(ctx); ctx.notify(); } /// Begin selecting at `offset`. pub fn select_at( &mut self, offset: CharOffset, multiselect: bool, ctx: &mut ModelContext, ) { self.begin_selection(offset, SelectionMode::Character, !multiselect, ctx); } // TODO(CLD-1593): This would need to be changed in the future when we have a syntax tree representation. pub fn select_word_at( &mut self, offset: CharOffset, multiselect: bool, ctx: &mut ModelContext, ) { let policy = SemanticSelection::as_ref(ctx).word_boundary_policy(); self.begin_selection(offset, SelectionMode::Word(policy), !multiselect, ctx); } pub fn select_line_at( &mut self, offset: CharOffset, multiselect: bool, ctx: &mut ModelContext, ) { self.begin_selection(offset, SelectionMode::Line, !multiselect, ctx); } // TODO(CLD-1593) pub fn forward_word(&mut self, select: bool, ctx: &mut ModelContext) { self.forward_word_with_unit(select, word_unit(ctx), ctx) } // TODO(CLD-1593) pub fn backward_word(&mut self, select: bool, ctx: &mut ModelContext) { self.backward_word_with_unit(select, word_unit(ctx), ctx) } /// Returns the word under or immediately after the primary cursor on the current line, /// if one exists. The search does not cross line boundaries. /// This is used to populate the find bar for vim's `search_word_at_cursor` (`*` and `#`) pub fn word_under_cursor_for_search(&self, app: &AppContext) -> Option { let buffer = self.content().as_ref(app); let selections = self.selections(app); let selection = *selections.first(); let cursor_offset = selection.head; get_word_range_at_offset(buffer, cursor_offset, None) .map(|range| buffer.text_in_range(range).into_string()) } pub fn word_range_at_offset( &self, offset: CharOffset, app: &AppContext, ) -> Option> { get_word_range_at_offset(self.content().as_ref(app), offset, None) } pub fn run_search( &self, config: &SearchConfig, ctx: &AppContext, ) -> anyhow::Result> { let buffer = self.content().as_ref(ctx); let search_future = buffer.search(buffer.prepare_search(config)?); Ok(async move { match search_future.await.1 { Ok(mut search_results) => { for match_result in &mut search_results.matches { match_result.start = match_result.start.saturating_sub(&CharOffset::from(1)); match_result.end = match_result.end.saturating_sub(&CharOffset::from(1)); } search_results } Err(err) => { log::warn!("Search failed: {err:?}"); SearchResults { matches: Vec::new(), } } } }) } /// Copy the current selection. /// Note that this is _not_ the only possible code path that could copy selected text to the /// clipboard. For example, [`CodeEditorView`] exposes a method that allows the owner of the /// view to access selected text and copy it to the clipboard. pub fn copy(&self, ctx: &mut ModelContext) { let clipboard = self.read_selected_text_as_clipboard_content(ctx); ctx.clipboard().write(clipboard); } #[cfg(windows)] /// If there is selected text, copy it. Otherwise, emit an event to allow /// an ancestor to handle the `WindowsCtrlC` event. pub fn handle_windows_ctrl_c(&self, ctx: &mut ModelContext) { let buffer = self.content().as_ref(ctx); let selected_text = buffer.selected_text_as_plain_text(self.buffer_selection_model().clone(), ctx); if !selected_text.as_str().is_empty() { self.copy(ctx); // If the code editor is in a blocklist, this won't clear the // selection model there, we have to do that separately. That is // handled by emitted the `WindowsCtrlC` event. self.clear_selections(ctx); ctx.emit(CodeEditorModelEvent::WindowsCtrlC { copied_selection: true, }); } else { ctx.emit(CodeEditorModelEvent::WindowsCtrlC { copied_selection: false, }); } } pub fn cut(&mut self, ctx: &mut ModelContext) { self.copy(ctx); self.backspace(ctx); } pub fn reset_content(&mut self, state: InitialBufferState, ctx: &mut ModelContext) { self.set_base(state.text, false, ctx); // Line ending is now inferred automatically by Buffer::reset. self.reset(state, ctx); } pub fn jump_to_line_column( &self, line: usize, column: Option, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); // If column number is not provided, we should place the cursor before the first non-tab stop character in the line. let col_num = match column { Some(idx) => idx, None => { let offset = Point::new(line as u32, 0).to_buffer_char_offset(buffer); buffer .indented_line_delta(offset) .unwrap_or(CharOffset::zero()) .as_usize() } }; let offset = Point::new(line as u32, col_num as u32).to_buffer_char_offset(buffer); self.selection_model().update(ctx, |selection, ctx| { selection.update_selection( BufferSelectAction::AddCursorAt { offset, clear_selections: true, }, AutoScrollBehavior::Override(AutoScrollMode::PositionOffsetInViewportCenter( offset, )), ctx, ) }); self.validate(ctx) } pub fn delete_all_left(&mut self, ctx: &mut ModelContext) { let selection_model = self.selection_model.as_ref(ctx); if selection_model.cursors_at_line_start(ctx) { self.backspace(ctx); } else { self.delete( TextDirection::Backwards, TextUnit::ParagraphBoundary, false, ctx, ); } } // The character offset at the start of the input row. pub fn start_of_line_offset(&self, row: usize, ctx: &AppContext) -> CharOffset { let buffer = self.content().as_ref(ctx); // TODO(CLD-558) Point::new(row as u32, 0) .to_buffer_char_offset(buffer) .saturating_sub(&CharOffset::from(1)) } /// Approximate the number of lines in the current viewport with viewport_height / base_line_height. pub fn lines_in_viewport(&self, ctx: &AppContext) -> usize { let render_state = self.render_state().as_ref(ctx); let viewport_height = render_state.viewport().height(); let line_height = render_state.styles().base_line_height(); (viewport_height.as_f32() / line_height.as_f32()).ceil() as usize } pub fn line_count(&self, ctx: &AppContext) -> usize { self.buffer().as_ref(ctx).max_point().row.saturating_sub(1) as usize } pub fn line_height(&self, ctx: &AppContext) -> f32 { let render_state = self.render_state().as_ref(ctx); render_state.styles().base_line_height().as_f32() } pub fn paste(&mut self, ctx: &mut ModelContext) { // We only want to read the plain text contents for code editor. let content = ctx.clipboard().read(); self.insert(content.plain_text.as_str(), EditOrigin::UserInitiated, ctx); } /// Append text to the end of the buffer regardless of cursor position. /// This is used for streaming content where we always want to append at the end, /// not at the current cursor position. pub fn append_at_end(&mut self, text: &str, ctx: &mut ModelContext) { let buffer = self.content().as_ref(ctx); let max_offset = buffer.max_charoffset(); let edits = vec1![(text.to_string(), max_offset..max_offset)]; let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::SystemEdit, selection_model, ctx, ); }, ctx, ); self.validate(ctx); } /// Set Vim visual tails to the current selection heads (cursor positions). pub fn vim_set_visual_tail_to_selection_heads(&mut self, ctx: &mut ModelContext) { let selection_model = self.selection_model.as_ref(ctx); let selections = selection_model.selection_offsets(); self.vim_visual_tails = selections.iter().map(|s| s.head).collect(); } pub fn vim_visual_tails(&self) -> &Vec { &self.vim_visual_tails } /// Expand the current selection(s) for a visual-mode operation using stored visual tails. /// Charwise visual mode includes the character under the block cursor; linewise visual mode /// expands to the full line bounds. /// /// This is used by operators to get the actual selection range for visual operations. pub fn vim_visual_selection_range( &mut self, motion_type: MotionType, include_newline: bool, ctx: &mut ModelContext, ) { let selection_model = self.selection_model.as_ref(ctx); let buffer = self.content().as_ref(ctx); let vim_visual_tails = mem::take(&mut self.vim_visual_tails); let new_selections = selection_model .selection_offsets() .iter() .zip(vim_visual_tails.iter()) .map(|(selection, visual_tail)| { let mut start = *visual_tail; let mut end = selection.head; if start > end { mem::swap(&mut start, &mut end); } let max_offset = buffer.max_charoffset(); // Include the char under the block cursor for charwise/visual. // For linewise, only include +1 if it won't move onto the next line (i.e., the // current char at `end` is not a newline). if end < max_offset && (motion_type != MotionType::Linewise || buffer.char_at(end).map(|c| c != '\n').unwrap_or(false)) { end += 1; } if motion_type == MotionType::Linewise { let point = start.to_buffer_point(buffer); let start_point = Point::new(point.row, 0); let end_point = end.to_buffer_point(buffer); let new_end = if end_point.column == 0 { end_point } else { Point::new(end_point.row, buffer.line_len(end_point.row)) }; start = start_point.to_buffer_char_offset(buffer); end = new_end.to_buffer_char_offset(buffer); if include_newline { let start_newline = start.as_usize() > 0 && buffer .char_at(start.saturating_sub(&1.into())) .map(|c| c == '\n') .unwrap_or(false); let end_newline = buffer.char_at(end).map(|c| c == '\n').unwrap_or(false); if end_newline && end < max_offset { end += 1; } else if start_newline { start = start.saturating_sub(&1.into()); } } } SelectionOffsets { head: start, tail: end, } }) .collect_vec(); if let Ok(new_selections) = Vec1::try_from_vec(new_selections) { self.vim_set_selections_preserving_goal_xs( new_selections, AutoScrollBehavior::Selection, ctx, ); } } pub fn toggle_comments(&mut self, ctx: &mut ModelContext) { let Some(prefix) = self.syntax_tree.as_ref(ctx).comment_prefix() else { return; }; let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let lines = selection_model.selected_lines(ctx); let all_selected_lines_commented = lines .iter() .all(|line| buffer.line_decorated_with_prefix(*line, prefix)); // When we are adding comments, typically editors add an additional whitespace between prefix // and the original content of the line. let prefix = if !all_selected_lines_commented { format!("{prefix} ") } else { prefix.to_string() }; let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::TogglePrefixForLines { lines, prefix: &prefix, remove: all_selected_lines_commented, }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); self.validate(ctx) } /// Whether the given range is wrapped in the supported bracket pairs of the active language. pub fn range_wrapped_in_bracket(&self, range: Range, ctx: &AppContext) -> bool { let Some(bracket_pairs) = self.syntax_tree.as_ref(ctx).bracket_pairs() else { return false; }; let buffer = self.content.as_ref(ctx); // Early return if the range is at the start of buffer. if range.start == CharOffset::zero() { return false; } let Some(opening_char) = buffer.char_at(range.start - 1) else { return false; }; let Some(ending_char) = buffer.char_at(range.end) else { return false; }; bracket_pairs .iter() .any(|(start, end)| *start == opening_char && *end == ending_char) } pub fn retrieve_unified_diff(&self, file_name: String, ctx: &mut ModelContext) { // Use the buffer's text with normalized line endings, for consistency with how we calculate diffs. let content = self .content() .as_ref(ctx) .text_with_line_ending_mode(LineEnding::LF); self.diff.update(ctx, move |diff, ctx| { diff.retrieve_unified_diff(content, file_name, ctx) }); } pub fn interaction_state(&self) -> InteractionState { self.interaction_state } pub fn set_interaction_state(&mut self, state: InteractionState, ctx: &mut ModelContext) { self.interaction_state = state; ctx.emit(CodeEditorModelEvent::InteractionStateChanged); } pub fn set_show_current_line_highlights(&mut self, show_current_line_highlights: bool) { self.show_current_line_highlights = show_current_line_highlights; } pub fn autocomplete_symbol(&mut self, open: char, close: char, ctx: &mut ModelContext) { let buffer = self.content().as_ref(ctx); let selections = self.selection_model.as_ref(ctx).selection_offsets(); let all_cursors = selections.iter().all(|s| s.head == s.tail); if all_cursors { let pair: String = [open, close].iter().collect(); let texts: Vec<(String, usize)> = selections.iter().map(|_| (pair.clone(), 1)).collect(); let selection_model = self.selection_model.clone(); if let Ok(texts) = vec1::Vec1::try_from_vec(texts) { self.update_content( |mut content, ctx| { content.apply_edit( galaxy_editor::content::buffer::BufferEditAction::InsertForEachSelection { texts: &texts, }, galaxy_editor::content::buffer::EditOrigin::UserTyped, selection_model, ctx, ); }, ctx, ); self.validate(ctx); } return; } let edits: Vec<(String, std::ops::Range)> = selections .iter() .map(|s| { let start = s.head.min(s.tail); let end = s.head.max(s.tail); let original = buffer.text_in_range(start..end).into_string(); let mut wrapped = String::new(); wrapped.push(open); wrapped.push_str(&original); wrapped.push(close); (wrapped, start..end) }) .collect(); if let Ok(edits) = vec1::Vec1::try_from_vec(edits) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( galaxy_editor::content::buffer::BufferEditAction::InsertAtCharOffsetRanges { edits: &edits, }, galaxy_editor::content::buffer::EditOrigin::UserTyped, selection_model, ctx, ); }, ctx, ); self.validate(ctx); } } pub fn all_cursors_next_character_matches_char( &self, character: char, ctx: &mut ModelContext, ) -> bool { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let selections = selection_model.selection_offsets(); selections .iter() .all(|s| buffer.char_at(s.head).is_some_and(|c| c == character)) } /// Replace char_count characters starting at the cursor, used by vim pub fn replace_char(&mut self, c: char, char_count: u32, ctx: &mut ModelContext) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let selections = selection_model.selection_offsets(); let mut edits = Vec::new(); let mut new_selections_vec = Vec::with_capacity(selections.len()); for selection in selections.iter() { let start = selection.head; let line_end = buffer.containing_line_end(start); // Don't make edits if we don't have space for the entire replacement on the line. // We subtract 1 from the line end to exclude the newline. let remaining = (line_end - 1) - start; if remaining.as_usize() < char_count as usize { // No replacement; keep cursor as-is new_selections_vec.push(SelectionOffsets { head: selection.head, tail: selection.tail, }); continue; } let replacement = c.to_string().repeat(char_count as usize); let end = start + CharOffset::from(char_count as usize); edits.push((replacement, start..end)); // After replacement, place cursor on the last replaced character (end - 1) let new_pos = end.saturating_sub(&CharOffset::from(1)); new_selections_vec.push(SelectionOffsets { head: new_pos, tail: new_pos, }); } if let Ok(edits) = Vec1::try_from_vec(edits) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); // Update selections to the computed positions if let Ok(new_selections) = vec1::Vec1::try_from_vec(new_selections_vec) { self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } } } fn set_color_map(&self, ctx: &mut ModelContext) { let color_map = Self::syntax_highlighting_color_map(ctx); self.syntax_tree.update(ctx, |syntax_tree, _ctx| { syntax_tree.set_color_map(color_map); }); } /// Clear any selections, leaving the cursor at the end of the first selection. pub fn clear_selections(&self, ctx: &mut ModelContext) { self.selection.update(ctx, |selection, ctx| { let first_selection = *selection.selections(ctx).first(); selection.update_selection( BufferSelectAction::SetSelectionOffsets { selections: vec1![SelectionOffsets { head: first_selection.tail, tail: first_selection.tail, }], }, AutoScrollBehavior::None, ctx, ); }) } /// Clear any selections, leaving the cursor at the start of the selection. /// This is used for vim-style deselection where the cursor should end up at /// the beginning of what was selected (e.g., after yank operations). pub fn vim_clear_selections(&mut self, ctx: &mut ModelContext) { self.selection.update(ctx, |selection, ctx| { let first_selection = *selection.selections(ctx).first(); selection.update_selection( BufferSelectAction::SetSelectionOffsets { selections: vec1![SelectionOffsets { head: first_selection.head, tail: first_selection.head, }], }, AutoScrollBehavior::None, ctx, ); }) } /// Returns true iff any selection is past the last character in the line. /// In Vim mode, this scenario needs to be corrected (see [`Self::vim_enforce_cursor_line_cap`]). pub fn vim_needs_line_capping(&self, ctx: &AppContext) -> bool { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); // Only consider capping when we're using block cursors (i.e., all selections are cursors). // Otherwise, if a user clicks-and-drags to select a range of text with vim mode enabled, they // might not be able to select the last char in a line. if !selection_model.all_single_cursors() { return false; } let selections = selection_model.selection_offsets(); selections.iter().any(|selection| { let head_point = selection.head.to_buffer_point(buffer); let line_len = buffer.line_len(head_point.row); line_len > 0 && head_point.column >= line_len }) } /// If the cursor is after the last character in the line, move it back /// so that it's covering the last character in the line instead. pub fn vim_enforce_cursor_line_cap(&mut self, ctx: &mut ModelContext) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let head_point = selection.head.to_buffer_point(buffer); let tail_point = selection.tail.to_buffer_point(buffer); // Compute line lengths for the head and tail rows independently so we don't // incorrectly clamp one end of a multi-line selection using the other's row length. let head_line_len = buffer.line_len(head_point.row); let tail_line_len = buffer.line_len(tail_point.row); let mut new_head = selection.head; let mut new_tail = selection.tail; if head_line_len > 0 && head_point.column >= head_line_len { new_head = selection.head.saturating_sub(&CharOffset::from(1)); } if tail_line_len > 0 && tail_point.column >= tail_line_len { new_tail = selection.tail.saturating_sub(&CharOffset::from(1)); } SelectionOffsets { head: new_head, tail: new_tail, } }); self.vim_set_selections_preserving_goal_xs(new_selections, AutoScrollBehavior::None, ctx); } /// Horziontal cursor movement for vim in the code editor. /// Separate from the model's `move_left` and `move_right` functions to allow for stopping at /// line boundaries and vim-specific selection logic. pub fn vim_move_horizontal_by_offset( &mut self, char_count: u32, direction: &Direction, keep_selection: bool, stop_at_line_boundary: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let mut head = selection.head; if stop_at_line_boundary { // For repeat motions that should not cross line boundaries, bound the movement within the line. let head_point = head.to_buffer_point(buffer); let offset_change = match direction { Direction::Backward => u32::min(head_point.column, char_count), Direction::Forward => { let line_len = buffer.line_len(head_point.row); u32::min(line_len.saturating_sub(head_point.column), char_count) } }; head = match direction { Direction::Backward => { head.saturating_sub(&CharOffset::from(offset_change as usize)) } Direction::Forward => { let max_offset = buffer.max_charoffset(); cmp::min(max_offset, head + offset_change as usize) } }; } else { // Wrapping behavior: step using CharOffsets only and skip over newline characters let max_offset = buffer.max_charoffset(); for _ in 0..char_count { match direction { Direction::Forward => { if head >= max_offset { break; } let next = cmp::min(max_offset, head + 1); if let Some('\n') = buffer.char_at(next) { if keep_selection { // When selecting (operator-pending), treat the newline as a counted step. head = next; } else { let after_next = cmp::min(max_offset, next + 1); if let Some('\n') = buffer.char_at(after_next) { // If two chars away is a newline, we have an empty line below // us that we want to land on head = next; } else { // If two chars away is not a newline, skip the end-of-line newline head = after_next; } } } else { head = next; } } Direction::Backward => { if head <= CharOffset::from(1) { break; } let prev = head.saturating_sub(&CharOffset::from(1)); if let Some('\n') = buffer.char_at(prev) { if keep_selection { // When selecting (operator-pending), treat the newline as a counted step. head = prev; } else { let prev2 = prev.saturating_sub(&CharOffset::from(1)); if let Some('\n') = buffer.char_at(prev2) { // If two chars before is a newline, we have an empty line above // us that we want to land on head = prev; } else { // If two chars before is not a newline, skip the end-of-line newline head = prev2; } } } else { // Normal move head = prev; } } } } } SelectionOffsets { head, tail: if keep_selection { selection.tail } else { head }, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// Vertical cursor movement for vim in the code editor. /// Treats movement as logical lines (ignores soft-wrap rows). pub fn vim_move_vertical_by_offset( &mut self, count: u32, direction: TextDirection, keep_selection: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); // Determine desired column for each selection. If goal_xs exist, interpret them as columns; // otherwise initialize from current buffer columns. let goal_cols: Vec = if let Some(existing) = self.selection().as_ref(ctx).goal_xs.as_ref() { existing .iter() .map(|px| px.as_f32().round() as u32) .collect() } else { current_selections .iter() .map(|sel| sel.head.to_buffer_point(buffer).column) .collect() }; let max_row = buffer.max_point().row; let new_selections_vec: Vec<_> = current_selections .iter() .enumerate() .map(|(i, current_selection)| { let cursor = current_selection.head; let point = cursor.to_buffer_point(buffer); let target_row = match direction { TextDirection::Backwards => point.row.saturating_sub(count), TextDirection::Forwards => cmp::min(max_row, point.row.saturating_add(count)), }; let line_len = buffer.line_len(target_row); let new_col = cmp::min(goal_cols[i], line_len); let new_offset = Point::new(target_row, new_col).to_buffer_char_offset(buffer); SelectionOffsets { head: new_offset, tail: if keep_selection { current_selection.tail } else { new_offset }, } }) .collect(); if let Ok(new_selections) = Vec1::try_from_vec(new_selections_vec) { self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); // Update goal_xs to the desired columns (stored as pixels for consistency with SelectionModel) let goal_pixels: Vec<_> = goal_cols .into_iter() .map(|c| (c as usize).into_pixels()) .collect(); self.selection().update(ctx, |selection, _| { selection.goal_xs = Vec1::try_from_vec(goal_pixels).ok(); }); } } /// Move cursor to the absolute beginning or end of the line. /// Unlike move_to_line_start/end, this always goes to column 0 or the line end regardless of whitespace. pub fn vim_move_to_line_bound( &mut self, bound: LineBound, keep_selection: bool, ctx: &mut ModelContext, ) { let current_selections = self.selections(ctx); let content = self.content().as_ref(ctx); let new_selections = current_selections.mapped(|selection_offset| { let cursor_offset = selection_offset.head; let point = cursor_offset.to_buffer_point(content); let new_column = match bound { LineBound::Start => 0, LineBound::End => content.line_len(point.row), }; let new_offset = Point::new(point.row, new_column).to_buffer_char_offset(content); SelectionOffsets { head: new_offset, tail: if keep_selection { selection_offset.tail } else { new_offset }, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// Move by paragraph boundaries for vim motions. pub fn vim_move_by_paragraph( &mut self, count: u32, direction: &Direction, keep_selection: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let max = buffer.max_charoffset(); let new_selections = current_selections.mapped(|selection| { let mut offset = selection.head; match direction { Direction::Forward => { for _ in 0..count { offset = find_next_paragraph_end(buffer, offset).unwrap_or(max); } } Direction::Backward => { for _ in 0..count { offset = find_previous_paragraph_start(buffer, offset) .unwrap_or(CharOffset::from(1)); } } } SelectionOffsets { head: offset, tail: if keep_selection { selection.tail } else { offset }, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// Navigate by words using vim-specific word boundaries. pub fn vim_navigate_word( &mut self, direction: Direction, bound: WordBound, word_type: WordType, word_count: u32, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let start_offset = selection.head; let end_offset = if let Ok(boundaries) = vim_word_iterator_from_offset(start_offset, buffer, direction, bound, word_type) { boundaries .take(word_count as usize) .last() .unwrap_or(start_offset) } else { start_offset }; SelectionOffsets { head: end_offset, tail: end_offset, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } pub fn vim_select_for_char_motion( &mut self, char_motion: &CharacterMotion, motion_type: &MotionType, operator: &VimOperator, operand_count: u32, ctx: &mut ModelContext, ) { match char_motion { CharacterMotion::Right => { self.vim_move_horizontal_by_offset( operand_count, &Direction::Forward, true, // keep_selection true, // stop_at_line_boundary ctx, ); } CharacterMotion::Left => { self.vim_move_horizontal_by_offset( operand_count, &Direction::Backward, true, // keep_selection true, // stop_at_line_boundary ctx, ); } CharacterMotion::Down => { self.vim_move_vertical_by_offset(operand_count, TextDirection::Forwards, true, ctx); } CharacterMotion::Up => { self.vim_move_vertical_by_offset( operand_count, TextDirection::Backwards, true, ctx, ); } CharacterMotion::WrappingLeft => { self.vim_move_horizontal_by_offset( operand_count, &Direction::Backward, true, // keep_selection false, // don't stop_at_line_boundary (allows wrapping) ctx, ); } CharacterMotion::WrappingRight => { self.vim_move_horizontal_by_offset( operand_count, &Direction::Forward, true, // keep_selection false, // don't stop_at_line_boundary (allows wrapping) ctx, ); } } let include_newline = *operator != VimOperator::Change; if *motion_type == MotionType::Linewise { self.vim_extend_selection_linewise(include_newline, ctx); } } pub fn vim_select_for_word_motion( &mut self, word_motion: &WordMotion, word_count: u32, motion_type: &MotionType, operator: &VimOperator, ctx: &mut ModelContext, ) { let WordMotion { direction, bound, word_type, } = word_motion; let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let start_offset = selection.head; let (cursor_position, selection_start) = if let Ok(boundaries) = vim_word_iterator_from_offset(start_offset, buffer, *direction, *bound, *word_type) { let mut target_pos = boundaries .take(word_count as usize) .last() .unwrap_or(start_offset); // Apply vim word boundary quirks and calculate selection boundaries match direction { Direction::Forward => { // `de`, unlike other word motions, will include character it lands on // in the operation. if *bound == WordBound::End { target_pos += 1; } else if *bound == WordBound::Start && word_count == 1 { // `dw`, cannot traverse a newline unless the count > 1. We have // to check this range for newlines and cut the range short in that // case. let text = buffer.text_in_range(start_offset..target_pos); if let Some(newline_pos) = text.as_str().find('\n') { target_pos = start_offset + newline_pos; } } (target_pos, start_offset) } Direction::Backward => { // `db` will traverse *but not delete* a newline if the count is 1 and // the cursor starts on column zero and the line above is not empty. let mut actual_start = start_offset; if *bound == WordBound::Start && word_count == 1 { if let Ok(mut char_iter) = buffer.chars_rev_at(start_offset) { if char_iter.next().is_some_and(|c| c == '\n') && char_iter.next().is_some_and(|c| c != '\n') { actual_start -= 1; } } } (target_pos, actual_start) } } } else { (start_offset, start_offset) }; SelectionOffsets { head: cursor_position, tail: selection_start, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); let include_newline = *operator != VimOperator::Change; if *motion_type == MotionType::Linewise { self.vim_extend_selection_linewise(include_newline, ctx); } } pub fn vim_select_for_line_motion( &mut self, line_motion: &LineMotion, operand_count: u32, motion_type: &MotionType, operator: &VimOperator, ctx: &mut ModelContext, ) { match line_motion { LineMotion::Start => { self.vim_move_to_line_bound(LineBound::Start, true, ctx); } LineMotion::End => { // For $ with count, move down lines first if operand_count > 1 { self.vim_move_vertical_by_offset( operand_count - 1, TextDirection::Forwards, true, ctx, ); } self.vim_move_to_line_bound(LineBound::End, true, ctx); } LineMotion::FirstNonWhitespace => { self.vim_move_to_first_nonwhitespace(true, ctx); } } let include_newline = *operator != VimOperator::Change; if *motion_type == MotionType::Linewise { self.vim_extend_selection_linewise(include_newline, ctx); } } pub fn vim_select_for_first_nonwhitespace_motion( &mut self, nonws_motion: &FirstNonWhitespaceMotion, motion_type: &MotionType, operator: &VimOperator, operand_count: u32, ctx: &mut ModelContext, ) { match nonws_motion { FirstNonWhitespaceMotion::Up => { self.vim_move_vertical_by_offset( operand_count, TextDirection::Backwards, true, ctx, ); } FirstNonWhitespaceMotion::Down => { self.vim_move_vertical_by_offset(operand_count, TextDirection::Forwards, true, ctx); } FirstNonWhitespaceMotion::DownMinusOne => { if operand_count > 0 { self.vim_move_vertical_by_offset( operand_count - 1, TextDirection::Forwards, true, ctx, ); } } }; self.vim_move_to_first_nonwhitespace(true, ctx); let include_newline = *operator != VimOperator::Change; if *motion_type == MotionType::Linewise { self.vim_extend_selection_linewise(include_newline, ctx); } } pub fn vim_select_to_buffer_bound( &mut self, direction: TextDirection, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let cursor_pos = selection.head; let target_pos = match direction { TextDirection::Backwards => Point::new(0, 0).to_buffer_char_offset(buffer), TextDirection::Forwards => buffer.max_charoffset(), }; SelectionOffsets { head: target_pos, tail: cursor_pos, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// Select from cursor to buffer start (for dgg command). pub fn vim_select_to_buffer_start(&mut self, ctx: &mut ModelContext) { self.vim_select_to_buffer_bound(TextDirection::Backwards, ctx); } /// Select from cursor to buffer end (for dG command). pub fn vim_select_to_buffer_end(&mut self, ctx: &mut ModelContext) { self.vim_select_to_buffer_bound(TextDirection::Forwards, ctx); } pub fn vim_extend_selection_linewise( &mut self, include_newline: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let start_pos = selection.tail.min(selection.head); let end_pos = selection.tail.max(selection.head); let start_point = start_pos.to_buffer_point(buffer); let end_point = end_pos.to_buffer_point(buffer); let line_start = Point::new(start_point.row, 0).to_buffer_char_offset(buffer); let start_of_end_line = Point::new(end_point.row, 0).to_buffer_char_offset(buffer); let line_end = if include_newline { buffer.containing_line_end(start_of_end_line) } else { // Don't include newline (for change operations) buffer.containing_line_end(start_of_end_line) - 1 }; SelectionOffsets { head: line_end, tail: line_start, } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } fn calculate_text_object_range( &self, text_object: &VimTextObject, cursor_pos: CharOffset, operator: Option<&VimOperator>, ctx: &ModelContext, ) -> Option> { let buffer = self.content().as_ref(ctx); match text_object.object_type { TextObjectType::Word(word_type) => match text_object.inclusion { TextObjectInclusion::Inner => vim_inner_word(buffer, cursor_pos, word_type), TextObjectInclusion::Around => vim_a_word(buffer, cursor_pos, word_type), }, TextObjectType::Paragraph => { let paragraph_range = match text_object.inclusion { TextObjectInclusion::Inner => vim_inner_paragraph(buffer, cursor_pos), TextObjectInclusion::Around => vim_a_paragraph(buffer, cursor_pos), }; if operator.is_none() { // `vim_a_paragraph` and `vim_inner_paragraph` do _not_ include the trailing // newline. For other operators, we rely on [`Self::vim_extend_selection_linewise`] // to grow the range to include the newline, but we don't call that for visual // mode expansion of text objects. paragraph_range.map(|range| range.start..range.end + 1) } else { paragraph_range } } TextObjectType::Quote(quote_type) => match text_object.inclusion { TextObjectInclusion::Inner => vim_inner_quote(buffer, cursor_pos, quote_type), TextObjectInclusion::Around => vim_a_quote(buffer, cursor_pos, quote_type), }, TextObjectType::Block(bracket_type) => match text_object.inclusion { TextObjectInclusion::Inner => { // For inner blocks, preserve leading padding for change operations // Change operations (c) preserve leading padding, delete operations (d) don't // Visual mode (operator is None) doesn't preserve padding let preserve_leading_padding = operator .map(|op| *op == VimOperator::Change) .unwrap_or(false); vim_inner_block(buffer, cursor_pos, bracket_type, preserve_leading_padding) } TextObjectInclusion::Around => vim_a_block(buffer, cursor_pos, bracket_type), }, } } /// Select a vim text object, preserving leading padding for change ops. /// If operator is None, does selection for visual mode. pub fn vim_select_text_object( &mut self, text_object: &VimTextObject, operator: Option<&VimOperator>, ctx: &mut ModelContext, ) { let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); // Visual mode (operator is None): compute and store visual tails match operator { None => { let mut visual_tails: Vec = Vec::new(); let new_selections = current_selections.mapped(|selection| { let cursor_pos = selection.head; if let Some(mut range) = self.calculate_text_object_range(text_object, cursor_pos, operator, ctx) { visual_tails.push(range.start); if range.end > range.start { range.end -= 1; } let buffer = self.content().as_ref(ctx); // For visual text objects, start the selection at the beginning of the line // (column = 0) of the computed range end. let end_point = range.end.to_buffer_point(buffer); let new_head = Point::new(end_point.row, 0).to_buffer_char_offset(buffer); SelectionOffsets { head: new_head, tail: new_head, } } else { visual_tails.push(cursor_pos); SelectionOffsets { head: cursor_pos, tail: cursor_pos, } } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); self.vim_visual_tails = visual_tails; } Some(op) => { let new_selections = current_selections.mapped(|selection| { let cursor_pos = selection.head; if let Some(range) = self.calculate_text_object_range(text_object, cursor_pos, operator, ctx) { SelectionOffsets { head: range.end, tail: range.start, } } else { SelectionOffsets { head: cursor_pos, tail: cursor_pos, } } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); if let TextObjectType::Paragraph = text_object.object_type { let include_newline = *op != VimOperator::Change; self.vim_extend_selection_linewise(include_newline, ctx); } } } } /// This method does Vim's "%" command. This command checks if there is a bracket under the /// cursor, or to the right of the cursor on the same line. If so, jump to the bracket that /// matches it. pub fn vim_jump_to_matching_bracket( &mut self, keep_selection: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let cursor = selection.head; // Create char iterator from current position to the end of the line let line_end = buffer.containing_line_end(selection.head); let line_text = buffer.text_in_range(cursor..line_end).into_string(); let mut iter = line_text.chars(); let Some(c) = iter.next() else { return selection; }; let (bracket, start_offset) = match BracketChar::try_from(c) { Ok(bracket) => (bracket, cursor), Err(_) => match iter .enumerate() .find_map(|(i, c)| Some((i, BracketChar::try_from(c).ok()?))) { None => return selection, Some((i, bracket)) => (bracket, cursor + i + 1), }, }; if let Some(bracket_position) = vim_find_matching_bracket(buffer, &bracket, start_offset) { SelectionOffsets { head: bracket_position, tail: if keep_selection { cursor } else { bracket_position }, } } else { selection } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// This method does Vim's `[` command. It moves to the enclosing bracket around the cursor. It /// is similar to the `%` command, but it does not require the cursor to start on a bracket. pub fn vim_jump_to_unmatched_bracket( &mut self, bracket: &BracketChar, keep_selection: bool, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let cursor = selection.head; if let Some(bracket_position) = vim_find_matching_bracket(buffer, bracket, cursor) { SelectionOffsets { head: bracket_position, tail: if keep_selection { cursor } else { bracket_position }, } } else { selection } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } /// Vim find character functionality (f, F, t, T commands) /// Searches for a character on the current line and moves the cursor to it or before it pub fn vim_find_char( &mut self, keep_selection: bool, occurrence_count: u32, motion: &FindCharMotion, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let current_selections = selection_model.selection_offsets(); let new_selections = current_selections.mapped(|selection| { let head_point = selection.head.to_buffer_point(buffer); let current_column = head_point.column as usize; let line_start = buffer.containing_line_start(selection.head); let line_end = buffer.containing_line_end(selection.head); let line_text = buffer.text_in_range(line_start..line_end).into_string(); if let Some(new_column) = vim_find_char_on_line( &line_text, current_column, motion, occurrence_count, keep_selection, ) { let target_point = Point::new(head_point.row, new_column as u32); let new_head = target_point.to_buffer_char_offset(buffer); SelectionOffsets { head: new_head, tail: if keep_selection { selection.tail } else { new_head }, } } else { // Character not found selection } }); self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx); } fn compute_smart_indent_results( &self, mode: IndentMode, ctx: &mut ModelContext, ) -> vec1::Vec1 { let buffer = self.content().as_ref(ctx); self.selections(ctx).mapped(|selection| { let indent_unit = self.syntax_tree.as_ref(ctx).indent_unit(); let (start, end) = if selection.head > selection.tail { (selection.tail, selection.head) } else { (selection.head, selection.tail) }; let mut position = end.to_buffer_point(buffer); // Adjust the position so we calculate the correct smart indentation match mode { IndentMode::NewlineAbove => { // For indentation, we care about the syntax level of the end of the line above us position.row = position.row.saturating_sub(1); position.column = buffer.line_len(position.row); } IndentMode::NewlineBelow => { // Want to use end of row position for newline below indent behavior position.column = buffer.line_len(position.row); } IndentMode::LinewiseChange => { // Want to use the beginning of line indent level for `cc` and newline below position.column = 0; } IndentMode::Enter => {} }; // The current indent level of the end selection position (used only by // IndentMode::Enter). let current_indent_num = buffer .indented_line_tab_stops(Point::new(position.row, 0).to_buffer_char_offset(buffer)) .unwrap_or(0); // TODO(CLD-558): point and buffer indices off by one position.row = position.row.saturating_sub(1); let mut levels_to_indent = self .syntax_tree .as_ref(ctx) .indentation_at_point(position, ctx) .map(|res| res.delta) .unwrap_or(0); // Check if the range is wrapped in a bracket for enter behavior. // // This is relevant in cases like fn {|}, if the cursor is the | and // we want to create a higher level of indentation and extra newline // when we press enter. let range_wrapped_in_bracket = self.range_wrapped_in_bracket(start..end, ctx); if range_wrapped_in_bracket { // Make sure we don't increase more than one from the current indent level of the line. levels_to_indent = (levels_to_indent + 1).min(current_indent_num as u8 + 1); } let mut insert_before_cursor = String::new(); let mut insert_after_cursor = None; if let Some(text) = indent_unit.map(|indent_unit| indent_unit.text_with_num_tab_stops(1)) { insert_before_cursor.push_str(&text.as_str().repeat(levels_to_indent.into())); if range_wrapped_in_bracket { // If we are applying bracket expansion, we need to insert additional newline and indentation // for the trailing bracket after the selection. let mut s = String::from("\n"); let trail_unit = levels_to_indent.saturating_sub(1); if trail_unit > 0 { s.push_str(&text.as_str().repeat(trail_unit.into())) } insert_after_cursor = Some(s); } } IndentResult { insert_before_cursor, insert_after_cursor, } }) } /// Insert a newline above or below the current line with smart indentation pub fn vim_newline(&mut self, is_above: bool, ctx: &mut ModelContext) { let indent_mode = if is_above { IndentMode::NewlineAbove } else { IndentMode::NewlineBelow }; let res = self.compute_smart_indent_results(indent_mode, ctx); let indents: Vec = res.iter().map(|r| r.insert_before_cursor.clone()).collect(); if let Ok(indents_vec1) = vec1::Vec1::try_from_vec(indents) { let to_insert = format!("{}\n", indents_vec1.first()); let insert_point = match is_above { true => VimInsertPoint::LineStart, false => VimInsertPoint::NextLine, }; let offset = to_insert.len(); let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::VimEvent { text: to_insert, insert_point, cursor_offset_len: offset - 1, }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ) } } /// Insert text for Vim insert-repeat ("3I", "4o", etc.) respecting smart indents for `o` and /// `O` commands pub fn vim_insert_text( &mut self, text: &str, position: &InsertPosition, count: u32, ctx: &mut ModelContext, ) { use InsertPosition as I; let text_to_repeat = match position { I::LineAbove | I::LineBelow => { let is_above = matches!(position, I::LineAbove); let indent_mode = if is_above { IndentMode::NewlineAbove } else { IndentMode::NewlineBelow }; let res = self.compute_smart_indent_results(indent_mode, ctx); let indents: Vec = res.iter().map(|r| r.insert_before_cursor.clone()).collect(); let indent = vec1::Vec1::try_from_vec(indents) .map(|v| v.first().clone()) .unwrap_or_default(); let mut indented_text = String::with_capacity(indent.len() + text.len() + 1); if is_above { // Insert above: indent + text + newline indented_text.push_str(&indent); indented_text.push_str(text); indented_text.push('\n'); } else { // Insert below: newline + indent + text indented_text.push('\n'); indented_text.push_str(&indent); indented_text.push_str(text); } indented_text } _ => text.to_owned(), }; let repeated = text_to_repeat.repeat(count as usize); let insert_point = match position { I::AtCursor => VimInsertPoint::BeforeCursor, I::AfterCursor => VimInsertPoint::AtCursor, I::LineFirstNonWhitespace => VimInsertPoint::LineFirstNonWhitespace, I::LineEnd => VimInsertPoint::LineEnd, I::LineAbove => VimInsertPoint::LineStart, I::LineBelow => VimInsertPoint::LineEnd, }; let cursor_offset_len = match position { I::LineAbove => { // Insert text only handles (count - 1) lines of the repetitions. // For the `O` command, we need to move the cursor back down to // the end of the new count-line insertion. (text_to_repeat.len() * (count + 1) as usize).saturating_sub(1) } _ => repeated.len().saturating_sub(1), }; let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::VimEvent { text: repeated, insert_point, cursor_offset_len, }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); } /// Change a line (`cc`) with smart indentation pub fn vim_change_line_with_smart_indent(&mut self, ctx: &mut ModelContext) { let res = self.compute_smart_indent_results(IndentMode::LinewiseChange, ctx); let texts: Vec<(String, usize)> = res .iter() .map(|r| { let indent = r.insert_before_cursor.clone(); (indent, r.insert_before_cursor.chars().count()) }) .collect(); if let Ok(texts_vec1) = vec1::Vec1::try_from_vec(texts) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertForEachSelection { texts: &texts_vec1 }, EditOrigin::UserTyped, selection_model, ctx, ); }, ctx, ); } } /// Apply a case transformation after adjusting selections with the provided closure. pub fn apply_case_transformation_with_selection_change( &mut self, selection_change: impl FnOnce(&mut CodeEditorModel, &mut ModelContext), transform: CaseTransform, ctx: &mut ModelContext, ) { selection_change(self, ctx); let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let ranges = selection_model.selections_to_offset_ranges(); let edits_vec: Vec<(String, Range)> = ranges .iter() .map(|range| { let original = buffer.text_in_range(range.clone()).into_string(); let transformed = transform.apply_to(original); (transformed, range.clone()) }) .collect(); if let Ok(edits) = vec1::Vec1::try_from_vec(edits_vec) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); } // Clear selection after transformation self.vim_clear_selections(ctx); } /// Transform the current selections without changing them first (useful for Visual mode). pub fn transform_current_selections_case( &mut self, transform: CaseTransform, ctx: &mut ModelContext, ) { let buffer = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); let ranges = selection_model.selections_to_offset_ranges(); let edits_vec: Vec<(String, Range)> = ranges .iter() .map(|range| { let original = buffer.text_in_range(range.clone()).into_string(); let transformed = transform.apply_to(original); (transformed, range.clone()) }) .collect(); if let Ok(edits) = vec1::Vec1::try_from_vec(edits_vec) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); } self.vim_clear_selections(ctx); } /// Toggle the case of the next `count` characters on the current line and move the cursor forward. pub fn vim_toggle_case_chars(&mut self, count: u32, ctx: &mut ModelContext) { // Determine how many characters to toggle on this line let buffer = self.content().as_ref(ctx); let current_offset = self.selections(ctx).first().head; let point = current_offset.to_buffer_point(buffer); let line_len = buffer.line_len(point.row); let max_count = line_len.saturating_sub(point.column); let chars_to_toggle = u32::min(count, max_count); // Select from current cursor position forward by chars_to_toggle let new_selections = self.selections(ctx).mapped(|selection| SelectionOffsets { head: selection.head, tail: selection.head + chars_to_toggle as usize, }); self.vim_set_selections(new_selections, AutoScrollBehavior::None, ctx); // Apply toggle-case to the current selections self.transform_current_selections_case(CaseTransform::Toggle, ctx); // Move the cursor to the right by the number of toggled characters if chars_to_toggle > 0 { self.vim_move_horizontal_by_offset( chars_to_toggle, &Direction::Forward, false, true, ctx, ); } } /// Given a line number, return the text for that line. fn text_for_line(&self, line_number: LineCount, ctx: &AppContext) -> String { let buffer = self.content().as_ref(ctx); let offset_start = Point::new(line_number.as_usize() as u32, 0).to_buffer_char_offset(buffer); let offset_end = Point::new(line_number.as_usize() as u32 + 1, 0).to_buffer_char_offset(buffer); buffer.text_in_range(offset_start..offset_end).into_string() } /// Given an original text and its original index, find the closest line in the new version that matches the text. fn match_line_to_text( &self, original_text: &str, current_idx: usize, max_line: usize, predicate: impl Fn(&Self, &str, usize, &AppContext) -> bool, ctx: &AppContext, ) -> Option { let max_distance = current_idx.max(max_line.saturating_sub(current_idx)); // For better performance, we search by gradually increasing the search window and early return once we found a match. for distance in 0..=max_distance { let mut possible_matches = vec![]; if current_idx >= distance { possible_matches.push(current_idx - distance); } let next_line = current_idx + distance; if next_line <= max_line && Some(&next_line) != possible_matches.last() { possible_matches.push(next_line); } for line in possible_matches { if predicate(self, original_text, line, ctx) { return Some(line); } } } None } /// After a modification to the code, update the locations of review comments to match their new positions. pub fn get_new_line_location( &self, location: &EditorLineLocation, line_text: String, ctx: &ModelContext, ) -> (EditorLineLocation, LineDiffContent, bool) { let mut used_fallback = false; let buffer = self.content.as_ref(ctx); let diff_model = self.diff.as_ref(ctx); let max_line = buffer.max_point().row as usize; let updated_loc = match location { EditorLineLocation::Current { line_number, .. } => { // For lines in the current version, find the matching comment line // closest to the original line number. let current_idx = line_number.as_usize(); let matched_line = self.match_line_to_text( line_text.as_str(), current_idx, max_line, |me, original_text, line, ctx| { let line_text = me.text_for_line(LineCount::from(line + 1), ctx); line_text.trim_end_matches('\n') == original_text }, ctx, ); let new_line_number = if let Some(idx) = matched_line { LineCount::from(idx) } else { used_fallback = true; *line_number }; let line_range = diff_model .diff_status() .added_diff_range(new_line_number) .unwrap_or_else(|| { new_line_number..LineCount::from(new_line_number.as_usize() + 1) }); EditorLineLocation::Current { line_number: new_line_number, line_range, } } EditorLineLocation::Removed { line_number, index, .. } => { // For removed lines, we perform the following to find the best match // 1. Approximate the line number of the removed line in the base version (This is best effort since we // don't track the exact line number in editor locations) // 2. Find the matching line closest to (1) in the base version // 3. Convert that matching line into a diff editor location let current_idx = line_number.as_usize() + *index; let max_line = diff_model.base_line_count(); let matched_line = self.match_line_to_text( line_text.as_str(), current_idx, max_line, |me, original_text, line, ctx| { let line_text = me.diff().as_ref(ctx).base_line(line); line_text.as_ref().map(|s| s.trim_end_matches('\n')) == Some(original_text) }, ctx, ); let location = matched_line.and_then(|line| diff_model.base_line_index_to_line_location(line)); // If we can't convert the location, attach to the closest existing line. // TODO: We should have a better flow to handle lines we can't match. location.unwrap_or_else(|| { used_fallback = true; let new_line_number = *line_number; let line_range = diff_model .diff_status() .removed_diff_range(new_line_number) .unwrap_or_else(|| { new_line_number..LineCount::from(new_line_number.as_usize() + 1) }); EditorLineLocation::Current { line_number: new_line_number, line_range, } }) } EditorLineLocation::Collapsed { .. } => location.clone(), }; // Get the new content for the updated location let content = if used_fallback { LineDiffContent::from_content(line_text.as_str()) } else { self.get_diff_content_for_line(&updated_loc, ctx) }; (updated_loc, content, used_fallback) } } impl CoreEditorModel for CodeEditorModel { type T = CodeEditorModel; fn on_buffer_version_updated( &self, buffer_version: BufferVersion, ctx: &mut ModelContext, ) { // Synchronously convert hidden range anchors into offsets for the given version. This allows the render model // to accurately hide line ranges based on the corresponding incoming buffer state. self.hidden_lines.update(ctx, |hidden_lines_model, ctx| { hidden_lines_model.materialize_hidden_range_offsets(buffer_version, ctx); }); } fn content(&self) -> &ModelHandle { &self.content } fn buffer_selection_model(&self) -> &ModelHandle { &self.selection_model } fn selection_model(&self) -> &ModelHandle { &self.selection } fn render_state(&self) -> &ModelHandle { &self.render_state } fn indent(&mut self, shift: bool, ctx: &mut ModelContext) { let content = self.content().as_ref(ctx); let selection_model = self.selection_model.as_ref(ctx); // Only apply auto-indent if 1) not shift indenting 2) there is only one single cursor. let mut unit = 1; if !shift && selection_model.is_single_selection() && selection_model.first_selection_is_single_cursor() { let selection_head = selection_model.first_selection_head(); let mut position = selection_head.to_buffer_point(content); // TODO(CLD-558) position.row = position.row.saturating_sub(1); // Do not apply more than 1 indentation unit if the cursor is not in the leading indentation // of the line. if content .indented_line_start(selection_head) .map(|indented_start| indented_start >= selection_head) .unwrap_or(false) { // Check the current indentation level at the cursor position. let current_indent_level = content .indented_units_at_offset(selection_head) .unwrap_or(0); // The extra indentation delta should be expected_indent_level - current_indent_level. // Minimum the delta should be 1. unit = self .syntax_tree .as_ref(ctx) .indentation_at_point(position, ctx) .map(|res| res.delta.saturating_sub(current_indent_level).max(1)) .unwrap_or(1); } } let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::Indent { num_unit: unit, shift, }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); self.validate(ctx) } // TODO(kevin): Add validation to the content model. fn validate(&self, _ctx: &impl galaxyui::ModelAsRef) {} // Since this is a plain text editor, there is no text styles. fn active_text_style(&self) -> galaxy_editor::content::text::TextStyles { Default::default() } fn backspace(&mut self, ctx: &mut ModelContext) { // Check if all selections are a single cursor and one of them is directly after hidden section let selection_model = self.buffer_selection_model().as_ref(ctx); if selection_model.all_single_cursors() && self.hidden_lines.as_ref(ctx).after_hidden_section(ctx) { return; } let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::Backspace, EditOrigin::UserInitiated, selection_model, ctx, ) }, ctx, ); self.validate(ctx); } fn delete_internal( &mut self, direction: TextDirection, unit: TextUnit, cut: bool, write_to_clipboard: B, ctx: &mut ModelContext, ) where B: FnOnce( &ModelHandle, &ModelHandle, Option>>, &mut ModelContext, ), { // Use the shared method to compute ranges for deletion if let Some(ranges) = self.replacement_range_for_deletion(direction, unit, ctx) { // Check if any range would affect hidden sections if ranges.iter().any(|range| { self.hidden_lines .as_ref(ctx) .contains_hidden_section(range, ctx) }) { return; } if cut { let content = self.content(); write_to_clipboard( content, self.buffer_selection_model(), Some(ranges.clone()), ctx, ); } let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::Delete(ranges), EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ) } else { // If there's already a selection range, treat this as backspace instead if cut { let content = self.content(); write_to_clipboard(content, self.buffer_selection_model(), None, ctx); } self.backspace(ctx); } self.validate(ctx); } } impl CodeEditorModel { pub fn open_comment_line(&mut self, line: &EditorLineLocation, ctx: &mut ModelContext) { // Telemetry: comment editor opened for a new inline review comment. send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentEditorOpened, ctx); self.comments.update(ctx, |comments, ctx| { comments.pending_comment = PendingComment::Open { line: line.clone() }; ctx.emit(PendingCommentEvent::NewPendingComment(line.clone())); }); } pub fn reopen_comment_line( &mut self, id: &CommentId, line: &EditorLineLocation, comment_text: &str, origin: &CommentOrigin, ctx: &mut ModelContext, ) { // Telemetry: comment editor opened for editing an existing inline review comment. send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentEditorOpened, ctx); self.comments.update(ctx, |comments, ctx| { comments.pending_comment = PendingComment::Open { line: line.clone() }; ctx.emit(PendingCommentEvent::ReopenPendingComment { id: *id, line: line.to_owned(), comment_text: comment_text.to_owned(), origin: origin.to_owned(), }); }); } } impl PlainTextEditorModel for CodeEditorModel { fn enter(&mut self, ctx: &mut ModelContext) { let res = self.compute_smart_indent_results(IndentMode::Enter, ctx); let texts: Vec<(String, usize)> = res .iter() .map(|r| { let mut content = String::from('\n'); content.push_str(&r.insert_before_cursor); if let Some(after) = &r.insert_after_cursor { content.push_str(after); } (content, r.insert_before_cursor.chars().count() + 1) // add one for the newline }) .collect(); if let Ok(texts) = vec1::Vec1::try_from_vec(texts) { let selection_model = self.selection_model.clone(); self.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertForEachSelection { texts: &texts }, EditOrigin::UserTyped, selection_model, ctx, ); }, ctx, ); } } } impl Entity for CodeEditorModel { type Event = CodeEditorModelEvent; } #[cfg(test)] #[path = "model_tests.rs"] mod tests;