authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:18:20-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 03:11:20-07:00
log0aabcf30b762dc823a9b403a7a514e5a86f62fd5
tree6c200c0de16a480278fe568c74603df9c9329a99
parent9aaaf21dee13990267048e726313ee04c7861882
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

3 files changed, 72 insertions(+), 13 deletions(-)

crates/ui/src/lib.rs+19-11
......@@ -394,8 +394,8 @@ pub struct Spec<'a> {
394394 /// paint or input, and its boxes take the group's rectangle, so what opens from them
395395 /// opens from the form shown.
396396 pub fold: Option<u32>,
397 /// How far the children fade out into the box's fill towards its leading and trailing
398 /// edges, as a row of them cut there does.
397 /// How far the children fade out into the box's fill towards its start and end along
398 /// its axis, as a row or list of them cut there does.
399399 pub fade: [f32; 2],
400400 /// What the box is to assistive technology; without one, its text shows as a label and
401401 /// its children as its parent's. `Ui::access` adds the rest of what it says.
......@@ -1791,12 +1791,13 @@ impl Ui {
17911791 }
17921792 }
17931793
1794 /// Fades box `index`'s children out towards its ends by its `fade`, in steps of a point,
1795 /// into its fill however opaque that is: each step clears what lies beneath towards
1796 /// transparency and lays the fill over it.
1794 /// Fades box `index`'s children out towards its ends along its axis by its `fade`, in
1795 /// steps of a point, into its fill however opaque that is: each step clears what lies
1796 /// beneath towards transparency and lays the fill over it.
17971797 fn fade(&mut self, index: usize) {
17981798 let node = &self.nodes[index];
1799 let [left, top, right, bottom] = node.rect;
1799 let along = usize::from(node.axis == Axis::Y);
1800 let rect = node.rect;
18001801 let fill = node.fill.unwrap_or_default();
18011802 let mut steps = Vec::new();
18021803 for (side, width) in node.fade.into_iter().enumerate() {
......@@ -1805,15 +1806,22 @@ impl Ui {
18051806 // Through the step's middle, eased so the fade leaves the children softly.
18061807 let alpha = 1.0 - (step as f32 + 0.5) / count as f32;
18071808 let alpha = alpha * alpha * (3.0 - 2.0 * alpha);
1808 let x = if side == 0 {
1809 left + step as f32
1809 let at = if side == 0 {
1810 rect[along] + step as f32
18101811 } else {
1811 right - 1.0 - step as f32
1812 rect[along + 2] - 1.0 - step as f32
18121813 };
1813 steps.push(([x, top, x + 1.0, bottom], alpha));
1814 let mut band = rect;
1815 [band[along], band[along + 2]] = [at, at + 1.0];
1816 steps.push((band, alpha));
18141817 }
18151818 }
1816 let data = format!("M0 0H1V{}H0Z", bottom - top);
1819 let [width, height] = [rect[2] - rect[0], rect[3] - rect[1]];
1820 let data = if along == 0 {
1821 format!("M0 0H1V{height}H0Z")
1822 } else {
1823 format!("M0 0H{width}V1H0Z")
1824 };
18171825 for (rect, alpha) in steps {
18181826 // Laid over what the erasing leaves, the fill's share makes up the rest of the
18191827 // children's lost opacity: all of it where the fill is opaque, none where clear.
crates/ui/src/list.rs+12-2
......@@ -6,6 +6,8 @@ use winit::keyboard::NamedKey;
66
77/// Room beside a scrolling list's rows for its scrollbar.
88pub(crate) const GUTTER: f32 = 12.0;
9/// How far rows fade out towards an end of the list they are cut at.
10const FADE: f32 = 12.0;
911
1012/// Items a list shows, in order.
1113pub trait Rows {
......@@ -181,8 +183,13 @@ pub fn list<R: Rows>(
181183 }
182184 if *selected != state.selected {
183185 if let Some(at) = selected.and_then(|key| rows.find(key)) {
184 let lowest = (top(at) + height - view).min(top(at));
185 state.target = state.target.clamp(lowest, top(at)).clamp(0.0, most);
186 // The selection settles clear of the fades; the list's own ends have none.
187 let [first, last] = [
188 top(at) - f64::from(FADE),
189 top(at) + height + f64::from(FADE),
190 ];
191 let lowest = (last - view).min(first);
192 state.target = state.target.clamp(lowest, first).clamp(0.0, most);
186193 }
187194 state.selected = *selected;
188195 }
......@@ -199,11 +206,14 @@ pub fn list<R: Rows>(
199206 // Rows fill all but a gutter for the scrollbar, from their first frame: the padding
200207 // narrows what they fill, and they float from the corner.
201208 let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 };
209 let fade = |cut: bool| if cut { FADE } else { 0.0 };
202210 ui.open_as(
203211 id,
204212 Spec {
205213 flags: spec.flags | Flags::SCROLL | Flags::CLIP,
214 axis: Axis::Y,
206215 pad: [spec.pad[0] + gutter, spec.pad[1]],
216 fade: [fade(scroll > 0.5), fade(scroll < most - 0.5)],
207217 ..spec
208218 },
209219 );
crates/ui/src/tests.rs+41
......@@ -1989,6 +1989,47 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {
19891989 );
19901990}
19911991
1992#[test]
1993fn a_long_menu_fades_the_ends_its_rows_are_cut_at_and_its_thumb_reaches_its_end() {
1994 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();
1995 let items: Vec<_> = names
1996 .iter()
1997 .map(|text| popup::Item {
1998 text,
1999 ..popup::Item::default()
2000 })
2001 .collect();
2002 let build = |ui: &mut Ui| {
2003 frame(ui, |ui| {
2004 popup::menu(ui, menu_id(), BELOW, &items, None);
2005 })
2006 };
2007 let rows = menu_id().child("rows");
2008 let fade = |ui: &Ui| ui.nodes.iter().find(|node| node.id == rows).unwrap().fade;
2009 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
2010 build(&mut ui);
2011 ui.open_popup(menu_id());
2012 build(&mut ui);
2013 build(&mut ui);
2014 assert_eq!(fade(&ui), [0.0, 12.0], "cut only at the bottom");
2015 let thumb = ui.rect(rows.child("bar")).unwrap();
2016 let x = (thumb[0] + thumb[2]) / 2.0;
2017 ui.event(Event::PointerMoved([x, thumb[1] + 2.0]));
2018 press(&mut ui, Instant::now(), true);
2019 build(&mut ui);
2020 ui.event(Event::PointerMoved([x, 1000.0]));
2021 build(&mut ui);
2022 build(&mut ui);
2023 assert_eq!(fade(&ui), [12.0, 0.0], "cut only at the top");
2024 let list = ui.rect(rows).unwrap();
2025 let thumb = ui.rect(rows.child("bar")).unwrap();
2026 assert_eq!(
2027 thumb[3],
2028 list[3] - 4.0,
2029 "the thumb ends as far from the bottom as it starts"
2030 );
2031}
2032
19922033#[test]
19932034fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() {
19942035 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();