Skip to content

Commit a911ba8

Browse files
authored
fix: scroll on text deletion and scroll on text alignment (#11)
1 parent a56adf9 commit a911ba8

File tree

8 files changed

+1258
-96
lines changed

8 files changed

+1258
-96
lines changed

src/action.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ pub enum Action {
2121
SelectAll,
2222
/// Delete the character before the cursor (backspace).
2323
DeleteBackward,
24-
/// Insert whitespace at the cursor position.
25-
InsertWhitespace,
2624
/// Move the cursor one position to the right.
2725
MoveCursorRight,
2826
/// Move the cursor one position to the left.

src/buffer_utils.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ pub(crate) fn update_buffer(
111111
let text_area_size = params.size();
112112
let font_family = &text_style.font_family;
113113
let metadata = params.metadata();
114+
let old_scroll = buffer.scroll();
114115

115116
buffer.set_metrics(font_system, params.metrics());
116117
buffer.set_wrap(font_system, wrap.into());
@@ -133,7 +134,7 @@ pub(crate) fn update_buffer(
133134
let mut buffer_measurement = Size::default();
134135
for line in buffer.lines.iter_mut() {
135136
line.set_align(horizontal_alignment.into());
136-
for line in line
137+
for layout_line in line
137138
.layout(
138139
font_system,
139140
text_style.font_size.value(),
@@ -145,10 +146,32 @@ pub(crate) fn update_buffer(
145146
)
146147
.iter()
147148
{
148-
buffer_measurement.y += line.line_height_opt.unwrap_or(text_style.line_height_pt());
149-
buffer_measurement.x = buffer_measurement.x.max(line.w);
149+
buffer_measurement.y += layout_line
150+
.line_height_opt
151+
.unwrap_or(text_style.line_height_pt());
152+
buffer_measurement.x = buffer_measurement.x.max(layout_line.w);
150153
}
151154
}
152155

156+
if buffer_measurement.x > text_area_size.x {
157+
// If the buffer is smaller than the text area, we need to set the width to the text area
158+
// size to ensure that the text is centered.
159+
// After we've measured the buffer, we need to run layout() again to realign the lines
160+
for line in buffer.lines.iter_mut() {
161+
line.reset_layout();
162+
line.set_align(horizontal_alignment.into());
163+
line.layout(
164+
font_system,
165+
text_style.font_size.value(),
166+
Some(buffer_measurement.x),
167+
text_style.wrap.unwrap_or_default().into(),
168+
None,
169+
// TODO: what is the default tab width? Make it configurable?
170+
2,
171+
);
172+
}
173+
}
174+
175+
buffer.set_scroll(old_scroll);
153176
buffer_measurement
154177
}

src/math.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! This module provides fundamental geometric types used throughout the text system,
44
//! including points, rectangles, and size representations.
55
6+
use std::ops::Sub;
7+
68
/// A 2D rectangle defined by minimum and maximum points.
79
///
810
/// Rectangles are used to define text areas, selection bounds, and other
@@ -167,6 +169,30 @@ impl Point {
167169
}
168170
}
169171

172+
impl Sub for Point {
173+
type Output = Point;
174+
175+
/// Subtracts two points, resulting in a new point that represents the vector
176+
/// from the second point to the first.
177+
///
178+
/// # Examples
179+
/// ```
180+
/// use protextinator::math::Point;
181+
///
182+
/// let p1 = Point::new(10.0, 20.0);
183+
/// let p2 = Point::new(5.0, 15.0);
184+
///
185+
/// let result = p1 - p2;
186+
/// assert_eq!(result, Point::new(5.0, 5.0));
187+
/// ```
188+
fn sub(self, other: Self) -> Self::Output {
189+
Point {
190+
x: self.x - other.x,
191+
y: self.y - other.y,
192+
}
193+
}
194+
}
195+
170196
/// Type alias for [`Point`] when used to represent dimensions (width, height).
171197
///
172198
/// While functionally identical to `Point`, this type alias provides semantic clarity

src/state.rs

Lines changed: 54 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,11 @@ impl<T> TextState<T> {
739739
}
740740
}
741741

742-
/// Sets the absolute scroll position of the text buffer.
742+
/// Sets the absolute scroll position of the text buffer. Note that text that has fixed
743+
/// alignment (e.g. `VerticalTextAlignment::Top`) will not be affected by this method,
744+
/// and the scroll position will be calculated based on the current text layout and line
745+
/// heights. For the scroll to take effect, alignment must be set to
746+
/// `VerticalTextAlignment::None`.
743747
///
744748
/// This allows you to programmatically scroll the text content to a specific position.
745749
/// The scroll position is calculated based on line heights and text layout.
@@ -758,34 +762,39 @@ impl<T> TextState<T> {
758762
pub fn set_absolute_scroll(&mut self, scroll: Point) {
759763
let mut new_scroll = self.buffer.scroll();
760764

765+
let can_scroll_vertically =
766+
matches!(self.style().vertical_alignment, VerticalTextAlignment::None);
767+
761768
new_scroll.horizontal = scroll.x;
762769

763-
let line_height = self.style().line_height_pt();
764-
let mut line_index = 0;
765-
let mut accumulated_height = 0.0;
770+
if can_scroll_vertically {
771+
let line_height = self.style().line_height_pt();
772+
let mut line_index = 0;
773+
let mut accumulated_height = 0.0;
766774

767-
for (i, line) in self.buffer.lines.iter().enumerate() {
768-
let mut line_height_total = 0.0;
775+
for (i, line) in self.buffer.lines.iter().enumerate() {
776+
let mut line_height_total = 0.0;
769777

770-
if let Some(layout_lines) = line.layout_opt() {
771-
for layout_line in layout_lines {
772-
line_height_total += layout_line.line_height_opt.unwrap_or(line_height);
778+
if let Some(layout_lines) = line.layout_opt() {
779+
for layout_line in layout_lines {
780+
line_height_total += layout_line.line_height_opt.unwrap_or(line_height);
781+
}
773782
}
774-
}
775783

776-
if accumulated_height + line_height_total > scroll.y {
777-
line_index = i;
778-
break;
784+
if accumulated_height + line_height_total > scroll.y {
785+
line_index = i;
786+
break;
787+
}
788+
789+
accumulated_height += line_height_total;
790+
line_index = i + 1; // In case we don't break, this will be the last line
779791
}
780792

781-
accumulated_height += line_height_total;
782-
line_index = i + 1; // In case we don't break, this will be the last line
793+
// Set the line and calculate the remaining vertical offset
794+
new_scroll.line = line_index;
795+
new_scroll.vertical = scroll.y - accumulated_height;
783796
}
784797

785-
// Set the line and calculate the remaining vertical offset
786-
new_scroll.line = line_index;
787-
new_scroll.vertical = scroll.y - accumulated_height;
788-
789798
self.buffer.set_scroll(new_scroll);
790799
}
791800

@@ -899,6 +908,8 @@ impl<T> TextState<T> {
899908
if update_reason.is_cursor_updated() {
900909
let text_area_size = self.params.size();
901910
let old_scroll = self.buffer.scroll();
911+
let old_relative_caret_x = self.relative_caret_position.map_or(0.0, |p| p.x);
912+
let old_absolute_caret_x = old_relative_caret_x + old_scroll.horizontal;
902913

903914
let caret_position_relative_to_buffer = adjust_vertical_scroll_to_make_caret_visible(
904915
&mut self.buffer,
@@ -908,17 +919,13 @@ impl<T> TextState<T> {
908919
self.params.style(),
909920
)?;
910921
let mut new_scroll = self.buffer.scroll();
911-
912-
let current_relative_caret_offset = caret_position_relative_to_buffer.x;
913-
914922
let text_area_width = text_area_size.x;
915923

916924
// TODO: there was some other implementation that took horizontal alignment into account,
917925
// check if it is needed
918926
let new_absolute_caret_offset = caret_position_relative_to_buffer.x;
919927

920928
// TODO: A little hack to set horizontal scroll
921-
922929
let current_absolute_visible_text_area = (
923930
old_scroll.horizontal,
924931
old_scroll.horizontal + text_area_width,
@@ -928,23 +935,32 @@ impl<T> TextState<T> {
928935
let is_new_caret_visible =
929936
new_absolute_caret_offset >= min && new_absolute_caret_offset <= max;
930937

931-
// If caret is within the visible text area, we don't need to scroll.
938+
// If the caret is within the visible text area, we don't need to scroll.
932939
// In that case, we should return the old scroll and modify the caret offset
933940
if is_new_caret_visible {
934-
let should_update_horizontal_scroll = self.should_update_horizontal_scroll(
935-
text_area_width,
936-
current_relative_caret_offset,
937-
new_absolute_caret_offset,
938-
old_scroll.horizontal,
939-
);
940-
941-
let is_moving_caret = matches!(update_reason, UpdateReason::MoveCaret);
942-
943-
if should_update_horizontal_scroll && !is_moving_caret {
944-
new_scroll.horizontal =
945-
new_absolute_caret_offset - current_relative_caret_offset;
946-
} else {
947-
new_scroll.horizontal = old_scroll.horizontal;
941+
let is_moving_caret_without_updating_the_text =
942+
matches!(update_reason, UpdateReason::MoveCaret);
943+
if !is_moving_caret_without_updating_the_text {
944+
let text_shift = old_absolute_caret_x - new_absolute_caret_offset;
945+
946+
// If a text was deleted (caret moved left), adjust the scroll to compensate
947+
if text_shift > 0.0 {
948+
// Adjust scroll to keep the caret visually in the same position
949+
new_scroll.horizontal = (old_scroll.horizontal - text_shift).max(0.0);
950+
951+
// Ensure we don't scroll beyond the text boundaries
952+
let inner_dimensions = self.inner_size();
953+
let area_width = self.outer_size().x;
954+
955+
if inner_dimensions.x > area_width {
956+
// Text is larger than viewport - clamp scroll to valid range
957+
let max_scroll = inner_dimensions.x - area_width + self.caret_width;
958+
new_scroll.horizontal = new_scroll.horizontal.min(max_scroll);
959+
} else {
960+
// Text fits within the viewport - no scroll needed
961+
new_scroll.horizontal = 0.0;
962+
}
963+
}
948964
}
949965
} else if new_absolute_caret_offset > max {
950966
new_scroll.horizontal =
@@ -963,57 +979,6 @@ impl<T> TextState<T> {
963979
None
964980
}
965981

966-
/// Determines if we should use improved scroll behavior where the caret stays visually
967-
/// fixed while deleting overflowing text, instead of moving the caret within the visible area.
968-
///
969-
/// This behavior is used when:
970-
/// 1. Text overflows the visible area (text is longer than area width)
971-
/// 2. We're likely deleting from the end (caret moved to the left)
972-
/// 3. There's horizontal scroll present
973-
fn should_update_horizontal_scroll(
974-
&self,
975-
text_area_width: f32,
976-
old_relative_caret_x: f32,
977-
new_absolute_caret_x: f32,
978-
current_scroll_x: f32,
979-
) -> bool {
980-
// Only apply improved behavior when there's existing scroll
981-
if current_scroll_x <= 0.0 {
982-
return false;
983-
}
984-
985-
// Calculate approximate text width based on buffer content
986-
let text_overflows = self.estimate_text_overflows(text_area_width);
987-
if !text_overflows {
988-
return false;
989-
}
990-
991-
// Check if caret moved to the left (likely deletion from end)
992-
let old_absolute_caret_x = old_relative_caret_x + current_scroll_x;
993-
994-
// Use improved behavior when text overflows and caret moved left
995-
new_absolute_caret_x < old_absolute_caret_x
996-
}
997-
998-
/// Estimates if a text overflows the given width by examining the buffer's layout
999-
fn estimate_text_overflows(&self, text_area_width: f32) -> bool {
1000-
// TODO: check if it's better done with inner_dimensions instead of trying to figure out width
1001-
// Look at the last glyph position to estimate if text overflows
1002-
if let Some(line) = &self.buffer.lines.last() {
1003-
if let Some(layouts) = line.layout_opt().as_ref() {
1004-
if let Some(layout) = layouts.last() {
1005-
if let Some(last_glyph) = layout.glyphs.last() {
1006-
let text_width = last_glyph.x + last_glyph.w;
1007-
return text_width > text_area_width;
1008-
}
1009-
}
1010-
}
1011-
}
1012-
1013-
// Fallback: assume no overflow if we can't determine
1014-
false
1015-
}
1016-
1017982
/// Reshapes the text buffer if parameters have changed since the last reshape.
1018983
///
1019984
/// This method checks if any text parameters (content, style, size) have changed

src/style.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,11 @@ pub struct TextStyle {
357357
#[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))]
358358
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
359359
pub enum HorizontalTextAlignment {
360-
/// Align text to the start of the container (left in LTR, right in RTL).
360+
/// No horizontal alignment, defaulting to the start of the text area. Set alignment
361+
/// to `None` to be able to scroll horizontally.
361362
#[default]
363+
None,
364+
/// Align text to the start of the container (left in LTR, right in RTL).
362365
Start,
363366
/// Align text to the end of the container (right in LTR, left in RTL).
364367
End,
@@ -379,6 +382,7 @@ impl From<HorizontalTextAlignment> for Option<Align> {
379382
/// `Align` variant for other alignment types.
380383
fn from(val: HorizontalTextAlignment) -> Self {
381384
match val {
385+
HorizontalTextAlignment::None => None,
382386
HorizontalTextAlignment::Start => None,
383387
HorizontalTextAlignment::End => Some(Align::End),
384388
HorizontalTextAlignment::Center => Some(Align::Center),

0 commit comments

Comments
 (0)