Skip to content

Commit 927f538

Browse files
authored
fix: caret scroll on text overflows (#19)
1 parent 092331e commit 927f538

File tree

5 files changed

+162
-59
lines changed

5 files changed

+162
-59
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "protextinator"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
edition = "2021"
55
description = "Text management, made simple"
66
keywords = ["text", "rendering", "gui", "graphics", "image"]

src/buffer_utils.rs

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub(crate) fn vertical_offset(
2727
}
2828
}
2929

30-
/// Ensures the caret is vertically visible by adjusting buffer scroll using DEVICE pixels.
30+
/// Ensures the caret is vertically visible by adjusting the buffer scroll using DEVICE pixels.
3131
/// Returns caret top-left in LOGICAL pixels relative to the viewport.
3232
pub(crate) fn adjust_vertical_scroll_to_make_caret_visible(
3333
buffer: &mut Buffer,
@@ -37,15 +37,21 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible(
3737
style: &TextStyle,
3838
scale_factor: f32,
3939
) -> Option<Point> {
40-
let mut editor = Editor::new(&mut *buffer);
41-
editor.set_cursor(current_char_byte_cursor.cursor);
42-
43-
let caret_position = editor.cursor_position();
40+
let mut caret_position =
41+
cursor_position_with_trailing_space_fallback(&mut *buffer, current_char_byte_cursor);
42+
43+
if caret_position.is_none() {
44+
let mut editor = Editor::new(&mut *buffer);
45+
editor.set_cursor(current_char_byte_cursor.cursor);
46+
editor.shape_as_needed(font_system, false);
47+
caret_position =
48+
cursor_position_with_trailing_space_fallback(&mut *buffer, current_char_byte_cursor);
49+
}
4450

4551
match caret_position {
4652
Some(position) => {
47-
// caret position from cosmic_text is in DEVICE pixels
48-
let mut caret_top_left_corner = Point::from(position);
53+
// caret position is in DEVICE pixels
54+
let mut caret_top_left_corner = position;
4955
let mut scroll = buffer.scroll();
5056
let scale = scale_factor.max(0.01);
5157
let line_height_device = style.line_height_pt() * scale;
@@ -68,38 +74,63 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible(
6874
caret_top_left_corner.y / scale,
6975
))
7076
}
71-
None => {
72-
// Caret is not visible, we need to shape the text and move the scroll
73-
editor.shape_as_needed(font_system, false);
74-
75-
// TODO: Let's keep it the code below for a little while, it might be useful in the
76-
// future.
77-
78-
// If it's not visible, and the scroll is already at the top, that means that we're
79-
// at the end of the text, and we need to scroll to the bottom to avoid jumping to
80-
// the top of the text.
81-
// if style.vertical_alignment == VerticalTextAlignment::End {
82-
// editor.with_buffer_mut(|buffer| {
83-
// let mut scroll = buffer.scroll();
84-
// if scroll.vertical == 0.0 && buffer_inner_dimensions.y < text_area_size.y {
85-
// let vertical_scroll_to_align_text = calculate_vertical_offset(
86-
// style,
87-
// text_area_size,
88-
// buffer_inner_dimensions,
89-
// );
90-
// scroll.vertical = vertical_scroll_to_align_text;
91-
// buffer.set_scroll(scroll);
92-
// }
93-
// });
94-
// }
95-
// Return caret position in LOGICAL pixels
96-
editor.cursor_position().map(|p| {
97-
let p = Point::from(p);
98-
let scale = scale_factor.max(0.01);
99-
Point::new(p.x / scale, p.y / scale)
100-
})
77+
None => None,
78+
}
79+
}
80+
81+
pub(crate) fn cursor_position_with_trailing_space_fallback(
82+
buffer: &mut Buffer,
83+
current_char_byte_cursor: ByteCursor,
84+
) -> Option<Point> {
85+
let cursor = current_char_byte_cursor.cursor;
86+
let mut caret_position = {
87+
let mut editor = Editor::new(&mut *buffer);
88+
editor.set_cursor(cursor);
89+
editor.cursor_position().map(Point::from)?
90+
};
91+
92+
if let Some(run_line_width) = run_line_width_for_cursor_with_matching_vertical_position(
93+
&*buffer,
94+
cursor,
95+
caret_position.y,
96+
) {
97+
if run_line_width > caret_position.x {
98+
caret_position.x = run_line_width;
99+
}
100+
}
101+
102+
Some(caret_position)
103+
}
104+
105+
fn run_line_width_for_cursor_with_matching_vertical_position(
106+
buffer: &Buffer,
107+
cursor: Cursor,
108+
cursor_y_position: f32,
109+
) -> Option<f32> {
110+
for run in buffer.layout_runs() {
111+
if run.line_i != cursor.line {
112+
continue;
113+
}
114+
115+
let cursor_is_at_line_end = cursor.index == run.text.len();
116+
let line_has_trailing_whitespace = run
117+
.text
118+
.chars()
119+
.last()
120+
.map(|character| character.is_whitespace())
121+
.unwrap_or(false);
122+
123+
if !cursor_is_at_line_end || !line_has_trailing_whitespace {
124+
continue;
125+
}
126+
127+
let same_visual_line = (run.line_top - cursor_y_position).abs() <= 1.0;
128+
if same_visual_line {
129+
return Some(run.line_w);
101130
}
102131
}
132+
133+
None
103134
}
104135

105136
/// Hit-test a character under a LOGICAL pixel coordinate, accounting for scroll and scale.

src/state.rs

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
66
use crate::action::{Action, ActionResult};
77
use crate::buffer_utils::{
8-
adjust_vertical_scroll_to_make_caret_visible, char_under_position, update_buffer,
9-
vertical_offset,
8+
adjust_vertical_scroll_to_make_caret_visible, char_under_position,
9+
cursor_position_with_trailing_space_fallback, update_buffer, vertical_offset,
1010
};
1111
use crate::byte_cursor::ByteCursor;
1212
use crate::math::Size;
@@ -958,17 +958,15 @@ impl<T> TextState<T> {
958958
// Return caret position in LOGICAL pixels relative to viewport
959959
let horizontal_scroll_device = self.buffer.scroll().horizontal;
960960
let scale = self.params.scale_factor().max(0.01);
961-
let mut editor = Editor::new(&mut self.buffer);
962-
editor.set_cursor(self.cursor.cursor);
963-
964-
editor.cursor_position().map(|pos| {
965-
// pos from cosmic_text is in DEVICE pixels
966-
let mut point_device = Point::from(pos);
967-
// Adjust by horizontal scroll (device px)
968-
point_device.x -= horizontal_scroll_device;
969-
// Convert to logical
970-
Point::new(point_device.x / scale, point_device.y / scale)
971-
})
961+
cursor_position_with_trailing_space_fallback(&mut self.buffer, self.cursor).map(
962+
|mut point_device| {
963+
// pos from cosmic_text is in DEVICE pixels
964+
// Adjust by horizontal scroll (device px)
965+
point_device.x -= horizontal_scroll_device;
966+
// Convert to logical
967+
Point::new(point_device.x / scale, point_device.y / scale)
968+
},
969+
)
972970
}
973971

974972
fn align_vertically(&mut self) {
@@ -1159,23 +1157,61 @@ impl<T> TextState<T> {
11591157
let base_color = cosmic_text::Color::rgba(0, 0, 0, 0);
11601158
let text_width = width;
11611159
let text_height = height;
1160+
let horizontal_scroll_device = self.buffer.scroll().horizontal.round() as i64;
11621161
// TODO: make an atlas via an adapter trait or something that can be passed to here from the renderer
11631162
self.buffer.draw(
11641163
&mut ctx.font_system,
11651164
&mut ctx.swash_cache,
11661165
base_color,
11671166
|x, y, mut w, mut h, color| {
1168-
// Clip to buffer bounds
1169-
let (x0, y0) = ((x as u32).min(text_width), (y as u32).min(text_height));
1170-
if x0 >= text_width || y0 >= text_height || w == 0 || h == 0 {
1167+
if w == 0 || h == 0 {
11711168
return;
11721169
}
1173-
if x0 + w > text_width {
1174-
w = text_width - x0;
1170+
1171+
// Use signed clipping first because scrolled glyphs can produce negative device
1172+
// coordinates. Casting negatives to unsigned would incorrectly wrap and skip
1173+
// visible glyph portions near the viewport edge.
1174+
// Cosmic-text horizontal scroll is not reflected in draw callback coordinates,
1175+
// so apply it explicitly here to keep rasterized output in sync with the buffer.
1176+
let mut x_device = x as i64 - horizontal_scroll_device;
1177+
let mut y_device = y as i64;
1178+
let mut width_device = w as i64;
1179+
let mut height_device = h as i64;
1180+
1181+
if x_device < 0 {
1182+
let cut = -x_device;
1183+
if cut >= width_device {
1184+
return;
1185+
}
1186+
x_device = 0;
1187+
width_device -= cut;
1188+
}
1189+
if y_device < 0 {
1190+
let cut = -y_device;
1191+
if cut >= height_device {
1192+
return;
1193+
}
1194+
y_device = 0;
1195+
height_device -= cut;
1196+
}
1197+
1198+
if x_device >= text_width as i64 || y_device >= text_height as i64 {
1199+
return;
11751200
}
1176-
if y0 + h > text_height {
1177-
h = text_height - y0;
1201+
let max_width = text_width as i64 - x_device;
1202+
let max_height = text_height as i64 - y_device;
1203+
width_device = width_device.min(max_width);
1204+
height_device = height_device.min(max_height);
1205+
1206+
if width_device <= 0 || height_device <= 0 {
1207+
return;
11781208
}
1209+
1210+
let x0 = x_device as u32;
1211+
let y0 = y_device as u32;
1212+
w = width_device as u32;
1213+
h = height_device as u32;
1214+
11791215
// Precompute the 4-byte pixel once per rectangle and use row-wise fills
11801216
let mut packed_px = [0u8; 4];
11811217
match alpha_mode {

src/tests/caret_positioning.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,39 @@ pub fn test_insert_newline_at_end_of_text() {
233233
assert_eq!(text_state.text(), "Hello\n\n");
234234
assert_eq!(text_state.cursor_char_index(), Some(6));
235235
}
236+
237+
#[test]
238+
pub fn test_click_after_text_then_insert_keeps_caret_moving_forward() {
239+
let mut ctx = TextContext::default();
240+
let initial_text = "Hehe".to_string();
241+
242+
let mut text_state = TextState::new_with_text(initial_text, &mut ctx.font_system, ());
243+
text_state.set_outer_size(&Point::from((200.0, 25.0)));
244+
text_state.is_editable = true;
245+
text_state.is_editing = true;
246+
text_state.is_selectable = true;
247+
text_state.are_actions_enabled = true;
248+
text_state.recalculate(&mut ctx);
249+
250+
// Click in trailing empty space to place caret at the end.
251+
text_state.handle_press(&mut ctx, Point::new(198.0, 10.0));
252+
assert_eq!(text_state.cursor_char_index(), Some(4));
253+
let caret_x_before_insert = text_state
254+
.caret_position_relative()
255+
.expect("Caret should be visible")
256+
.x;
257+
258+
let result = text_state.apply_action(&mut ctx, &Action::InsertChar("x".into()));
259+
assert!(matches!(result, ActionResult::TextChanged));
260+
assert_eq!(text_state.text(), "Hehex");
261+
assert_eq!(text_state.cursor_char_index(), Some(5));
262+
263+
let caret_x_after_insert = text_state
264+
.caret_position_relative()
265+
.expect("Caret should be visible")
266+
.x;
267+
assert!(
268+
caret_x_after_insert > caret_x_before_insert,
269+
"Caret should move to the right after inserting at end. before={caret_x_before_insert}, after={caret_x_after_insert}"
270+
);
271+
}

0 commit comments

Comments
 (0)