Skip to content

Commit e5fd921

Browse files
committed
fix(tui): preserve insert and visual unicode edits
Keep an opened trailing line navigable while insert mode is active so `o` at EOF keeps the cursor on the new line. Convert visual selections from grapheme positions to Rope character positions before deleting text.
1 parent a46f016 commit e5fd921

4 files changed

Lines changed: 67 additions & 10 deletions

File tree

src/editor.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,7 +1142,11 @@ impl Editor {
11421142

11431143
fn check_bounds(&mut self) -> bool {
11441144
let old_position = (self.cx, self.cy, self.vtop);
1145-
let last_line = self.last_navigable_line();
1145+
let last_line = if self.is_insert() {
1146+
self.current_buffer().len()
1147+
} else {
1148+
self.last_navigable_line()
1149+
};
11461150
let viewport_height = self.vheight().max(1);
11471151
let max_vtop = last_line.saturating_sub(viewport_height.saturating_sub(1));
11481152

@@ -3751,14 +3755,18 @@ impl Editor {
37513755

37523756
for y in y0..=y1 {
37533757
if let Some(line) = self.current_buffer().get(y) {
3754-
let line_len = line.trim_end_matches('\n').chars().count();
3758+
let line = line.trim_end_matches('\n');
3759+
let line_len = grapheme_len(line);
37553760
if min_x >= line_len {
37563761
continue;
37573762
}
3763+
let start = self.grapheme_to_char_on_line(min_x, y);
3764+
let end =
3765+
self.grapheme_to_char_on_line((max_x + 1).min(line_len), y);
37583766
self.replace_range(
37593767
TextRange::new(
3760-
TextPosition::new(y, min_x),
3761-
TextPosition::new(y, (max_x + 1).min(line_len)),
3768+
TextPosition::new(y, start),
3769+
TextPosition::new(y, end),
37623770
),
37633771
"",
37643772
);
@@ -3767,18 +3775,22 @@ impl Editor {
37673775
}
37683776
Mode::Visual => {
37693777
if y0 == y1 {
3778+
let start = self.grapheme_to_char_on_line(x0, y0);
3779+
let end = self.grapheme_to_char_on_line(x1 + 1, y0);
37703780
self.replace_range(
37713781
TextRange::new(
3772-
TextPosition::new(y0, x0),
3773-
TextPosition::new(y0, x1 + 1),
3782+
TextPosition::new(y0, start),
3783+
TextPosition::new(y0, end),
37743784
),
37753785
"",
37763786
);
37773787
} else {
3788+
let start = self.grapheme_to_char_on_line(x0, y0);
3789+
let end = self.grapheme_to_char_on_line(x1 + 1, y1);
37783790
self.replace_range(
37793791
TextRange::new(
3780-
TextPosition::new(y0, x0),
3781-
TextPosition::new(y1, x1 + 1),
3792+
TextPosition::new(y0, start),
3793+
TextPosition::new(y1, end),
37823794
),
37833795
"",
37843796
);

src/editor/rendering.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ impl Editor {
109109
);
110110

111111
// Render the gutter for this window
112-
self.render_gutter_in_window(buffer, &window)?;
112+
self.render_gutter_in_window(buffer, &window, window_id)?;
113113

114114
// Render the window content with proper boundaries
115115
self.render_main_content_in_window(buffer, &window)?;
@@ -1157,6 +1157,7 @@ impl Editor {
11571157
&mut self,
11581158
buffer: &mut RenderBuffer,
11591159
window: &crate::window::Window,
1160+
window_id: usize,
11601161
) -> anyhow::Result<()> {
11611162
use crate::log;
11621163
let width = self.gutter_width_for_window(window);
@@ -1175,7 +1176,10 @@ impl Editor {
11751176

11761177
for y in 0..window.inner_height() {
11771178
let line_number = y + 1 + window.vtop;
1178-
let line_count = window_buffer.navigable_line_count();
1179+
let mut line_count = window_buffer.navigable_line_count();
1180+
if self.window_manager.active_window_id() == window_id && self.is_insert() {
1181+
line_count = line_count.max(window.vtop + window.cy + 1);
1182+
}
11791183
let text = if line_number <= line_count {
11801184
format!("{:>width$} ", line_number)
11811185
} else {

tests/common/editor_harness.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,22 @@ mod tests {
435435
assert_eq!(third_row.chars().take(3).collect::<String>(), " ");
436436
}
437437

438+
#[tokio::test]
439+
async fn test_insert_mode_gutter_keeps_opened_trailing_line_visible() {
440+
let mut harness = EditorHarness::with_content("Line 1");
441+
442+
harness
443+
.execute_action(Action::InsertLineBelowCursor)
444+
.await
445+
.unwrap();
446+
447+
harness.assert_mode(Mode::Insert);
448+
harness.assert_cursor_at(0, 1);
449+
450+
let second_row = harness.render_row(1).unwrap();
451+
assert_eq!(second_row.chars().take(3).collect::<String>(), " 2 ");
452+
}
453+
438454
#[test]
439455
fn test_search_commandline_renders_search_text_on_small_width() {
440456
let mut harness = EditorHarness::new();

tests/unicode.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,31 @@ async fn test_family_emoji_zwj_sequence() {
162162
assert_eq!(line, "\n", "Entire emoji sequence should be deleted");
163163
}
164164

165+
#[tokio::test]
166+
async fn test_visual_delete_removes_entire_zwj_grapheme() {
167+
let mut h = EditorHarness::with_content("👨‍👩‍👧‍👦 family");
168+
169+
h.execute_action(Action::EnterMode(Mode::Visual))
170+
.await
171+
.unwrap();
172+
h.execute_action(Action::Delete).await.unwrap();
173+
174+
h.assert_buffer_contents(" family");
175+
}
176+
177+
#[tokio::test]
178+
async fn test_visual_block_delete_removes_entire_combining_grapheme() {
179+
let mut h = EditorHarness::with_content("e\u{301}x\ne\u{301}y");
180+
181+
h.execute_action(Action::EnterMode(Mode::VisualBlock))
182+
.await
183+
.unwrap();
184+
h.execute_action(Action::MoveDown).await.unwrap();
185+
h.execute_action(Action::Delete).await.unwrap();
186+
187+
h.assert_buffer_contents("x\ny");
188+
}
189+
165190
#[tokio::test]
166191
async fn test_flag_emoji() {
167192
let mut h = EditorHarness::new();

0 commit comments

Comments
 (0)