ltk/widget/text_edit/
cursor.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use std::rc::Rc;

use crate::render::Canvas;
use crate::types::Rect;

use super::hit_test::byte_offset_in_line;
use super::metrics::MultilineMetrics;
use super::scroll::{ TextScroll, Window };
use super::tabs::line_x;
use super::wrapping::{ compute_visual_lines, VisualLine };
use super::TextFont;

/// Locate the visual line containing `cursor` in the wrap layout for
/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap
/// boundary (so it satisfies both the previous line's `end` and the
/// next line's `start`), prefer the *previous* line — that matches the
/// rendering convention in `draw_multiline`, which paints the caret at
/// the trailing edge of the prior visual row rather than the leading
/// edge of the new one.
fn current_visual_line(
	canvas: &Canvas,
	font:   &TextFont,
	m:      &MultilineMetrics,
	rect:   Rect,
	value:  &str,
	cursor: usize,
) -> Option<( Rc<Vec<VisualLine>>, usize )>
{
	let visual_lines = compute_visual_lines( canvas, font, value, m.inner_width( rect ), m.font_size );
	if visual_lines.is_empty() { return None; }
	let safe_cursor  = cursor.min( value.len() );
	let idx = visual_lines.iter()
		.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
		.unwrap_or( visual_lines.len() - 1 );
	Some( ( visual_lines, idx ) )
}

/// Move the text cursor one visual row up. Returns the new byte offset
/// or `None` when the cursor is already on the first visual row, so
/// the caller can fall through to sibling-widget keyboard navigation.
///
/// Visual X is preserved across the move: the new cursor lands as close
/// as possible (with snap-to-nearest-glyph) to the same horizontal
/// position the cursor had on its origin line. No "preferred column"
/// state is kept across multiple consecutive Up presses, so a long
/// short → long sequence will drift toward the end of every short
/// line — same simplification GTK / Cocoa make for plain controls.
pub( crate ) fn cursor_visual_up(
	canvas:    &Canvas,
	rect:      Rect,
	value:     &str,
	cursor:    usize,
	font:      &TextFont,
) -> Option<usize>
{
	let m              = MultilineMetrics::resolve( canvas, font.size );
	let ( lines, idx ) = current_visual_line( canvas, font, &m, rect, value, cursor )?;
	if idx == 0 { return None; }
	let cur      = lines[ idx ];
	let safe     = cursor.min( value.len() );
	let target_x = line_x( canvas, font, value, cur.start, safe, m.font_size );
	let prev     = lines[ idx - 1 ];
	Some( byte_offset_in_line( canvas, font, value, prev.start, prev.end, target_x, false, true, m.font_size ) )
}

/// Mirror of [`cursor_visual_up`] for the Down arrow.
pub( crate ) fn cursor_visual_down(
	canvas:    &Canvas,
	rect:      Rect,
	value:     &str,
	cursor:    usize,
	font:      &TextFont,
) -> Option<usize>
{
	let m              = MultilineMetrics::resolve( canvas, font.size );
	let ( lines, idx ) = current_visual_line( canvas, font, &m, rect, value, cursor )?;
	if idx + 1 >= lines.len() { return None; }
	let cur      = lines[ idx ];
	let safe     = cursor.min( value.len() );
	let target_x = line_x( canvas, font, value, cur.start, safe, m.font_size );
	let next     = lines[ idx + 1 ];
	Some( byte_offset_in_line( canvas, font, value, next.start, next.end, target_x, false, true, m.font_size ) )
}

/// Byte offset of the start of the cursor's current visual line.
/// Falls back to `0` when the wrap layout cannot be computed (e.g.
/// degenerate rect), so the Home key always has a sensible target.
pub( crate ) fn cursor_visual_home(
	canvas:    &Canvas,
	rect:      Rect,
	value:     &str,
	cursor:    usize,
	font:      &TextFont,
) -> usize
{
	let m = MultilineMetrics::resolve( canvas, font.size );
	current_visual_line( canvas, font, &m, rect, value, cursor )
		.map( |( lines, idx )| lines[ idx ].start )
		.unwrap_or( 0 )
}

/// Byte offset of the end of the cursor's current visual line. For a
/// hard-`\n`-terminated row this is the byte just before the newline;
/// for a soft-wrapped row it is the wrap point (which is also the
/// start of the next visual row, but the caret renders at the trailing
/// edge of *this* row by convention).
pub( crate ) fn cursor_visual_end(
	canvas:    &Canvas,
	rect:      Rect,
	value:     &str,
	cursor:    usize,
	font:      &TextFont,
) -> usize
{
	let m = MultilineMetrics::resolve( canvas, font.size );
	current_visual_line( canvas, font, &m, rect, value, cursor )
		.map( |( lines, idx )| lines[ idx ].end )
		.unwrap_or( value.len() )
}

/// The cursor a page up or down from `cursor`, a page being the lines
/// on screen but one, at the same height on screen as before, and the
/// view moved by that page to show it there. From the first page up,
/// or the last down, the cursor goes to the start or the end.
pub( crate ) fn cursor_visual_page(
	canvas:    &Canvas,
	rect:      Rect,
	value:     &str,
	cursor:    usize,
	font:      &TextFont,
	scroll:    TextScroll,
	up:        bool,
) -> ( usize, TextScroll )
{
	let window = Window::shown( canvas, rect, value, font, cursor, scroll );
	let lines  = &window.lines;
	let page   = window.visible.saturating_sub( 1 ).max( 1 );
	let cursor = cursor.min( value.len() );
	let line   = window.cursor_line;
	let target = if up { line.saturating_sub( page ) } else { ( line + page ).min( lines.len().saturating_sub( 1 ) ) };
	let moved  = if target == line
	{
		if up { 0 } else { value.len() }
	} else {
		let font_size = window.metrics.font_size;
		let x         = line_x( canvas, font, value, lines[ line ].start, cursor, font_size );
		let row       = lines[ target ];
		byte_offset_in_line( canvas, font, value, row.start, row.end, x, false, true, font_size )
	};
	let first = if up { window.first_line.saturating_sub( page ) } else { ( window.first_line + page ).min( window.max_first ) };
	( moved, TextScroll { first_line: first, cursor_pos: moved, remainder: 0.0 } )
}