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> {...@@ -394,8 +394,8 @@ pub struct Spec<'a> {
394 /// paint or input, and its boxes take the group's rectangle, so what opens from them394 /// paint or input, and its boxes take the group's rectangle, so what opens from them
395 /// opens from the form shown.395 /// opens from the form shown.
396 pub fold: Option<u32>,396 pub fold: Option<u32>,
397 /// How far the children fade out into the box's fill towards its leading and trailing397 /// How far the children fade out into the box's fill towards its start and end along
398 /// edges, as a row of them cut there does.398 /// its axis, as a row or list of them cut there does.
399 pub fade: [f32; 2],399 pub fade: [f32; 2],
400 /// What the box is to assistive technology; without one, its text shows as a label and400 /// What the box is to assistive technology; without one, its text shows as a label and
401 /// its children as its parent's. `Ui::access` adds the rest of what it says.401 /// its children as its parent's. `Ui::access` adds the rest of what it says.
...@@ -1791,12 +1791,13 @@ impl Ui {...@@ -1791,12 +1791,13 @@ impl Ui {
1791 }1791 }
1792 }1792 }
17931793
1794 /// Fades box `index`'s children out towards its ends by its `fade`, in steps of a point,1794 /// Fades box `index`'s children out towards its ends along its axis by its `fade`, in
1795 /// into its fill however opaque that is: each step clears what lies beneath towards1795 /// steps of a point, into its fill however opaque that is: each step clears what lies
1796 /// transparency and lays the fill over it.1796 /// beneath towards transparency and lays the fill over it.
1797 fn fade(&mut self, index: usize) {1797 fn fade(&mut self, index: usize) {
1798 let node = &self.nodes[index];1798 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;
1800 let fill = node.fill.unwrap_or_default();1801 let fill = node.fill.unwrap_or_default();
1801 let mut steps = Vec::new();1802 let mut steps = Vec::new();
1802 for (side, width) in node.fade.into_iter().enumerate() {1803 for (side, width) in node.fade.into_iter().enumerate() {
...@@ -1805,15 +1806,22 @@ impl Ui {...@@ -1805,15 +1806,22 @@ impl Ui {
1805 // Through the step's middle, eased so the fade leaves the children softly.1806 // Through the step's middle, eased so the fade leaves the children softly.
1806 let alpha = 1.0 - (step as f32 + 0.5) / count as f32;1807 let alpha = 1.0 - (step as f32 + 0.5) / count as f32;
1807 let alpha = alpha * alpha * (3.0 - 2.0 * alpha);1808 let alpha = alpha * alpha * (3.0 - 2.0 * alpha);
1808 let x = if side == 0 {1809 let at = if side == 0 {
1809 left + step as f321810 rect[along] + step as f32
1810 } else {1811 } else {
1811 right - 1.0 - step as f321812 rect[along + 2] - 1.0 - step as f32
1812 };1813 };
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));
1814 }1817 }
1815 }1818 }
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 };
1817 for (rect, alpha) in steps {1825 for (rect, alpha) in steps {
1818 // Laid over what the erasing leaves, the fill's share makes up the rest of the1826 // Laid over what the erasing leaves, the fill's share makes up the rest of the
1819 // children's lost opacity: all of it where the fill is opaque, none where clear.1827 // 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;...@@ -6,6 +6,8 @@ use winit::keyboard::NamedKey;
66
7/// Room beside a scrolling list's rows for its scrollbar.7/// Room beside a scrolling list's rows for its scrollbar.
8pub(crate) const GUTTER: f32 = 12.0;8pub(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
10/// Items a list shows, in order.12/// Items a list shows, in order.
11pub trait Rows {13pub trait Rows {
...@@ -181,8 +183,13 @@ pub fn list<R: Rows>(...@@ -181,8 +183,13 @@ pub fn list<R: Rows>(
181 }183 }
182 if *selected != state.selected {184 if *selected != state.selected {
183 if let Some(at) = selected.and_then(|key| rows.find(key)) {185 if let Some(at) = selected.and_then(|key| rows.find(key)) {
184 let lowest = (top(at) + height - view).min(top(at));186 // The selection settles clear of the fades; the list's own ends have none.
185 state.target = state.target.clamp(lowest, top(at)).clamp(0.0, most);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);
186 }193 }
187 state.selected = *selected;194 state.selected = *selected;
188 }195 }
...@@ -199,11 +206,14 @@ pub fn list<R: Rows>(...@@ -199,11 +206,14 @@ pub fn list<R: Rows>(
199 // Rows fill all but a gutter for the scrollbar, from their first frame: the padding206 // Rows fill all but a gutter for the scrollbar, from their first frame: the padding
200 // narrows what they fill, and they float from the corner.207 // narrows what they fill, and they float from the corner.
201 let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 };208 let gutter = if most > 0.0 { GUTTER / 2.0 } else { 0.0 };
209 let fade = |cut: bool| if cut { FADE } else { 0.0 };
202 ui.open_as(210 ui.open_as(
203 id,211 id,
204 Spec {212 Spec {
205 flags: spec.flags | Flags::SCROLL | Flags::CLIP,213 flags: spec.flags | Flags::SCROLL | Flags::CLIP,
214 axis: Axis::Y,
206 pad: [spec.pad[0] + gutter, spec.pad[1]],215 pad: [spec.pad[0] + gutter, spec.pad[1]],
216 fade: [fade(scroll > 0.5), fade(scroll < most - 0.5)],
207 ..spec217 ..spec
208 },218 },
209 );219 );
crates/ui/src/tests.rs+41
...@@ -1989,6 +1989,47 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {...@@ -1989,6 +1989,47 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {
1989 );1989 );
1990}1990}
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
1992#[test]2033#[test]
1993fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() {2034fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() {
1994 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();2035 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();