The previous commit put the password eye, the caret and the whole multiline layout in physical pixels, but the single-line variant still inset its text by the raw `theme::PAD_H`: 20 physical px whatever the scale. On a scale-2 panel a field 88 px tall, drawn as a pill of radius 44, kept a 20 px inset, so the first glyph sat inside the rounded end while everything else in the field had grown. The padding now follows the field height, like the eye already did: `metrics::single_line_pad_h( rect )` is `PAD_H × rect.height / theme::HEIGHT`, built on `single_line_scale`, which replaces the private `password_toggle_scale` of `draw.rs`. The height is the right reference rather than `canvas.geom_px( PAD_H )`: the inset exists to clear the pill's rounded end, whose radius is half the height. With the default height the two agree, since that height is itself `geom_px( HEIGHT )`; when the app pins the height the padding follows that choice instead of the fluid mode, which on a large surface would push it to 1.5 times the design value next to a field the app deliberately kept smaller. It also needs no canvas, so `password_toggle_hit_zone( rect )` keeps its signature. `draw` resolves the padding once and uses it for the narrowed text rect, the inner clip, the selection band, the placeholder, the text and the caret; `password_toggle_hit_zone` uses it for the half padding beside the eye. `single_line_scroll_x`, `single_line_align_offset` and the single-line branch of `byte_offset_at` use the same helper, so a click still maps to the glyph that was drawn. `hit_test.rs` no longer references `theme` and drops the import. A field `HEIGHT` tall at scale 1 keeps exactly the previous geometry.
405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
// 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::metrics::{ single_line_pad_h, single_line_scale, MultilineMetrics };
|
|
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).
|
|
/// Clip for the field interior. An empty intersection with the parent clip
|
|
/// must still clip everything: an empty rect list would lift the clip.
|
|
pub( super ) fn interior_clip( outer: &[ Rect ], inner: Rect ) -> Vec<Rect>
|
|
{
|
|
if outer.is_empty()
|
|
{
|
|
return vec![ inner ];
|
|
}
|
|
let clip: Vec<Rect> = outer.iter()
|
|
.filter_map( |o| rect_intersect( *o, inner ) )
|
|
.collect();
|
|
if clip.is_empty()
|
|
{
|
|
vec![ Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } ]
|
|
} else {
|
|
clip
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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 ) * 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.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 );
|
|
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 );
|
|
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 + 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,
|
|
);
|
|
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,
|
|
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 );
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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, 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
|
|
// `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 first_line = m.first_visible_line( rect, visual_lines.len(), cursor_visual_idx );
|
|
|
|
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 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 + 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 prefix_text = &self.value[ vl.start..span_start ];
|
|
let span_text = &self.value[ span_start..span_end ];
|
|
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,
|
|
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 + 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;
|
|
}
|
|
let line = &self.value[ vl.start..vl.end ];
|
|
canvas.draw_text(
|
|
line,
|
|
rect.x + m.pad_h,
|
|
y,
|
|
m.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 + 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 + 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 outer_clip.is_empty()
|
|
{
|
|
canvas.clear_clip();
|
|
} else {
|
|
canvas.set_clip_rects( &outer_clip );
|
|
}
|
|
}
|
|
}
|