use galaxyui::{AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, units::Pixels}; use num_traits::SaturatingSub; use std::ops::Range; use vec1::Vec1; use crate::{ content::{ buffer::{ AutoScrollBehavior, Buffer, BufferEvent, BufferSelectAction, SelectionOffsets, ToBufferCharOffset, ToBufferPoint, }, hidden_lines_model::HiddenLinesModel, selection_model::BufferSelectionModel, text::{BlockType, BufferBlockStyle, CodeBlockType}, }, render::model::{RenderState, SoftWrapPoint}, }; use galaxyui::text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy}; use string_offset::CharOffset; #[cfg(test)] #[path = "selection_tests.rs"] mod tests; /// A generic selection and navigation model for text editors. pub struct SelectionModel { render: ModelHandle, content: ModelHandle, selection_model: ModelHandle, hidden_lines: Option>, /// The goal x-coordinate in pixels. When moving between lines, the desired column /// might not exist on the new line (because it's shorter than the previous line). Storing /// the goal column lets us move back to that column when changing to a longer line. /// /// This is stored in pixels instead of characters so that, like other editors, we can /// match the visual column, accounting for any differences in padding and character width. pub goal_xs: Option>, /// The in-progress selection. pending_selection: Option, /// Whether navigation through hidden sections is allowed. /// When false, navigation operations that would pass through hidden sections /// will be prevented, keeping the selection at its current position. allow_hidden_navigation: bool, } /// A unit for text movement. #[derive(Debug, Clone)] pub enum TextUnit { /// Move by a single character. Character, /// Move by a single word, as defined by the given policy. Word(WordBoundariesPolicy), /// Move to line boundaries (the start or end of a soft-wrapped line). LineBoundary, /// Move by lines. Up is backwards and down is forwards. Line, /// Move to the paragraph boundaries (the start or end of a hard-wrapped line). ParagraphBoundary, } /// The mode for selection dragging. #[derive(Debug, Clone)] pub enum SelectionMode { /// Dragging extends the selection by a single character. Character, /// Dragging extends the selection by words. Word(WordBoundariesPolicy), /// Dragging extends the selection by entire lines. Line, } /// State for an in-progress selection. A selection is in-progress after the mouse is pressed and /// before it's released, and may be extended by dragging. #[derive(Debug)] struct PendingSelection { mode: SelectionMode, head: CharOffset, tail: CharOffset, } /// A direction that a text-editing operation may apply over. /// /// For example, the Backspace key deletes backwards, while the Delete key deletes forwards. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextDirection { Forwards, Backwards, } #[derive(Debug, Clone, Copy)] pub struct NavigationResult { /// The resulting character offset from text navigation. pub offset: CharOffset, /// The goal column based on the original offset, if it's different from the character offset. pub goal_x: Option, } impl SelectionModel { pub fn new( content: ModelHandle, render: ModelHandle, selection_model: ModelHandle, hidden_lines: Option>, ctx: &mut ModelContext, ) -> Self { ctx.subscribe_to_model(&content, Self::handle_buffer_event); Self { content, render, selection_model, goal_xs: None, pending_selection: None, hidden_lines, allow_hidden_navigation: true, } } pub fn with_disable_hidden_navigation(mut self) -> Self { self.allow_hidden_navigation = false; self } pub fn cursors(&self, ctx: &impl ModelAsRef) -> Vec1 { self.selection_model.as_ref(ctx).selection_heads() } /// Get whether navigation through hidden sections is allowed. pub fn allow_hidden_navigation(&self) -> bool { self.allow_hidden_navigation } fn rendered_mermaid_ranges(&self, ctx: &impl ModelAsRef) -> Vec> { self.render.as_ref(ctx).content().mermaid_block_ranges() } fn has_rendered_mermaid(&self, ctx: &impl ModelAsRef) -> bool { !self.rendered_mermaid_ranges(ctx).is_empty() } fn is_mermaid_code_block_offset(&self, offset: CharOffset, ctx: &impl ModelAsRef) -> bool { matches!( self.content.as_ref(ctx).block_type_at_point(offset), BlockType::Text(BufferBlockStyle::CodeBlock { code_block_type: CodeBlockType::Mermaid, }) ) } fn normalize_line_navigation_offset( &self, _start: CharOffset, direction: TextDirection, offset: CharOffset, ctx: &impl ModelAsRef, ) -> CharOffset { if !self.has_rendered_mermaid(ctx) { return offset; } let content = self.content.as_ref(ctx); let max_offset = content.max_charoffset(); match direction { TextDirection::Forwards if offset < max_offset && self.is_mermaid_code_block_offset(offset, ctx) => { content.block_or_line_end(offset).min(max_offset) } TextDirection::Backwards if offset > CharOffset::from(1) && self.is_mermaid_code_block_offset(offset, ctx) => { content.block_or_line_start(offset) } _ => offset, } } fn normalize_selection_offsets( &self, selection: SelectionOffsets, direction: Option, mermaid_ranges: &[Range], ) -> SelectionOffsets { if mermaid_ranges.is_empty() { return selection; } if selection.head == selection.tail { let offset = Self::normalize_cursor_offset(selection.head, direction, mermaid_ranges); return SelectionOffsets { head: offset, tail: offset, }; } let head_is_end = selection.head > selection.tail; let mut start = selection.head.min(selection.tail); let mut end = selection.head.max(selection.tail); let first_intersecting = mermaid_ranges.partition_point(|range| range.end <= start); for range in &mermaid_ranges[first_intersecting..] { if range.start >= end { break; } if start < range.end && range.start < end { start = start.min(range.start); end = end.max(range.end); } } if head_is_end { SelectionOffsets { head: end, tail: start, } } else { SelectionOffsets { head: start, tail: end, } } } fn normalize_cursor_offset( offset: CharOffset, direction: Option, mermaid_ranges: &[Range], ) -> CharOffset { let containing_range = mermaid_ranges.partition_point(|range| range.end <= offset); let Some(range) = mermaid_ranges .get(containing_range) .filter(|range| range.start < offset) else { return offset; }; match direction { Some(TextDirection::Backwards) => range.start, Some(TextDirection::Forwards) => range.end, None => { let distance_to_start = offset - range.start; let distance_to_end = range.end - offset; if distance_to_start <= distance_to_end { range.start } else { range.end } } } } fn normalize_selections( &self, selections: Vec1, direction: Option, ctx: &impl ModelAsRef, ) -> Vec1 { let mermaid_ranges = self.rendered_mermaid_ranges(ctx); selections.mapped(|selection| { self.normalize_selection_offsets(selection, direction, &mermaid_ranges) }) } fn normalize_character_selection_offsets( &self, selection: SelectionOffsets, action: &BufferSelectAction, direction: TextDirection, max_offset: CharOffset, mermaid_ranges: &[Range], ) -> SelectionOffsets { match action { BufferSelectAction::MoveLeft => { let offset = if selection.head == selection.tail { selection .head .saturating_sub(&CharOffset::from(1)) .max(1.into()) } else { selection.head.min(selection.tail) }; let offset = Self::normalize_cursor_offset(offset, Some(direction), mermaid_ranges); SelectionOffsets { head: offset, tail: offset, } } BufferSelectAction::MoveRight => { let offset = if selection.head == selection.tail { (selection.head + CharOffset::from(1)).min(max_offset) } else { selection.head.max(selection.tail) }; let offset = Self::normalize_cursor_offset(offset, Some(direction), mermaid_ranges); SelectionOffsets { head: offset, tail: offset, } } BufferSelectAction::ExtendLeft => { let head = selection .head .saturating_sub(&CharOffset::from(1)) .max(1.into()); let head = Self::normalize_cursor_offset(head, Some(direction), mermaid_ranges); SelectionOffsets { head, tail: selection.tail, } } BufferSelectAction::ExtendRight => { let head = (selection.head + CharOffset::from(1)).min(max_offset); let head = Self::normalize_cursor_offset(head, Some(direction), mermaid_ranges); SelectionOffsets { head, tail: selection.tail, } } _ => unreachable!(), } } fn normalized_character_action( &self, action: &BufferSelectAction, ctx: &impl ModelAsRef, ) -> Option { let direction = match action { BufferSelectAction::MoveLeft | BufferSelectAction::ExtendLeft => { TextDirection::Backwards } BufferSelectAction::MoveRight | BufferSelectAction::ExtendRight => { TextDirection::Forwards } _ => return None, }; let max_offset = self.content.as_ref(ctx).max_charoffset(); let selections = self.selections(ctx); let mermaid_ranges = self.rendered_mermaid_ranges(ctx); let raw = selections.clone().mapped(|selection| match action { BufferSelectAction::MoveLeft => { let offset = if selection.head == selection.tail { selection .head .saturating_sub(&CharOffset::from(1)) .max(1.into()) } else { selection.head.min(selection.tail) }; SelectionOffsets { head: offset, tail: offset, } } BufferSelectAction::MoveRight => { let offset = if selection.head == selection.tail { (selection.head + CharOffset::from(1)).min(max_offset) } else { selection.head.max(selection.tail) }; SelectionOffsets { head: offset, tail: offset, } } BufferSelectAction::ExtendLeft => SelectionOffsets { head: selection .head .saturating_sub(&CharOffset::from(1)) .max(1.into()), tail: selection.tail, }, BufferSelectAction::ExtendRight => SelectionOffsets { head: (selection.head + CharOffset::from(1)).min(max_offset), tail: selection.tail, }, _ => unreachable!(), }); let normalized = selections.mapped(|selection| { self.normalize_character_selection_offsets( selection, action, direction, max_offset, &mermaid_ranges, ) }); (normalized != raw).then_some(BufferSelectAction::UpdateSelectionOffsets { selections: normalized, }) } fn normalize_select_action( &self, action: BufferSelectAction, ctx: &impl ModelAsRef, ) -> BufferSelectAction { if let Some(action) = self.normalized_character_action(&action, ctx) { return action; } let current_selections = self.selections(ctx); if let Some(selections) = action.selection_offsets(Some(¤t_selections)) { action.with_selection_offsets(self.normalize_selections(selections, None, ctx)) } else { action } } fn validate_select_action(&self, action: &BufferSelectAction, ctx: &AppContext) -> bool { if self.allow_hidden_navigation() { return true; } let selection_model = self.selection_model.as_ref(ctx); let hidden_lines = self.hidden_lines.as_ref().map(|hl| hl.as_ref(ctx)); match action { BufferSelectAction::MoveLeft => { !(selection_model.all_single_cursors() && hidden_lines .map(|hl| hl.after_hidden_section(ctx)) .unwrap_or(false)) } BufferSelectAction::MoveRight => { !(selection_model.all_single_cursors() && hidden_lines .map(|hl| hl.before_hidden_section(ctx)) .unwrap_or(false)) } BufferSelectAction::ExtendLeft => !hidden_lines .map(|hl| hl.after_hidden_section(ctx)) .unwrap_or(false), BufferSelectAction::ExtendRight => !hidden_lines .map(|hl| hl.before_hidden_section(ctx)) .unwrap_or(false), _ => action .selection_offsets(Some(&selection_model.selection_offsets())) .is_none_or(|selections| { selections.iter().all(|selection| { let range = if selection.head < selection.tail { selection.head..selection.tail } else { selection.tail..selection.head }; !hidden_lines .map(|hl| hl.contains_hidden_section(&range, ctx)) .unwrap_or(false) }) }), } } /// Updates the buffer-level selection. Editors **must** use this instead of calling /// [`Buffer::update_selection`] directly, unless they are updating the selection in /// order to edit. /// Returns true if the selection action proposed adheres to invariants around hidden sections. pub fn update_selection( &mut self, action: BufferSelectAction, autoscroll: AutoScrollBehavior, ctx: &mut ModelContext, ) -> bool { let action = self.normalize_select_action(action, ctx); if !self.validate_select_action(&action, ctx) { return false; } // Horizontal movement resets the goal column. If this is called by a vertical movement // action, it will save the new goal afterwards. self.goal_xs = None; let selection_model = self.selection_model.clone(); self.content.update(ctx, |content, ctx| { content.update_selection(selection_model, action, autoscroll, ctx) }); true } /// End any ongoing selection. pub fn end_selection(&mut self, _ctx: &mut ModelContext) { self.pending_selection = None; } /// Set a single cursor at the offset. pub fn set_cursor(&mut self, offset: CharOffset, ctx: &mut ModelContext) { self.update_selection( BufferSelectAction::AddCursorAt { offset, clear_selections: true, }, AutoScrollBehavior::Selection, ctx, ); } // Add a new cursor at the offset. pub fn add_cursor(&mut self, offset: CharOffset, ctx: &mut ModelContext) { self.update_selection( BufferSelectAction::AddCursorAt { offset, clear_selections: false, }, AutoScrollBehavior::Selection, ctx, ); } /// Update the head and tail offsets for the currently active selections. fn update_selections( &mut self, selections: Vec1, ctx: &mut ModelContext, ) { self.update_selection( BufferSelectAction::UpdateSelectionOffsets { selections }, AutoScrollBehavior::Selection, ctx, ); } pub fn set_last_head(&mut self, offset: CharOffset, ctx: &mut ModelContext) { self.update_selection( BufferSelectAction::SetLastHead { offset }, AutoScrollBehavior::Selection, ctx, ); } /// Begin a new selection, starting from the given offset. pub fn begin_selection( &mut self, offset: CharOffset, mode: SelectionMode, clear_selections: bool, ctx: &mut ModelContext, ) { // TODO(INT-266): Support multiselect with semantic selection. let offsets = match &mode { SelectionMode::Character => SelectionOffsets { head: offset, tail: offset, }, SelectionMode::Word(policy) => { let content = self.content.as_ref(ctx); let start = content.word_start(offset, policy); let end = content.word_end(offset, policy); SelectionOffsets { head: end, tail: start, } } SelectionMode::Line => { let content = self.content.as_ref(ctx); let point = offset.to_buffer_point(content); let line_start = Point::new(point.row, 0); let line_end = Point::new(point.row, content.line_len(point.row)); let start = line_start.to_buffer_char_offset(content); let end = line_end.to_buffer_char_offset(content); SelectionOffsets { head: end, tail: start, } } }; let offsets = self .normalize_selections(Vec1::new(offsets), None, ctx) .into_iter() .next() .expect("single selection should exist"); self.pending_selection = Some(PendingSelection { mode, head: offsets.head, tail: offsets.tail, }); self.update_selection( BufferSelectAction::AddSelection { head: offsets.head, tail: offsets.tail, clear_selections, }, AutoScrollBehavior::Selection, ctx, ); } /// Update the pending selection. Pending selections are started when the mouse is pressed, and /// active as it is dragged. If the original selection were semantic (selecting by word or /// line), that selection mode is kept until the mouse is released. pub fn update_pending_selection(&mut self, offset: CharOffset, ctx: &mut ModelContext) { if let Some(pending_selection) = &self.pending_selection { let mut head = pending_selection.head; let mut tail = pending_selection.tail; match &pending_selection.mode { SelectionMode::Character => { head = offset; } SelectionMode::Word(policy) => { let content = self.content.as_ref(ctx); if offset > head { // If the cursor is after the pending selection, extend to the next word's // end. head = content.word_end(offset, policy); } else if offset < tail { // If the cursor is before the pending selection, extend to the previous // word's start. tail = head; head = content.word_start(offset, policy); } } SelectionMode::Line => { let content = self.content.as_ref(ctx); let point = offset.to_buffer_point(content); if offset > head { // If the cursor is after the pending selection, extend to the next lines. let end_column = content.line_len(point.row); head = Point::new(point.row, end_column).to_buffer_char_offset(content); } else if offset < tail { // If the cursor is before the pending selection, extend to the previous // lines. tail = head; head = Point::new(point.row, 0).to_buffer_char_offset(content); } } } self.update_selection( BufferSelectAction::SetLastSelection { head, tail }, AutoScrollBehavior::Selection, ctx, ); } } /// The selected offset ranges. /// This will always return the range from the lowest offset to highest. /// The head or tail may be the lowest offset. pub fn selections(&self, ctx: &impl ModelAsRef) -> Vec1 { self.selection_model.as_ref(ctx).selection_offsets() } /// The starting point of the selection. /// /// This may be the head or tail, depending on the selection's direction. pub fn selection_start(&self, ctx: &impl ModelAsRef) -> CharOffset { let selection_model = self.selection_model.as_ref(ctx); selection_model .first_selection_head() .min(selection_model.first_selection_tail()) } /// The end point of the selection. /// /// This may be the head or tail, depending on the selection's direction. pub fn selection_end(&self, ctx: &impl ModelAsRef) -> CharOffset { let selection_model = self.selection_model.as_ref(ctx); selection_model .first_selection_head() .max(selection_model.first_selection_tail()) } /// Modify all of the selections in a given way. /// The currently active selections are looped though, along with any current x-pixel goal values, /// and a new head and tail offsets and a new x-pixel goal value are calculated for each selection. /// /// selection_update: A function that takes the current selection, the current goal x, and the current /// selection offsets, and returns the new goal x and the new selection offsets. fn update_selections_internal(&mut self, selection_update: T, ctx: &mut ModelContext) where T: Fn( &SelectionModel, &mut ModelContext, &SelectionOffsets, &Option, ) -> (Option, SelectionOffsets), { // Before we take action, merge any overlapping selections. self.selection_model.update(ctx, |selection_model, _ctx| { selection_model.merge_overlapping_selections(); }); let selections = self.selections(ctx); let goal_xs = match self.goal_xs.take() { Some(goals) => goals.mapped(Some), None => selections.mapped_ref(|_| None), }; // Compute the new head location and new goal x, given the old head position and the old goal x. let (new_goal_xs, new_selections): (Vec<_>, Vec<_>) = selections .iter() .zip(goal_xs.iter()) .map(|(selection, old_goal_x)| selection_update(self, ctx, selection, old_goal_x)) .unzip(); // Convert from Vec