mirror of https://github.com/microsoft/edit
Move lines with Alt+Up/Down (#230)
Closes #27 Co-authored-by: Leonard Hecker <leonard@hecker.io>
This commit is contained in:
parent
b277a1e67b
commit
091b74240c
|
|
@ -91,6 +91,8 @@ struct HistoryEntry {
|
|||
/// [`TextBuffer::stats`] before the change was made.
|
||||
stats_before: TextBufferStatistics,
|
||||
/// [`GapBuffer::generation`] before the change was made.
|
||||
///
|
||||
/// **NOTE:** Entries with the same generation are grouped together.
|
||||
generation_before: u32,
|
||||
/// Logical cursor position where the change took place.
|
||||
/// The position is at the start of the changed range.
|
||||
|
|
@ -154,12 +156,36 @@ struct ActiveEditLineInfo {
|
|||
distance_next_line_start: usize,
|
||||
}
|
||||
|
||||
/// Undo/redo grouping works by recording a set of "overrides",
|
||||
/// which are then applied in [`TextBuffer::edit_begin()`].
|
||||
/// This allows us to create a group of edits that all share a
|
||||
/// common `generation_before` and can be undone/redone together.
|
||||
/// This struct stores those overrides.
|
||||
struct ActiveEditGroupInfo {
|
||||
/// [`TextBuffer::cursor`] position before the change was made.
|
||||
cursor_before: Point,
|
||||
/// [`TextBuffer::selection`] before the change was made.
|
||||
selection_before: Option<TextBufferSelection>,
|
||||
/// [`TextBuffer::stats`] before the change was made.
|
||||
stats_before: TextBufferStatistics,
|
||||
/// [`GapBuffer::generation`] before the change was made.
|
||||
///
|
||||
/// **NOTE:** Entries with the same generation are grouped together.
|
||||
generation_before: u32,
|
||||
}
|
||||
|
||||
/// Char- or word-wise navigation? Your choice.
|
||||
pub enum CursorMovement {
|
||||
Grapheme,
|
||||
Word,
|
||||
}
|
||||
|
||||
/// See [`TextBuffer::move_selected_lines`].
|
||||
pub enum MoveLineDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// The result of a call to [`TextBuffer::render()`].
|
||||
pub struct RenderResult {
|
||||
/// The maximum visual X position we encountered during rendering.
|
||||
|
|
@ -184,6 +210,7 @@ pub struct TextBuffer {
|
|||
last_history_type: HistoryType,
|
||||
last_save_generation: u32,
|
||||
|
||||
active_edit_group: Option<ActiveEditGroupInfo>,
|
||||
active_edit_line_info: Option<ActiveEditLineInfo>,
|
||||
active_edit_depth: i32,
|
||||
active_edit_off: usize,
|
||||
|
|
@ -235,6 +262,7 @@ impl TextBuffer {
|
|||
last_history_type: HistoryType::Other,
|
||||
last_save_generation: 0,
|
||||
|
||||
active_edit_group: None,
|
||||
active_edit_line_info: None,
|
||||
active_edit_depth: 0,
|
||||
active_edit_off: 0,
|
||||
|
|
@ -2010,6 +2038,7 @@ impl TextBuffer {
|
|||
|
||||
fn write(&mut self, text: &[u8], at: Cursor, raw: bool) {
|
||||
let history_type = if raw { HistoryType::Other } else { HistoryType::Write };
|
||||
let mut edit_begun = false;
|
||||
|
||||
// If we have an active selection, writing an empty `text`
|
||||
// will still delete the selection. As such, we check this first.
|
||||
|
|
@ -2017,19 +2046,20 @@ impl TextBuffer {
|
|||
self.edit_begin(history_type, beg);
|
||||
self.edit_delete(end);
|
||||
self.set_selection(None);
|
||||
edit_begun = true;
|
||||
}
|
||||
|
||||
// If the text is empty the remaining code won't do anything,
|
||||
// allowing us to exit early.
|
||||
if text.is_empty() {
|
||||
// ...we still need to end any active edit session though.
|
||||
if self.active_edit_depth > 0 {
|
||||
if edit_begun {
|
||||
self.edit_end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.active_edit_depth <= 0 {
|
||||
if !edit_begun {
|
||||
self.edit_begin(history_type, at);
|
||||
}
|
||||
|
||||
|
|
@ -2238,73 +2268,142 @@ impl TextBuffer {
|
|||
/// * The cursor movement at the end is rather costly, but at least without word wrap
|
||||
/// it should be possible to calculate it directly from the removed amount.
|
||||
pub fn unindent(&mut self) {
|
||||
let selection = self.selection;
|
||||
let mut selection_beg = self.cursor.logical_pos;
|
||||
let mut selection_end = selection_beg;
|
||||
|
||||
if let Some(TextBufferSelection { beg, end }) = self.selection {
|
||||
selection_beg = beg;
|
||||
selection_end = end;
|
||||
if let Some(TextBufferSelection { beg, end }) = &selection {
|
||||
selection_beg = *beg;
|
||||
selection_end = *end;
|
||||
}
|
||||
|
||||
let [beg, end] = minmax(selection_beg, selection_end);
|
||||
let beg = self.cursor_move_to_logical_internal(self.cursor, Point { x: 0, y: beg.y });
|
||||
let end = self.cursor_move_to_logical_internal(beg, Point { x: CoordType::MAX, y: end.y });
|
||||
self.edit_begin_grouping();
|
||||
|
||||
let mut replacement = Vec::new();
|
||||
self.buffer.extract_raw(beg.offset..end.offset, &mut replacement, 0);
|
||||
for y in selection_beg.y.min(selection_end.y)..=selection_beg.y.max(selection_end.y) {
|
||||
self.cursor_move_to_logical(Point { x: 0, y });
|
||||
|
||||
let initial_len = replacement.len();
|
||||
let mut offset = 0;
|
||||
let mut y = beg.logical_pos.y;
|
||||
let mut offset = self.cursor.offset;
|
||||
let mut width = 0;
|
||||
let mut remove = 0;
|
||||
|
||||
loop {
|
||||
if offset >= replacement.len() {
|
||||
// Figure out how many characters to `remove`.
|
||||
'outer: loop {
|
||||
let chunk = self.read_forward(offset);
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut remove = 0;
|
||||
|
||||
if replacement[offset] == b'\t' {
|
||||
remove = 1;
|
||||
} else {
|
||||
while remove < self.tab_size as usize
|
||||
&& offset + remove < replacement.len()
|
||||
&& replacement[offset + remove] == b' '
|
||||
{
|
||||
for &c in chunk {
|
||||
width += match c {
|
||||
b' ' => 1,
|
||||
b'\t' => self.tab_size,
|
||||
_ => COORD_TYPE_SAFE_MAX,
|
||||
};
|
||||
if width > self.tab_size {
|
||||
break 'outer;
|
||||
}
|
||||
remove += 1;
|
||||
}
|
||||
|
||||
offset += chunk.len();
|
||||
|
||||
// No need to do another round if we
|
||||
// already got the exact right amount.
|
||||
if width >= self.tab_size {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if remove > 0 {
|
||||
replacement.drain(offset..offset + remove);
|
||||
}
|
||||
|
||||
// As the lines get unindented, the selection should shift with them.
|
||||
if y == selection_beg.y {
|
||||
selection_beg.x -= remove as CoordType;
|
||||
selection_beg.x -= remove;
|
||||
}
|
||||
if y == selection_end.y {
|
||||
selection_end.x -= remove as CoordType;
|
||||
selection_end.x -= remove;
|
||||
}
|
||||
|
||||
(offset, y) = simd::lines_fwd(&replacement, offset, y, y + 1);
|
||||
self.delete(CursorMovement::Grapheme, remove);
|
||||
}
|
||||
}
|
||||
self.edit_end_grouping();
|
||||
|
||||
// Move the cursor to the new end of the selection.
|
||||
self.set_cursor_internal(self.cursor_move_to_logical_internal(self.cursor, selection_end));
|
||||
|
||||
// NOTE: If the selection was previously `None`,
|
||||
// it should continue to be `None` after this.
|
||||
self.set_selection(
|
||||
selection.map(|_| TextBufferSelection { beg: selection_beg, end: selection_end }),
|
||||
);
|
||||
}
|
||||
|
||||
if replacement.len() == initial_len {
|
||||
// Nothing to do.
|
||||
/// Displaces the current, cursor or the selection, line(s) in the given direction.
|
||||
pub fn move_selected_lines(&mut self, direction: MoveLineDirection) {
|
||||
let selection = self.selection;
|
||||
let cursor = self.cursor;
|
||||
|
||||
// If there's no selection, we move the line the cursor is on instead.
|
||||
let [beg, end] = match self.selection {
|
||||
Some(s) => minmax(s.beg.y, s.end.y),
|
||||
None => [cursor.logical_pos.y, cursor.logical_pos.y],
|
||||
};
|
||||
|
||||
// Check if this would be a no-op.
|
||||
if match direction {
|
||||
MoveLineDirection::Up => beg <= 0,
|
||||
MoveLineDirection::Down => end >= self.stats.logical_lines - 1,
|
||||
} {
|
||||
return;
|
||||
}
|
||||
|
||||
self.edit_begin(HistoryType::Other, beg);
|
||||
self.edit_delete(end);
|
||||
self.edit_write(&replacement);
|
||||
self.edit_end();
|
||||
let delta = match direction {
|
||||
MoveLineDirection::Up => -1,
|
||||
MoveLineDirection::Down => 1,
|
||||
};
|
||||
let (cut, paste) = match direction {
|
||||
MoveLineDirection::Up => (beg - 1, end),
|
||||
MoveLineDirection::Down => (end + 1, beg),
|
||||
};
|
||||
|
||||
if let Some(TextBufferSelection { beg, end }) = &mut self.selection {
|
||||
*beg = selection_beg;
|
||||
*end = selection_end;
|
||||
self.edit_begin_grouping();
|
||||
{
|
||||
// Let's say this is `MoveLineDirection::Up`.
|
||||
// In that case, we'll cut (remove) the line above the selection here...
|
||||
self.cursor_move_to_logical(Point { x: 0, y: cut });
|
||||
let line = self.extract_selection(true);
|
||||
|
||||
// ...and paste it below the selection. This will then
|
||||
// appear to the user as if the selection was moved up.
|
||||
self.cursor_move_to_logical(Point { x: 0, y: paste });
|
||||
self.edit_begin(HistoryType::Write, self.cursor);
|
||||
// The `extract_selection` call can return an empty `Vec`),
|
||||
// if the `cut` line was at the end of the file. Since we want to
|
||||
// paste the line somewhere it needs a trailing newline at the minimum.
|
||||
//
|
||||
// Similarly, if the `paste` line is at the end of the file
|
||||
// and there's no trailing newline, we'll have failed to reach
|
||||
// that end in which case `logical_pos.y != past`.
|
||||
if line.is_empty() || self.cursor.logical_pos.y != paste {
|
||||
self.write_canon(b"\n");
|
||||
}
|
||||
if !line.is_empty() {
|
||||
self.write_raw(&line);
|
||||
}
|
||||
self.edit_end();
|
||||
}
|
||||
self.edit_end_grouping();
|
||||
|
||||
self.set_cursor_internal(self.cursor_move_to_logical_internal(self.cursor, selection_end));
|
||||
// Shift the cursor and selection together with the moved lines.
|
||||
self.cursor_move_to_logical(Point {
|
||||
x: cursor.logical_pos.x,
|
||||
y: cursor.logical_pos.y + delta,
|
||||
});
|
||||
self.set_selection(selection.map(|mut s| {
|
||||
s.beg.y += delta;
|
||||
s.end.y += delta;
|
||||
s
|
||||
}));
|
||||
}
|
||||
|
||||
/// Extracts the contents of the current selection.
|
||||
|
|
@ -2378,6 +2477,19 @@ impl TextBuffer {
|
|||
if beg.offset < end.offset { Some((beg, end)) } else { None }
|
||||
}
|
||||
|
||||
fn edit_begin_grouping(&mut self) {
|
||||
self.active_edit_group = Some(ActiveEditGroupInfo {
|
||||
cursor_before: self.cursor.logical_pos,
|
||||
selection_before: self.selection,
|
||||
stats_before: self.stats,
|
||||
generation_before: self.buffer.generation(),
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_end_grouping(&mut self) {
|
||||
self.active_edit_group = None;
|
||||
}
|
||||
|
||||
/// Starts a new edit operation.
|
||||
/// This is used for tracking the undo/redo history.
|
||||
fn edit_begin(&mut self, history_type: HistoryType, cursor: Cursor) {
|
||||
|
|
@ -2390,7 +2502,8 @@ impl TextBuffer {
|
|||
self.set_cursor_internal(cursor);
|
||||
|
||||
// If both the last and this are a Write/Delete operation, we skip allocating a new undo history item.
|
||||
if history_type != self.last_history_type
|
||||
if cursor_before.offset != cursor.offset
|
||||
|| history_type != self.last_history_type
|
||||
|| !matches!(history_type, HistoryType::Write | HistoryType::Delete)
|
||||
{
|
||||
self.redo_stack.clear();
|
||||
|
|
@ -2408,6 +2521,16 @@ impl TextBuffer {
|
|||
deleted: Vec::new(),
|
||||
added: Vec::new(),
|
||||
}));
|
||||
|
||||
if let Some(info) = &self.active_edit_group
|
||||
&& let Some(entry) = self.undo_stack.back()
|
||||
{
|
||||
let mut entry = entry.borrow_mut();
|
||||
entry.cursor_before = info.cursor_before;
|
||||
entry.selection_before = info.selection_before;
|
||||
entry.stats_before = info.stats_before;
|
||||
entry.generation_before = info.generation_before;
|
||||
}
|
||||
}
|
||||
|
||||
self.active_edit_off = cursor.offset;
|
||||
|
|
@ -2461,9 +2584,12 @@ impl TextBuffer {
|
|||
let mut out_off = usize::MAX;
|
||||
|
||||
let mut undo = self.undo_stack.back_mut().unwrap().borrow_mut();
|
||||
|
||||
// If this is a continued backspace operation,
|
||||
// we need to prepend the deleted portion to the undo entry.
|
||||
if self.cursor.logical_pos < undo.cursor {
|
||||
out_off = 0; // Prepend the deleted portion.
|
||||
undo.cursor = self.cursor.logical_pos; // Note the start of the deleted portion.
|
||||
out_off = 0;
|
||||
undo.cursor = self.cursor.logical_pos;
|
||||
}
|
||||
|
||||
// Copy the deleted portion into the undo entry.
|
||||
|
|
@ -2481,7 +2607,7 @@ impl TextBuffer {
|
|||
/// and recalculates the line statistics.
|
||||
fn edit_end(&mut self) {
|
||||
self.active_edit_depth -= 1;
|
||||
assert!(self.active_edit_depth >= 0);
|
||||
debug_assert!(self.active_edit_depth >= 0);
|
||||
if self.active_edit_depth > 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2536,6 +2662,10 @@ impl TextBuffer {
|
|||
}
|
||||
|
||||
fn undo_redo(&mut self, undo: bool) {
|
||||
let buffer_generation = self.buffer.generation();
|
||||
let mut entry_buffer_generation = None;
|
||||
|
||||
loop {
|
||||
// Transfer the last entry from the undo stack to the redo stack or vice versa.
|
||||
{
|
||||
let (from, to) = if undo {
|
||||
|
|
@ -2544,8 +2674,14 @@ impl TextBuffer {
|
|||
(&mut self.redo_stack, &mut self.undo_stack)
|
||||
};
|
||||
|
||||
if let Some(g) = entry_buffer_generation
|
||||
&& from.back().is_none_or(|c| c.borrow().generation_before != g)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(list) = from.cursor_back_mut().remove_current_as_list() else {
|
||||
return;
|
||||
break;
|
||||
};
|
||||
|
||||
to.cursor_back_mut().splice_after(list);
|
||||
|
|
@ -2556,8 +2692,13 @@ impl TextBuffer {
|
|||
to.back().unwrap()
|
||||
};
|
||||
|
||||
// Move to the point where the modification took place.
|
||||
let cursor = self.cursor_move_to_logical_internal(self.cursor, change.borrow().cursor);
|
||||
// Remember the buffer generation of the change so we can stop popping undos/redos.
|
||||
// Also, move to the point where the modification took place.
|
||||
let cursor = {
|
||||
let change = change.borrow();
|
||||
entry_buffer_generation = Some(change.generation_before);
|
||||
self.cursor_move_to_logical_internal(self.cursor, change.cursor)
|
||||
};
|
||||
|
||||
let safe_cursor = if self.word_wrap_column > 0 {
|
||||
// If word-wrap is enabled, we need to move the cursor to the beginning of the line.
|
||||
|
|
@ -2568,7 +2709,6 @@ impl TextBuffer {
|
|||
};
|
||||
|
||||
{
|
||||
let buffer_generation = self.buffer.generation();
|
||||
let mut change = change.borrow_mut();
|
||||
let change = &mut *change;
|
||||
|
||||
|
|
@ -2635,9 +2775,12 @@ impl TextBuffer {
|
|||
self.last_history_type = HistoryType::Other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entry_buffer_generation.is_some() {
|
||||
self.recalc_after_content_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// For interfacing with ICU.
|
||||
pub(crate) fn read_backward(&self, off: usize) -> &[u8] {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ use std::fmt::Write as _;
|
|||
use std::{iter, mem, ptr, time};
|
||||
|
||||
use crate::arena::{Arena, ArenaString, scratch_arena};
|
||||
use crate::buffer::{CursorMovement, RcTextBuffer, TextBuffer, TextBufferCell};
|
||||
use crate::buffer::{CursorMovement, MoveLineDirection, RcTextBuffer, TextBuffer, TextBufferCell};
|
||||
use crate::cell::*;
|
||||
use crate::clipboard::Clipboard;
|
||||
use crate::document::WriteableDocument;
|
||||
|
|
@ -2547,6 +2547,7 @@ impl<'a> Context<'a, '_> {
|
|||
y: tb.cursor_visual_pos().y - 1,
|
||||
});
|
||||
}
|
||||
kbmod::ALT => tb.move_selected_lines(MoveLineDirection::Up),
|
||||
kbmod::CTRL_ALT => {
|
||||
// TODO: Add cursor above
|
||||
}
|
||||
|
|
@ -2617,6 +2618,7 @@ impl<'a> Context<'a, '_> {
|
|||
tc.preferred_column = tb.cursor_visual_pos().x;
|
||||
}
|
||||
}
|
||||
kbmod::ALT => tb.move_selected_lines(MoveLineDirection::Down),
|
||||
kbmod::CTRL_ALT => {
|
||||
// TODO: Add cursor above
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue