From 8006b2a8b9b5eb679184ddaee902038ad19abd65 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sun, 1 Mar 2026 16:14:16 -0300 Subject: [PATCH 1/2] fix: carret scroll on overflows --- src/buffer_utils.rs | 105 +++++++++++++++++++++------------ src/state.rs | 76 +++++++++++++++++------- src/tests/caret_positioning.rs | 36 +++++++++++ 3 files changed, 160 insertions(+), 57 deletions(-) diff --git a/src/buffer_utils.rs b/src/buffer_utils.rs index 4557425..c86b961 100644 --- a/src/buffer_utils.rs +++ b/src/buffer_utils.rs @@ -27,7 +27,7 @@ pub(crate) fn vertical_offset( } } -/// Ensures the caret is vertically visible by adjusting buffer scroll using DEVICE pixels. +/// Ensures the caret is vertically visible by adjusting the buffer scroll using DEVICE pixels. /// Returns caret top-left in LOGICAL pixels relative to the viewport. pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( buffer: &mut Buffer, @@ -37,15 +37,21 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( style: &TextStyle, scale_factor: f32, ) -> Option { - let mut editor = Editor::new(&mut *buffer); - editor.set_cursor(current_char_byte_cursor.cursor); - - let caret_position = editor.cursor_position(); + let mut caret_position = + cursor_position_with_trailing_space_fallback(&mut *buffer, current_char_byte_cursor); + + if caret_position.is_none() { + let mut editor = Editor::new(&mut *buffer); + editor.set_cursor(current_char_byte_cursor.cursor); + editor.shape_as_needed(font_system, false); + caret_position = + cursor_position_with_trailing_space_fallback(&mut *buffer, current_char_byte_cursor); + } match caret_position { Some(position) => { - // caret position from cosmic_text is in DEVICE pixels - let mut caret_top_left_corner = Point::from(position); + // caret position is in DEVICE pixels + let mut caret_top_left_corner = position; let mut scroll = buffer.scroll(); let scale = scale_factor.max(0.01); let line_height_device = style.line_height_pt() * scale; @@ -68,38 +74,63 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( caret_top_left_corner.y / scale, )) } - None => { - // Caret is not visible, we need to shape the text and move the scroll - editor.shape_as_needed(font_system, false); - - // TODO: Let's keep it the code below for a little while, it might be useful in the - // future. - - // If it's not visible, and the scroll is already at the top, that means that we're - // at the end of the text, and we need to scroll to the bottom to avoid jumping to - // the top of the text. - // if style.vertical_alignment == VerticalTextAlignment::End { - // editor.with_buffer_mut(|buffer| { - // let mut scroll = buffer.scroll(); - // if scroll.vertical == 0.0 && buffer_inner_dimensions.y < text_area_size.y { - // let vertical_scroll_to_align_text = calculate_vertical_offset( - // style, - // text_area_size, - // buffer_inner_dimensions, - // ); - // scroll.vertical = vertical_scroll_to_align_text; - // buffer.set_scroll(scroll); - // } - // }); - // } - // Return caret position in LOGICAL pixels - editor.cursor_position().map(|p| { - let p = Point::from(p); - let scale = scale_factor.max(0.01); - Point::new(p.x / scale, p.y / scale) - }) + None => None, + } +} + +pub(crate) fn cursor_position_with_trailing_space_fallback( + buffer: &mut Buffer, + current_char_byte_cursor: ByteCursor, +) -> Option { + let cursor = current_char_byte_cursor.cursor; + let mut caret_position = { + let mut editor = Editor::new(&mut *buffer); + editor.set_cursor(cursor); + editor.cursor_position().map(Point::from)? + }; + + if let Some(run_line_width) = run_line_width_for_cursor_with_matching_vertical_position( + &*buffer, + cursor, + caret_position.y, + ) { + if run_line_width > caret_position.x { + caret_position.x = run_line_width; + } + } + + Some(caret_position) +} + +fn run_line_width_for_cursor_with_matching_vertical_position( + buffer: &Buffer, + cursor: Cursor, + cursor_y_position: f32, +) -> Option { + for run in buffer.layout_runs() { + if run.line_i != cursor.line { + continue; + } + + let cursor_is_at_line_end = cursor.index == run.text.len(); + let line_has_trailing_whitespace = run + .text + .chars() + .last() + .map(|character| character.is_whitespace()) + .unwrap_or(false); + + if !cursor_is_at_line_end || !line_has_trailing_whitespace { + continue; + } + + let same_visual_line = (run.line_top - cursor_y_position).abs() <= 1.0; + if same_visual_line { + return Some(run.line_w); } } + + None } /// Hit-test a character under a LOGICAL pixel coordinate, accounting for scroll and scale. diff --git a/src/state.rs b/src/state.rs index c0bfb8f..8fb2531 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,8 +5,8 @@ use crate::action::{Action, ActionResult}; use crate::buffer_utils::{ - adjust_vertical_scroll_to_make_caret_visible, char_under_position, update_buffer, - vertical_offset, + adjust_vertical_scroll_to_make_caret_visible, char_under_position, + cursor_position_with_trailing_space_fallback, update_buffer, vertical_offset, }; use crate::byte_cursor::ByteCursor; use crate::math::Size; @@ -958,17 +958,15 @@ impl TextState { // Return caret position in LOGICAL pixels relative to viewport let horizontal_scroll_device = self.buffer.scroll().horizontal; let scale = self.params.scale_factor().max(0.01); - let mut editor = Editor::new(&mut self.buffer); - editor.set_cursor(self.cursor.cursor); - - editor.cursor_position().map(|pos| { - // pos from cosmic_text is in DEVICE pixels - let mut point_device = Point::from(pos); - // Adjust by horizontal scroll (device px) - point_device.x -= horizontal_scroll_device; - // Convert to logical - Point::new(point_device.x / scale, point_device.y / scale) - }) + cursor_position_with_trailing_space_fallback(&mut self.buffer, self.cursor).map( + |mut point_device| { + // pos from cosmic_text is in DEVICE pixels + // Adjust by horizontal scroll (device px) + point_device.x -= horizontal_scroll_device; + // Convert to logical + Point::new(point_device.x / scale, point_device.y / scale) + }, + ) } fn align_vertically(&mut self) { @@ -1159,23 +1157,61 @@ impl TextState { let base_color = cosmic_text::Color::rgba(0, 0, 0, 0); let text_width = width; let text_height = height; + let horizontal_scroll_device = self.buffer.scroll().horizontal.round() as i64; // TODO: make an atlas via an adapter trait or something that can be passed to here from the renderer self.buffer.draw( &mut ctx.font_system, &mut ctx.swash_cache, base_color, |x, y, mut w, mut h, color| { - // Clip to buffer bounds - let (x0, y0) = ((x as u32).min(text_width), (y as u32).min(text_height)); - if x0 >= text_width || y0 >= text_height || w == 0 || h == 0 { + if w == 0 || h == 0 { return; } - if x0 + w > text_width { - w = text_width - x0; + + // Use signed clipping first because scrolled glyphs can produce negative device + // coordinates. Casting negatives to unsigned would incorrectly wrap and skip + // visible glyph portions near the viewport edge. + // Cosmic-text horizontal scroll is not reflected in draw callback coordinates, + // so apply it explicitly here to keep rasterized output in sync with the buffer. + let mut x_device = x as i64 - horizontal_scroll_device; + let mut y_device = y as i64; + let mut width_device = w as i64; + let mut height_device = h as i64; + + if x_device < 0 { + let cut = -x_device; + if cut >= width_device { + return; + } + x_device = 0; + width_device -= cut; + } + if y_device < 0 { + let cut = -y_device; + if cut >= height_device { + return; + } + y_device = 0; + height_device -= cut; + } + + if x_device >= text_width as i64 || y_device >= text_height as i64 { + return; } - if y0 + h > text_height { - h = text_height - y0; + let max_width = text_width as i64 - x_device; + let max_height = text_height as i64 - y_device; + width_device = width_device.min(max_width); + height_device = height_device.min(max_height); + + if width_device <= 0 || height_device <= 0 { + return; } + + let x0 = x_device as u32; + let y0 = y_device as u32; + w = width_device as u32; + h = height_device as u32; + // Precompute the 4-byte pixel once per rectangle and use row-wise fills let mut packed_px = [0u8; 4]; match alpha_mode { diff --git a/src/tests/caret_positioning.rs b/src/tests/caret_positioning.rs index 318084d..d67cae4 100644 --- a/src/tests/caret_positioning.rs +++ b/src/tests/caret_positioning.rs @@ -233,3 +233,39 @@ pub fn test_insert_newline_at_end_of_text() { assert_eq!(text_state.text(), "Hello\n\n"); assert_eq!(text_state.cursor_char_index(), Some(6)); } + +#[test] +pub fn test_click_after_text_then_insert_keeps_caret_moving_forward() { + let mut ctx = TextContext::default(); + let initial_text = "Hehe".to_string(); + + let mut text_state = TextState::new_with_text(initial_text, &mut ctx.font_system, ()); + text_state.set_outer_size(&Point::from((200.0, 25.0))); + text_state.is_editable = true; + text_state.is_editing = true; + text_state.is_selectable = true; + text_state.are_actions_enabled = true; + text_state.recalculate(&mut ctx); + + // Click in trailing empty space to place caret at the end. + text_state.handle_press(&mut ctx, Point::new(198.0, 10.0)); + assert_eq!(text_state.cursor_char_index(), Some(4)); + let caret_x_before_insert = text_state + .caret_position_relative() + .expect("Caret should be visible") + .x; + + let result = text_state.apply_action(&mut ctx, &Action::InsertChar("x".into())); + assert!(matches!(result, ActionResult::TextChanged)); + assert_eq!(text_state.text(), "Hehex"); + assert_eq!(text_state.cursor_char_index(), Some(5)); + + let caret_x_after_insert = text_state + .caret_position_relative() + .expect("Caret should be visible") + .x; + assert!( + caret_x_after_insert > caret_x_before_insert, + "Caret should move to the right after inserting at end. before={caret_x_before_insert}, after={caret_x_after_insert}" + ); +} From 0e1d222a4307434d8cd4ea11467b84b6fa4794f5 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sun, 1 Mar 2026 16:16:06 -0300 Subject: [PATCH 2/2] bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5146dba..ca24520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1697,7 +1697,7 @@ checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" [[package]] name = "protextinator" -version = "0.5.0" +version = "0.5.1" dependencies = [ "ahash", "cosmic-text 0.16.0", diff --git a/Cargo.toml b/Cargo.toml index e37e5dc..b40c469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "protextinator" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "Text management, made simple" keywords = ["text", "rendering", "gui", "graphics", "image"]