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
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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.
This commit is contained in:
2026-09-11 19:24:49 +02:00
parent 92b090a441
commit 0a4afd8f81
4 changed files with 102 additions and 20 deletions
+5 -1
View File
@@ -42,9 +42,13 @@ impl SoftwareCanvas
mask.fill_path( &path, FillRule::Winding, false, Transform::identity() ); mask.fill_path( &path, FillRule::Winding, false, Transform::identity() );
self.clip_mask = Some( mask ); self.clip_mask = Some( mask );
self.clip_bounds = rects.to_vec(); self.clip_bounds = rects.to_vec();
} else { } else if rects.is_empty() {
self.clip_mask = None; self.clip_mask = None;
self.clip_bounds = Vec::new(); 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();
} }
} }
+51 -1
View File
@@ -16,7 +16,9 @@ pub fn find_widget_at<Msg: Clone>(
{ {
for w in widget_rects.iter().rev() 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 None
} }
@@ -88,3 +90,51 @@ pub fn next_focusable_index<Msg: Clone>(
}; };
Some( next ) 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 );
}
}
+21 -18
View File
@@ -13,6 +13,25 @@ use super::wrapping::compute_visual_lines;
/// do not overlap (zero-area / negative-extent results count as /// do not overlap (zero-area / negative-extent results count as
/// "no overlap" so the caller can `filter_map` straight into a clip /// "no overlap" so the caller can `filter_map` straight into a clip
/// list). /// 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> fn rect_intersect( a: Rect, b: Rect ) -> Option<Rect>
{ {
let x0 = a.x.max( b.x ); let x0 = a.x.max( b.x );
@@ -118,15 +137,7 @@ impl<Msg: Clone> TextEdit<Msg>
width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ), width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ),
height: rect.height, height: rect.height,
}; };
let composed_clip: Vec<Rect> = if outer_clip.is_empty() canvas.set_clip_rects( &interior_clip( &outer_clip, inner_rect ) );
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (single-line). Painted before the text // Selection highlight (single-line). Painted before the text
// so the glyphs sit on top of the tinted band. // so the glyphs sit on top of the tinted band.
@@ -300,15 +311,7 @@ impl<Msg: Clone> TextEdit<Msg>
width: ( rect.width - theme::PAD_H ).max( 0.0 ), width: ( rect.width - theme::PAD_H ).max( 0.0 ),
height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ), height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ),
}; };
let composed_clip: Vec<Rect> = if outer_clip.is_empty() canvas.set_clip_rects( &interior_clip( &outer_clip, inner_rect ) );
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (multiline). Painted before the text so // Selection highlight (multiline). Painted before the text so
// the glyphs sit on top of the tinted band(s). Iterates // the glyphs sit on top of the tinted band(s). Iterates
+25
View File
@@ -735,3 +735,28 @@ fn password_toggle_drop_zeros_underlying_string_buffer()
// path runs unconditionally for password_toggle fields. // path runs unconditionally for password_toggle fields.
let _ = ( ptr, len ); 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 );
}