From 7b463ea6eb54eb5e98e67e77f033c266a575f3c2 Mon Sep 17 00:00:00 2001 From: Scott Hebert Date: Thu, 20 Aug 2026 09:55:56 -0700 Subject: [PATCH] fix: scroll offset_x by visual width, not char count, in focus() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit focus() compared the cursor's raw character-count column against the viewport width when deciding whether to scroll horizontally. For lines with double-width glyphs (CJK, emoji), character count under-estimates the true display width, so focus() could decide no scroll was needed while the cursor was actually off-screen — disagreeing with get_visible_cursor(), which already computes visual columns correctly via grapheme widths. focus() now computes cursor/offset_x visual columns the same way get_visible_cursor() does, and resolves scroll targets back to a char index via a new char_idx_for_visual_col() helper. Fixes #15 --- src/editor.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++----- tests/input.rs | 13 +++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index cec967a..9646e8c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -151,16 +151,34 @@ impl Editor { let line_number_width = self.get_line_number_width(); let line = self.code.char_to_line(self.cursor); - let col = self.cursor - self.code.line_to_char(line); + let line_start_char = self.code.line_to_char(line); + let line_len = self.code.line_len(line); + let col = self.cursor - line_start_char; let visible_width = width.saturating_sub(line_number_width); let visible_height = height; let step_size = 10; - if col < self.offset_x { - self.offset_x = col.saturating_sub(step_size); - } else if col >= self.offset_x + visible_width { - self.offset_x = col.saturating_sub(visible_width.saturating_sub(step_size)); + + let visual_col_of = |char_col: usize| -> usize { + let slice = self + .code + .char_slice(line_start_char, line_start_char + char_col.min(line_len)); + RopeGraphemes::new(&slice).map(grapheme_width).sum() + }; + + let cursor_visual_col = visual_col_of(col); + let offset_visual_col = visual_col_of(self.offset_x); + + if cursor_visual_col < offset_visual_col { + let target_visual_col = cursor_visual_col.saturating_sub(step_size); + self.offset_x = + Self::char_idx_for_visual_col(&self.code, line_start_char, line_len, target_visual_col); + } else if cursor_visual_col >= offset_visual_col + visible_width { + let target_visual_col = + cursor_visual_col.saturating_sub(visible_width.saturating_sub(step_size)); + self.offset_x = + Self::char_idx_for_visual_col(&self.code, line_start_char, line_len, target_visual_col); } let visual_line = self.visual_line_idx(line); @@ -175,6 +193,28 @@ impl Editor { } } + /// Finds the char index (relative to `line_start_char`) whose cumulative grapheme + /// display width first reaches `target_visual_col`, so it can be assigned to `offset_x`. + fn char_idx_for_visual_col( + code: &Code, + line_start_char: usize, + line_len: usize, + target_visual_col: usize, + ) -> usize { + let slice = code.char_slice(line_start_char, line_start_char + line_len); + let mut visual_col = 0; + let mut char_idx = 0; + for g in RopeGraphemes::new(&slice) { + if visual_col >= target_visual_col { + break; + } + let (g_width, g_chars) = grapheme_width_and_chars_len(g); + visual_col += g_width; + char_idx += g_chars; + } + char_idx + } + /// Handles a mouse button press at the given cursor position, updating selection and click state. pub fn handle_mouse_down(&mut self, cursor: usize) { let kind = self.clicks.register(cursor); diff --git a/tests/input.rs b/tests/input.rs index 7e13a06..e73ee1b 100644 --- a/tests/input.rs +++ b/tests/input.rs @@ -45,3 +45,16 @@ fn toggle_fold_at_cursor_toggles_cursor_line_fold() { editor.set_cursor(source.find("value").unwrap()); assert!(editor.get_visible_cursor(&area).is_none()); } + +#[test] +fn focus_scrolls_horizontally_for_wide_glyph_lines() { + let line: String = std::iter::repeat('界').take(20).collect(); + let mut editor = Editor::new("text", &line, vec![]).unwrap(); + let area = Rect::new(0, 0, 40, 10); + + editor.set_cursor(line.chars().count()); + editor.focus(&area); + + assert!(editor.get_offset_x() > 0); + assert!(editor.get_visible_cursor(&area).is_some()); +}