ltk/widget/text_edit/
draw.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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

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

use super::TextEdit;
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
use super::theme;
use super::wrapping::compute_visual_lines;

/// Axis-aligned intersection of two rects. Returns `None` when the rects
/// do not overlap (zero-area / negative-extent results count as
/// "no overlap" so the caller can `filter_map` straight into a clip
/// list).
fn rect_intersect( a: Rect, b: Rect ) -> Option<Rect>
{
	let x0 = a.x.max( b.x );
	let y0 = a.y.max( b.y );
	let x1 = ( a.x + a.width  ).min( b.x + b.width  );
	let y1 = ( a.y + a.height ).min( b.y + b.height );
	if x1 > x0 && y1 > y0
	{
		Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
	} else {
		None
	}
}

/// Hit rect for the eye icon at the right edge of a `TextEdit`
/// configured with [`TextEdit::password_toggle`]. Returned in the
/// same coordinate space as the field's `rect`. Pointer / touch
/// dispatch consult this before falling through to cursor placement
/// — a tap inside the zone fires the toggle message instead of
/// moving the caret.
pub fn password_toggle_hit_zone( rect: Rect ) -> Rect
{
	let zone_w = ( theme::PASSWORD_TOGGLE_SIZE
		+ theme::PASSWORD_TOGGLE_SLOP * 2.0
		+ theme::PAD_H * 0.5 ).min( rect.width );
	Rect
	{
		x:      rect.x + rect.width - zone_w,
		y:      rect.y,
		width:  zone_w,
		height: rect.height,
	}
}

