A text field that serves as an editor, a file chooser, ltk::files with callbacks, Text::fit, App::window_title, and a stack that keeps its children inside it
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

A multiline text field was a box for a few lines: it wrapped its whole value by shaping each character alone on every frame, every click and every caret motion, so a file of some size made it crawl; it drew a tab as the font's glyph for it, had no scroll bar, no page keys and no way to number its lines, kept no record of the view it showed, so once the cursor had left the first screen it stayed on the last row and the arrows moved the text, and an app could neither colour its text, type a tab with Tab, give it a face of its own, ask it to copy, cut, paste or select, nor hear where its cursor was. Notes, the Eydos text editor, needs all of it, and every app that opens or saves the user's files needed a dialog of its own.
Speed. `Canvas::measure_char` measures a character's advance once per font and size and keeps it (`text_shaping::char_advance_cached`, emptied with the shaped lines), and `compute_visual_lines` keeps the last four layouts by the value's hash and length, the row width, the size and the face, so a value is wrapped again only when one of them changes, and drawing, hit testing and caret motion over the same value wrap it once.
The view. `TextEdit::draw` returns the view it showed, which the frame keeps in `text_scroll`, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. `Window::shown` gives the lines as the last frame showed them, wherever the cursor went since, and a press, the wheel, the scroll bar and the page keys act on them: a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. The cursor a scroll keeps is taken back to the value's length as the view's is, so a scroll made with the cursor at its end sentinel holds instead of going back to the cursor at once.
Tabs. In a multiline field a tab reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`, `text_edit/tabs.rs`), when it is drawn, selected, clicked and crossed with the arrows. `TextEdit::tab_text( text )` has Tab type `text` while the field has the focus instead of moving the focus on; Shift+Tab still moves it back and Ctrl+Tab still reaches the app.
The scroll bar. A field whose lines do not all fit shows a scroll bar along its right edge (`Window::scrollbar`): its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor (`AppData::text_scrollbar_grab`, `handle_text_scrollbar_press`, `handle_text_scrollbar_drag`); the pointer over it is the arrow rather than the text cursor, and a long press that holds it no longer opens the Copy / Cut / Paste menu.
Page Up and Page Down move the cursor of a multiline field by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page (`cursor_visual_page`, `handle_cursor_page`); Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. A key without Ctrl that types no text, such as F3, was dropped while a text field had the focus; it now goes to `App::on_key_with_modifiers`, as it does with no field focused.
The text as an editor's. `TextEdit::highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, what no run covers in the field's colour and a run off a character boundary left out. `TextEdit::font( face )` lays a multiline field's text out in a face of its own at the field's size: wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure through one `TextFont`, the size and the face, which `text_input_geometry` now gives the runtime's paths where it gave them the size; `Canvas::measure_char_with_font` is `measure_char` in another face. `ltk::monospace_font()` finds a monospace face, DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's installed, loaded once. `TextEdit::line_numbers( true )` numbers the lines in a column at the field's left, in the monospace face when there is one, as wide as the number of the last line needs, a wrapped line numbered on its first row and the cursor's in the text colour while the field has the focus; the text starts right of the column alike when drawn and in every runtime path, since they all lay it out in the rect `gutter::text_rect` gives. `borderless()` now also drops a multiline field's background and border.
Talking to the field. `App::take_text_command` hands a field, by its `WidgetId`, a `TextCommand` after the next frame: `Copy`, `Cut`, `Paste` and `SelectAll` through the paths the keys take, and `Select { start, end }`, which focuses the field, selects the range with its ends taken back to the value and to its characters' starts, and scrolls to it. `Reveal { start, end }` does the same without moving the focus, as a search does while its own field keeps the keys, and a multiline field without the focus now shows its selection dimmed (`theme::selection_inactive`). `TextEdit::on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length (`report_text_cursors`, after every frame), as an editor's status bar showing the line needs.
A file chooser. `ltk::FileChooser` (`src/file_chooser/`) picks a file to open or a folder and a name to save one as, over `ltk::files`, alike natively and in a browser. The app holds it, puts its `view()` in its own and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings reach it through the app's `ChannelSender`. It offers the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; it lists folders first, the hidden entries left out, each with its type's icon, a file with its size and the date of its last access or change, sorted as Crate sorts, by name, last access or size, each key in a direction of its own (`Sort`, `SortKey`, `Directions`). `dir` starts elsewhere, `filter( &[ "text/*" ] )` shows only some types of files, `compact( true )` puts the places behind a button for a phone, `sort` starts in an order kept from `current_sort`, and `take_focus_request` gives the field to focus. Saving asks before replacing a file and can create a folder; its errors are in the user's words, and its strings in ltk's seven locales. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps. `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it, in `make examples`.
ltk::files::callback. The operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser.
Closing. An app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request, from the compositor, the title bar's close button, back and the browser, now goes through `AppData::request_close`, which draws the view again when the app refused it.
`App::window_title`. The main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
`Text::fit` and `Text::valign`. `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it, on one line within its width and height or, with `wrap`, all its lines within its height, so a text whose length varies, such as a calculator's display, neither overflows a box the window sizes nor needs its size worked out from the window and its content; its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top, the centre or the bottom of the rect.
A stack keeps its children inside it. A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned; children placed with `push_placed` and the offsets of `push_translated` are as they were.
Tests cover Tab with and without `tab_text`, the text commands, `Reveal` leaving the focus alone, a highlight into a character, `on_cursor` reporting once, a refused close drawing again, a key that types nothing reaching the app from a field, tab stops, a click on a tab, the kept layouts, the scroll bar, a view kept while the cursor moves inside it and after a scroll with the cursor at its sentinel, a click landing on the view shown, the page keys, the width of the line numbers, the file chooser's listing, filters, names, user folders, sizes, sorting and its open, save and replace flows, `Text::fit` and a stack's aligned children. `CHANGELOG.md` and `docs/widgets.md` describe the changes.
This commit is contained in:
2026-10-26 23:00:33 +01:00
parent 18a0e02249
commit 8882078743
62 changed files with 3191 additions and 185 deletions
+7 -2
View File
@@ -99,12 +99,12 @@ impl<Msg: Clone> Element<Msg>
cursor_pos: usize,
selection_anchor: usize,
text_scroll: text_edit::TextScroll,
)
) -> Option<text_edit::TextScroll>
{
match self
{
Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor, text_scroll ),
Element::TextEdit( t ) => return t.draw( canvas, rect, focused, cursor_pos, selection_anchor, text_scroll ),
Element::Image( i ) => i.draw( canvas, rect ),
Element::Column( c ) => c.draw( canvas, rect, focused ),
Element::Row( r ) => r.draw( canvas, rect, focused ),
@@ -136,6 +136,7 @@ impl<Msg: Clone> Element<Msg>
Element::TabCells( _ ) => {}
Element::TabCards( _ ) => {}
}
None
}
pub fn hit_test( &self, rect: Rect, pos: Point ) -> bool
@@ -323,6 +324,7 @@ impl<Msg: Clone> Element<Msg>
{
value: t.value.clone(),
on_change: t.on_change.clone(),
on_cursor: t.on_cursor.clone(),
on_submit: t.on_submit.clone(),
on_focus: t.on_focus.clone(),
// `secure` here drives memory wipe-on-drop and
@@ -338,6 +340,9 @@ impl<Msg: Clone> Element<Msg>
select_on_focus: t.select_on_focus,
pin_cursor_to_end: t.pin_cursor_to_end,
password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ),
tab_text: t.tab_text.clone(),
line_numbers: t.line_numbers && t.is_multiline(),
face: t.text_font().face,
}
}
Element::PatternGrid( g ) =>
+32 -1
View File
@@ -39,6 +39,7 @@ pub enum WidgetHandlers<Msg: Clone>
{
value: String,
on_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_cursor: Option<Arc<dyn Fn( usize ) -> Msg>>,
on_submit: Option<Msg>,
on_focus: Option<Msg>,
/// `true` when the source `TextEdit` was built with
@@ -78,6 +79,12 @@ pub enum WidgetHandlers<Msg: Clone>
/// before falling through to cursor placement and fires
/// this message instead.
password_toggle_msg: Option<Msg>,
/// What Tab types in the field, from [`crate::widget::text_edit::TextEdit::tab_text`].
tab_text: Option<String>,
/// Whether a multiline field shows line numbers, whose column its text starts after.
line_numbers: bool,
/// The face of a multiline field's text, from [`crate::widget::text_edit::TextEdit::font`].
face: Option<Arc<crate::font_face::FontFace>>,
},
Slider
{
@@ -146,12 +153,13 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected },
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, pin_cursor_to_end, password_toggle_msg } =>
WidgetHandlers::TextEdit { value, on_change, on_cursor, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, pin_cursor_to_end, password_toggle_msg, tab_text, line_numbers, face } =>
{
WidgetHandlers::TextEdit
{
value: value.clone(),
on_change: on_change.clone(),
on_cursor: on_cursor.clone(),
on_submit: on_submit.clone(),
on_focus: on_focus.clone(),
secure: *secure,
@@ -163,6 +171,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
select_on_focus: *select_on_focus,
pin_cursor_to_end: *pin_cursor_to_end,
password_toggle_msg: password_toggle_msg.clone(),
tab_text: tab_text.clone(),
line_numbers: *line_numbers,
face: face.clone(),
}
}
WidgetHandlers::Slider { on_change, on_release, axis, value, thumb_px } =>
@@ -455,4 +466,24 @@ impl<Msg: Clone> WidgetHandlers<Msg>
_ => None,
}
}
/// The message a text field that asked for its cursor sends with the cursor at `at`.
pub fn text_cursor_msg( &self, at: usize ) -> Option<Msg>
{
match self
{
WidgetHandlers::TextEdit { on_cursor: Some( f ), .. } => Some( f( at ) ),
_ => None,
}
}
/// What Tab types in a text field that asked for it.
pub fn tab_text( &self ) -> Option<&str>
{
match self
{
WidgetHandlers::TextEdit { tab_text, .. } => tab_text.as_deref(),
_ => None,
}
}
}
+97 -30
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use crate::font_face::FontFace;
use crate::layout::stack::VAlign;
use crate::theme::FontStyle;
use crate::types::{ Color, Length, Rect };
use crate::render::Canvas;
@@ -57,6 +57,11 @@ pub struct Text
/// onto multiple lines. `1.0` (default) uses the font-declared leading;
/// values above `1.0` open up the gap between wrapped lines.
pub( crate ) line_height: f32,
/// Drawn at the largest size up to [`Self::size`] at which it fits its
/// layout rect, instead of overflowing it.
pub( crate ) fit: bool,
/// Where the lines sit in a rect taller than they are.
pub( crate ) valign: VAlign,
}
impl Text
@@ -76,6 +81,8 @@ impl Text
font: None,
face: None,
line_height: 1.0,
fit: false,
valign: VAlign::Top,
}
}
@@ -171,6 +178,27 @@ impl Text
self
}
/// Draw at the largest size, up to [`Self::size`], at which the text
/// fits the rect the layout gives it: on one line within its width and
/// height, or, with [`Self::wrap`], all its lines within its height.
/// For text whose length varies, such as a number, a total or a title,
/// in a box whose size the window sets. The preferred size is still
/// the one at [`Self::size`].
pub fn fit( mut self ) -> Self
{
self.fit = true;
self
}
/// Where the lines sit in a rect taller than they are, as a fitted text's
/// may be: at its top (the default), its centre or its bottom.
/// [`VAlign::Fill`] is the top.
pub fn valign( mut self, v: VAlign ) -> Self
{
self.valign = v;
self
}
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<FontFace>>
{
if let Some( face ) = &self.face
@@ -183,9 +211,8 @@ impl Text
} )
}
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
fn measure( &self, text: &str, size: f32, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
{
let size = self.resolved_size( canvas );
match font
{
Some( f ) => canvas.measure_text_with_font( text, size, f ),
@@ -193,9 +220,8 @@ impl Text
}
}
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
fn measure_char( &self, ch: char, size: f32, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
{
let size = self.resolved_size( canvas );
match font
{
Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width,
@@ -203,9 +229,8 @@ impl Text
}
}
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<FontFace>> )
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, size: f32, font: Option<&Arc<FontFace>> )
{
let size = self.resolved_size( canvas );
match font
{
Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ),
@@ -213,17 +238,22 @@ impl Text
}
}
/// `new_line_size = ascent - descent + line_gap` is the standard
/// typographic line height; `ascent - descent` alone discards the
/// font-declared leading and at large sizes leaves adjacent rows
/// visibly overlapping when stacked tight in a column.
fn line_height_at( &self, size: f32, canvas: &Canvas ) -> f32
{
canvas.font_line_metrics( size )
.map( |m| m.new_line_size )
.unwrap_or( size ) * self.line_height
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let size = self.resolved_size( canvas );
// `new_line_size = ascent - descent + line_gap` is the standard
// typographic line height; the previous `ascent - descent` discarded
// the font-declared leading and at large sizes left adjacent rows
// visibly overlapping when stacked tight in a column.
let line_h = canvas.font_line_metrics( size )
.map( |m| m.new_line_size )
.unwrap_or( size ) * self.line_height;
let font = self.resolve_font( canvas );
let line_h = self.line_height_at( size, canvas );
let font = self.resolve_font( canvas );
if self.wrap
{
@@ -233,28 +263,65 @@ impl Text
}
else
{
let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width );
let w = ( self.measure( &self.content, size, canvas, font.as_ref() ) + 8.0 ).min( max_width );
( w, line_h )
}
}
/// [`Self::size`], or with [`Self::fit`] the largest size up to it at which the text fits `rect`.
fn fitted_size( &self, rect: Rect, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
{
let size = self.resolved_size( canvas );
if !self.fit { return size; }
let fits = |s: f32|
{
let line_h = self.line_height_at( s, canvas );
if self.wrap
{
wrap_lines( &self.content, s, rect.width - WRAP_PAD, canvas, font ).len() as f32 * line_h <= rect.height
} else {
self.measure( &self.content, s, canvas, font ) + 8.0 <= rect.width && line_h <= rect.height
}
};
if fits( size ) { return size; }
// Bisection: wrapping makes the fit a step function of the size, so it cannot be solved for directly.
let ( mut lo, mut hi ) = ( 1.0_f32.min( size ), size );
for _ in 0..12
{
let mid = ( lo + hi ) / 2.0;
if fits( mid ) { lo = mid; } else { hi = mid; }
}
lo
}
/// How far down `rect` lines `block` high start, as [`Self::valign`] says.
fn top( &self, rect: Rect, block: f32 ) -> f32
{
let slack = ( rect.height - block ).max( 0.0 );
match self.valign
{
VAlign::Top | VAlign::Fill => rect.y,
VAlign::Center => rect.y + slack / 2.0,
VAlign::Bottom => rect.y + slack,
}
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let size = self.resolved_size( canvas );
let font = self.resolve_font( canvas );
let size = self.fitted_size( rect, canvas, font.as_ref() );
let ascent = canvas.font_line_metrics( size )
.map( |m| m.ascent )
.unwrap_or( size * 0.8 );
let line_h = canvas.font_line_metrics( size )
.map( |m| m.new_line_size )
.unwrap_or( size ) * self.line_height;
let font = self.resolve_font( canvas );
let line_h = self.line_height_at( size, canvas );
if self.wrap
{
let lines = wrap_lines( &self.content, size, rect.width - WRAP_PAD, canvas, font.as_ref() );
let top = self.top( rect, line_h * lines.len() as f32 );
for ( i, line ) in lines.iter().enumerate()
{
let line_w = self.measure( line, canvas, font.as_ref() );
let line_w = self.measure( line, size, canvas, font.as_ref() );
let slack = ( rect.width - line_w ).max( 0.0 );
let pad = WRAP_PAD.min( slack );
let tx = match self.align
@@ -263,18 +330,18 @@ impl Text
TextAlign::Center => rect.x + slack / 2.0,
TextAlign::Right => rect.x + rect.width - line_w - pad,
};
let ty = rect.y + ascent + line_h * i as f32;
self.paint( canvas, line, tx, ty, font.as_ref() );
let ty = top + ascent + line_h * i as f32;
self.paint( canvas, line, tx, ty, size, font.as_ref() );
}
return;
}
let text_w = self.measure( &self.content, canvas, font.as_ref() );
let text_w = self.measure( &self.content, size, canvas, font.as_ref() );
let display = if self.truncate && text_w > rect.width && rect.width > 0.0
{
let ellipsis = "...";
let ell_w = self.measure( ellipsis, canvas, font.as_ref() );
let ell_w = self.measure( ellipsis, size, canvas, font.as_ref() );
let budget = rect.width - ell_w;
if budget <= 0.0
{
@@ -285,7 +352,7 @@ impl Text
let mut accum = 0.0_f32;
let truncated: String = self.content.chars().take_while( |ch|
{
let cw = self.measure_char( *ch, canvas, font.as_ref() );
let cw = self.measure_char( *ch, size, canvas, font.as_ref() );
accum += cw;
accum <= budget
} ).collect();
@@ -297,7 +364,7 @@ impl Text
self.content.clone()
};
let disp_w = self.measure( &display, canvas, font.as_ref() );
let disp_w = self.measure( &display, size, canvas, font.as_ref() );
let slack = ( rect.width - disp_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let tx = match self.align
@@ -307,8 +374,8 @@ impl Text
TextAlign::Right => rect.x + rect.width - disp_w - pad,
};
let ty = rect.y + ascent;
self.paint( canvas, &display, tx, ty, font.as_ref() );
let ty = self.top( rect, line_h ) + ascent;
self.paint( canvas, &display, tx, ty, size, font.as_ref() );
}
}
+57
View File
@@ -140,3 +140,60 @@ fn text_align_enum_implements_partial_eq()
assert_eq!( TextAlign::Left, TextAlign::Left );
assert_ne!( TextAlign::Left, TextAlign::Right );
}
fn rect( x: f32, y: f32, width: f32, height: f32 ) -> Rect
{
Rect { x, y, width, height }
}
#[ test ]
fn without_fit_the_text_keeps_its_size()
{
let canvas = make_canvas();
let t = Text::new( "0123456789" ).size( 64.0 );
assert_eq!( t.fitted_size( rect( 0.0, 0.0, 40.0, 20.0 ), &canvas, None ), 64.0 );
}
#[ test ]
fn a_fitted_text_that_fits_keeps_its_size()
{
let canvas = make_canvas();
let t = Text::new( "0" ).size( 32.0 ).fit();
assert_eq!( t.fitted_size( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas, None ), 32.0 );
}
#[ test ]
fn a_fitted_line_shrinks_to_the_width_and_the_height()
{
let canvas = make_canvas();
let wide = Text::new( "0123456789" ).size( 64.0 ).fit();
let size = wide.fitted_size( rect( 0.0, 0.0, 120.0, 300.0 ), &canvas, None );
assert!( size < 64.0, "size={size}" );
assert!( wide.measure( &wide.content, size, &canvas, None ) + 8.0 <= 120.0 );
let low = Text::new( "0" ).size( 64.0 ).fit();
let size = low.fitted_size( rect( 0.0, 0.0, 400.0, 30.0 ), &canvas, None );
assert!( size < 64.0 && low.line_height_at( size, &canvas ) <= 30.0, "size={size}" );
}
#[ test ]
fn a_fitted_wrapped_text_keeps_all_its_lines_within_the_height()
{
let canvas = make_canvas();
let t = Text::new( "the quick brown fox jumps over the lazy dog" ).size( 48.0 ).wrap( true ).fit();
let area = rect( 0.0, 0.0, 160.0, 90.0 );
let size = t.fitted_size( area, &canvas, None );
let lines = wrap_lines( &t.content, size, area.width - WRAP_PAD, &canvas, None );
assert!( size < 48.0 );
assert!( lines.len() as f32 * t.line_height_at( size, &canvas ) <= area.height, "size={size} lines={lines:?}" );
}
#[ test ]
fn valign_places_the_lines_in_a_taller_rect()
{
let area = rect( 0.0, 10.0, 100.0, 100.0 );
assert_eq!( Text::new( "" ).top( area, 20.0 ), 10.0 );
assert_eq!( Text::new( "" ).valign( VAlign::Center ).top( area, 20.0 ), 50.0 );
assert_eq!( Text::new( "" ).valign( VAlign::Bottom ).top( area, 20.0 ), 90.0 );
assert_eq!( Text::new( "" ).valign( VAlign::Bottom ).top( area, 150.0 ), 10.0 );
}
+58 -19
View File
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::rc::Rc;
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use crate::types::Rect;
use super::hit_test::byte_offset_in_line;
use super::metrics::MultilineMetrics;
use super::scroll::{ TextScroll, Window };
use super::tabs::line_x;
use super::wrapping::{ compute_visual_lines, VisualLine };
use super::TextFont;
/// Locate the visual line containing `cursor` in the wrap layout for
/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap
@@ -17,13 +22,14 @@ use super::wrapping::{ compute_visual_lines, VisualLine };
/// edge of the new one.
fn current_visual_line(
canvas: &Canvas,
font: &TextFont,
m: &MultilineMetrics,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<( Vec<VisualLine>, usize )>
) -> Option<( Rc<Vec<VisualLine>>, usize )>
{
let visual_lines = compute_visual_lines( canvas, value, m.inner_width( rect ), m.font_size );
let visual_lines = compute_visual_lines( canvas, font, 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()
@@ -47,17 +53,17 @@ pub( crate ) fn cursor_visual_up(
rect: Rect,
value: &str,
cursor: usize,
font_size: Option<Length>,
font: &TextFont,
) -> Option<usize>
{
let m = MultilineMetrics::resolve( canvas, font_size );
let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?;
let m = MultilineMetrics::resolve( canvas, font.size );
let ( lines, idx ) = current_visual_line( canvas, font, &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], m.font_size );
let target_x = line_x( canvas, font, 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, m.font_size ) )
Some( byte_offset_in_line( canvas, font, value, prev.start, prev.end, target_x, false, true, m.font_size ) )
}
/// Mirror of [`cursor_visual_up`] for the Down arrow.
@@ -66,17 +72,17 @@ pub( crate ) fn cursor_visual_down(
rect: Rect,
value: &str,
cursor: usize,
font_size: Option<Length>,
font: &TextFont,
) -> Option<usize>
{
let m = MultilineMetrics::resolve( canvas, font_size );
let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?;
let m = MultilineMetrics::resolve( canvas, font.size );
let ( lines, idx ) = current_visual_line( canvas, font, &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], m.font_size );
let target_x = line_x( canvas, font, 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, m.font_size ) )
Some( byte_offset_in_line( canvas, font, value, next.start, next.end, target_x, false, true, m.font_size ) )
}
/// Byte offset of the start of the cursor's current visual line.
@@ -87,11 +93,11 @@ pub( crate ) fn cursor_visual_home(
rect: Rect,
value: &str,
cursor: usize,
font_size: Option<Length>,
font: &TextFont,
) -> usize
{
let m = MultilineMetrics::resolve( canvas, font_size );
current_visual_line( canvas, &m, rect, value, cursor )
let m = MultilineMetrics::resolve( canvas, font.size );
current_visual_line( canvas, font, &m, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].start )
.unwrap_or( 0 )
}
@@ -106,11 +112,44 @@ pub( crate ) fn cursor_visual_end(
rect: Rect,
value: &str,
cursor: usize,
font_size: Option<Length>,
font: &TextFont,
) -> usize
{
let m = MultilineMetrics::resolve( canvas, font_size );
current_visual_line( canvas, &m, rect, value, cursor )
let m = MultilineMetrics::resolve( canvas, font.size );
current_visual_line( canvas, font, &m, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].end )
.unwrap_or( value.len() )
}
/// The cursor a page up or down from `cursor`, a page being the lines
/// on screen but one, at the same height on screen as before, and the
/// view moved by that page to show it there. From the first page up,
/// or the last down, the cursor goes to the start or the end.
pub( crate ) fn cursor_visual_page(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
font: &TextFont,
scroll: TextScroll,
up: bool,
) -> ( usize, TextScroll )
{
let window = Window::shown( canvas, rect, value, font, cursor, scroll );
let lines = &window.lines;
let page = window.visible.saturating_sub( 1 ).max( 1 );
let cursor = cursor.min( value.len() );
let line = window.cursor_line;
let target = if up { line.saturating_sub( page ) } else { ( line + page ).min( lines.len().saturating_sub( 1 ) ) };
let moved = if target == line
{
if up { 0 } else { value.len() }
} else {
let font_size = window.metrics.font_size;
let x = line_x( canvas, font, value, lines[ line ].start, cursor, font_size );
let row = lines[ target ];
byte_offset_in_line( canvas, font, value, row.start, row.end, x, false, true, font_size )
};
let first = if up { window.first_line.saturating_sub( page ) } else { ( window.first_line + page ).min( window.max_first ) };
( moved, TextScroll { first_line: first, cursor_pos: moved, remainder: 0.0 } )
}
+111 -29
View File
@@ -2,12 +2,15 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
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;
/// Axis-aligned intersection of two rects. Returns `None` when the rects
@@ -79,7 +82,8 @@ impl<Msg: Clone> TextEdit<Msg>
/// 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 scrolled by hand.
/// `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,
@@ -88,12 +92,11 @@ impl<Msg: Clone> TextEdit<Msg>
cursor_pos: usize,
selection_anchor: usize,
scroll: TextScroll,
)
) -> Option<TextScroll>
{
if self.is_multiline()
{
self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor, scroll );
return;
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
@@ -261,6 +264,41 @@ impl<Msg: Clone> TextEdit<Msg>
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(
@@ -271,14 +309,20 @@ impl<Msg: Clone> TextEdit<Msg>
cursor_pos: usize,
selection_anchor: usize,
scroll: TextScroll,
)
) -> TextScroll
{
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 );
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 window = Window::resolve( canvas, rect, &self.value, self.font_size, cursor_pos, scroll );
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;
@@ -307,10 +351,11 @@ impl<Msg: Clone> TextEdit<Msg>
// 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
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 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;
@@ -321,16 +366,16 @@ impl<Msg: Clone> TextEdit<Msg>
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 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, theme::selection(), 2.0 );
canvas.fill_rect( sel_rect, color, 2.0 );
}
}
@@ -352,23 +397,15 @@ impl<Msg: Clone> TextEdit<Msg>
{
break;
}
let line = &self.value[ vl.start..vl.end ];
canvas.draw_text(
line,
rect.x + m.pad_h,
y,
m.font_size,
self.color.unwrap_or_else( theme::text ),
);
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 line_prefix = &self.value[ vl.start..safe_cursor ];
let cursor_x = rect.x + m.pad_h
+ canvas.measure_text( line_prefix, m.font_size );
+ 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
@@ -384,11 +421,56 @@ impl<Msg: Clone> TextEdit<Msg>
// 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 ),
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The column of line numbers at the left of a multiline field.
use crate::render::Canvas;
use crate::types::Rect;
use super::metrics::MultilineMetrics;
use super::theme;
use super::TextFont;
pub( crate ) fn line_count( value: &str ) -> usize
{
value.bytes().filter( |&b| b == b'\n' ).count() + 1
}
/// The width of `label` in the face the numbers are drawn in: the monospace one, else the field's.
pub( crate ) fn measure( canvas: &Canvas, label: &str, font_size: f32 ) -> f32
{
match crate::system_fonts::monospace()
{
Some( mono ) => canvas.measure_text_with_font( label, font_size, &mono ),
None => canvas.measure_text( label, font_size ),
}
}
/// Wide enough for the number of the last line, with the gap before it.
pub( crate ) fn width( canvas: &Canvas, value: &str, font: &TextFont ) -> f32
{
let font_size = MultilineMetrics::resolve( canvas, font.size ).font_size;
let digits = "0".repeat( line_count( value ).to_string().len() );
measure( canvas, &digits, font_size ) + canvas.geom_px( theme::GUTTER_PAD )
}
/// Where the text of a field `rect` goes: right of the line numbers when it shows them.
pub( crate ) fn text_rect( canvas: &Canvas, rect: Rect, value: &str, font: &TextFont, line_numbers: bool ) -> Rect
{
if !line_numbers { return rect; }
let width = width( canvas, value, font ).min( rect.width );
Rect { x: rect.x + width, width: rect.width - width, ..rect }
}
+23 -9
View File
@@ -2,9 +2,11 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::{ Length, Point, Rect };
use crate::types::{ Point, Rect };
use super::metrics::single_line_pad_h;
use super::tabs::{ next_stop, tab_width };
use super::TextFont;
use super::scroll::{ TextScroll, Window };
/// Convert a pointer position inside the widget rect to the byte offset
@@ -27,7 +29,7 @@ pub( crate ) fn byte_offset_at(
cursor_pos: usize,
scroll: TextScroll,
align: crate::widget::text::TextAlign,
font_size: Option<Length>,
font: &TextFont,
pad_h: Option<f32>,
) -> usize
{
@@ -35,7 +37,7 @@ pub( crate ) fn byte_offset_at(
if multiline && !secure
{
let window = Window::resolve( canvas, rect, value, font_size, cursor_pos, scroll );
let window = Window::shown( canvas, rect, value, font, cursor_pos, scroll );
let m = window.metrics;
let total_lines = window.lines.len();
let visual_idx = ( ( pos.y - rect.y - m.pad_v ) / m.line_h )
@@ -43,11 +45,11 @@ pub( crate ) fn byte_offset_at(
let target_idx = ( window.first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = window.lines[ target_idx ];
byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, m.font_size )
byte_offset_in_line( canvas, font, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, true, 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.
let font_size = super::resolve_font_size( canvas, font_size );
let font_size = super::resolve_font_size( canvas, font.size );
// Mirror the horizontal scroll *and* alignment the renderer
// applies so a click lands on the glyph the user actually
// sees, not on the byte offset that would correspond to an
@@ -55,9 +57,10 @@ pub( crate ) fn byte_offset_at(
let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size, pad_h );
let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size, pad_h );
byte_offset_in_line(
canvas, value, 0, value.len(),
canvas, &TextFont::default(), value, 0, value.len(),
pos.x - rect.x - single_line_pad_h( rect, pad_h ) - align_x + scroll_x,
secure,
false,
font_size,
)
}
@@ -163,25 +166,36 @@ pub( crate ) fn single_line_align_offset(
/// rule is the standard `text input is text input` behaviour: if the
/// click is past the half-glyph mark, the cursor lands *after* the
/// glyph. `secure` toggles bullet substitution at measurement time so
/// the same logic works for password fields.
/// the same logic works for password fields; `tabs` has a tab reach
/// the next stop, as a multiline field draws it.
#[ allow( clippy::too_many_arguments ) ]
pub( super ) fn byte_offset_in_line(
canvas: &Canvas,
font: &TextFont,
value: &str,
line_start: usize,
line_end: usize,
target_x: f32,
secure: bool,
tabs: bool,
font_size: f32,
) -> usize
{
let line = &value[line_start..line_end];
if target_x <= 0.0 { return line_start; }
let tab_w = if tabs { tab_width( canvas, font, font_size ) } else { 0.0 };
let mut acc_w = 0.0f32;
let mut last_byte = line_start;
for ( ofs, ch ) in line.char_indices()
{
let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() };
let w = canvas.measure_text( &glyph_str, font_size );
let w = if secure
{
font.measure_char( canvas, '\u{2022}', font_size )
} else if tabs && ch == '\t' {
next_stop( acc_w, tab_w ) - acc_w
} else {
font.measure_char( canvas, ch, font_size )
};
if target_x < acc_w + w * 0.5
{
return line_start + ofs;
+146 -2
View File
@@ -20,6 +20,8 @@ pub( crate ) mod hit_test;
pub( crate ) mod cursor;
pub( crate ) mod metrics;
pub( crate ) mod scroll;
pub( crate ) mod tabs;
pub( crate ) mod gutter;
mod draw;
#[ cfg( test ) ]
mod tests;
@@ -27,13 +29,76 @@ mod tests;
pub use draw::password_toggle_hit_zone;
pub( crate ) use hit_test::byte_offset_at;
pub( crate ) use scroll::TextScroll;
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up };
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_page, cursor_visual_up };
/// Resolve the font size into a concrete logical px. `Some( len )` resolves
/// against [`Canvas::viewport_logical`] — the font space, so `× dpi_scale` at
/// raster yields the right physical size — exactly like the button label;
/// `None` follows the widget-scaling mode at the theme default. Shared by the
/// draw and hit-test paths, which all carry a canvas.
/// A run of a multiline field's value drawn in a colour of its own, as a
/// syntax highlighter marks keywords, strings and comments. `range` is in
/// bytes of the value and should fall on character boundaries; runs are
/// expected in order and not overlapping, and what no run covers takes the
/// field's colour.
#[ derive( Clone, Debug, PartialEq ) ]
pub struct Highlight
{
pub range: std::ops::Range<usize>,
pub color: Color,
}
/// The size of a field's text and, for a multiline field, its face,
/// which is the canvas's unless [`TextEdit::font`] gave another; every
/// path that lays the text out measures and draws it through this, so
/// they all agree.
#[ derive( Clone, Default ) ]
pub( crate ) struct TextFont
{
pub size: Option<Length>,
pub face: Option<Arc<crate::font_face::FontFace>>,
}
impl TextFont
{
pub( crate ) fn measure( &self, canvas: &Canvas, text: &str, px: f32 ) -> f32
{
match &self.face
{
Some( face ) => canvas.measure_text_with_font( text, px, face ),
None => canvas.measure_text( text, px ),
}
}
pub( crate ) fn measure_char( &self, canvas: &Canvas, ch: char, px: f32 ) -> f32
{
match &self.face
{
Some( face ) => canvas.measure_char_with_font( ch, px, face ),
None => canvas.measure_char( ch, px ),
}
}
pub( crate ) fn draw( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, px: f32, color: Color )
{
match &self.face
{
Some( face ) => canvas.draw_text_with_font( text, x, y, px, color, face ),
None => canvas.draw_text( text, x, y, px, color ),
}
}
/// Which face, for the caches keyed by it.
pub( crate ) fn face_key( &self, canvas: &Canvas ) -> usize
{
match &self.face
{
Some( face ) => Arc::as_ptr( face ) as usize,
None => canvas.font() as *const crate::font_face::FontFace as usize,
}
}
}
pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option<Length> ) -> f32
{
fs
@@ -105,6 +170,8 @@ pub struct TextEdit<Msg: Clone>
/// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf
/// handler snapshot for O(1) dispatch on input events.
pub( crate ) on_change: Option<Arc<dyn Fn(String) -> Msg>>,
/// Callback invoked with the cursor's byte offset whenever it moves.
pub( crate ) on_cursor: Option<Arc<dyn Fn( usize ) -> Msg>>,
/// Message emitted when the user presses Enter.
pub( crate ) on_submit: Option<Msg>,
/// Message emitted when the field takes the keyboard focus.
@@ -192,6 +259,14 @@ pub struct TextEdit<Msg: Clone>
/// When `true`, the field renders its box and value but takes no keyboard
/// focus and accepts no input — a read-only display styled as a text field.
pub( crate ) read_only: bool,
/// Runs of a multiline value drawn in colours of their own.
pub( crate ) highlights: Option<Arc<Vec<Highlight>>>,
/// What the Tab key types while the field has the focus, instead of moving the focus on.
pub( crate ) tab_text: Option<String>,
/// Whether a multiline field numbers its lines in a column at its left.
pub( crate ) line_numbers: bool,
/// The face of a multiline field's text; `None` is the canvas's.
pub( crate ) font: Option<Arc<crate::font_face::FontFace>>,
}
impl<Msg: Clone> TextEdit<Msg>
@@ -207,6 +282,7 @@ impl<Msg: Clone> TextEdit<Msg>
placeholder,
value,
on_change: None,
on_cursor: None,
on_submit: None,
on_focus: None,
color: None,
@@ -227,6 +303,10 @@ impl<Msg: Clone> TextEdit<Msg>
pin_cursor_to_end: false,
password_toggle: None,
read_only: false,
highlights: None,
tab_text: None,
line_numbers: false,
font: None,
}
}
@@ -374,6 +454,49 @@ impl<Msg: Clone> TextEdit<Msg>
self
}
/// Draw runs of a multiline value in colours of their own (see
/// [`Highlight`]), as an editor highlights code. Shared through an
/// `Arc`, since a view is rebuilt on every frame and a long text has
/// many runs. Single-line fields draw in one colour.
pub fn highlights( mut self, runs: Arc<Vec<Highlight>> ) -> Self
{
self.highlights = Some( runs );
self
}
/// Have the Tab key type `text`, such as four spaces, while the field
/// has the focus, as an editor does, instead of moving the focus to
/// the next widget.
pub fn tab_text( mut self, text: impl Into<String> ) -> Self
{
self.tab_text = Some( text.into() );
self
}
/// Number the lines of a multiline field in a column at its left, as
/// wide as the number of the last line needs; a line that wraps is
/// numbered on its first row.
pub fn line_numbers( mut self, on: bool ) -> Self
{
self.line_numbers = on;
self
}
/// Draw a multiline field's text in `face`, such as [`crate::monospace_font`]
/// for an editor of code, instead of the theme's; its size is still
/// [`Self::font_size`]'s.
pub fn font( mut self, face: Arc<crate::font_face::FontFace> ) -> Self
{
self.font = Some( face );
self
}
/// The size and face its text is laid out in.
pub( crate ) fn text_font( &self ) -> TextFont
{
TextFont { size: self.font_size, face: self.font.clone().filter( |_| self.is_multiline() ) }
}
/// Set the callback invoked on every keystroke with the updated value.
pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self
{
@@ -381,6 +504,15 @@ impl<Msg: Clone> TextEdit<Msg>
self
}
/// Set the callback invoked with the cursor's byte offset in the value
/// once it has been placed and whenever it moves or the value changes
/// length, as an editor's status bar showing the line needs.
pub fn on_cursor( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_cursor = Some( Arc::new( f ) );
self
}
/// Set the message emitted when Enter is pressed.
pub fn on_submit( mut self, msg: Msg ) -> Self
{
@@ -534,8 +666,10 @@ impl<Msg: Clone> TextEdit<Msg>
scroll: TextScroll,
) -> usize
{
let font = self.text_font();
let rect = gutter::text_rect( canvas, rect, &self.value, &font, self.line_numbers && self.is_multiline() );
byte_offset_at(
canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, self.font_size, self.pad_h,
canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, &font, self.pad_h,
)
}
@@ -600,11 +734,17 @@ impl<Msg: Clone> TextEdit<Msg>
let mapper = Arc::clone( f );
Arc::new( move |s| ( *mapper )( ( *old )( s ) ) )
} );
let on_cursor = self.on_cursor.clone().map( |old| -> Arc<dyn Fn( usize ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |at| ( *mapper )( ( *old )( at ) ) )
} );
TextEdit
{
placeholder: self.placeholder.clone(),
value: self.value.clone(),
on_change,
on_cursor,
on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ),
on_focus: self.on_focus.clone().map( |m| ( *f )( m ) ),
color: self.color,
@@ -625,6 +765,10 @@ impl<Msg: Clone> TextEdit<Msg>
pin_cursor_to_end: self.pin_cursor_to_end,
password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ),
read_only: self.read_only,
highlights: self.highlights.clone(),
tab_text: self.tab_text.clone(),
line_numbers: self.line_numbers,
font: self.font.clone(),
}
}
}
+73 -6
View File
@@ -4,10 +4,14 @@
//! Vertical scroll of a multiline field: the window of visual lines on
//! screen, and how the wheel moves it.
use std::rc::Rc;
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use crate::types::Rect;
use super::metrics::MultilineMetrics;
use super::theme;
use super::TextFont;
use super::wrapping::{ compute_visual_lines, VisualLine };
/// Visual lines one wheel notch moves.
@@ -77,10 +81,22 @@ impl TextScroll
pub( crate ) struct Window
{
pub metrics: MultilineMetrics,
pub lines: Vec<VisualLine>,
pub lines: Rc<Vec<VisualLine>>,
pub cursor_line: usize,
pub first_line: usize,
pub max_first: usize,
pub visible: usize,
}
/// The scroll bar of a multiline field whose lines do not all fit:
/// the track along its right edge, the thumb on it, and the band a
/// press takes as one on the bar.
#[ derive( Clone, Copy, Debug ) ]
pub( crate ) struct Scrollbar
{
pub track: Rect,
pub thumb: Rect,
pub hit: Rect,
}
impl Window
@@ -89,21 +105,72 @@ impl Window
canvas: &Canvas,
rect: Rect,
value: &str,
font_size: Option<Length>,
font: &TextFont,
cursor_pos: usize,
scroll: TextScroll,
) -> Self
{
let metrics = MultilineMetrics::resolve( canvas, font_size );
let lines = compute_visual_lines( canvas, value, metrics.inner_width( rect ), metrics.font_size );
let metrics = MultilineMetrics::resolve( canvas, font.size );
let lines = compute_visual_lines( canvas, font, value, metrics.inner_width( rect ), metrics.font_size );
let cursor_pos = cursor_pos.min( value.len() );
let scroll = TextScroll { cursor_pos: scroll.cursor_pos.min( value.len() ), ..scroll };
let cursor_line = lines.iter()
.position( |vl| cursor_pos >= vl.start && cursor_pos <= vl.end )
.unwrap_or( lines.len().saturating_sub( 1 ) );
let visible = metrics.visible_lines( rect );
let first_line = scroll.first_visible_line( visible, lines.len(), cursor_line, cursor_pos );
let max_first = lines.len().saturating_sub( visible );
Self { metrics, lines, cursor_line, first_line, max_first }
Self { metrics, lines, cursor_line, first_line, max_first, visible }
}
/// The lines as the field last showed them, wherever the cursor went
/// since, so that a press, the wheel or a page acts on what is on
/// screen; `scroll` is the view the last frame kept.
pub( crate ) fn shown(
canvas: &Canvas,
rect: Rect,
value: &str,
font: &TextFont,
cursor_pos: usize,
scroll: TextScroll,
) -> Self
{
let mut window = Self::resolve( canvas, rect, value, font, cursor_pos, scroll );
window.first_line = scroll.first_line.min( window.max_first );
window
}
pub( crate ) fn scrollbar( &self, canvas: &Canvas, rect: Rect ) -> Option<Scrollbar>
{
if self.max_first == 0 { return None; }
let margin = canvas.geom_px( theme::SCROLLBAR_MARGIN );
let width = canvas.geom_px( theme::SCROLLBAR_W );
let track = Rect
{
x: rect.x + rect.width - margin - width,
y: rect.y + margin,
width,
height: ( rect.height - margin * 2.0 ).max( 0.0 ),
};
let share = self.visible as f32 / self.lines.len() as f32;
let thumb_h = ( track.height * share ).max( canvas.geom_px( theme::SCROLLBAR_MIN ) ).min( track.height );
let thumb_y = track.y + ( track.height - thumb_h ) * self.first_line as f32 / self.max_first as f32;
let hit_w = canvas.geom_px( theme::SCROLLBAR_HIT ).min( rect.width );
Some( Scrollbar
{
track,
thumb: Rect { y: thumb_y, height: thumb_h, ..track },
hit: Rect { x: rect.x + rect.width - hit_w, width: hit_w, ..rect },
} )
}
/// The first line on screen with the top of `bar`'s thumb at `thumb_y`.
pub( crate ) fn first_line_for( &self, bar: &Scrollbar, thumb_y: f32 ) -> usize
{
let travel = bar.track.height - bar.thumb.height;
if travel <= 0.0 { return 0; }
let share = ( ( thumb_y - bar.track.y ) / travel ).clamp( 0.0, 1.0 );
( share * self.max_first as f32 ).round() as usize
}
pub( crate ) fn can_scroll( &self, delta_px: f32 ) -> bool
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Horizontal positions in a row of a multiline field, where a tab
//! reaches the next stop.
use crate::render::Canvas;
use super::theme;
use super::TextFont;
pub( crate ) fn tab_width( canvas: &Canvas, font: &TextFont, px: f32 ) -> f32
{
font.measure_char( canvas, '0', px ) * theme::TAB_COLUMNS
}
/// The first tab stop past `x`, measured from the start of the row.
pub( crate ) fn next_stop( x: f32, tab_w: f32 ) -> f32
{
if tab_w <= 0.0 { return x; }
( ( x / tab_w ).floor() + 1.0 ) * tab_w
}
/// How far into the row starting at byte `start` the byte `at` is.
pub( crate ) fn line_x( canvas: &Canvas, font: &TextFont, value: &str, start: usize, at: usize, px: f32 ) -> f32
{
let text = &value[ start..at ];
if !text.contains( '\t' ) { return font.measure( canvas, text, px ); }
let tab_w = tab_width( canvas, font, px );
let mut x = 0.0;
for ( i, piece ) in text.split( '\t' ).enumerate()
{
if i > 0 { x = next_stop( x, tab_w ); }
if !piece.is_empty() { x += font.measure( canvas, piece, px ); }
}
x
}
+132
View File
@@ -760,3 +760,135 @@ fn interior_clip_outside_the_parent_clip_clips_everything()
assert_eq!( clip.len(), 1 );
assert_eq!( clip[ 0 ].width * clip[ 0 ].height, 0.0 );
}
// ── Tabs, wrapping and the scroll bar ─────────────────────────────────────
fn canvas() -> crate::render::Canvas
{
crate::render::Canvas::new( 800, 600 )
}
#[ test ]
fn a_tab_reaches_the_next_stop()
{
let canvas = canvas();
let tab_w = super::tabs::tab_width( &canvas, &super::TextFont::default(), 16.0 );
assert!( tab_w > canvas.measure_text( "a", 16.0 ) );
assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "\tx", 0, 1, 16.0 ), tab_w );
assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "a\tx", 0, 2, 16.0 ), tab_w );
assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "\t\tx", 0, 2, 16.0 ), tab_w * 2.0 );
}
#[ test ]
fn a_click_on_a_tab_lands_on_the_nearer_side()
{
let canvas = canvas();
let tab_w = super::tabs::tab_width( &canvas, &super::TextFont::default(), 16.0 );
let at = |x: f32| super::hit_test::byte_offset_in_line( &canvas, &super::TextFont::default(), "\tx", 0, 2, x, false, true, 16.0 );
assert_eq!( at( tab_w * 0.25 ), 0 );
assert_eq!( at( tab_w * 0.75 ), 1 );
}
#[ test ]
fn the_same_value_is_wrapped_once()
{
let canvas = canvas();
let value = "one two three\nfour";
let first = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), value, 300.0, 16.0 );
let again = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), value, 300.0, 16.0 );
assert!( std::rc::Rc::ptr_eq( &first, &again ) );
let other = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), "one two three\nfive", 300.0, 16.0 );
assert!( !std::rc::Rc::ptr_eq( &first, &other ) );
}
#[ test ]
fn the_scroll_bar_shows_only_when_lines_are_hidden_and_spans_them()
{
use super::scroll::{ TextScroll, Window };
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let short = Window::resolve( &canvas, rect, "a\nb", &super::TextFont::default(), 0, TextScroll::default() );
assert!( short.scrollbar( &canvas, rect ).is_none() );
let long = "line\n".repeat( 100 );
let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 0, TextScroll::default() );
let bar = window.scrollbar( &canvas, rect ).expect( "a scroll bar" );
assert_eq!( bar.thumb.y, bar.track.y );
assert!( bar.thumb.height < bar.track.height );
assert!( bar.hit.contains( crate::types::Point { x: bar.thumb.x, y: bar.thumb.y } ) );
assert_eq!( window.first_line_for( &bar, bar.track.y ), 0 );
assert_eq!( window.first_line_for( &bar, bar.track.y + bar.track.height ), window.max_first );
}
#[ test ]
fn a_scroll_made_with_the_cursor_at_its_end_sentinel_holds()
{
use super::scroll::{ TextScroll, Window };
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let long = "line\n".repeat( 100 );
let scroll = TextScroll { first_line: 10, cursor_pos: usize::MAX, remainder: 0.0 };
let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, scroll );
assert_eq!( window.first_line, 10 );
}
#[ test ]
fn a_view_shown_stays_while_the_cursor_moves_inside_it()
{
use super::scroll::{ TextScroll, Window };
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let long = "line\n".repeat( 100 );
let shown = TextScroll { first_line: 20, cursor_pos: 125, remainder: 0.0 };
let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 120, shown );
assert_eq!( window.first_line, 20 );
}
#[ test ]
fn a_page_keeps_the_cursor_at_its_height_and_moves_the_view()
{
use super::cursor::cursor_visual_page;
use super::scroll::{ TextScroll, Window };
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let long = "line\n".repeat( 100 );
let page = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 0, TextScroll::default() ).visible - 1;
let ( down, scroll ) = cursor_visual_page( &canvas, rect, &long, 0, &super::TextFont::default(), TextScroll::default(), false );
assert_eq!( ( down, scroll.first_line ), ( page * 5, page ) );
let ( up, scroll ) = cursor_visual_page( &canvas, rect, &long, down, &super::TextFont::default(), scroll, true );
assert_eq!( ( up, scroll.first_line ), ( 0, 0 ) );
let ( start, _ ) = cursor_visual_page( &canvas, rect, &long, 0, &super::TextFont::default(), TextScroll::default(), true );
assert_eq!( start, 0 );
let ( end, _ ) = cursor_visual_page( &canvas, rect, &long, long.len(), &super::TextFont::default(), TextScroll::default(), false );
assert_eq!( end, long.len() );
}
#[ test ]
fn a_click_lands_on_the_view_shown_whatever_the_cursor_did_since()
{
use super::scroll::{ TextScroll, Window };
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let long = "line\n".repeat( 100 );
let shown = TextScroll { first_line: 20, cursor_pos: 0, remainder: 0.0 };
assert_eq!( Window::shown( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, shown ).first_line, 20 );
let m = Window::shown( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, shown ).metrics;
let click = crate::types::Point { x: rect.x + m.pad_h + 1.0, y: rect.y + m.pad_v + m.line_h * 2.5 };
let at = super::byte_offset_at( &canvas, rect, click, &long, true, false, usize::MAX, shown, crate::widget::text::TextAlign::Left, &super::TextFont::default(), None );
assert_eq!( at, 22 * 5 );
}
#[ test ]
fn the_line_numbers_take_the_width_of_the_last_number()
{
use super::gutter::text_rect;
let canvas = canvas();
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
assert_eq!( text_rect( &canvas, rect, "a", &super::TextFont::default(), false ).x, 0.0 );
let nine = text_rect( &canvas, rect, &"a\n".repeat( 8 ), &super::TextFont::default(), true );
let ten = text_rect( &canvas, rect, &"a\n".repeat( 9 ), &super::TextFont::default(), true );
assert!( nine.x > 0.0 && ten.x > nine.x );
assert!( ( ten.x + ten.width - rect.width ).abs() < 1e-3 );
}
+24
View File
@@ -17,6 +17,13 @@ pub fn selection() -> Color
Color::rgba( a.r, a.g, a.b, 0.35 )
}
/// The selection of a multiline field without the focus.
pub fn selection_inactive() -> Color
{
let c = crate::theme::palette().text_secondary;
Color::rgba( c.r, c.g, c.b, 0.25 )
}
pub const BORDER_W: f32 = 1.5;
pub const FOCUS_BORDER_W: f32 = 2.0;
pub const HEIGHT: f32 = 48.0;
@@ -44,3 +51,20 @@ 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.
pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0;
/// Columns from one tab stop of a multiline field to the next, each as wide as a digit, as a monospace column is.
pub const TAB_COLUMNS: f32 = 4.0;
/// Width of a multiline field's scroll bar, and its gap to the field's edge.
pub const SCROLLBAR_W: f32 = 6.0;
pub const SCROLLBAR_MARGIN: f32 = 3.0;
/// Width from the field's right edge that a press takes as one on the scroll bar.
pub const SCROLLBAR_HIT: f32 = 20.0;
pub const SCROLLBAR_MIN: f32 = 32.0;
pub fn scrollbar_track() -> Color
{
let c = crate::theme::palette().divider;
Color::rgba( c.r, c.g, c.b, c.a * 0.5 )
}
pub fn scrollbar_thumb() -> Color { crate::theme::palette().text_secondary }
/// Before the line numbers of a multiline field that shows them.
pub const GUTTER_PAD: f32 = 12.0;
+72 -14
View File
@@ -1,8 +1,14 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::hash::{ Hash, Hasher };
use std::rc::Rc;
use crate::render::Canvas;
use super::tabs::{ next_stop, tab_width };
use super::TextFont;
/// One visual row of a wrapped multiline TextEdit. `start..end` is a
/// byte range inside the value; the renderer draws those bytes on a
/// single visual row, even if the underlying logical line (the run
@@ -17,22 +23,64 @@ pub( crate ) struct VisualLine
pub end: usize,
}
/// What a wrap layout depends on: the value's length and hash, the row width, and the font and its size.
#[ derive( Clone, Copy, PartialEq ) ]
struct LayoutKey
{
hash: u64,
len: usize,
width: u32,
px: u32,
font: usize,
}
const LAYOUT_CACHE_CAP: usize = 4;
thread_local!
{
static LAYOUTS: std::cell::RefCell<Vec<( LayoutKey, Rc<Vec<VisualLine>> )>> = const { std::cell::RefCell::new( Vec::new() ) };
}
/// Wrap `value` into a list of visual lines that each fit within
/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are
/// always row boundaries; long logical lines are additionally split
/// at the last whitespace before the row would overflow, falling
/// back to a per-character break inside a single oversized word.
///
/// O(N) per call where N is `value.len()` — `draw_multiline` runs
/// it once per frame, which is fine for the kilobyte-scale buffers a
/// `text_edit` is meant to hold (anything larger should use a
/// dedicated text-area widget with cached layout).
/// The last layouts are kept, so drawing, hit testing and caret
/// motion over the same value wrap it once.
pub( crate ) fn compute_visual_lines(
canvas: &Canvas,
font: &TextFont,
value: &str,
inner_width: f32,
font_size: f32,
) -> Vec<VisualLine>
) -> Rc<Vec<VisualLine>>
{
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash( &mut hasher );
let key = LayoutKey
{
hash: hasher.finish(),
len: value.len(),
width: inner_width.to_bits(),
px: ( font_size * canvas.dpi_scale() ).to_bits(),
font: font.face_key( canvas ),
};
if let Some( lines ) = LAYOUTS.with( |c| c.borrow().iter().find( |( k, _ )| *k == key ).map( |( _, l )| Rc::clone( l ) ) )
{
return lines;
}
let lines = Rc::new( wrap( canvas, font, value, inner_width, font_size ) );
LAYOUTS.with( |c|
{
let mut c = c.borrow_mut();
if c.len() >= LAYOUT_CACHE_CAP { c.remove( 0 ); }
c.push( ( key, Rc::clone( &lines ) ) );
} );
lines
}
fn wrap( canvas: &Canvas, font: &TextFont, value: &str, inner_width: f32, font_size: f32 ) -> Vec<VisualLine>
{
let mut out = Vec::new();
if value.is_empty()
@@ -40,7 +88,8 @@ pub( crate ) fn compute_visual_lines(
out.push( VisualLine { start: 0, end: 0 } );
return out;
}
let max_w = inner_width.max( 1.0 );
let max_w = inner_width.max( 1.0 );
let tab_w = tab_width( canvas, font, font_size );
let mut byte_pos = 0usize;
for logical_line in value.split( '\n' )
{
@@ -48,23 +97,25 @@ pub( crate ) fn compute_visual_lines(
let line_end = byte_pos + logical_line.len();
if line_start == line_end
{
// Empty logical line still occupies a row.
out.push( VisualLine { start: line_start, end: line_end } );
} else {
wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out );
wrap_logical_line( canvas, font, value, line_start, line_end, max_w, font_size, tab_w, &mut out );
}
byte_pos = line_end + 1; // step past the '\n'
byte_pos = line_end + 1;
}
out
}
#[ allow( clippy::too_many_arguments ) ]
fn wrap_logical_line(
canvas: &Canvas,
font: &TextFont,
value: &str,
start: usize,
end: usize,
max_width: f32,
font_size: f32,
tab_w: f32,
out: &mut Vec<VisualLine>,
)
{
@@ -72,12 +123,13 @@ fn wrap_logical_line(
while cur < end
{
let segment = &value[cur..end];
let break_at = find_wrap_offset( canvas, segment, max_width, font_size );
let break_at = find_wrap_offset( canvas, font, segment, max_width, font_size, tab_w );
// Always advance at least one byte so a degenerate measure
// (zero-width font, ridiculously narrow rect) still
// terminates instead of looping forever.
let raw_next = cur + break_at;
let next = raw_next.max( cur + 1 ).min( end );
let mut next = raw_next.max( cur + 1 ).min( end );
while !value.is_char_boundary( next ) { next += 1; }
out.push( VisualLine { start: cur, end: next } );
cur = next;
}
@@ -90,17 +142,23 @@ fn wrap_logical_line(
/// `max_width`.
fn find_wrap_offset(
canvas: &Canvas,
font: &TextFont,
segment: &str,
max_width: f32,
font_size: f32,
tab_w: f32,
) -> usize
{
let mut acc_w = 0.0f32;
let mut last_break_after: Option<usize> = None;
for ( i, ch ) in segment.char_indices()
{
let glyph = ch.to_string();
let w = canvas.measure_text( &glyph, font_size );
let w = if ch == '\t'
{
next_stop( acc_w, tab_w ) - acc_w
} else {
font.measure_char( canvas, ch, font_size )
};
if acc_w + w > max_width && i > 0
{
return last_break_after.unwrap_or( i );