text_edit: size the password eye and the caret in physical pixels, and resolve the multiline layout through the canvas
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Canvas coordinates are physical pixels while font sizes are handed to the raster path in font space and multiplied by `dpi_scale` there. `TextEdit` mixed both: on a 2560×1440 panel at scale 2 a field 44 logical px tall measures 88 physical px and its text 32, yet the eye of `password_toggle` was rasterised at the 20 px of `PASSWORD_TOGGLE_SIZE` and the caret was `font_size + 4` = 20 px tall and 2 px wide, so both came out at less than half their intended size next to a field that otherwise scaled correctly.
Password toggle. The icon, the slop around it and the column reserved for it on the right edge now follow the field height, which is already physical: `password_toggle_scale` is `rect.height / theme::HEIGHT` and `password_toggle_reserve` applies it to the icon plus its slop. `password_toggle_hit_zone( rect )` keeps its signature and is built from the same helpers, so the zone that `handle_password_toggle_press` tests still matches what `draw` paints. A field `HEIGHT` tall at scale 1 gives exactly the previous geometry, which is what the existing hit-zone tests cover.
Caret. The single-line caret multiplies its height and width by `canvas.dpi_scale()`, the same factor the glyphs are rasterised with. The literals become `theme::CARET_W` and `theme::CARET_OVERHANG`, shared with the multiline variant.
Multiline. The multiline variant had the same defect throughout: `preferred_size`, `draw_multiline`, the multiline branch of `byte_offset_at` and the `cursor_visual_*` helpers all used `theme::FONT_SIZE`, `LINE_H_MULT`, `PAD_V_MULTI` and `PAD_H` as raw numbers. The text was rasterised at `16 × dpi_scale` but the line pitch stayed at 22.4 physical px, so at scale 2 the rows overlapped, the first one was clipped at the top, the caret was half height and the box claimed half the height its rows needed. It also ignored `.font_size( … )` and the widget-scaling mode, which the single-line variant honours. The new `metrics.rs` holds `MultilineMetrics`, resolved once per call from the canvas and the field's `font_size`: the font through `resolve_font_size`, the glyph and line heights from it times `dpi_scale`, the paddings through `canvas.geom_px` like the other stock widgets, and the caret size. It also owns the inner width, the visible row count and the first visible row, which `draw_multiline` and `byte_offset_at` each computed on their own. Sizing, drawing, hit-testing and vertical caret navigation now agree by construction. `cursor_visual_up`, `cursor_visual_down`, `cursor_visual_home` and `cursor_visual_end` gain a `font_size: Option<Length>` parameter; their callers in `event_loop/text_editing/cursor.rs` already received it from `text_input_geometry` and discarded it.
One visible change at scale 1: the multiline paddings and the default multiline font now go through `geom_px` and `font_px`, so in fluid mode they scale with the surface between 0.7 and 1.5 times the design value instead of staying at 20 / 12 / 16 px. The horizontal padding of the single-line variant is still the raw `PAD_H`.
This commit is contained in:
2026-09-19 16:00:58 +02:00
parent 53e7ccc46e
commit 495968fca5
7 changed files with 172 additions and 91 deletions
+43 -38
View File
@@ -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<Rect>
}
}
// 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<Rect>
/// 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<Msg: Clone> TextEdit<Msg>
// 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<Msg: Clone> TextEdit<Msg>
// 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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
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<Msg: Clone> TextEdit<Msg>
{
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<Msg: Clone> TextEdit<Msg>
{
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<Msg: Clone> TextEdit<Msg>
{
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 );
}