text_edit: size the password eye and the caret in physical pixels, and resolve the multiline layout through the canvas
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:
@@ -98,14 +98,14 @@ impl<A: App> AppData<A>
|
|||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
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()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.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,
|
Some( g ) => g,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
if !multiline || secure { return false; }
|
if !multiline || secure { return false; }
|
||||||
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => 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,
|
Some( n ) => n,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -124,14 +124,14 @@ impl<A: App> AppData<A>
|
|||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
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()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.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,
|
Some( g ) => g,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
if !multiline || secure { return false; }
|
if !multiline || secure { return false; }
|
||||||
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => 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,
|
Some( n ) => n,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -155,9 +155,9 @@ impl<A: App> AppData<A>
|
|||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let new_cursor = match self.text_input_geometry( focus, idx )
|
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 ),
|
None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
|
||||||
},
|
},
|
||||||
_ => 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<A: App> AppData<A>
|
|||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let new_cursor = match self.text_input_geometry( focus, idx )
|
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() ),
|
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() ),
|
_ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
|
|
||||||
use super::hit_test::byte_offset_in_line;
|
use super::hit_test::byte_offset_in_line;
|
||||||
use super::theme;
|
use super::metrics::MultilineMetrics;
|
||||||
use super::wrapping::{ compute_visual_lines, VisualLine };
|
use super::wrapping::{ compute_visual_lines, VisualLine };
|
||||||
|
|
||||||
/// Locate the visual line containing `cursor` in the wrap layout for
|
/// 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.
|
/// edge of the new one.
|
||||||
fn current_visual_line(
|
fn current_visual_line(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
|
m: &MultilineMetrics,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
value: &str,
|
value: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
) -> Option<( Vec<VisualLine>, usize )>
|
) -> Option<( Vec<VisualLine>, usize )>
|
||||||
{
|
{
|
||||||
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
|
let visual_lines = compute_visual_lines( canvas, value, m.inner_width( rect ), m.font_size );
|
||||||
let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE );
|
|
||||||
if visual_lines.is_empty() { return None; }
|
if visual_lines.is_empty() { return None; }
|
||||||
let safe_cursor = cursor.min( value.len() );
|
let safe_cursor = cursor.min( value.len() );
|
||||||
let idx = visual_lines.iter()
|
let idx = visual_lines.iter()
|
||||||
@@ -47,15 +47,17 @@ pub( crate ) fn cursor_visual_up(
|
|||||||
rect: Rect,
|
rect: Rect,
|
||||||
value: &str,
|
value: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
|
font_size: Option<Length>,
|
||||||
) -> Option<usize>
|
) -> Option<usize>
|
||||||
{
|
{
|
||||||
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; }
|
if idx == 0 { return None; }
|
||||||
let cur = lines[ idx ];
|
let cur = lines[ idx ];
|
||||||
let safe = cursor.min( value.len() );
|
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 ];
|
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.
|
/// Mirror of [`cursor_visual_up`] for the Down arrow.
|
||||||
@@ -64,15 +66,17 @@ pub( crate ) fn cursor_visual_down(
|
|||||||
rect: Rect,
|
rect: Rect,
|
||||||
value: &str,
|
value: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
|
font_size: Option<Length>,
|
||||||
) -> Option<usize>
|
) -> Option<usize>
|
||||||
{
|
{
|
||||||
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; }
|
if idx + 1 >= lines.len() { return None; }
|
||||||
let cur = lines[ idx ];
|
let cur = lines[ idx ];
|
||||||
let safe = cursor.min( value.len() );
|
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 ];
|
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.
|
/// Byte offset of the start of the cursor's current visual line.
|
||||||
@@ -83,9 +87,11 @@ pub( crate ) fn cursor_visual_home(
|
|||||||
rect: Rect,
|
rect: Rect,
|
||||||
value: &str,
|
value: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
|
font_size: Option<Length>,
|
||||||
) -> usize
|
) -> 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 )
|
.map( |( lines, idx )| lines[ idx ].start )
|
||||||
.unwrap_or( 0 )
|
.unwrap_or( 0 )
|
||||||
}
|
}
|
||||||
@@ -100,9 +106,11 @@ pub( crate ) fn cursor_visual_end(
|
|||||||
rect: Rect,
|
rect: Rect,
|
||||||
value: &str,
|
value: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
|
font_size: Option<Length>,
|
||||||
) -> usize
|
) -> 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 )
|
.map( |( lines, idx )| lines[ idx ].end )
|
||||||
.unwrap_or( value.len() )
|
.unwrap_or( value.len() )
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::types::Rect;
|
|||||||
|
|
||||||
use super::TextEdit;
|
use super::TextEdit;
|
||||||
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
|
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
|
||||||
|
use super::metrics::MultilineMetrics;
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use super::wrapping::compute_visual_lines;
|
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`
|
/// Hit rect for the eye icon at the right edge of a `TextEdit`
|
||||||
/// configured with [`TextEdit::password_toggle`]. Returned in the
|
/// configured with [`TextEdit::password_toggle`]. Returned in the
|
||||||
/// same coordinate space as the field's `rect`. Pointer / touch
|
/// 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.
|
/// moving the caret.
|
||||||
pub fn password_toggle_hit_zone( rect: Rect ) -> Rect
|
pub fn password_toggle_hit_zone( rect: Rect ) -> Rect
|
||||||
{
|
{
|
||||||
let zone_w = ( theme::PASSWORD_TOGGLE_SIZE
|
let zone_w = ( password_toggle_reserve( rect )
|
||||||
+ theme::PASSWORD_TOGGLE_SLOP * 2.0
|
+ theme::PAD_H * 0.5 * password_toggle_scale( rect ) ).min( rect.width );
|
||||||
+ theme::PAD_H * 0.5 ).min( rect.width );
|
|
||||||
Rect
|
Rect
|
||||||
{
|
{
|
||||||
x: rect.x + rect.width - zone_w,
|
x: rect.x + rect.width - zone_w,
|
||||||
@@ -110,7 +121,7 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
// never slide under the icon.
|
// never slide under the icon.
|
||||||
let toggle_reserve = if self.password_toggle.is_some()
|
let toggle_reserve = if self.password_toggle.is_some()
|
||||||
{
|
{
|
||||||
theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0
|
password_toggle_reserve( rect )
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -209,12 +220,13 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
// Caret height follows the text line, not the field box — a
|
// Caret height follows the text line, not the field box — a
|
||||||
// tall field (launcher pill) otherwise grows a caret twice
|
// tall field (launcher pill) otherwise grows a caret twice
|
||||||
// the glyph height. Matches the multiline caret.
|
// 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
|
let cursor_rect = Rect
|
||||||
{
|
{
|
||||||
x: cursor_x,
|
x: cursor_x,
|
||||||
y: rect.y + ( rect.height - cursor_h ) / 2.0,
|
y: rect.y + ( rect.height - cursor_h ) / 2.0,
|
||||||
width: 2.0,
|
width: ( theme::CARET_W * dpi ).round(),
|
||||||
height: cursor_h,
|
height: cursor_h,
|
||||||
};
|
};
|
||||||
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
|
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()
|
if let Some( ( visible, _ ) ) = self.password_toggle.as_ref()
|
||||||
{
|
{
|
||||||
let icon_name = if *visible { "actions/invisible" } else { "actions/visible" };
|
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 )
|
if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px )
|
||||||
{
|
{
|
||||||
let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() );
|
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.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI );
|
||||||
canvas.stroke_rect( rect, border_c, border_w, 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 m = MultilineMetrics::resolve( canvas, self.font_size );
|
||||||
let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
|
let line_h = m.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
|
// Compute the visual-line layout once per draw. Iterators
|
||||||
// later go through this list; long logical lines are
|
// later go through this list; long logical lines are
|
||||||
// soft-wrapped at the last whitespace before the row would
|
// soft-wrapped at the last whitespace before the row would
|
||||||
// overflow without mutating the buffer.
|
// 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
|
// Vertical auto-scroll: keep the cursor's visual line on
|
||||||
// screen. The cursor sits in the *first* visual line whose
|
// 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()
|
let cursor_visual_idx = visual_lines.iter()
|
||||||
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
|
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
|
||||||
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
|
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
|
||||||
let total_lines = visual_lines.len();
|
let first_line = m.first_visible_line( rect, visual_lines.len(), cursor_visual_idx );
|
||||||
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;
|
let baseline_0 = rect.y + m.pad_v + m.glyph_h;
|
||||||
|
|
||||||
// Clip text to the inner rect so partial lines at the bottom
|
// Clip text to the inner rect so partial lines at the bottom
|
||||||
// stay inside the box and do not bleed onto sibling widgets
|
// 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 outer_clip = canvas.clip_bounds();
|
||||||
let inner_rect = Rect
|
let inner_rect = Rect
|
||||||
{
|
{
|
||||||
x: rect.x + theme::PAD_H * 0.5,
|
x: rect.x + m.pad_h * 0.5,
|
||||||
y: rect.y + theme::PAD_V_MULTI * 0.5,
|
y: rect.y + m.pad_v * 0.5,
|
||||||
width: ( rect.width - theme::PAD_H ).max( 0.0 ),
|
width: ( rect.width - m.pad_h ).max( 0.0 ),
|
||||||
height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ),
|
height: ( rect.height - m.pad_v ).max( 0.0 ),
|
||||||
};
|
};
|
||||||
canvas.set_clip_rects( &interior_clip( &outer_clip, inner_rect ) );
|
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 )
|
for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
|
||||||
{
|
{
|
||||||
let visual_idx = i - first_line;
|
let visual_idx = i - first_line;
|
||||||
let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h;
|
let y = rect.y + m.pad_v + visual_idx as f32 * line_h;
|
||||||
if y > rect.y + rect.height - theme::PAD_V_MULTI { break; }
|
if y > rect.y + rect.height - m.pad_v { break; }
|
||||||
// Intersection of [s, e] with this visual line's
|
// Intersection of [s, e] with this visual line's
|
||||||
// byte range. Empty / non-overlapping → skip.
|
// byte range. Empty / non-overlapping → skip.
|
||||||
let span_start = s.max( vl.start );
|
let span_start = s.max( vl.start );
|
||||||
@@ -333,8 +338,8 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
if span_start >= span_end { continue; }
|
if span_start >= span_end { continue; }
|
||||||
let prefix_text = &self.value[ vl.start..span_start ];
|
let prefix_text = &self.value[ vl.start..span_start ];
|
||||||
let span_text = &self.value[ span_start..span_end ];
|
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 x0 = rect.x + m.pad_h + canvas.measure_text( prefix_text, m.font_size );
|
||||||
let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 );
|
let w = canvas.measure_text( span_text, m.font_size ).max( m.caret_w * 2.0 );
|
||||||
let sel_rect = Rect
|
let sel_rect = Rect
|
||||||
{
|
{
|
||||||
x: x0, y,
|
x: x0, y,
|
||||||
@@ -348,9 +353,9 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
{
|
{
|
||||||
canvas.draw_text(
|
canvas.draw_text(
|
||||||
&self.placeholder,
|
&self.placeholder,
|
||||||
rect.x + theme::PAD_H,
|
rect.x + m.pad_h,
|
||||||
baseline_0,
|
baseline_0,
|
||||||
theme::FONT_SIZE,
|
m.font_size,
|
||||||
theme::placeholder(),
|
theme::placeholder(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -358,16 +363,16 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
{
|
{
|
||||||
let visual_idx = i - first_line;
|
let visual_idx = i - first_line;
|
||||||
let y = baseline_0 + visual_idx as f32 * line_h;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
let line = &self.value[ vl.start..vl.end ];
|
let line = &self.value[ vl.start..vl.end ];
|
||||||
canvas.draw_text(
|
canvas.draw_text(
|
||||||
line,
|
line,
|
||||||
rect.x + theme::PAD_H,
|
rect.x + m.pad_h,
|
||||||
y,
|
y,
|
||||||
theme::FONT_SIZE,
|
m.font_size,
|
||||||
theme::text(),
|
theme::text(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -377,16 +382,16 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
{
|
{
|
||||||
let vl = visual_lines[ cursor_visual_idx ];
|
let vl = visual_lines[ cursor_visual_idx ];
|
||||||
let line_prefix = &self.value[ vl.start..safe_cursor ];
|
let line_prefix = &self.value[ vl.start..safe_cursor ];
|
||||||
let cursor_x = rect.x + theme::PAD_H
|
let cursor_x = rect.x + m.pad_h
|
||||||
+ canvas.measure_text( line_prefix, theme::FONT_SIZE );
|
+ canvas.measure_text( line_prefix, m.font_size );
|
||||||
let visual_y_idx = cursor_visual_idx.saturating_sub( first_line );
|
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
|
let cursor_rect = Rect
|
||||||
{
|
{
|
||||||
x: cursor_x,
|
x: cursor_x,
|
||||||
y: cursor_y + 2.0,
|
y: cursor_y + ( line_h - m.caret_h ) / 2.0,
|
||||||
width: 2.0,
|
width: m.caret_w,
|
||||||
height: theme::FONT_SIZE + 4.0,
|
height: m.caret_h,
|
||||||
};
|
};
|
||||||
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
|
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use crate::types::{ Length, Point, Rect };
|
use crate::types::{ Length, Point, Rect };
|
||||||
|
|
||||||
|
use super::metrics::MultilineMetrics;
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use super::wrapping::compute_visual_lines;
|
use super::wrapping::compute_visual_lines;
|
||||||
|
|
||||||
@@ -33,32 +34,25 @@ pub( crate ) fn byte_offset_at(
|
|||||||
|
|
||||||
if multiline && !secure
|
if multiline && !secure
|
||||||
{
|
{
|
||||||
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
|
let m = MultilineMetrics::resolve( canvas, font_size );
|
||||||
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 );
|
|
||||||
|
|
||||||
// Recompute the visual layout from current state. Same call
|
// Recompute the visual layout from current state. Same call
|
||||||
// as `draw_multiline` makes, so the click maps to the line /
|
// as `draw_multiline` makes, so the click maps to the line /
|
||||||
// column the user actually sees.
|
// 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 safe_cursor = cursor_pos.min( value.len() );
|
||||||
let cursor_visual_idx = visual_lines.iter()
|
let cursor_visual_idx = visual_lines.iter()
|
||||||
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
|
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
|
||||||
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
|
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
|
||||||
let total_lines = visual_lines.len();
|
let total_lines = visual_lines.len();
|
||||||
let max_first = total_lines.saturating_sub( visible_lines );
|
let first_line = m.first_visible_line( rect, total_lines, cursor_visual_idx );
|
||||||
let first_line = if cursor_visual_idx + 1 > visible_lines
|
|
||||||
{
|
|
||||||
( cursor_visual_idx + 1 - visible_lines ).min( max_first )
|
|
||||||
} else { 0 };
|
|
||||||
|
|
||||||
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;
|
.max( 0.0 ) as usize;
|
||||||
let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
|
let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
|
||||||
let vl = visual_lines[ target_idx ];
|
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 {
|
} else {
|
||||||
// Resolve the font-size sentinel (0.0 / negative → widget-scaling mode)
|
// Resolve the font-size sentinel (0.0 / negative → widget-scaling mode)
|
||||||
// so a click maps against the same size the renderer drew with.
|
// so a click maps against the same size the renderer drew with.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
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<Length> ) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ pub( crate ) mod theme;
|
|||||||
pub( crate ) mod wrapping;
|
pub( crate ) mod wrapping;
|
||||||
pub( crate ) mod hit_test;
|
pub( crate ) mod hit_test;
|
||||||
pub( crate ) mod cursor;
|
pub( crate ) mod cursor;
|
||||||
|
pub( crate ) mod metrics;
|
||||||
mod draw;
|
mod draw;
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -439,9 +440,8 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
{
|
{
|
||||||
if self.multiline && !self.effective_secure()
|
if self.multiline && !self.effective_secure()
|
||||||
{
|
{
|
||||||
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
|
let m = metrics::MultilineMetrics::resolve( canvas, self.font_size );
|
||||||
let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0;
|
( max_width, m.height_for_rows( self.rows ) )
|
||||||
( max_width, h )
|
|
||||||
} else {
|
} else {
|
||||||
let w = self.fixed_width
|
let w = self.fixed_width
|
||||||
.map( |l| canvas.resolve_geom( l ).min( max_width ) )
|
.map( |l| canvas.resolve_geom( l ).min( max_width ) )
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub const HEIGHT: f32 = 48.0;
|
|||||||
pub const RADIUS: f32 = 100.0;
|
pub const RADIUS: f32 = 100.0;
|
||||||
pub const FONT_SIZE: f32 = 16.0;
|
pub const FONT_SIZE: f32 = 16.0;
|
||||||
pub const PAD_H: f32 = 20.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
|
/// Vertical padding inside multiline boxes — the single-line variant
|
||||||
/// centres the text vertically in `HEIGHT`, so it doesn't need this.
|
/// centres the text vertically in `HEIGHT`, so it doesn't need this.
|
||||||
pub const PAD_V_MULTI: f32 = 12.0;
|
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
|
/// Corner radius applied to multiline boxes — the pill shape used by
|
||||||
/// the single-line variant looks wrong at multi-row heights.
|
/// the single-line variant looks wrong at multi-row heights.
|
||||||
pub const RADIUS_MULTI: f32 = 12.0;
|
pub const RADIUS_MULTI: f32 = 12.0;
|
||||||
/// Side length, in logical pixels, of the
|
/// Side length of the [`super::TextEdit::password_toggle`] eye icon in a
|
||||||
/// [`super::TextEdit::password_toggle`] eye icon.
|
/// field [`HEIGHT`] tall; it scales with the field height.
|
||||||
pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0;
|
pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0;
|
||||||
/// Extra horizontal slop on each side of the eye icon's hit zone
|
/// Extra horizontal slop on each side of the eye icon's hit zone
|
||||||
/// to make the toggle finger-friendly even on touch panels.
|
/// to make the toggle finger-friendly even on touch panels.
|
||||||
|
|||||||
Reference in New Issue
Block a user