Skip to content
178 changes: 103 additions & 75 deletions src/buffer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,27 @@ impl TextBuffer {
}
}

/// Calculates the amount of spaces a tab key press would insert at the given column.
/// This also equals the visual width of an actual tab character.
///
/// This exists because Rust doesn't have range constraints yet, and without
/// them assembly blows up in size by 7x. It's a recurring issue with Rust.
#[inline]
fn tab_size_eval(&self, column: CoordType) -> CoordType {
// SAFETY: `set_tab_size` clamps `self.tab_size` to 1-8.
unsafe { std::hint::assert_unchecked(self.tab_size >= 1 && self.tab_size <= 8) };
self.tab_size - (column % self.tab_size)
}

/// If the cursor is at an indentation of `column`, this returns
/// the column to which a backspace key press would delete to.
#[inline]
fn tab_size_prev_column(&self, column: CoordType) -> CoordType {
// SAFETY: `set_tab_size` clamps `self.tab_size` to 1-8.
unsafe { std::hint::assert_unchecked(self.tab_size >= 1 && self.tab_size <= 8) };
(column - 1).max(0) / self.tab_size * self.tab_size
}

/// Returns whether tabs are used for indentation.
pub fn indent_with_tabs(&self) -> bool {
self.indent_with_tabs
Expand Down Expand Up @@ -1874,11 +1895,8 @@ impl TextBuffer {
self.cursor_move_to_offset_internal(cursor_line, global_off);
}

let tab_size = if is_tab {
self.tab_size - (cursor_line.column % self.tab_size)
} else {
1
};
let tab_size =
if is_tab { self.tab_size_eval(cursor_line.column) } else { 1 };

if visualize {
// If the whitespace is part of the selection,
Expand Down Expand Up @@ -2113,7 +2131,7 @@ impl TextBuffer {

// Now replace tabs with spaces.
while line_off < line.len() && line[line_off] == b'\t' {
let spaces = self.tab_size - (self.cursor.column % self.tab_size);
let spaces = self.tab_size_eval(self.cursor.column);
let spaces = &TAB_WHITESPACE.as_bytes()[..spaces as usize];
self.edit_write(spaces);
line_off += 1;
Expand Down Expand Up @@ -2144,11 +2162,10 @@ impl TextBuffer {
// because " a\n a\n" should give the 3rd line a total indentation of 4.
// Assuming your terminal has bracketed paste, this won't be a concern though.
// (If it doesn't, use a different terminal.)
let tab_size = self.tab_size as usize;
let line_beg = self.goto_line_start(self.cursor, self.cursor.logical_pos.y);
let limit = self.cursor.offset;
let mut off = line_beg.offset;
let mut newline_indentation = 0usize;
let mut newline_indentation = 0;

'outer: while off < limit {
let chunk = self.read_forward(off);
Expand All @@ -2158,7 +2175,7 @@ impl TextBuffer {
if c == b' ' {
newline_indentation += 1;
} else if c == b'\t' {
newline_indentation += tab_size - (newline_indentation % tab_size);
newline_indentation += self.tab_size_eval(newline_indentation);
} else {
break 'outer;
}
Expand All @@ -2169,14 +2186,14 @@ impl TextBuffer {

// If tabs are enabled, add as many tabs as we can.
if self.indent_with_tabs {
let tab_count = newline_indentation / tab_size;
newline_buffer.push_repeat('\t', tab_count);
newline_indentation -= tab_count * tab_size;
let tab_count = newline_indentation / self.tab_size;
newline_buffer.push_repeat('\t', tab_count as usize);
newline_indentation -= tab_count * self.tab_size;
}

// If tabs are disabled, or if the indentation wasn't a multiple of the tab size,
// add spaces to make up the difference.
newline_buffer.push_repeat(' ', newline_indentation);
newline_buffer.push_repeat(' ', newline_indentation as usize);
}

self.edit_write(newline_buffer.as_bytes());
Expand Down Expand Up @@ -2262,36 +2279,12 @@ impl TextBuffer {
/// Return `.x == 0` if there are no non-whitespace characters.
pub fn indent_end_logical_pos(&self) -> Point {
let cursor = self.goto_line_start(self.cursor, self.cursor.logical_pos.y);
let mut chars = 0;
let mut offset = cursor.offset;

'outer: loop {
let chunk = self.read_forward(offset);
if chunk.is_empty() {
break;
}

for &c in chunk {
if c == b'\n' || c == b'\r' || (c != b' ' && c != b'\t') {
break 'outer;
}
chars += 1;
}

offset += chunk.len();
}

let (chars, _) = self.measure_indent_internal(cursor.offset, CoordType::MAX);
Point { x: chars, y: cursor.logical_pos.y }
}

/// Unindents the current selection or line.
///
/// TODO: This function is ripe for some optimizations:
/// * Instead of replacing the entire selection,
/// it should unindent each line directly (as if multiple cursors had been used).
/// * 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) {
/// Indents/unindents the current selection or line.
pub fn indent_change(&mut self, direction: CoordType) {
let selection = self.selection;
let mut selection_beg = self.cursor.logical_pos;
let mut selection_end = selection_beg;
Expand All @@ -2301,53 +2294,49 @@ impl TextBuffer {
selection_end = *end;
}

if direction >= 0 && self.selection.is_none_or(|sel| sel.beg.y == sel.end.y) {
self.write_canon(b"\t");
return;
}

self.edit_begin_grouping();

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 mut offset = self.cursor.offset;
let mut width = 0;
let mut remove = 0;
let line_start_offset = self.cursor.offset;
let (curr_chars, curr_columns) =
self.measure_indent_internal(line_start_offset, CoordType::MAX);

// Figure out how many characters to `remove`.
'outer: loop {
let chunk = self.read_forward(offset);
if chunk.is_empty() {
break;
}
self.cursor_move_to_logical(Point { x: curr_chars, y: self.cursor.logical_pos.y });

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;
let delta;

if direction < 0 {
// Unindent the line. If there's no indentation, skip.
if curr_columns <= 0 {
continue;
}

offset += chunk.len();
let (prev_chars, _) = self.measure_indent_internal(
line_start_offset,
self.tab_size_prev_column(curr_columns),
);

// No need to do another round if we
// already got the exact right amount.
if width >= self.tab_size {
break 'outer;
}
delta = prev_chars - curr_chars;
self.delete(CursorMovement::Grapheme, delta);
} else {
// Indent the line. `self.cursor` is already at the level of indentation.
delta = self.tab_size_eval(curr_columns);
self.write_canon(b"\t");
}

if remove > 0 {
// As the lines get unindented, the selection should shift with them.
if y == selection_beg.y {
selection_beg.x -= remove;
}
if y == selection_end.y {
selection_end.x -= remove;
}

self.delete(CursorMovement::Grapheme, remove);
// As the lines get unindented, the selection should shift with them.
if y == selection_beg.y {
selection_beg.x += delta;
}
if y == selection_end.y {
selection_end.x += delta;
}
}
self.edit_end_grouping();
Expand All @@ -2362,6 +2351,45 @@ impl TextBuffer {
);
}

fn measure_indent_internal(
&self,
mut offset: usize,
max_columns: CoordType,
) -> (CoordType, CoordType) {
let mut chars = 0;
let mut columns = 0;

'outer: loop {
let chunk = self.read_forward(offset);
if chunk.is_empty() {
break;
}

for &c in chunk {
let next = match c {
b' ' => columns + 1,
b'\t' => columns + self.tab_size_eval(columns),
_ => break 'outer,
};
if next > max_columns {
break 'outer;
}
chars += 1;
columns = next;
}

offset += chunk.len();

// No need to do another round if we
// already got the exact right amount.
if columns >= max_columns {
break;
}
}

(chars, columns)
}

/// 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;
Expand Down
6 changes: 1 addition & 5 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2331,11 +2331,7 @@ impl<'a> Context<'a, '_> {
// If this is just a simple input field, don't consume Tab (= early return).
return false;
}
if modifiers == kbmod::SHIFT {
tb.unindent();
} else {
write = b"\t";
}
tb.indent_change(if modifiers == kbmod::SHIFT { -1 } else { 1 });
}
vk::RETURN => {
if single_line {
Expand Down