From 0aabcf30b762dc823a9b403a7a514e5a86f62fd5 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 02:18:20 -0700 Subject: [PATCH] fix: a scrolled list fades out the rows it cuts A long menu or list cut its rows off flat where it ended, so the notebook menu opened on half of Notebook Recycle Bin with its highlight sliced through. Rows now fade out over 12 points towards an end where more lie beyond it, as the section tabs fade at the ends of their row, and arrowing through the list stops the highlighted row clear of the fade. Assisted-by: claude-opus-5.5 --- crates/ui/src/lib.rs | 30 +++++++++++++++++++----------- crates/ui/src/list.rs | 14 ++++++++++++-- crates/ui/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 64e9604ab0c933d64339386d0954cfcb76c055ff..c8d6ac4f0d910d8a7df9b2d2f44984fd5a9a1a83 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -394,8 +394,8 @@ pub struct Spec<'a> { /// paint or input, and its boxes take the group's rectangle, so what opens from them /// opens from the form shown. pub fold: Option, - /// How far the children fade out into the box's fill towards its leading and trailing - /// edges, as a row of them cut there does. + /// How far the children fade out into the box's fill towards its start and end along + /// its axis, as a row or list of them cut there does. pub fade: [f32; 2], /// What the box is to assistive technology; without one, its text shows as a label and /// its children as its parent's. `Ui::access` adds the rest of what it says. @@ -1791,12 +1791,13 @@ impl Ui { } } - /// Fades box `index`'s children out towards its ends by its `fade`, in steps of a point, - /// into its fill however opaque that is: each step clears what lies beneath towards - /// transparency and lays the fill over it. + /// Fades box `index`'s children out towards its ends along its axis by its `fade`, in + /// steps of a point, into its fill however opaque that is: each step clears what lies + /// beneath towards transparency and lays the fill over it. fn fade(&mut self, index: usize) { let node = &self.nodes[index]; - let [left, top, right, bottom] = node.rect; + let along = usize::from(node.axis == Axis::Y); + let rect = node.rect; let fill = node.fill.unwrap_or_default(); let mut steps = Vec::new(); for (side, width) in node.fade.into_iter().enumerate() { @@ -1805,15 +1806,22 @@ impl Ui { // Through the step's middle, eased so the fade leaves the children softly. let alpha = 1.0 - (step as f32 + 0.5) / count as f32; let alpha = alpha * alpha * (3.0 - 2.0 * alpha); - let x = if side == 0 { - left + step as f32 + let at = if side == 0 { + rect[along] + step as f32 } else { - right - 1.0 - step as f32 + rect[along + 2] - 1.0 - step as f32 }; - steps.push(([x, top, x + 1.0, bottom], alpha)); + let mut band = rect; + [band[along], band[along + 2]] = [at, at + 1.0]; + steps.push((band, alpha)); } } - let data = format!("M0 0H1V{}H0Z", bottom - top); + let [width, height] = [rect[2] - rect[0], rect[3] - rect[1]]; + let data = if along == 0 { + format!("M0 0H1V{height}H0Z") + } else { + format!("M0 0H{width}V1H0Z") + }; for (rect, alpha) in steps { // Laid over what the erasing leaves, the fill's share makes up the rest of the // children's lost opacity: all of it where the fill is opaque, none where clear. diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 9ab81d72671a2c69a90f9c0b894e5a758807a744..f2239326690c3c4c58d91304f11c15aa5b336f40 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -6,6 +6,8 @@ use winit::keyboard::NamedKey; /// Room beside a scrolling list's rows for its scrollbar. pub(crate) const GUTTER: f32 = 12.0; +/// How far rows fade out towards an end of the list they are cut at. +const FADE: f32 = 12.0; /// Items a list shows, in order. pub trait Rows { @@ -181,8 +183,13 @@ pub fn list( } if *selected != state.selected { if let Some(at) = selected.and_then(|key| rows.find(key)) { - let lowest = (top(at) + height - view).min(top(at)); - state.target = state.target.clamp(lowest, top(at)).clamp(0.0, most); + // The selection settles clear of the fades; the list's own ends have none. + let [first, last] = [ + top(at) - f64::from(FADE), + top(at) + height + f64::from(FADE), + ]; + let lowest = (last - view).min(first); + state.target = state.target.clamp(lowest, first).clamp(0.0, most); } state.selected = *selected; } @@ -199,11 +206,14 @@ pub fn list( // Rows fill all but a gutter for the scrollbar, from their first frame: the padding // narrows what they fill, and they float from the corner. let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 }; + let fade = |cut: bool| if cut { FADE } else { 0.0 }; ui.open_as( id, Spec { flags: spec.flags | Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, pad: [spec.pad[0] + gutter, spec.pad[1]], + fade: [fade(scroll > 0.5), fade(scroll < most - 0.5)], ..spec }, ); diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 84c3b4b8c2831a0e04740111aeff06aec4175e13..3bc93204a6bb413a7b51552742e7562a42105736 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -1989,6 +1989,47 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { ); } +#[test] +fn a_long_menu_fades_the_ends_its_rows_are_cut_at_and_its_thumb_reaches_its_end() { + let names: Vec = (0..40).map(|index| format!("Item {index}")).collect(); + let items: Vec<_> = names + .iter() + .map(|text| popup::Item { + text, + ..popup::Item::default() + }) + .collect(); + let build = |ui: &mut Ui| { + frame(ui, |ui| { + popup::menu(ui, menu_id(), BELOW, &items, None); + }) + }; + let rows = menu_id().child("rows"); + let fade = |ui: &Ui| ui.nodes.iter().find(|node| node.id == rows).unwrap().fade; + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + build(&mut ui); + ui.open_popup(menu_id()); + build(&mut ui); + build(&mut ui); + assert_eq!(fade(&ui), [0.0, 12.0], "cut only at the bottom"); + let thumb = ui.rect(rows.child("bar")).unwrap(); + let x = (thumb[0] + thumb[2]) / 2.0; + ui.event(Event::PointerMoved([x, thumb[1] + 2.0])); + press(&mut ui, Instant::now(), true); + build(&mut ui); + ui.event(Event::PointerMoved([x, 1000.0])); + build(&mut ui); + build(&mut ui); + assert_eq!(fade(&ui), [12.0, 0.0], "cut only at the top"); + let list = ui.rect(rows).unwrap(); + let thumb = ui.rect(rows.child("bar")).unwrap(); + assert_eq!( + thumb[3], + list[3] - 4.0, + "the thumb ends as far from the bottom as it starts" + ); +} + #[test] fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() { let names: Vec = (0..40).map(|index| format!("Item {index}")).collect(); -- 2.54.0