From 16c04f4159fa8eac23e84839c8f1ee3daf50dda7 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 30 Jul 2026 01:02:30 +0200 Subject: [PATCH] =?UTF-8?q?event=5Floop,=20text=5Fedit,=20layout:=20focus-?= =?UTF-8?q?time=20cursor=20as=20an=20end-of-value=20sentinel,=20caret=20he?= =?UTF-8?q?ight=20tied=20to=20the=20text=20line,=20row=20align=5Ftop/fill?= =?UTF-8?q?=5Fheight=20modes=20Focus-time=20cursor=20(event=5Floop/focus.r?= =?UTF-8?q?s):=20focusing=20a=20text=20input=20pinned=20the=20cursor=20to?= =?UTF-8?q?=20a=20concrete=20`value.len()`=20snapshot.=20When=20the=20valu?= =?UTF-8?q?e=20keeps=20growing=20after=20focus=20without=20the=20widget=20?= =?UTF-8?q?seeing=20keystrokes=20=E2=80=94=20crustace's=20launcher=20searc?= =?UTF-8?q?h=20field=20is=20fed=20over=20IPC=20while=20forge=20routes=20th?= =?UTF-8?q?e=20type-to-search=20keys=20around=20Wayland=20=E2=80=94=20that?= =?UTF-8?q?=20snapshot=20goes=20stale,=20and=20the=20first=20key=20deliver?= =?UTF-8?q?ed=20normally=20afterwards=20inserts=20mid-string.=20The=20curs?= =?UTF-8?q?or=20is=20now=20seeded=20with=20the=20`usize::MAX`=20sentinel?= =?UTF-8?q?=20("end=20of=20whatever=20value=20is=20rendered"),=20the=20sam?= =?UTF-8?q?e=20convention=20`select=5Fon=5Ffocus`=20fields=20already=20rel?= =?UTF-8?q?y=20on;=20every=20consumer=20(insert/delete,=20arrow=20keys,=20?= =?UTF-8?q?draw,=20hit-test,=20context-menu=20paste=20offset,=20a11y=20tre?= =?UTF-8?q?e)=20clamps=20via=20`cursor.min(=20value.len()=20)`,=20so=20the?= =?UTF-8?q?=20cursor=20tracks=20external=20growth=20and=20collapses=20to?= =?UTF-8?q?=20a=20concrete=20position=20on=20the=20first=20real=20keystrok?= =?UTF-8?q?e=20or=20click.=20Click=20placement=20is=20untouched=20?= =?UTF-8?q?=E2=80=94=20the=20pointer=20path=20writes=20its=20own=20hit-tes?= =?UTF-8?q?ted=20offset.=20Caret=20height=20(widget/text=5Fedit/draw.rs):?= =?UTF-8?q?=20the=20single-line=20caret=20spanned=20`rect.height=20-=2016`?= =?UTF-8?q?,=20so=20a=20field=20taller=20than=20its=20text=20line=20(the?= =?UTF-8?q?=20launcher's=20borderless=20search=20pill)=20grew=20a=20caret?= =?UTF-8?q?=20about=20twice=20the=20glyph=20height.=20It=20now=20measures?= =?UTF-8?q?=20`font=5Fsize=20+=204`,=20vertically=20centered=20like=20the?= =?UTF-8?q?=20text=20=E2=80=94=20matching=20what=20the=20multiline=20caret?= =?UTF-8?q?=20already=20did.=20Row=20alignment=20modes=20(layout/row.rs):?= =?UTF-8?q?=20`Row`=20gains=20`align=5Ftop()`=20(children=20pinned=20to=20?= =?UTF-8?q?the=20top=20edge=20instead=20of=20the=20default=20vertical=20ce?= =?UTF-8?q?ntering)=20and=20`fill=5Fheight()`=20(every=20non-spacer=20chil?= =?UTF-8?q?d=20stretched=20to=20the=20row's=20inner=20height,=20the=20row?= =?UTF-8?q?=20itself=20still=20sized=20by=20its=20tallest=20child).=20Both?= =?UTF-8?q?=20exist=20for=20siblings=20whose=20natural=20heights=20differ?= =?UTF-8?q?=20by=20a=20few=20font-metric=20pixels=20=E2=80=94=20like=20the?= =?UTF-8?q?=20QS=20media=20card=20next=20to=20the=20wifi/bluetooth=20chip?= =?UTF-8?q?=20column=20=E2=80=94=20where=20equal-height=20layouts=20cannot?= =?UTF-8?q?=20be=20achieved=20by=20estimating=20text=20heights:=20`new=5Fl?= =?UTF-8?q?ine=5Fsize`=20is=20font-dependent,=20so=20px=20arithmetic=20in?= =?UTF-8?q?=20the=20app=20always=20drifts.=20Containers=20paint=20their=20?= =?UTF-8?q?chrome=20over=20the=20full=20rect=20they=20receive=20and=20colu?= =?UTF-8?q?mns=20absorb=20the=20extra=20in=20weighted=20spacers,=20so=20a?= =?UTF-8?q?=20stretched=20card=20keeps=20its=20content=20anchored=20where?= =?UTF-8?q?=20its=20internal=20spacers=20put=20it.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/event_loop/focus.rs | 28 ++++++++++++++------------- src/layout/row.rs | 37 ++++++++++++++++++++++++++++++++++-- src/widget/text_edit/draw.rs | 8 ++++++-- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index adc4ceb..0dda36f 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -119,28 +119,30 @@ impl AppData } ); ss.request_redraw(); - // Sync cursor to end of current value when focusing a text - // input. Default behaviour collapses the selection to the - // cursor — focus changes always discard any prior - // selection state. Fields built with `.select_on_focus( - // true )` instead anchor the selection at `0` so the - // whole value is highlighted, ready to be replaced by the - // next keystroke (typical for numeric pickers). + // Sync cursor to the end of the value when focusing a text + // input, via the `usize::MAX` sentinel ("end of whatever + // value is rendered" — every consumer clamps with + // `cursor.min( value.len() )`). A concrete `value.len()` + // snapshot here would go stale if the value keeps growing + // after focus (e.g. updated externally over IPC), leaving + // later insertions mid-string. Default behaviour collapses + // the selection to the cursor — focus changes always + // discard any prior selection state. Fields built with + // `.select_on_focus( true )` instead anchor the selection + // at `0` so the whole value is highlighted, ready to be + // replaced by the next keystroke (typical for numeric + // pickers). if is_text_input { if let Some( i ) = idx { let handler = find_handlers( &ss.frame.widget_rects, i ); - let cursor = handler - .and_then( |h| h.current_value() ) - .map( |v| v.len() ) - .unwrap_or( 0 ); let anchor = match handler { Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0, - _ => cursor, + _ => usize::MAX, }; - ss.frame.cursor_state.insert( i, cursor ); + ss.frame.cursor_state.insert( i, usize::MAX ); ss.frame.selection_anchor.insert( i, anchor ); } } diff --git a/src/layout/row.rs b/src/layout/row.rs index 48e97ac..295bc36 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -47,6 +47,8 @@ pub struct Row /// Padding on all sides. [`Length`]; default `0.0` px. pub( crate ) padding: Length, pub( crate ) align_right: bool, + pub( crate ) align_top: bool, + pub( crate ) fill_height: bool, } impl Row @@ -59,6 +61,8 @@ impl Row spacing: Length::px( 8.0 ), padding: Length::px( 0.0 ), align_right: false, + align_top: false, + fill_height: false, } } @@ -104,6 +108,26 @@ impl Row self } + /// Pin children to the top edge instead of the default vertical + /// centering, so siblings of slightly different heights (e.g. two + /// cards whose text metrics differ) share the same top line. + pub fn align_top( mut self ) -> Self + { + self.align_top = true; + self + } + + /// Stretch every child to the row's inner height, so siblings share + /// both the top and the bottom line regardless of their natural + /// heights. The row's own height still comes from the tallest + /// child's preferred size; children whose content should track the + /// stretch need internal flexible spacers. + pub fn fill_height( mut self ) -> Self + { + self.fill_height = true; + self + } + /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { @@ -240,8 +264,15 @@ impl Row Element::Flex( f ) => flex_unit * f.weight as f32, _ => w, }; - let y = rect.y + pad + ( inner_h - h ) / 2.0; - result.push( ( Rect { x, y, width, height: h }, i ) ); + let ( y, height ) = if self.fill_height && !matches!( child, Element::Spacer( _ ) ) + { + ( rect.y + pad, inner_h ) + } else if self.align_top { + ( rect.y + pad, h ) + } else { + ( rect.y + pad + ( inner_h - h ) / 2.0, h ) + }; + result.push( ( Rect { x, y, width, height }, i ) ); x += width + spacing; } result @@ -258,6 +289,8 @@ impl Row spacing: self.spacing, padding: self.padding, align_right: self.align_right, + align_top: self.align_top, + fill_height: self.fill_height, } } } diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index ad918e0..1230147 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -195,12 +195,16 @@ impl TextEdit }; let cursor_x = rect.x + theme::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 cursor_h = font_size + 4.0; let cursor_rect = Rect { x: cursor_x, - y: rect.y + 8.0, + y: rect.y + ( rect.height - cursor_h ) / 2.0, width: 2.0, - height: rect.height - 16.0, + height: cursor_h, }; canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); }