External textures. `ExternalSource::Texture` stretches its texture over the widget's rect, so a producer that is a few frames behind a new rect shows its old picture stretched or squeezed. A web page while its window is resized is the clearest case: the page keeps sending frames of the old size until it has laid itself out again, and the content wobbles as the window moves. The new `ExternalSource::Frame` closure returns the texture with its size in physical pixels, and the texture is drawn at that size from the rect's top left corner, cut to the rect. A late frame then leaves an empty edge or loses one for a moment, as other browsers do, instead of being distorted. The cut uses `interior_clip`, which moves from the text field's drawing code to `render/clip.rs` so both widgets can use it, and the clip that was active before is restored afterwards. Repaint without a view. An application whose only change is a new frame in an external texture had to report `InvalidationScope::All`, which rebuilds the whole view just to draw the same widgets again. `App::needs_repaint` is asked once per iteration, after the messages are processed. When it returns `true`, the main surface and the secondary surfaces are redrawn from the cached view, without calling `view`. The default is `false`, so existing applications behave as before. List rows. `ListItem::icon_size` overrides the size of the leading icon, which was always the theme's 24 design px, for dense lists such as a menu, where an icon of that size dominates the row. Tab strip. On the desktop, a tab's title is drawn at the regular weight whether it is selected or not; the selected tab already stands out by its fill and edge, and the bold title made the strip heavy. The desktop tab's close icon is 10 widget pixels instead of the 16 that the cards of the tab overview keep, so `parts::close` now takes the icon size from its caller.
441 lines
16 KiB
Rust
441 lines
16 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::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<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.
|
|
/// `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<TextScroll>
|
|
{
|
|
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<usize> )
|
|
{
|
|
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 ),
|
|
}
|
|
}
|
|
}
|
|
}
|