From 495968fca5cea600f994b949e988493346d9ac8c Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sat, 19 Sep 2026 16:00:58 +0200 Subject: [PATCH] =?UTF-8?q?text=5Fedit:=20size=20the=20password=20eye=20an?= =?UTF-8?q?d=20the=20caret=20in=20physical=20pixels,=20and=20resolve=20the?= =?UTF-8?q?=20multiline=20layout=20through=20the=20canvas=20Canvas=20coord?= =?UTF-8?q?inates=20are=20physical=20pixels=20while=20font=20sizes=20are?= =?UTF-8?q?=20handed=20to=20the=20raster=20path=20in=20font=20space=20and?= =?UTF-8?q?=20multiplied=20by=20`dpi=5Fscale`=20there.=20`TextEdit`=20mixe?= =?UTF-8?q?d=20both:=20on=20a=202560=C3=971440=20panel=20at=20scale=202=20?= =?UTF-8?q?a=20field=2044=20logical=20px=20tall=20measures=2088=20physical?= =?UTF-8?q?=20px=20and=20its=20text=2032,=20yet=20the=20eye=20of=20`passwo?= =?UTF-8?q?rd=5Ftoggle`=20was=20rasterised=20at=20the=2020=20px=20of=20`PA?= =?UTF-8?q?SSWORD=5FTOGGLE=5FSIZE`=20and=20the=20caret=20was=20`font=5Fsiz?= =?UTF-8?q?e=20+=204`=20=3D=2020=20px=20tall=20and=202=20px=20wide,=20so?= =?UTF-8?q?=20both=20came=20out=20at=20less=20than=20half=20their=20intend?= =?UTF-8?q?ed=20size=20next=20to=20a=20field=20that=20otherwise=20scaled?= =?UTF-8?q?=20correctly.=20Password=20toggle.=20The=20icon,=20the=20slop?= =?UTF-8?q?=20around=20it=20and=20the=20column=20reserved=20for=20it=20on?= =?UTF-8?q?=20the=20right=20edge=20now=20follow=20the=20field=20height,=20?= =?UTF-8?q?which=20is=20already=20physical:=20`password=5Ftoggle=5Fscale`?= =?UTF-8?q?=20is=20`rect.height=20/=20theme::HEIGHT`=20and=20`password=5Ft?= =?UTF-8?q?oggle=5Freserve`=20applies=20it=20to=20the=20icon=20plus=20its?= =?UTF-8?q?=20slop.=20`password=5Ftoggle=5Fhit=5Fzone(=20rect=20)`=20keeps?= =?UTF-8?q?=20its=20signature=20and=20is=20built=20from=20the=20same=20hel?= =?UTF-8?q?pers,=20so=20the=20zone=20that=20`handle=5Fpassword=5Ftoggle=5F?= =?UTF-8?q?press`=20tests=20still=20matches=20what=20`draw`=20paints.=20A?= =?UTF-8?q?=20field=20`HEIGHT`=20tall=20at=20scale=201=20gives=20exactly?= =?UTF-8?q?=20the=20previous=20geometry,=20which=20is=20what=20the=20exist?= =?UTF-8?q?ing=20hit-zone=20tests=20cover.=20Caret.=20The=20single-line=20?= =?UTF-8?q?caret=20multiplies=20its=20height=20and=20width=20by=20`canvas.?= =?UTF-8?q?dpi=5Fscale()`,=20the=20same=20factor=20the=20glyphs=20are=20ra?= =?UTF-8?q?sterised=20with.=20The=20literals=20become=20`theme::CARET=5FW`?= =?UTF-8?q?=20and=20`theme::CARET=5FOVERHANG`,=20shared=20with=20the=20mul?= =?UTF-8?q?tiline=20variant.=20Multiline.=20The=20multiline=20variant=20ha?= =?UTF-8?q?d=20the=20same=20defect=20throughout:=20`preferred=5Fsize`,=20`?= =?UTF-8?q?draw=5Fmultiline`,=20the=20multiline=20branch=20of=20`byte=5Fof?= =?UTF-8?q?fset=5Fat`=20and=20the=20`cursor=5Fvisual=5F*`=20helpers=20all?= =?UTF-8?q?=20used=20`theme::FONT=5FSIZE`,=20`LINE=5FH=5FMULT`,=20`PAD=5FV?= =?UTF-8?q?=5FMULTI`=20and=20`PAD=5FH`=20as=20raw=20numbers.=20The=20text?= =?UTF-8?q?=20was=20rasterised=20at=20`16=20=C3=97=20dpi=5Fscale`=20but=20?= =?UTF-8?q?the=20line=20pitch=20stayed=20at=2022.4=20physical=20px,=20so?= =?UTF-8?q?=20at=20scale=202=20the=20rows=20overlapped,=20the=20first=20on?= =?UTF-8?q?e=20was=20clipped=20at=20the=20top,=20the=20caret=20was=20half?= =?UTF-8?q?=20height=20and=20the=20box=20claimed=20half=20the=20height=20i?= =?UTF-8?q?ts=20rows=20needed.=20It=20also=20ignored=20`.font=5Fsize(=20?= =?UTF-8?q?=E2=80=A6=20)`=20and=20the=20widget-scaling=20mode,=20which=20t?= =?UTF-8?q?he=20single-line=20variant=20honours.=20The=20new=20`metrics.rs?= =?UTF-8?q?`=20holds=20`MultilineMetrics`,=20resolved=20once=20per=20call?= =?UTF-8?q?=20from=20the=20canvas=20and=20the=20field's=20`font=5Fsize`:?= =?UTF-8?q?=20the=20font=20through=20`resolve=5Ffont=5Fsize`,=20the=20glyp?= =?UTF-8?q?h=20and=20line=20heights=20from=20it=20times=20`dpi=5Fscale`,?= =?UTF-8?q?=20the=20paddings=20through=20`canvas.geom=5Fpx`=20like=20the?= =?UTF-8?q?=20other=20stock=20widgets,=20and=20the=20caret=20size.=20It=20?= =?UTF-8?q?also=20owns=20the=20inner=20width,=20the=20visible=20row=20coun?= =?UTF-8?q?t=20and=20the=20first=20visible=20row,=20which=20`draw=5Fmultil?= =?UTF-8?q?ine`=20and=20`byte=5Foffset=5Fat`=20each=20computed=20on=20thei?= =?UTF-8?q?r=20own.=20Sizing,=20drawing,=20hit-testing=20and=20vertical=20?= =?UTF-8?q?caret=20navigation=20now=20agree=20by=20construction.=20`cursor?= =?UTF-8?q?=5Fvisual=5Fup`,=20`cursor=5Fvisual=5Fdown`,=20`cursor=5Fvisual?= =?UTF-8?q?=5Fhome`=20and=20`cursor=5Fvisual=5Fend`=20gain=20a=20`font=5Fs?= =?UTF-8?q?ize:=20Option`=20parameter;=20their=20callers=20in=20`e?= =?UTF-8?q?vent=5Floop/text=5Fediting/cursor.rs`=20already=20received=20it?= =?UTF-8?q?=20from=20`text=5Finput=5Fgeometry`=20and=20discarded=20it.=20O?= =?UTF-8?q?ne=20visible=20change=20at=20scale=201:=20the=20multiline=20pad?= =?UTF-8?q?dings=20and=20the=20default=20multiline=20font=20now=20go=20thr?= =?UTF-8?q?ough=20`geom=5Fpx`=20and=20`font=5Fpx`,=20so=20in=20fluid=20mod?= =?UTF-8?q?e=20they=20scale=20with=20the=20surface=20between=200.7=20and?= =?UTF-8?q?=201.5=20times=20the=20design=20value=20instead=20of=20staying?= =?UTF-8?q?=20at=2020=20/=2012=20/=2016=20px.=20The=20horizontal=20padding?= =?UTF-8?q?=20of=20the=20single-line=20variant=20is=20still=20the=20raw=20?= =?UTF-8?q?`PAD=5FH`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/event_loop/text_editing/cursor.rs | 16 +++--- src/widget/text_edit/cursor.rs | 64 ++++++++++++--------- src/widget/text_edit/draw.rs | 81 ++++++++++++++------------- src/widget/text_edit/hit_test.rs | 18 ++---- src/widget/text_edit/metrics.rs | 71 +++++++++++++++++++++++ src/widget/text_edit/mod.rs | 6 +- src/widget/text_edit/theme.rs | 7 ++- 7 files changed, 172 insertions(+), 91 deletions(-) create mode 100644 src/widget/text_edit/metrics.rs diff --git a/src/event_loop/text_editing/cursor.rs b/src/event_loop/text_editing/cursor.rs index 8193ba1..a87bfa4 100644 --- a/src/event_loop/text_editing/cursor.rs +++ b/src/event_loop/text_editing/cursor.rs @@ -98,14 +98,14 @@ impl AppData let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); - let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, _, multiline, secure, _align, font_size ) = match self.text_input_geometry( focus, idx ) { Some( g ) => g, None => return false, }; if !multiline || secure { return false; } let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; - let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor ) + let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor, font_size ) { Some( n ) => n, None => return false, @@ -124,14 +124,14 @@ impl AppData let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); - let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, _, multiline, secure, _align, font_size ) = match self.text_input_geometry( focus, idx ) { Some( g ) => g, None => return false, }; if !multiline || secure { return false; } let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; - let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor ) + let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor, font_size ) { Some( n ) => n, None => return false, @@ -155,9 +155,9 @@ impl AppData .unwrap_or( value.len() ).min( value.len() ); let new_cursor = match self.text_input_geometry( focus, idx ) { - Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref() + Some( ( rect, _, true, false, _, font_size ) ) => match self.surface( focus ).canvas.as_ref() { - Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor ), + Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor, font_size ), None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), }, _ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), @@ -178,9 +178,9 @@ impl AppData .unwrap_or( value.len() ).min( value.len() ); let new_cursor = match self.text_input_geometry( focus, idx ) { - Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref() + Some( ( rect, _, true, false, _, font_size ) ) => match self.surface( focus ).canvas.as_ref() { - Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor ), + Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor, font_size ), None => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), }, _ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), diff --git a/src/widget/text_edit/cursor.rs b/src/widget/text_edit/cursor.rs index 0aa4603..99d1c28 100644 --- a/src/widget/text_edit/cursor.rs +++ b/src/widget/text_edit/cursor.rs @@ -2,10 +2,10 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; -use crate::types::Rect; +use crate::types::{ Length, Rect }; use super::hit_test::byte_offset_in_line; -use super::theme; +use super::metrics::MultilineMetrics; use super::wrapping::{ compute_visual_lines, VisualLine }; /// Locate the visual line containing `cursor` in the wrap layout for @@ -17,13 +17,13 @@ use super::wrapping::{ compute_visual_lines, VisualLine }; /// edge of the new one. fn current_visual_line( canvas: &Canvas, + m: &MultilineMetrics, rect: Rect, value: &str, cursor: usize, ) -> Option<( Vec, usize )> { - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); - let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + let visual_lines = compute_visual_lines( canvas, 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() @@ -43,49 +43,55 @@ fn current_visual_line( /// 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, + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + font_size: Option, ) -> Option { - let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + let m = MultilineMetrics::resolve( canvas, font_size ); + let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?; if idx == 0 { return None; } let cur = lines[ idx ]; let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let target_x = canvas.measure_text( &value[cur.start..safe], m.font_size ); let prev = lines[ idx - 1 ]; - Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) ) + Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, 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, + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + font_size: Option, ) -> Option { - let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + let m = MultilineMetrics::resolve( canvas, font_size ); + let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?; if idx + 1 >= lines.len() { return None; } let cur = lines[ idx ]; let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let target_x = canvas.measure_text( &value[cur.start..safe], m.font_size ); let next = lines[ idx + 1 ]; - Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) ) + Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, 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, + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + font_size: Option, ) -> usize { - current_visual_line( canvas, rect, value, cursor ) + let m = MultilineMetrics::resolve( canvas, font_size ); + current_visual_line( canvas, &m, rect, value, cursor ) .map( |( lines, idx )| lines[ idx ].start ) .unwrap_or( 0 ) } @@ -96,13 +102,15 @@ pub( crate ) fn cursor_visual_home( /// 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, + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + font_size: Option, ) -> usize { - current_visual_line( canvas, rect, value, cursor ) + let m = MultilineMetrics::resolve( canvas, font_size ); + current_visual_line( canvas, &m, rect, value, cursor ) .map( |( lines, idx )| lines[ idx ].end ) .unwrap_or( value.len() ) } diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 655fc48..c31ade8 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -6,6 +6,7 @@ use crate::types::Rect; use super::TextEdit; use super::hit_test::{ single_line_align_offset, single_line_scroll_x }; +use super::metrics::MultilineMetrics; use super::theme; use super::wrapping::compute_visual_lines; @@ -46,6 +47,17 @@ fn rect_intersect( a: Rect, b: Rect ) -> Option } } +// The toggle follows the field height, which is already in physical pixels. +fn password_toggle_scale( rect: Rect ) -> f32 +{ + rect.height / theme::HEIGHT +} + +fn password_toggle_reserve( rect: Rect ) -> f32 +{ + ( theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 ) * password_toggle_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 @@ -54,9 +66,8 @@ fn rect_intersect( a: Rect, b: Rect ) -> Option /// 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 ); + let zone_w = ( password_toggle_reserve( rect ) + + theme::PAD_H * 0.5 * password_toggle_scale( rect ) ).min( rect.width ); Rect { x: rect.x + rect.width - zone_w, @@ -110,7 +121,7 @@ impl TextEdit // never slide under the icon. let toggle_reserve = if self.password_toggle.is_some() { - theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 + password_toggle_reserve( rect ) } else { @@ -209,12 +220,13 @@ impl TextEdit // 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 cursor_h = font_size + 4.0; + 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: 2.0, + width: ( theme::CARET_W * dpi ).round(), height: cursor_h, }; canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); @@ -237,7 +249,7 @@ impl TextEdit 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; + let icon_px = ( theme::PASSWORD_TOGGLE_SIZE * password_toggle_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() ); @@ -268,16 +280,14 @@ impl TextEdit 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 ); + let m = MultilineMetrics::resolve( canvas, self.font_size ); + let line_h = m.line_h; // 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 ); + let visual_lines = compute_visual_lines( canvas, &self.value, m.inner_width( rect ), m.font_size ); // Vertical auto-scroll: keep the cursor's visual line on // screen. The cursor sits in the *first* visual line whose @@ -289,14 +299,9 @@ impl TextEdit 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 first_line = m.first_visible_line( rect, visual_lines.len(), cursor_visual_idx ); - let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE; + 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 @@ -306,10 +311,10 @@ impl TextEdit 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 ), + 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 ) ); @@ -324,8 +329,8 @@ impl TextEdit 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; } + 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 ); @@ -333,8 +338,8 @@ impl TextEdit 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 x0 = rect.x + m.pad_h + canvas.measure_text( prefix_text, m.font_size ); + let w = canvas.measure_text( span_text, m.font_size ).max( m.caret_w * 2.0 ); let sel_rect = Rect { x: x0, y, @@ -348,9 +353,9 @@ impl TextEdit { canvas.draw_text( &self.placeholder, - rect.x + theme::PAD_H, + rect.x + m.pad_h, baseline_0, - theme::FONT_SIZE, + m.font_size, theme::placeholder(), ); } else { @@ -358,16 +363,16 @@ impl TextEdit { 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 + if y > rect.y + rect.height - m.pad_v + line_h { break; } let line = &self.value[ vl.start..vl.end ]; canvas.draw_text( line, - rect.x + theme::PAD_H, + rect.x + m.pad_h, y, - theme::FONT_SIZE, + m.font_size, theme::text(), ); } @@ -377,16 +382,16 @@ impl TextEdit { 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 cursor_x = rect.x + m.pad_h + + canvas.measure_text( line_prefix, m.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_y = rect.y + m.pad_v + 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, + 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 ); } diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index 8edfaf3..de705ea 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -4,6 +4,7 @@ use crate::render::Canvas; use crate::types::{ Length, Point, Rect }; +use super::metrics::MultilineMetrics; use super::theme; use super::wrapping::compute_visual_lines; @@ -33,32 +34,25 @@ pub( crate ) fn byte_offset_at( if multiline && !secure { - 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 ); + let m = MultilineMetrics::resolve( canvas, font_size ); // Recompute the visual layout from current state. Same call // as `draw_multiline` makes, so the click maps to the line / // column the user actually sees. - let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + let visual_lines = compute_visual_lines( canvas, value, m.inner_width( rect ), m.font_size ); let safe_cursor = cursor_pos.min( 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 first_line = m.first_visible_line( rect, total_lines, cursor_visual_idx ); - let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h ) + let visual_idx = ( ( pos.y - rect.y - m.pad_v ) / m.line_h ) .max( 0.0 ) as usize; let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) ); let vl = visual_lines[ target_idx ]; - byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE ) + byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, m.font_size ) } else { // Resolve the font-size sentinel (0.0 / negative → widget-scaling mode) // so a click maps against the same size the renderer drew with. diff --git a/src/widget/text_edit/metrics.rs b/src/widget/text_edit/metrics.rs new file mode 100644 index 0000000..0b611ee --- /dev/null +++ b/src/widget/text_edit/metrics.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::{ Length, Rect }; + +use super::theme; + +/// Resolved layout of a multiline field, shared by sizing, drawing, +/// hit-testing and caret navigation so they all agree. `font_size` is in +/// font space (pre-`dpi_scale`); every other field is physical px. +#[ derive( Clone, Copy, Debug ) ] +pub( crate ) struct MultilineMetrics +{ + pub font_size: f32, + pub glyph_h: f32, + pub line_h: f32, + pub pad_h: f32, + pub pad_v: f32, + pub caret_w: f32, + pub caret_h: f32, +} + +impl MultilineMetrics +{ + pub( crate ) fn resolve( canvas: &Canvas, font_size: Option ) -> Self + { + let font_size = super::resolve_font_size( canvas, font_size ); + let dpi = canvas.dpi_scale().max( 1.0 ); + let glyph_h = font_size * dpi; + Self + { + font_size, + glyph_h, + line_h: glyph_h * theme::LINE_H_MULT, + pad_h: canvas.geom_px( theme::PAD_H ), + pad_v: canvas.geom_px( theme::PAD_V_MULTI ), + caret_w: ( theme::CARET_W * dpi ).round(), + caret_h: glyph_h + theme::CARET_OVERHANG * dpi, + } + } + + pub( crate ) fn height_for_rows( &self, rows: u32 ) -> f32 + { + rows as f32 * self.line_h + self.pad_v * 2.0 + } + + pub( crate ) fn inner_width( &self, rect: Rect ) -> f32 + { + ( rect.width - self.pad_h * 2.0 ).max( self.glyph_h ) + } + + pub( crate ) fn visible_lines( &self, rect: Rect ) -> usize + { + let inner_h = ( rect.height - self.pad_v * 2.0 ).max( self.line_h ); + ( inner_h / self.line_h ).floor().max( 1.0 ) as usize + } + + /// First visual row on screen so that the caret's row stays visible. + pub( crate ) fn first_visible_line( &self, rect: Rect, total_lines: usize, cursor_line: usize ) -> usize + { + let visible = self.visible_lines( rect ); + let max_first = total_lines.saturating_sub( visible ); + if cursor_line + 1 > visible + { + ( cursor_line + 1 - visible ).min( max_first ) + } else { + 0 + } + } +} diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 8c3aeae..09c6e0a 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -18,6 +18,7 @@ pub( crate ) mod theme; pub( crate ) mod wrapping; pub( crate ) mod hit_test; pub( crate ) mod cursor; +pub( crate ) mod metrics; mod draw; #[ cfg( test ) ] mod tests; @@ -439,9 +440,8 @@ impl TextEdit { if self.multiline && !self.effective_secure() { - let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; - let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0; - ( max_width, h ) + let m = metrics::MultilineMetrics::resolve( canvas, self.font_size ); + ( max_width, m.height_for_rows( self.rows ) ) } else { let w = self.fixed_width .map( |l| canvas.resolve_geom( l ).min( max_width ) ) diff --git a/src/widget/text_edit/theme.rs b/src/widget/text_edit/theme.rs index 09ddfbf..80a77b3 100644 --- a/src/widget/text_edit/theme.rs +++ b/src/widget/text_edit/theme.rs @@ -23,6 +23,9 @@ pub const HEIGHT: f32 = 48.0; pub const RADIUS: f32 = 100.0; pub const FONT_SIZE: f32 = 16.0; pub const PAD_H: f32 = 20.0; +pub const CARET_W: f32 = 2.0; +/// How much taller than the glyph box the caret is drawn. +pub const CARET_OVERHANG: f32 = 4.0; /// Vertical padding inside multiline boxes — the single-line variant /// centres the text vertically in `HEIGHT`, so it doesn't need this. pub const PAD_V_MULTI: f32 = 12.0; @@ -35,8 +38,8 @@ pub const ROWS_DEFAULT: u32 = 5; /// Corner radius applied to multiline boxes — the pill shape used by /// the single-line variant looks wrong at multi-row heights. pub const RADIUS_MULTI: f32 = 12.0; -/// Side length, in logical pixels, of the -/// [`super::TextEdit::password_toggle`] eye icon. +/// Side length of the [`super::TextEdit::password_toggle`] eye icon in a +/// field [`HEIGHT`] tall; it scales with the field height. pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0; /// Extra horizontal slop on each side of the eye icon's hit zone /// to make the toggle finger-friendly even on touch panels.