Two additions driven by apps that host another UI inside an ltk surface: trust, whose pages have their own form fields, and rustdroid, which projects Android views into ltk widgets. External text input. ltk only ever enabled `zwp_text_input_v3` for its own `TextEdit`s, so nothing told the compositor that an editable inside an embedded web view had the focus: the on-screen keyboard never came up inside a page, which makes a browser unusable on a phone without a hardware keyboard. Three hooks on `App` close that. `external_text_input() -> Option<u64>` asks for the input method on behalf of such a field: while it returns `Some( serial )` and no ltk text widget holds the focus, the runtime keeps a text-input enabled, and the compositor raises its keyboard. `on_external_text_commit( text )` and `on_external_text_delete( before, after )` receive what the input method sends, the latter in UTF-8 bytes as the protocol counts them. Keys such as Backspace and Enter keep arriving as key events, which hosts already forward. The request is a serial rather than a `bool` so that it can be renewed. A compositor that folds its keyboard while the field keeps the focus, which forge does on back, has nothing to observe when the user taps that same field again, because the text-input never stopped being active; a change of serial makes the runtime send `enable` again through `reenable_text_input`, the same signal ltk now sends for its own fields. The host chooses what bumps it; trust counts taps on the page, so dragging to scroll does not bring a folded keyboard back. `AppData::sync_external_text_input`, in `text_editing/ime.rs`, is polled after every batch of `update`s next to `sync_fullscreen`. An ltk text widget always wins: while one has the focus the function only clears its record, and `set_focus` manages the text-input as before. When that widget loses the focus `set_focus` destroys the text-input, and the next poll finds the request still standing with no text-input and enables a new one, so the two owners hand it back and forth without either knowing about the other. The `zwp_text_input_v3` dispatch routes `commit_string` and `delete_surrounding_text` to the app only when the external request is held and no ltk text field is focused; that second check covers the one iteration between a widget taking the focus and the next poll. The empty-string filter on `commit_string` is folded into the `Option` it already lived in. `TextEdit::color( c )`. The colour of a field's value always came from the theme. A field placed over a surface the theme knows nothing about, an Android `EditText` with its own text colour on its own background, could end up unreadable. `color` sets the value's colour in both draw paths, single-line and multiline; the placeholder keeps the theme's, and a field without it draws exactly as before. `TextEdit::on_focus( msg )`. `App::on_text_input_focused( bool )` says that some text field is focused, not which. An app that mirrors focus into another model needs to know which: rustdroid keeps Android's single focused view in step, and could only learn the field from its first `on_change`, so `View.isFocused()` stayed false until the user typed. A `WidgetId` cannot carry that, being a `&'static str` while such handles are created at run time. A per-widget message can, since the app builds it and puts in it whatever it needs. It rides the `WidgetHandlers::TextEdit` snapshot, and `set_focus` pushes it when the focus actually moves to the field, from a press, Tab or `App::take_focus_request`, and not again for a press on the field that already holds it. Every other match on that handler variant uses `..`, so the new field touches its definition, its clone and the snapshot in `element.rs`. Documented in the `App` rustdoc, the trait surface list of `docs/architecture.md`, the `text_edit` entry of `docs/widgets.md` and the changelog. Neither path has a test: both need a live Wayland connection and a compositor to exercise.
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,
|
|
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 );
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
self.color.unwrap_or_else( 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 );
|
|
}
|
|
}
|
|
}
|