From 5cc6adfbd135239a317c52c03800872ab0673a3a Mon Sep 17 00:00:00 2001 From: Elijah Koulaxis Date: Fri, 3 Apr 2026 23:52:23 +0300 Subject: [PATCH 1/2] feat: unindent on backspace within leading whitespace --- crates/edit/src/buffer/mod.rs | 31 +++++++++++++++++++++++++++++++ crates/edit/src/tui.rs | 11 ++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index 4ec01f103a7..5e111ba5199 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -2472,6 +2472,37 @@ impl TextBuffer { self.set_selection(None); } + pub fn backspace_unindent(&mut self) -> bool { + if self.selection.is_some() || self.cursor.offset == 0 { + return false; + } + + let line_start = self.goto_line_start(self.cursor, self.cursor.logical_pos.y); + let (indent_chars, _) = self.measure_indent_internal(line_start.offset, CoordType::MAX); + + let chars_from_line_start = self.cursor.logical_pos.x; + if chars_from_line_start == 0 || chars_from_line_start > indent_chars { + return false; + } + + let cursor_column = self.cursor.column; + if cursor_column == 0 { + return false; + } + + let prev_column = self.tab_size_prev_column(cursor_column); + let (prev_chars, _) = self.measure_indent_internal(line_start.offset, prev_column); + + let chars_to_delete = chars_from_line_start - prev_chars; + if chars_to_delete <= 0 { + return false; + } + + self.delete(CursorMovement::Grapheme, -chars_to_delete); + + true + } + /// Returns the logical position of the first character on this line. /// Return `.x == 0` if there are no non-whitespace characters. pub fn indent_end_logical_pos(&self) -> Point { diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 314cdc30cc6..ad0887d3694 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -2365,12 +2365,13 @@ impl<'a> Context<'a, '_> { match key { vk::BACK => { - let granularity = if modifiers == kbmod::CTRL { - CursorMovement::Word + if modifiers == kbmod::CTRL { + tb.delete(CursorMovement::Word, -1); + } else if modifiers == kbmod::NONE && tb.backspace_unindent() { + return false; } else { - CursorMovement::Grapheme - }; - tb.delete(granularity, -1); + tb.delete(CursorMovement::Grapheme, -1); + } } vk::TAB => { if single_line { From 2a84ff4a45652475139bc98581e3001364a4e459 Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Fri, 18 Sep 2026 00:25:49 +0200 Subject: [PATCH 2/2] Simplify implementation --- crates/edit/src/buffer/mod.rs | 95 +++++++++++++++++++++-------------- crates/edit/src/tui.rs | 13 +++-- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index e0cf7380257..171b748e195 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -201,12 +201,14 @@ struct ActiveEditGroupInfo { } /// Char- or word-wise navigation? Your choice. +#[derive(Clone, Copy, PartialEq, Eq)] pub enum CursorMovement { Grapheme, Word, } /// See [`TextBuffer::move_selected_lines`]. +#[derive(Clone, Copy, PartialEq, Eq)] pub enum MoveLineDirection { Up, Down, @@ -2492,37 +2494,6 @@ impl TextBuffer { self.set_selection(None); } - pub fn backspace_unindent(&mut self) -> bool { - if self.selection.is_some() || self.cursor.offset == 0 { - return false; - } - - let line_start = self.goto_line_start(self.cursor, self.cursor.logical_pos.y); - let (indent_chars, _) = self.measure_indent_internal(line_start.offset, CoordType::MAX); - - let chars_from_line_start = self.cursor.logical_pos.x; - if chars_from_line_start == 0 || chars_from_line_start > indent_chars { - return false; - } - - let cursor_column = self.cursor.column; - if cursor_column == 0 { - return false; - } - - let prev_column = self.tab_size_prev_column(cursor_column); - let (prev_chars, _) = self.measure_indent_internal(line_start.offset, prev_column); - - let chars_to_delete = chars_from_line_start - prev_chars; - if chars_to_delete <= 0 { - return false; - } - - self.delete(CursorMovement::Grapheme, -chars_to_delete); - - true - } - /// Returns the logical position of the first character on this line. /// Return `.x == 0` if there are no non-whitespace characters. pub fn indent_end_logical_pos(&self) -> Point { @@ -2611,7 +2582,7 @@ impl TextBuffer { let mut chars = 0; let mut columns = 0; - 'outer: loop { + 'outer: while columns < max_columns { let chunk = self.read_forward(offset); if chunk.is_empty() { break; @@ -2631,15 +2602,65 @@ impl TextBuffer { } offset += chunk.len(); + } - // No need to do another round if we - // already got the exact right amount. - if columns >= max_columns { - break; + (chars, columns) + } + + /// This is basically the backspace operation, the way editors typically want it: + /// It unindents the line if the cursor is within the leading indentation. + pub fn backspace_with_auto_unindent(&mut self, granularity: CursorMovement) { + 'unindent: { + // If there's a selection backspace deletes it. + if self.selection.is_some() { + break 'unindent; + } + + // If we're at a line start backspace deletes the newline. + if self.cursor.logical_pos.x <= 0 { + break 'unindent; } + + let line_start = self.goto_line_start(self.cursor, self.cursor.logical_pos.y); + + // Determine the position of the new (reduced) indentation. + // For Backspace (Grapheme) it's one "tab", but for Ctrl+Backspace (Word) it's to the line start. + let prev_column = if granularity == CursorMovement::Grapheme { + self.tab_size_prev_column(self.cursor.column) + } else { + 0 // Ctrl+Backspace (Word) = line start + }; + let (from_pos, from_col) = self.measure_indent_internal(line_start.offset, prev_column); + + // Check if the cursor is within the leading indentation. + // This continues the measurement where we left off, so there's some extra arithmetic involved. + let (delta, _) = self.measure_indent_internal( + line_start.offset + from_pos as usize, + self.cursor.column - from_col, + ); + if delta + from_pos < self.cursor.logical_pos.x { + break 'unindent; + } + + // Here would technically just do `self.delete(CursorMovement::Grapheme, -delta);` + // but since we already got the `line_start`, etc., this is a bit more straightforward. + let to = self.cursor; + let from = if granularity == CursorMovement::Grapheme { + self.cursor_move_to_logical_internal( + line_start, + Point { x: from_pos, y: line_start.logical_pos.y }, + ) + } else { + line_start + }; + self.edit_begin(HistoryType::Delete, from); + self.edit_delete(to); + self.edit_end(); + return; } - (chars, columns) + // If we didn't perform an unindent, fall back to a regular backspace. + self.delete(granularity, -1); } /// Displaces the current, cursor or the selection, line(s) in the given direction. diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 5506655cfea..a32d56e4790 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -2418,12 +2418,15 @@ impl<'a> Context<'a, '_> { match key { vk::BACK => { - if modifiers == kbmod::CTRL { - tb.delete(CursorMovement::Word, -1); - } else if modifiers == kbmod::NONE && tb.backspace_unindent() { - return false; + let granularity = if modifiers == kbmod::CTRL { + CursorMovement::Word + } else { + CursorMovement::Grapheme + }; + if single_line { + tb.delete(granularity, -1); } else { - tb.delete(CursorMovement::Grapheme, -1); + tb.backspace_with_auto_unindent(granularity); } } vk::TAB => {