impl<Msg: Clone> TextEdit<Msg>
{
	/// Draw the field into `canvas` at `rect`.
	///
	/// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime
	/// from its persistent cursor state rather than from the widget itself.
	/// `selection_anchor` is the other end of the selection range; when
	/// `selection_anchor == cursor_pos` no highlight is painted.
	pub fn draw(
		&self,
		canvas:           &mut Canvas,
		rect:             Rect,
		focused:          bool,
		cursor_pos:       usize,
		selection_anchor: usize,
	)
	{
		if self.is_multiline()
		{
			self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor );
			return;
		}
		// Borderless fields skip the surface fill + border stroke
		// entirely. They still get the inner clip + scroll + alignment
		// treatment below, so the field behaves like a regular
		// text input — only the chrome is suppressed.
		if !self.borderless
		{
			let border_c = if focused { theme::focus_border() } else { theme::border() };
			let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
			canvas.fill_rect( rect, theme::bg(), theme::RADIUS );
			canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS );
		}

		let font_size = self.font_size;
		let text_y    = rect.y + (rect.height + font_size) / 2.0 - 2.0;
		let text      = self.display_text();
		let secure    = self.effective_secure();
		// When `password_toggle` is set we reserve a column on the
		// right edge for the eye icon: scroll / align / clip all
		// behave as if the field were narrower so cursor + text
		// never slide under the icon.
		let toggle_reserve = if self.password_toggle.is_some()
		{
			theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0
		}
		else
		{
			0.0
		};
		let text_rect = Rect
		{
			width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ),
			..rect
		};
		let scroll_x  = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size );
		let align_x   = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size );

		// Clip text / cursor / selection to the inner band so the
		// scrolled-off portion does not bleed past the pill stroke.
		// Save the parent clip first, intersect with our inner rect,
		// and restore on exit so a `text_edit` wrapped in scroll(...)
		// keeps the parent's clip honoured.
		let outer_clip = canvas.clip_bounds();
		let inner_rect = Rect
		{
			x:      rect.x + theme::PAD_H * 0.5,
			y:      rect.y,
			width:  ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ),
			height: rect.height,
		};
		let composed_clip: Vec<Rect> = if outer_clip.is_empty()
		{
			vec![ inner_rect ]
		} else {
			outer_clip.iter()
				.filter_map( |o| rect_intersect( *o, inner_rect ) )
				.collect()
		};
		canvas.set_clip_rects( &composed_clip );

		// Selection highlight (single-line). Painted before the text
		// so the glyphs sit on top of the tinted band.
		if focused && selection_anchor != cursor_pos
		{
			let ( s, e ) = (
				cursor_pos.min( selection_anchor ).min( self.value.len() ),
				cursor_pos.max( selection_anchor ).min( self.value.len() ),
			);
			let prefix_text = if secure
			{
				"\u{2022}".repeat( self.value[..s].chars().count() )
			} else {
				self.value[..s].to_string()
			};
			let span_text = if secure
			{
				"\u{2022}".repeat( self.value[s..e].chars().count() )
			} else {
				self.value[s..e].to_string()
			};
			let x0 = rect.x + theme::PAD_H + align_x - scroll_x
				+ canvas.measure_text( &prefix_text, font_size );
			let w  = canvas.measure_text( &span_text, font_size );
			let sel_rect = Rect
			{
				x: x0, y: rect.y + 6.0,
				width: w, height: rect.height - 12.0,
			};
			canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
		}

		if text.is_empty()
		{
			// Placeholder follows the same alignment as the value
			// would, so an empty centred / right-aligned field still
			// reads with the placeholder where the digits will land.
			let placeholder_align_x = single_line_align_offset(
				canvas, rect, &self.placeholder, false, self.align, font_size,
			);
			canvas.draw_text(
				&self.placeholder,
				rect.x + theme::PAD_H + placeholder_align_x,
				text_y,
				font_size,
				theme::placeholder(),
			);
		} else {
			canvas.draw_text(
				&text,
				rect.x + theme::PAD_H + align_x - scroll_x,
				text_y,
				font_size,
				theme::text(),
			);
		}

		if focused
		{
			let safe_cursor = cursor_pos.min( self.value.len() );
			let cursor_text = if secure
			{
				"\u{2022}".repeat( self.value[..safe_cursor].chars().count() )
			} else {
				self.value[..safe_cursor].to_string()
			};
			let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x
				+ canvas.measure_text( &cursor_text, font_size );
			let cursor_rect = Rect
			{
				x:      cursor_x,
				y:      rect.y + 8.0,
				width:  2.0,
				height: rect.height - 16.0,
			};
			canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
		}

		// Restore whatever clip was active on entry.
		if outer_clip.is_empty()
		{
			canvas.clear_clip();
		} else {
			canvas.set_clip_rects( &outer_clip );
		}

		// Eye icon for `password_toggle`. Drawn outside the inner
		// clip so it never gets occluded by overflowing text — the
		// preceding clip-restore is the reason this lives here and
		// not earlier in the function. Tinted with the placeholder
		// colour so the icon reads as ambient affordance rather
		// than a primary control.
		if let Some( ( visible, _ ) ) = self.password_toggle.as_ref()
		{
			let icon_name = if *visible { "actions/invisible" } else { "actions/visible" };
			let icon_px   = theme::PASSWORD_TOGGLE_SIZE.round() as u32;
			if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px )
			{
				let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() );
				let zone   = password_toggle_hit_zone( rect );
				let dest   = Rect
				{
					x:      ( zone.x + ( zone.width  - iw as f32 ) / 2.0 ).round(),
					y:      ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(),
					width:  iw as f32,
					height: ih as f32,
				};
				canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
			}
		}
	}

	fn draw_multiline(
		&self,
		canvas:           &mut Canvas,
		rect:             Rect,
		focused:          bool,
		cursor_pos:       usize,
		selection_anchor: usize,
	)
	{
		let border_c = if focused { theme::focus_border() } else { theme::border() };
		let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
		canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI );
		canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI );

		let line_h        = theme::FONT_SIZE * theme::LINE_H_MULT;
		let inner_h       = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
		let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize;
		let inner_width   = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );

		// Compute the visual-line layout once per draw. Iterators
		// later go through this list; long logical lines are
		// soft-wrapped at the last whitespace before the row would
		// overflow without mutating the buffer.
		let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE );

		// Vertical auto-scroll: keep the cursor's visual line on
		// screen. The cursor sits in the *first* visual line whose
		// `end >= cursor` — at a wrap boundary that places it at the
		// trailing edge of the previous line rather than the start of
		// the next, matching the convention of every standard text
		// editor.
		let safe_cursor       = cursor_pos.min( self.value.len() );
		let cursor_visual_idx = visual_lines.iter()
			.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
			.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
		let total_lines = visual_lines.len();
		let max_first   = total_lines.saturating_sub( visible_lines );
		let first_line  = if cursor_visual_idx + 1 > visible_lines
		{
			( cursor_visual_idx + 1 - visible_lines ).min( max_first )
		} else { 0 };

		let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE;

		// Clip text to the inner rect so partial lines at the bottom
		// stay inside the box and do not bleed onto sibling widgets
		// or the border stroke. Save the parent clip first, then
		// intersect it with our inner rect, so a `scroll(...)`-wrapped
		// multiline edit keeps the parent's clip honoured.
		let outer_clip = canvas.clip_bounds();
		let inner_rect = Rect
		{
			x:      rect.x + theme::PAD_H * 0.5,
			y:      rect.y + theme::PAD_V_MULTI * 0.5,
			width:  ( rect.width  - theme::PAD_H        ).max( 0.0 ),
			height: ( rect.height - theme::PAD_V_MULTI  ).max( 0.0 ),
		};
		let composed_clip: Vec<Rect> = if outer_clip.is_empty()
		{
			vec![ inner_rect ]
		} else {
			outer_clip.iter()
				.filter_map( |o| rect_intersect( *o, inner_rect ) )
				.collect()
		};
		canvas.set_clip_rects( &composed_clip );

		// Selection highlight (multiline). Painted before the text so
		// the glyphs sit on top of the tinted band(s). Iterates
		// visual lines, so a soft-wrapped logical line gets one
		// highlight rect per visual row automatically.
		if focused && selection_anchor != cursor_pos
		{
			let s = cursor_pos.min( selection_anchor ).min( self.value.len() );
			let e = cursor_pos.max( selection_anchor ).min( self.value.len() );
			for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
			{
				let visual_idx = i - first_line;
				let y          = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h;
				if y > rect.y + rect.height - theme::PAD_V_MULTI { break; }
				// Intersection of [s, e] with this visual line's
				// byte range. Empty / non-overlapping → skip.
				let span_start = s.max( vl.start );
				let span_end   = e.min( vl.end );
				if span_start >= span_end { continue; }
				let prefix_text = &self.value[ vl.start..span_start ];
				let span_text   = &self.value[ span_start..span_end ];
				let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE );
				let w  = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 );
				let sel_rect = Rect
				{
					x: x0, y,
					width: w, height: line_h,
				};
				canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
			}
		}

		if self.value.is_empty()
		{
			canvas.draw_text(
				&self.placeholder,
				rect.x + theme::PAD_H,
				baseline_0,
				theme::FONT_SIZE,
				theme::placeholder(),
			);
		} else {
			for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
			{
				let visual_idx = i - first_line;
				let y          = baseline_0 + visual_idx as f32 * line_h;
				if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h
				{
					break;
				}
				let line = &self.value[ vl.start..vl.end ];
				canvas.draw_text(
					line,
					rect.x + theme::PAD_H,
					y,
					theme::FONT_SIZE,
					theme::text(),
				);
			}
		}

		if focused
		{
			let vl          = visual_lines[ cursor_visual_idx ];
			let line_prefix = &self.value[ vl.start..safe_cursor ];
			let cursor_x    = rect.x + theme::PAD_H
				+ canvas.measure_text( line_prefix, theme::FONT_SIZE );
			let visual_y_idx = cursor_visual_idx.saturating_sub( first_line );
			let cursor_y    = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h;
			let cursor_rect = Rect
			{
				x:      cursor_x,
				y:      cursor_y + 2.0,
				width:  2.0,
				height: theme::FONT_SIZE + 4.0,
			};
			canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
		}

		// Restore whatever clip was active on entry — a single empty
		// `Vec` from `clip_bounds` means "no clip", which we model by
		// calling `clear_clip` instead of pushing an empty slice.
		if outer_clip.is_empty()
		{
			canvas.clear_clip();
		} else {
			canvas.set_clip_rects( &outer_clip );
		}
	}
}