Files
galaxy/app/src/code/editor/model.rs
T

3890 lines
152 KiB
Rust

#![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<String>,
}
/// 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<CharOffset>,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&mut AppContext)>>,
}
impl HoverableLink {
pub fn new(range: Range<CharOffset>) -> Self {
Self {
range,
on_click: None,
}
}
pub fn with_on_click(mut self, on_click: Box<dyn Fn(&mut AppContext)>) -> Self {
self.on_click = Some(on_click);
self
}
pub fn range(&self) -> &Range<CharOffset> {
&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<DiffResult>),
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<CodeEditorModel>) {
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<CodeEditorModel>) {
ctx.emit(CodeEditorModelEvent::DelayedRenderingFlushed);
}
}
pub struct CodeEditorModel {
render_state: ModelHandle<RenderState>,
content: ModelHandle<Buffer>,
selection_model: ModelHandle<BufferSelectionModel>,
diff: ModelHandle<DiffModel>,
selection: ModelHandle<SelectionModel>,
syntax_tree: ModelHandle<SyntaxTreeState>,
comments: ModelHandle<EditorCommentsModel>,
hidden_lines: ModelHandle<HiddenLinesModel>,
/// 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<DelayRendering>,
/// 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<CharOffset>,
hovered_symbol_range: Option<HoverableLink>,
/// Automatically hide lines outside of the active diff with X context lines.
hide_lines_outside_of_active_diff: Option<usize>,
/// 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<SessionPlatform>,
lazy_layout: bool,
buffer: Option<ModelHandle<Buffer>>, // Whether the editor is using an underlying shared buffer.
ctx: &mut ModelContext<Self>,
) -> 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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>) {
let normalized_text = MultilineString::<LF>::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<DiffModel> {
&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<HoverableLink>) -> 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<Self>) {
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<SelectionModel> {
&self.selection
}
pub fn comments(&self) -> &ModelHandle<EditorCommentsModel> {
&self.comments
}
// Set the following line ranges to be hidden in the editor.
pub fn set_hidden_lines(
&mut self,
ranges: RangeSet<galaxy_editor::content::text::LineCount>,
ctx: &mut ModelContext<Self>,
) {
self.hidden_lines.update(ctx, |model, ctx| {
model.set_hidden_lines(ranges, ctx);
});
}
pub fn hidden_ranges(&self, ctx: &AppContext) -> RangeSet<CharOffset> {
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<galaxy_editor::content::text::LineCount>,
ctx: &mut ModelContext<Self>,
) {
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<SelectionOffsets>,
autoscroll: AutoScrollBehavior,
ctx: &mut ModelContext<Self>,
) {
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<SelectionOffsets>,
autoscroll: AutoScrollBehavior,
ctx: &mut ModelContext<Self>,
) {
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<usize> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) -> 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<Pixels> {
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<Buffer> {
&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<char> {
self.content.as_ref(ctx).char_at(offset)
}
pub fn buffer_selection_model(&self) -> &ModelHandle<BufferSelectionModel> {
&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<DiffDelta>, ctx: &mut ModelContext<Self>) {
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<Self>) {
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::<Vec<Decoration>>)
}
Some(RenderableDiffHunk::Deletion { removed_lines }) => {
(removed_lines, None, None::<Vec<Decoration>>)
}
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::<Vec<Decoration>>),
};
// 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<Range<LineCount>>,
ctx: &mut ModelContext<Self>,
) -> 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>) {
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<Self>) {
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<Self>) {
let language = language_by_name(name);
if let Some(language) = language {
self.set_language(language, ctx);
}
}
fn set_language(&mut self, language: Arc<Language>, ctx: &mut ModelContext<Self>) {
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<Self>) {
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>) {
self.rebuild_layout(ctx);
if self.diff_nav_is_active() {
self.refresh_diff_state(ctx);
}
}
fn syntax_highlighting_color_map(ctx: &mut ModelContext<Self>) -> 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<CharOffset>,
render_buffer_version: Option<BufferVersion>,
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<Self>) {
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<galaxy_editor::content::text::LineCount> =
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> =
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::<RangeSet<galaxy_editor::content::text::LineCount>>();
self.set_hidden_lines(hidden_ranges, ctx);
}
}
fn handle_diff_model_event(&mut self, event: &DiffModelEvent, ctx: &mut ModelContext<Self>) {
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<Self>,
) {
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<Self>) {
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<CodeEditorModel>) {
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<Self>,
) {
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>,
) {
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<Self>,
) {
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>,
) {
self.begin_selection(offset, SelectionMode::Line, !multiselect, ctx);
}
// TODO(CLD-1593)
pub fn forward_word(&mut self, select: bool, ctx: &mut ModelContext<Self>) {
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>) {
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<String> {
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<Range<CharOffset>> {
get_word_range_at_offset(self.content().as_ref(app), offset, None)
}
pub fn run_search(
&self,
config: &SearchConfig,
ctx: &AppContext,
) -> anyhow::Result<impl Future<Output = SearchResults>> {
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<Self>) {
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<Self>) {
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>) {
self.copy(ctx);
self.backspace(ctx);
}
pub fn reset_content(&mut self, state: InitialBufferState, ctx: &mut ModelContext<Self>) {
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<usize>,
ctx: &mut ModelContext<Self>,
) {
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<Self>) {
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<Self>) {
// 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<Self>) {
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<Self>) {
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<CharOffset> {
&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<Self>,
) {
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<Self>) {
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<CharOffset>, 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<Self>) {
// 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>) {
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<Self>) {
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<CharOffset>)> = 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<Self>,
) -> 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<Self>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<u32> =
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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>) {
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>) {
self.vim_select_to_buffer_bound(TextDirection::Forwards, ctx);
}
pub fn vim_extend_selection_linewise(
&mut self,
include_newline: bool,
ctx: &mut ModelContext<Self>,
) {
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<Self>,
) -> Option<Range<CharOffset>> {
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<Self>,
) {
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<CharOffset> = 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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) -> vec1::Vec1<IndentResult> {
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<Self>) {
let indent_mode = if is_above {
IndentMode::NewlineAbove
} else {
IndentMode::NewlineBelow
};
let res = self.compute_smart_indent_results(indent_mode, ctx);
let indents: Vec<String> = 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<Self>,
) {
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<String> =
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<Self>) {
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<CodeEditorModel>),
transform: CaseTransform,
ctx: &mut ModelContext<Self>,
) {
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<CharOffset>)> = 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<Self>,
) {
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<CharOffset>)> = 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<Self>) {
// 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<usize> {
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<Self>,
) -> (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<Self::T>,
) {
// 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<Buffer> {
&self.content
}
fn buffer_selection_model(&self) -> &ModelHandle<BufferSelectionModel> {
&self.selection_model
}
fn selection_model(&self) -> &ModelHandle<SelectionModel> {
&self.selection
}
fn render_state(&self) -> &ModelHandle<RenderState> {
&self.render_state
}
fn indent(&mut self, shift: bool, ctx: &mut ModelContext<Self>) {
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<Self::T>) {
// 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<B>(
&mut self,
direction: TextDirection,
unit: TextUnit,
cut: bool,
write_to_clipboard: B,
ctx: &mut ModelContext<Self::T>,
) where
B: FnOnce(
&ModelHandle<Buffer>,
&ModelHandle<BufferSelectionModel>,
Option<vec1::Vec1<std::ops::Range<string_offset::CharOffset>>>,
&mut ModelContext<Self::T>,
),
{
// 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<Self>) {
// 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<Self>,
) {
// 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<Self>) {
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;