From 0a4afd8f81bd10941d79854bbef21fda268e3000 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 11 Sep 2026 19:24:49 +0200 Subject: [PATCH] tree, text_edit, render: hit-test only the visible part of scrolled widgets; keep the clip when a text field's interior misses the dirty region Two partial-repaint bugs seen in Settings. A row scrolled half out of a `scroll` viewport kept its full layout rect for hit testing: only `paint_rect` is clamped to the viewport, and `find_widget_at` tested `rect` alone. The row whose bottom sliver showed just below the search field therefore owned, for the pointer, its whole height above the viewport, so hovering the field showed the hand cursor and highlighted that row, and a click opened it. `find_widget_at` now requires the point to lie in `paint_rect` as well. Inside a `scroll` or `viewport` that is the visible part; elsewhere `paint_rect` always encloses `rect`, so nothing else changes. `rect` is left intact because sliders, focus scrolling, the IME rect and tooltips need the full geometry. Two tests cover a clipped row and an unclipped button. The search field's placeholder darkened as the pointer moved over the list, until a full repaint restored it. `TextEdit::draw` composes its interior clip as the intersection of the parent clip with the inner rect; during a hover repaint of a row the intersection is empty, and `set_clip_rects` with an empty list means "no clip", so the placeholder was painted unclipped over the previous pixels on every partial frame. With `text_secondary` being `text_primary` at 0.6 alpha, two or three passes read as bold. `interior_clip` now returns a zero-area rect for an empty intersection, shared by the single-line and multiline paths, which the GLES backend already turns into a zero scissor; the software backend gains the matching rule, keeping an all-zero mask when the rect list is non-empty but has no area instead of dropping the clip. A test pins the three cases. --- src/render/clip.rs | 6 +++- src/tree.rs | 52 ++++++++++++++++++++++++++++++++++- src/widget/text_edit/draw.rs | 39 ++++++++++++++------------ src/widget/text_edit/tests.rs | 25 +++++++++++++++++ 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/render/clip.rs b/src/render/clip.rs index 9809e31..78b8bc9 100644 --- a/src/render/clip.rs +++ b/src/render/clip.rs @@ -42,9 +42,13 @@ impl SoftwareCanvas mask.fill_path( &path, FillRule::Winding, false, Transform::identity() ); self.clip_mask = Some( mask ); self.clip_bounds = rects.to_vec(); - } else { + } else if rects.is_empty() { self.clip_mask = None; self.clip_bounds = Vec::new(); + } else { + // Only zero-area rects: clip everything, like the GLES zero scissor. + self.clip_mask = Some( mask ); + self.clip_bounds = Vec::new(); } } diff --git a/src/tree.rs b/src/tree.rs index 1ae896e..f4f1695 100755 --- a/src/tree.rs +++ b/src/tree.rs @@ -16,7 +16,9 @@ pub fn find_widget_at( { for w in widget_rects.iter().rev() { - if w.rect.contains( pos ) { return Some( w.flat_idx ); } + // `paint_rect` is clamped to the viewport by Scroll / Viewport, so a + // row scrolled half out of view is only hit on its visible part. + if w.rect.contains( pos ) && w.paint_rect.contains( pos ) { return Some( w.flat_idx ); } } None } @@ -88,3 +90,51 @@ pub fn next_focusable_index( }; Some( next ) } + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::types::Rect; + + fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect + { + Rect { x, y, width: w, height: h } + } + + fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()> + { + LaidOutWidget + { + rect, + flat_idx: idx, + id: None, + paint_rect: paint, + handlers: WidgetHandlers::None, + keyboard_focusable: true, + cursor: crate::types::CursorShape::Default, + tooltip: None, + accessible_label: None, + is_live_region: false, + } + } + + #[ test ] + fn find_widget_at_ignores_the_part_of_a_row_scrolled_out_of_view() + { + // A row whose top 60 px sit above a viewport starting at y = 100. + let row = lw( 3, r( 0.0, 40.0, 200.0, 80.0 ), r( 0.0, 100.0, 200.0, 20.0 ) ); + let widgets = [ row ]; + assert_eq!( find_widget_at( &widgets, Point { x: 50.0, y: 70.0 } ), None ); + assert_eq!( find_widget_at( &widgets, Point { x: 50.0, y: 110.0 } ), Some( 3 ) ); + } + + #[ test ] + fn find_widget_at_keeps_the_full_rect_when_paint_rect_encloses_it() + { + let button = lw( 1, r( 10.0, 10.0, 100.0, 40.0 ), r( 8.0, 8.0, 104.0, 44.0 ) ); + let widgets = [ button ]; + assert_eq!( find_widget_at( &widgets, Point { x: 10.0, y: 10.0 } ), Some( 1 ) ); + assert_eq!( find_widget_at( &widgets, Point { x: 9.0, y: 9.0 } ), None ); + } +} diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 1230147..655fc48 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -13,6 +13,25 @@ use super::wrapping::compute_visual_lines; /// 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 +{ + if outer.is_empty() + { + return vec![ inner ]; + } + let clip: Vec = 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 { let x0 = a.x.max( b.x ); @@ -118,15 +137,7 @@ impl TextEdit width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ), height: rect.height, }; - let composed_clip: Vec = if outer_clip.is_empty() - { - vec![ inner_rect ] - } else { - outer_clip.iter() - .filter_map( |o| rect_intersect( *o, inner_rect ) ) - .collect() - }; - canvas.set_clip_rects( &composed_clip ); + 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. @@ -300,15 +311,7 @@ impl TextEdit width: ( rect.width - theme::PAD_H ).max( 0.0 ), height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ), }; - let composed_clip: Vec = if outer_clip.is_empty() - { - vec![ inner_rect ] - } else { - outer_clip.iter() - .filter_map( |o| rect_intersect( *o, inner_rect ) ) - .collect() - }; - canvas.set_clip_rects( &composed_clip ); + 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 diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index a2069be..a56084a 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -735,3 +735,28 @@ fn password_toggle_drop_zeros_underlying_string_buffer() // path runs unconditionally for password_toggle fields. let _ = ( ptr, len ); } + +// ── Interior clip ───────────────────────────────────────────────────────── + +#[ test ] +fn interior_clip_outside_the_parent_clip_clips_everything() +{ + use super::draw::interior_clip; + use crate::types::Rect; + + let inner = Rect { x: 10.0, y: 10.0, width: 100.0, height: 20.0 }; + assert_eq!( interior_clip( &[], inner ), vec![ inner ] ); + + let overlapping = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 }; + assert_eq!( + interior_clip( &[ overlapping ], inner ), + vec![ Rect { x: 10.0, y: 10.0, width: 40.0, height: 20.0 } ], + ); + + // A partial repaint of a row below the field: the intersection is empty + // and the clip must not be lifted, or the text paints over itself. + let below = Rect { x: 0.0, y: 200.0, width: 300.0, height: 40.0 }; + let clip = interior_clip( &[ below ], inner ); + assert_eq!( clip.len(), 1 ); + assert_eq!( clip[ 0 ].width * clip[ 0 ].height, 0.0 ); +}