Skip to main content

crabshell_ui/
scroll.rs

1//! [`ScrollView`], a box that shows part of a taller child, and
2//! [`ListView`], a scrolling list that builds only the rows in view.
3
4use std::hash::Hash;
5
6use crabshell_reactive::{Memo, Signal, untrack};
7
8use crate::cx::Cx;
9use crate::prop::{IntoProp, Prop};
10use crate::style::Style;
11use crate::widgets::{Column, Element, ElementChild, ElementKind, Stack, Widget};
12
13/// Logical pixels a mouse wheel scrolls per notch.
14pub(crate) const PIXELS_PER_NOTCH: f32 = 48.0;
15
16pub(crate) struct ScrollProps {
17  pub(crate) offset: Option<Signal<f32>>,
18}
19
20/// A box that shows part of its child, which may be taller, and scrolls
21/// it with the mouse wheel or touchpad. Children are cut to the box. Give
22/// it a height through its style, or let its parent size it.
23///
24/// ```
25/// use crabshell_ui::{Column, ScrollView, Style, Text};
26///
27/// let log = ScrollView::new(Column::new().child(Text::new("first line")))
28///     .style(Style::new().height(200.0));
29/// ```
30pub struct ScrollView {
31  scroll: ScrollProps,
32  style: Option<Prop<Style>>,
33  child: Box<dyn Widget>,
34}
35
36impl ScrollView {
37  /// A scroll view of `child`.
38  pub fn new(child: impl Widget) -> ScrollView {
39    ScrollView {
40      scroll: ScrollProps { offset: None },
41      style: None,
42      child: Box::new(child),
43    }
44  }
45
46  /// Shares the offset: how many logical pixels of the child are
47  /// scrolled out of view above. Scrolling writes it, and setting it
48  /// scrolls. Values past either end show that end.
49  pub fn offset(mut self, offset: Signal<f32>) -> ScrollView {
50    self.scroll.offset = Some(offset);
51    self
52  }
53
54  /// Sets the style. A scroll view cuts its child to the inside of its
55  /// border whatever `clip` says.
56  pub fn style(mut self, style: impl IntoProp<Style>) -> ScrollView {
57    self.style = Some(style.into_prop());
58    self
59  }
60}
61
62impl Widget for ScrollView {
63  fn into_element(self: Box<Self>) -> Element {
64    let ScrollView {
65      scroll,
66      style,
67      child,
68    } = *self;
69    Element {
70      name: "ScrollView",
71      style,
72      kind: ElementKind::Scroll(scroll),
73      children: vec![ElementChild::One(Box::new(child.into_element()))],
74    }
75  }
76}
77
78/// A scrolling list of rows of one height that builds only the rows in
79/// view, so a list of thousands costs what a screenful does. Offsets are
80/// `f32`, which stays exact to about 16 million pixels: some 700,000 rows
81/// of 24 pixels. It follows a
82/// list of items as [`children_keyed`](Column::children_keyed) does: each
83/// row is kept while its item's key stays in the list.
84///
85/// ```
86/// use crabshell_ui::{ListView, Text};
87///
88/// let apps = ListView::new(
89///     || (1..=500).map(|n| format!("app {n}")).collect::<Vec<_>>(),
90///     |name| name.clone(),
91///     |_cx, name| Text::new(move || name.get()),
92/// )
93/// .row_height(28)
94/// .height(280);
95/// ```
96pub struct ListView {
97  /// Builds the list once it has a context for its signals.
98  build: Build,
99  props: ListProps,
100}
101
102/// Builds a list's element from its properties, with a context.
103type Build = Box<dyn FnOnce(&Cx, ListProps) -> Element>;
104
105struct ListProps {
106  row_height: Option<Prop<u32>>,
107  height: Option<Prop<u32>>,
108  current: Option<Prop<Option<usize>>>,
109  offset: Option<Signal<f32>>,
110  style: Option<Prop<Style>>,
111}
112
113impl ListView {
114  /// The default row height and list height, in logical pixels.
115  pub const DEFAULT_ROW_HEIGHT: u32 = 24;
116  /// The default height of the list, in logical pixels.
117  pub const DEFAULT_HEIGHT: u32 = 240;
118
119  /// A list with a row for each of `items`, built by `view` and kept by
120  /// `key`, as in [`children_keyed`](Column::children_keyed).
121  pub fn new<T, K, W>(
122    items: impl FnMut() -> Vec<T> + 'static,
123    key: impl FnMut(&T) -> K + 'static,
124    view: impl FnMut(&Cx, Memo<T>) -> W + 'static,
125  ) -> ListView
126  where
127    T: Clone + PartialEq + 'static,
128    K: Eq + Hash + 'static,
129    W: Widget,
130  {
131    ListView {
132      build: Box::new(move |cx, props| build(cx, props, items, key, view)),
133      props: ListProps {
134        row_height: None,
135        height: None,
136        current: None,
137        offset: None,
138        style: None,
139      },
140    }
141  }
142
143  /// Sets every row's height in logical pixels.
144  pub fn row_height(mut self, height: impl IntoProp<u32>) -> ListView {
145    self.props.row_height = Some(height.into_prop());
146    self
147  }
148
149  /// Sets the list's height in logical pixels.
150  pub fn height(mut self, height: impl IntoProp<u32>) -> ListView {
151    self.props.height = Some(height.into_prop());
152    self
153  }
154
155  /// Keeps the row at this index in view, scrolling as little as needed
156  /// whenever it changes. `None` scrolls nothing.
157  pub fn current(mut self, index: impl IntoProp<Option<usize>>) -> ListView {
158    self.props.current = Some(index.into_prop());
159    self
160  }
161
162  /// Shares the offset, as [`ScrollView::offset`] does.
163  pub fn offset(mut self, offset: Signal<f32>) -> ListView {
164    self.props.offset = Some(offset);
165    self
166  }
167
168  /// Sets the list's style.
169  pub fn style(mut self, style: impl IntoProp<Style>) -> ListView {
170    self.props.style = Some(style.into_prop());
171    self
172  }
173}
174
175impl Widget for ListView {
176  fn into_element(self: Box<Self>) -> Element {
177    let ListView { build, props } = *self;
178    let mut once = Some((build, props));
179    // A keyed list of one is how a widget gets a context to make its
180    // signals with.
181    let mut element = Box::new(Stack::new().children_keyed(
182      || vec![()],
183      |_| (),
184      move |cx, _| Built(once.take().map(|(build, props)| build(cx, props))),
185    ))
186    .into_element();
187    element.name = "ListView";
188    element
189  }
190}
191
192/// An element already described, as a widget.
193struct Built(Option<Element>);
194
195impl Widget for Built {
196  fn into_element(self: Box<Self>) -> Element {
197    self
198      .0
199      .unwrap_or_else(|| Box::new(Stack::new()).into_element())
200  }
201}
202
203fn build<T, K, W>(
204  cx: &Cx,
205  props: ListProps,
206  mut items: impl FnMut() -> Vec<T> + 'static,
207  mut key: impl FnMut(&T) -> K + 'static,
208  mut view: impl FnMut(&Cx, Memo<T>) -> W + 'static,
209) -> Element
210where
211  T: Clone + PartialEq + 'static,
212  K: Eq + Hash + 'static,
213  W: Widget,
214{
215  let ListProps {
216    row_height,
217    height,
218    current,
219    offset,
220    style,
221  } = props;
222  let offset = offset.unwrap_or_else(|| cx.signal(0.0));
223  let row_height = cx.memo(prop_fn(row_height, ListView::DEFAULT_ROW_HEIGHT));
224  let height = cx.memo(prop_fn(height, ListView::DEFAULT_HEIGHT));
225  let style = style.map(Prop::shared);
226  // The height rows show in: the list's, less its padding and border.
227  let inner = {
228    let style = style.clone();
229    cx.memo(move || {
230      let own = style.as_ref().map(|style| style()).unwrap_or_default();
231      let padding = own.padding.unwrap_or_default();
232      let border = own.border.map_or(0.0, |border| border.width.max(0.0));
233      let insets = padding.top as f32 + padding.bottom as f32 + 2.0 * border;
234      (height.get() as f32 - insets).max(0.0)
235    })
236  };
237  let all = cx.signal(Vec::<T>::new());
238  cx.effect(move || all.set(items()));
239  let count = cx.memo(move || all.with(Vec::len));
240  // The offset as the scroll view shows it: past either end, that end.
241  let shown = move || {
242    let total = count.get() as f32 * row_height.get().max(1) as f32;
243    let most = (total - inner.get()).max(0.0);
244    let offset = offset.get();
245    if offset.is_nan() {
246      0.0
247    } else {
248      offset.clamp(0.0, most)
249    }
250  };
251  // The rows in view, and one more each side so a short scroll shows no
252  // gap before the next layout.
253  let range = cx.memo(move || {
254    let row = row_height.get().max(1) as f32;
255    let top = shown();
256    let first = ((top / row).floor() as usize).saturating_sub(1);
257    let last = (((top + inner.get()) / row).ceil() as usize + 1).min(count.get());
258    first..last.max(first)
259  });
260  if let Some(current) = current {
261    let current = current.shared();
262    cx.effect(move || {
263      let Some(index) = current() else {
264        return;
265      };
266      let row = row_height.get() as f32;
267      let (top, bottom) = (index as f32 * row, (index + 1) as f32 * row);
268      let viewport = inner.get();
269      let now = untrack(shown);
270      let next = if top < now {
271        top
272      } else if bottom > now + viewport {
273        bottom - viewport
274      } else {
275        now
276      };
277      if next != now {
278        offset.set(next);
279      }
280    });
281  }
282  let rows = Column::new()
283    .style(move || Style::new().height(count.get() as f32 * row_height.get() as f32))
284    .child(
285      Stack::new()
286        .style(move || Style::new().height(range.get().start as f32 * row_height.get() as f32)),
287    )
288    .children_keyed(
289      move || {
290        let range = range.get();
291        all.with(|all| {
292          let end = range.end.min(all.len());
293          all[range.start.min(end)..end].to_vec()
294        })
295      },
296      move |item| key(item),
297      move |cx, item| {
298        Stack::new()
299          .style(move || Style::new().height(row_height.get() as f32))
300          .child(view(cx, item))
301      },
302    );
303  let mut scroll = ScrollView::new(rows).offset(offset);
304  scroll = scroll.style(move || {
305    let own = style.as_ref().map(|style| style()).unwrap_or_default();
306    own.height(height.get() as f32)
307  });
308  Box::new(scroll).into_element()
309}
310
311/// `prop` as a closure, or `default` when unset.
312fn prop_fn<T: Clone + 'static>(prop: Option<Prop<T>>, default: T) -> impl FnMut() -> T + 'static {
313  let prop = prop.map(Prop::shared);
314  move || match prop.as_ref() {
315    Some(prop) => prop(),
316    None => default.clone(),
317  }
318}