Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
105 changes: 68 additions & 37 deletions src/buffer_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,15 +37,21 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible(
style: &TextStyle,
scale_factor: f32,
) -> Option<Point> {
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;
Expand All @@ -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<Point> {
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<f32> {
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.
Expand Down
76 changes: 56 additions & 20 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -958,17 +958,15 @@ impl<T> TextState<T> {
// 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) {
Expand Down Expand Up @@ -1159,23 +1157,61 @@ impl<T> TextState<T> {
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 {
Expand Down
36 changes: 36 additions & 0 deletions src/tests/caret_positioning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Loading