// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; use crate::render::clip::interior_clip; use crate::types::{ Color, Rect }; use super::TextEdit; use super::hit_test::{ single_line_align_offset, single_line_scroll_x }; use super::metrics::{ single_line_pad_h, single_line_scale }; use super::scroll::{ TextScroll, Window }; use super::gutter; use super::tabs::{ line_x, next_stop, tab_width }; use super::TextFont; use super::theme; fn password_toggle_reserve( rect: Rect ) -> f32 { ( theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 ) * single_line_scale( rect ) } /// 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 = ( password_toggle_reserve( rect ) + single_line_pad_h( rect, None ) * 0.5 ).min( rect.width ); Rect { x: rect.x + rect.width - zone_w, y: rect.y, width: zone_w, height: rect.height, } } impl TextEdit { /// 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. /// `scroll` is where a multiline field was last shown, which it /// returns as it shows it now, for the next frame to keep. pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, cursor_pos: usize, selection_anchor: usize, scroll: TextScroll, ) -> Option { if self.is_multiline() { return Some( self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor, scroll ) ); } // 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.effective_font_size( canvas ); 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() { password_toggle_reserve( rect ) } else { 0.0 }; let pad_h = single_line_pad_h( rect, self.pad_h ); let text_rect = Rect { width: ( rect.width - toggle_reserve ).max( pad_h * 2.0 ), ..rect }; let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size, self.pad_h ); let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size, self.pad_h ); // 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 + pad_h * 0.5, y: rect.y, width: ( rect.width - pad_h - toggle_reserve ).max( 0.0 ), height: rect.height, }; canvas.set_clip_rects( &interior_clip( &outer_clip, inner_rect ) ); // 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 + 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, self.pad_h, ); canvas.draw_text( &self.placeholder, rect.x + pad_h + placeholder_align_x, text_y, font_size, theme::placeholder(), ); } else { canvas.draw_text( &text, rect.x + pad_h + align_x - scroll_x, text_y, font_size, self.color.unwrap_or_else( 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 + pad_h + align_x - scroll_x + canvas.measure_text( &cursor_text, font_size ); // Caret height follows the text line, not the field box — a // tall field (launcher pill) otherwise grows a caret twice // the glyph height. Matches the multiline caret. let dpi = canvas.dpi_scale().max( 1.0 ); let cursor_h = ( font_size + theme::CARET_OVERHANG ) * dpi; let cursor_rect = Rect { x: cursor_x, y: rect.y + ( rect.height - cursor_h ) / 2.0, width: ( theme::CARET_W * dpi ).round(), height: cursor_h, }; 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 * single_line_scale( rect ) ).round().max( 1.0 ) 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 ); } } None } /// The bytes `start..end` of the value from `x` on the baseline `y`, each highlighted run in its colour and the rest in the field's. #[ allow( clippy::too_many_arguments ) ] fn draw_runs( &self, canvas: &mut Canvas, font: &TextFont, start: usize, end: usize, x: f32, y: f32, font_size: f32 ) { let plain = self.color.unwrap_or_else( theme::text ); let runs = self.highlights.as_deref().map( Vec::as_slice ).unwrap_or( &[] ); let origin = x; let tab_w = tab_width( canvas, font, font_size ); let mut x = x; let mut at = start; let draw = |canvas: &mut Canvas, from: usize, to: usize, color: Color, x: &mut f32| { for ( i, piece ) in self.value[ from..to ].split( '\t' ).enumerate() { if i > 0 { *x = origin + next_stop( *x - origin, tab_w ); } if piece.is_empty() { continue; } font.draw( canvas, piece, *x, y, font_size, color ); *x += font.measure( canvas, piece, font_size ); } }; let first = runs.partition_point( |run| run.range.end <= start ); for run in &runs[ first.. ] { if run.range.start >= end { break; } let from = run.range.start.max( at ); let to = run.range.end.min( end ); if to <= from || !self.value.is_char_boundary( from ) || !self.value.is_char_boundary( to ) { continue; } if from > at { draw( canvas, at, from, plain, &mut x ); } draw( canvas, from, to, run.color, &mut x ); at = to; } if at < end { draw( canvas, at, end, plain, &mut x ); } } fn draw_multiline( &self, canvas: &mut Canvas, rect: Rect, focused: bool, cursor_pos: usize, selection_anchor: usize, scroll: TextScroll, ) -> TextScroll { 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_MULTI ); canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); } let field = rect; let font = self.text_font(); let rect = gutter::text_rect( canvas, field, &self.value, &font, self.line_numbers ); let window = Window::resolve( canvas, rect, &self.value, &font, cursor_pos, scroll ); let m = window.metrics; let line_h = m.line_h; let visual_lines = &window.lines; let safe_cursor = cursor_pos.min( self.value.len() ); let cursor_visual_idx = window.cursor_line; let first_line = window.first_line; let baseline_0 = rect.y + m.pad_v + m.glyph_h; // 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 + m.pad_h * 0.5, y: rect.y + m.pad_v * 0.5, width: ( rect.width - m.pad_h ).max( 0.0 ), height: ( rect.height - m.pad_v ).max( 0.0 ), }; canvas.set_clip_rects( &interior_clip( &outer_clip, inner_rect ) ); // 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 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() ); let color = if focused { theme::selection() } else { theme::selection_inactive() }; for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) { let visual_idx = i - first_line; let y = rect.y + m.pad_v + visual_idx as f32 * line_h; if y > rect.y + rect.height - m.pad_v { 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 from = line_x( canvas, &font, &self.value, vl.start, span_start, m.font_size ); let to = line_x( canvas, &font, &self.value, vl.start, span_end, m.font_size ); let x0 = rect.x + m.pad_h + from; let w = ( to - from ).max( m.caret_w * 2.0 ); let sel_rect = Rect { x: x0, y, width: w, height: line_h, }; canvas.fill_rect( sel_rect, color, 2.0 ); } } if self.value.is_empty() { canvas.draw_text( &self.placeholder, rect.x + m.pad_h, baseline_0, m.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 - m.pad_v + line_h { break; } self.draw_runs( canvas, &font, vl.start, vl.end, rect.x + m.pad_h, y, m.font_size ); } } if focused { let vl = visual_lines[ cursor_visual_idx ]; let cursor_x = rect.x + m.pad_h + line_x( canvas, &font, &self.value, vl.start, safe_cursor, m.font_size ); let visual_y_idx = cursor_visual_idx.saturating_sub( first_line ); let cursor_y = rect.y + m.pad_v + visual_y_idx as f32 * line_h; let cursor_rect = Rect { x: cursor_x, y: cursor_y + ( line_h - m.caret_h ) / 2.0, width: m.caret_w, height: m.caret_h, }; 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 self.line_numbers { let band = Rect { x: field.x, width: rect.x - field.x, ..inner_rect }; canvas.set_clip_rects( &interior_clip( &outer_clip, band ) ); self.draw_line_numbers( canvas, rect.x, baseline_0, &window, focused.then_some( safe_cursor ) ); } if outer_clip.is_empty() { canvas.clear_clip(); } else { canvas.set_clip_rects( &outer_clip ); } if let Some( bar ) = window.scrollbar( canvas, rect ) { let radius = bar.track.width / 2.0; canvas.fill_rect( bar.track, theme::scrollbar_track(), radius ); canvas.fill_rect( bar.thumb, theme::scrollbar_thumb(), radius ); } TextScroll { first_line, cursor_pos: safe_cursor, remainder: scroll.remainder } } /// The number of each line on its first row on screen, right-aligned to `right`, the cursor's in the text colour. fn draw_line_numbers( &self, canvas: &mut Canvas, right: f32, baseline_0: f32, window: &Window, cursor: Option ) { let m = window.metrics; let bytes = self.value.as_bytes(); let lines = &window.lines; let Some( first ) = lines.get( window.first_line ) else { return }; let newlines = |end: usize| bytes[ ..end ].iter().filter( |&&b| b == b'\n' ).count(); let current = cursor.map( |at| newlines( at ) + 1 ); let mut number = newlines( first.start ) + 1; let mono = crate::system_fonts::monospace(); for ( k, vl ) in lines.iter().enumerate().skip( window.first_line ) { let row = k - window.first_line; if row > window.visible { break; } let y = baseline_0 + row as f32 * m.line_h; let starts = vl.start == 0 || bytes[ vl.start - 1 ] == b'\n'; if !starts { continue; } if k != window.first_line { number += 1; } let label = number.to_string(); let color = if current == Some( number ) { self.color.unwrap_or_else( theme::text ) } else { theme::placeholder() }; let x = right - gutter::measure( canvas, &label, m.font_size ); match &mono { Some( mono ) => canvas.draw_text_with_font( &label, x, y, m.font_size, color, mono ), None => canvas.draw_text( &label, x, y, m.font_size, color ), } } } }