From 34e59c47c3d4f207871f4c8bdb82b268a9993e98 Mon Sep 17 00:00:00 2001 From: "Dubrovin E. Iu." Date: Tue, 17 Jun 2025 15:18:26 +0300 Subject: [PATCH 1/3] implementation of a variant of the `copy_selection_on_line` function that uses visual lines and respects virtual text --- helix-term/src/commands.rs | 81 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2cbdeb451..ac1e0d129 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1965,7 +1965,82 @@ fn page_cursor_half_down(cx: &mut Context) { scroll(cx, offset, Direction::Forward, true); } -#[allow(deprecated)] +fn copy_selection_on_visual_line(cx: &mut Context, direction: Direction) { + + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let text_fmt = doc.text_format(view.inner_area(doc).width, None); + let mut annotations = view.text_annotations(doc, None); + annotations.clear_line_annotations(); + + let mut primary_idx = selection.primary_index(); + let mut new_ranges = SmallVec::with_capacity(selection.len() * (count + 1)); + new_ranges.extend_from_slice(selection.ranges()); + //TODO: copy the selection to the relative line number if `count` > 1 + + for range in selection.iter() { + let is_primary = *range == selection.primary(); + + // The range is always head exclusive + let (head, anchor) = + if range.anchor < range.head { (range.head - 1, range.anchor ) } + else { (range.head , range.anchor.saturating_sub(1)) }; + let min_idx = std::cmp::min(head, anchor); + + let (head_pos , _) = visual_offset_from_block( + text, min_idx, head , &text_fmt, &annotations); + let (anchor_pos, _) = visual_offset_from_block( + text, min_idx, anchor, &text_fmt, &annotations); + + let height = + std::cmp::max(head_pos.row, anchor_pos.row) + - std::cmp::min(head_pos.row, anchor_pos.row) + + 1; + + let mut i = 0; + let mut step = 0; + while step < count { + + use Direction::*; + i += match direction { Forward => 1, Backward => -1, }; + let offset = i * height as isize; + + let (new_head , _) = char_idx_at_visual_offset( + text, head , offset, head_pos.col , &text_fmt, &annotations); + let (new_anchor, _) = char_idx_at_visual_offset( + text, anchor, offset, anchor_pos.col, &text_fmt, &annotations); + + let (Position { col: new_head_col , ..}, _) = visual_offset_from_block( + text, new_head , new_head , &text_fmt, &annotations); + let (Position { col: new_anchor_col, ..}, _) = visual_offset_from_block( + text, new_anchor, new_anchor, &text_fmt, &annotations); + + // check the bottom doc boundary + if new_head >= text.len_chars() + || new_anchor >= text.len_chars() { break } + + // skip lines that are too short + if head_pos.col == new_head_col && anchor_pos.col == new_anchor_col { + new_ranges.push( + Range::point(new_anchor) + .put_cursor(text, new_head, true) ); + if is_primary { primary_idx = new_ranges.len() - 1 } + step += 1; + } + + // check the top doc boundary + if new_head == 0 + && new_anchor == 0 { break } + } } + + drop(annotations); + doc.set_selection(view.id, Selection::new(new_ranges, primary_idx)) +} + +#[deprecated = "Doesn't account for softwrap or decorations, use copy_selection_on_visual_line instead"] +#[allow(dead_code, deprecated)] // currently uses the deprecated `visual_coords_at_pos`/`pos_at_visual_coords` functions // as this function ignores softwrapping (and virtual text) and instead only cares // about "text visual position" @@ -2053,11 +2128,11 @@ fn copy_selection_on_line(cx: &mut Context, direction: Direction) { } fn copy_selection_on_prev_line(cx: &mut Context) { - copy_selection_on_line(cx, Direction::Backward) + copy_selection_on_visual_line(cx, Direction::Backward) } fn copy_selection_on_next_line(cx: &mut Context) { - copy_selection_on_line(cx, Direction::Forward) + copy_selection_on_visual_line(cx, Direction::Forward) } fn select_all(cx: &mut Context) { From 84401d753dd7a9349e069d39a2f9b3cb983ea239 Mon Sep 17 00:00:00 2001 From: "Dubrovin E. Iu." Date: Tue, 17 Jun 2025 15:34:08 +0300 Subject: [PATCH 2/3] the implementation of keeping the direction and approximate primary index in functions: `keep_or_remove_matches`, `select_on_matches`, `split_on_newline` and `split_on_matches` --- helix-core/src/selection.rs | 95 ++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 5bde08e31..4d422a64e 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -770,15 +770,28 @@ pub fn keep_or_remove_matches( regex: &rope::Regex, remove: bool, ) -> Option { + + let prim_range = selection.primary(); + let mut diff = usize::MAX; + let mut new_prim = 0; + let result: SmallVec<_> = selection .iter() .filter(|range| regex.is_match(text.regex_input_at(range.from()..range.to())) ^ remove) + .enumerate() + .map(|(idx, range)| { + let new_diff = range.head.abs_diff(prim_range.head); + if new_diff < diff { + diff = new_diff; + new_prim = idx; + } + range + }) .copied() .collect(); - // TODO: figure out a new primary index if !result.is_empty() { - return Some(Selection::new(result, 0)); + return Some(Selection::new(result, new_prim)); } None } @@ -789,45 +802,61 @@ pub fn select_on_matches( selection: &Selection, regex: &rope::Regex, ) -> Option { - let mut result = SmallVec::with_capacity(selection.len()); + + let prim_range = selection.primary(); + let mut diff = usize::MAX; + let mut new_prim = 0; + let mut result = SmallVec::with_capacity(selection.len()); for sel in selection { for mat in regex.find_iter(text.regex_input_at(sel.from()..sel.to())) { - // TODO: retain range direction let start = text.byte_to_char(mat.start()); let end = text.byte_to_char(mat.end()); - let range = Range::new(start, end); + let range = match sel.direction() { + Direction::Forward => Range::new(start, end), + Direction::Backward => Range::new(end, start) + }; + // Make sure the match is not right outside of the selection. // These invalid matches can come from using RegEx anchors like `^`, `$` if range != Range::point(sel.to()) { + let new_diff = range.head.abs_diff(prim_range.head); + if new_diff < diff { + diff = new_diff; + new_prim = result.len(); + } result.push(range); } } } - // TODO: figure out a new primary index if !result.is_empty() { - return Some(Selection::new(result, 0)); + return Some(Selection::new(result, new_prim)); } None } pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection { - let mut result = SmallVec::with_capacity(selection.len()); + + let mut new_prim = selection.primary_index(); + let mut result = SmallVec::with_capacity(selection.len()); for sel in selection { + let is_prim = *sel == selection.primary(); + // Special case: zero-width selection. if sel.from() == sel.to() { + if is_prim { new_prim = result.len() } result.push(*sel); continue; } + let saved_len = result.len(); let sel_start = sel.from(); - let sel_end = sel.to(); - + let sel_end = sel.to(); let mut start = sel_start; for line in sel.slice(text).lines() { @@ -835,48 +864,70 @@ pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection { break; }; let line_end = start + line.len_chars(); - // TODO: retain range direction - result.push(Range::new(start, line_end - line_ending.len_chars())); + let range = Range::new(start, line_end - line_ending.len_chars()); + result.push( + if sel.direction() == Direction::Backward { range.flip() } + else { range } + ); start = line_end; } if start < sel_end { result.push(Range::new(start, sel_end)); } + + if is_prim { + new_prim = if sel.head > sel.anchor { + result.len() - 1 + } else { saved_len }; + } } - // TODO: figure out a new primary index - Selection::new(result, 0) + Selection::new(result, new_prim) } pub fn split_on_matches(text: RopeSlice, selection: &Selection, regex: &rope::Regex) -> Selection { - let mut result = SmallVec::with_capacity(selection.len()); + + let mut new_prim = selection.primary_index(); + let mut result = SmallVec::with_capacity(selection.len()); for sel in selection { + let is_prim = *sel == selection.primary(); + // Special case: zero-width selection. if sel.from() == sel.to() { + if is_prim { new_prim = result.len() } result.push(*sel); continue; } + let saved_len = result.len(); let sel_start = sel.from(); let sel_end = sel.to(); let mut start = sel_start; for mat in regex.find_iter(text.regex_input_at(sel_start..sel_end)) { - // TODO: retain range direction let end = text.byte_to_char(mat.start()); - result.push(Range::new(start, end)); + let range = Range::new(start, end); + result.push( + if sel.direction() == Direction::Backward { range.flip() } + else { range } + ); start = text.byte_to_char(mat.end()); } if start < sel_end { result.push(Range::new(start, sel_end)); } + + if is_prim && result.len() > saved_len { + new_prim = if sel.head > sel.anchor { + result.len() - 1 + } else { saved_len }; + } } - // TODO: figure out a new primary index - Selection::new(result, 0) + Selection::new(result, new_prim) } #[cfg(test)] @@ -1106,7 +1157,7 @@ mod test { select_on_matches(s, &selection, &rope::Regex::new(r"[A-Z][a-z]*").unwrap()), Some(Selection::new( smallvec![Range::new(0, 6), Range::new(19, 26)], - 0 + 1 )) ); @@ -1145,7 +1196,7 @@ mod test { select_on_matches(s, &Selection::single(0, 6), &start_of_line), Some(Selection::new( smallvec![Range::point(0), Range::point(5)], - 0 + 1 )) ); assert_eq!( @@ -1165,7 +1216,7 @@ mod test { ), Some(Selection::new( smallvec![Range::point(12), Range::new(13, 30), Range::new(31, 36)], - 0 + 2 )) ); } From 9a780a8cb9524f06da5de7e647933454efc5c9cf Mon Sep 17 00:00:00 2001 From: "Dubrovin E. Iu." Date: Tue, 17 Jun 2025 15:56:33 +0300 Subject: [PATCH 3/3] the behavior of `copy_selection_on_visual_line` has changed so that, if `count` > 1, then the selection is copied to the relative line number, instead of copying it `count` times --- helix-term/src/commands.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index ac1e0d129..0cc366665 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1978,7 +1978,8 @@ fn copy_selection_on_visual_line(cx: &mut Context, direction: Direction) { let mut primary_idx = selection.primary_index(); let mut new_ranges = SmallVec::with_capacity(selection.len() * (count + 1)); new_ranges.extend_from_slice(selection.ranges()); - //TODO: copy the selection to the relative line number if `count` > 1 + //copy the selection to the relative line number + let to_relative_line_number = count > 1; for range in selection.iter() { let is_primary = *range == selection.primary(); @@ -2027,8 +2028,10 @@ fn copy_selection_on_visual_line(cx: &mut Context, direction: Direction) { Range::point(new_anchor) .put_cursor(text, new_head, true) ); if is_primary { primary_idx = new_ranges.len() - 1 } - step += 1; + if ! to_relative_line_number { step += 1 } } + // always increment if `count` > 1 + if to_relative_line_number { step += 1 } // check the top doc boundary if new_head == 0