From 1bdfca3657ef3a6d778d33c682059de555ca79d7 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Mon, 9 Feb 2026 15:28:43 +0000 Subject: [PATCH 1/5] ListView: store items at data_index % alloc_len --- crates/kas-view/src/list_view.rs | 69 +++++++++++++++++--------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/crates/kas-view/src/list_view.rs b/crates/kas-view/src/list_view.rs index 016b5275e..9c3724f7d 100644 --- a/crates/kas-view/src/list_view.rs +++ b/crates/kas-view/src/list_view.rs @@ -461,6 +461,7 @@ mod ListView { } fn position_solver(&self) -> PositionSolver { + let alloc_len = self.widgets.len(); let cur_len: usize = self.cur_len.cast(); let mut first_data: usize = self.first_data.cast(); let mut skip = Offset::ZERO; @@ -479,7 +480,7 @@ mod ListView { skip, size: self.child_size, first_data, - cur_len, + alloc_len, } } @@ -565,11 +566,12 @@ mod ListView { let id = self.id(); let solver = self.position_solver(); - for i in range.clone() { - let w = &mut self.widgets[i % solver.cur_len]; + let alloc_len = self.widgets.len(); + for di in range.clone() { + let w = &mut self.widgets[di % alloc_len]; let force = self.token_update != Update::None; - let changes = self.clerk.update_token(data, i, force, &mut w.token); + let changes = self.clerk.update_token(data, di, force, &mut w.token); w.is_mock = false; let Some(token) = w.token.as_ref() else { continue; @@ -577,7 +579,7 @@ mod ListView { let mut rect_update = self.rect_update; if changes.key() || self.token_update == Update::Configure { - w.item.index = i; + w.item.index = di; // TODO(opt): some impls of Driver::set_key do nothing // and do not need re-configure (beyond the first). self.driver.set_key(&mut w.item.inner, token.borrow()); @@ -600,7 +602,7 @@ mod ListView { if rect_update { w.item - .set_rect(&mut cx.size_cx(), solver.rect(i), self.align_hints); + .set_rect(&mut cx.size_cx(), solver.rect(di), self.align_hints); } } @@ -709,11 +711,12 @@ mod ListView { // action and we cannot guarantee that the requested // TIMER_UPDATE_WIDGETS event will be immediately.) let solver = self.position_solver(); - for i in 0..solver.cur_len { - let i = solver.first_data + i; - let w = &mut self.widgets[i % solver.cur_len]; + let alloc_len = self.widgets.len(); + for i in 0..self.cur_len { + let di = solver.first_data + usize::conv(i); + let w = &mut self.widgets[di % alloc_len]; if w.token.is_some() { - w.item.set_rect(cx, solver.rect(i), self.align_hints); + w.item.set_rect(cx, solver.rect(di), self.align_hints); } } @@ -753,7 +756,7 @@ mod ListView { fn draw_with_offset(&self, mut draw: DrawCx, viewport: Rect, offset: Offset) { // We use a new pass to clip and offset scrolled content: draw.with_clip_region(viewport, offset + self.virtual_offset(), |mut draw| { - for child in &self.widgets[..self.cur_len.cast()] { + for child in &self.widgets { if let Some(key) = child.key() { if self.selection.contains(key) { draw.selection(child.item.rect(), self.sel_style); @@ -786,8 +789,7 @@ mod ListView { fn find_child_index(&self, id: &Id) -> Option { let key = C::Key::reconstruct_key(self.id_ref(), id); if key.is_some() { - let num = self.cur_len.cast(); - for (i, w) in self.widgets[..num].iter().enumerate() { + for (i, w) in self.widgets.iter().enumerate() { if key.as_ref() == w.key() { return Some(i); } @@ -848,7 +850,7 @@ mod ListView { fn probe(&self, coord: Coord) -> Id { let coord = coord + self.translation(0); - for child in &self.widgets[..self.cur_len.cast()] { + for child in &self.widgets { if child.token.is_some() && let Some(id) = child.item.try_probe(coord) { @@ -886,7 +888,11 @@ mod ListView { if self.token_update != Update::None || changes != Changes::None { self.handle_update(cx, data, changes, true); } else { - for w in &mut self.widgets[..self.cur_len.cast()] { + // NOTE: we update all widgets with a valid token. Some of these + // may be outside the view range in which case they don't need + // to be updated now, but in that case we would need to mark + // them as needing an update (or just invalidate the token). + for w in &mut self.widgets { if let Some(ref token) = w.token { let item = self.clerk.item(data, token); cx.update(w.item.as_node(item)); @@ -935,7 +941,7 @@ mod ListView { None => return Unused, }; let is_vert = self.direction.is_vertical(); - let len = solver.cur_len; + let len: usize = self.cur_len.cast(); use Command as C; let data_index = match cmd { @@ -950,17 +956,16 @@ mod ListView { // TODO: C::ViewUp, ... _ => None, }; - if let Some(i_data) = data_index { + if let Some(di) = data_index { // Set nav focus to i_data and update scroll position - let rect = solver.rect(i_data) - self.virtual_offset(); + let rect = solver.rect(di) - self.virtual_offset(); cx.set_scroll(Scroll::Rect(rect)); - let index = i_data % usize::conv(self.cur_len); - let w = &self.widgets[index]; - if w.item.index == i_data && w.token.is_some() { + let w = &self.widgets[di % self.widgets.len()]; + if w.item.index == di && w.token.is_some() { cx.next_nav_focus(w.item.id(), false, FocusSource::Key); } else { self.immediate_scroll_update = true; - cx.send(self.id(), FocusIndex(i_data)); + cx.send(self.id(), FocusIndex(di)); } Used } else { @@ -999,13 +1004,13 @@ mod ListView { } fn handle_messages(&mut self, cx: &mut EventCx, data: &C::Data) { - if let Some(FocusIndex(i_data)) = cx.try_pop() { - let index = i_data % usize::conv(self.cur_len); + if let Some(FocusIndex(di)) = cx.try_pop() { + let index = di % self.widgets.len(); let w = &self.widgets[index]; - if w.item.index == i_data && w.token.is_some() { + if w.item.index == di && w.token.is_some() { cx.next_nav_focus(w.item.id(), false, FocusSource::Key); } else { - log::error!("ListView failed to set focus: data item {i_data:?} not in view"); + log::error!("ListView failed to set focus: data item {di:?} not in view"); } } @@ -1087,22 +1092,22 @@ struct PositionSolver { skip: Offset, size: Size, first_data: usize, - cur_len: usize, + alloc_len: usize, } impl PositionSolver { /// Map a child index to a data index fn child_to_data(&self, index: usize) -> usize { - let mut data = (self.first_data / self.cur_len) * self.cur_len + index; + let mut data = (self.first_data / self.alloc_len) * self.alloc_len + index; if data < self.first_data { - data += self.cur_len; + data += self.alloc_len; } data } - /// Rect of data item i - fn rect(&self, i: usize) -> Rect { - let pos = self.pos_start + self.skip * i32::conv(i); + /// Rect of data item `di` + fn rect(&self, di: usize) -> Rect { + let pos = self.pos_start + self.skip * i32::conv(di); Rect::new(pos, self.size) } } From f60c4beb4e1a814b8d6651e248ac85f7af289cc7 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Mon, 9 Feb 2026 15:01:49 +0000 Subject: [PATCH 2/5] ListView: add field visible_range --- crates/kas-view/src/list_view.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/kas-view/src/list_view.rs b/crates/kas-view/src/list_view.rs index 9c3724f7d..dffb1be2d 100644 --- a/crates/kas-view/src/list_view.rs +++ b/crates/kas-view/src/list_view.rs @@ -154,6 +154,7 @@ mod ListView { first_data: u32, /// Last data item to have navigation focus last_focus: u32, + visible_range: Range, direction: D, align_hints: AlignHints, ideal_visible: i32, @@ -244,6 +245,7 @@ mod ListView { cur_len: 0, first_data: 0, last_focus: 0, + visible_range: 0..0, direction, align_hints: Default::default(), ideal_visible: 5, @@ -503,13 +505,17 @@ mod ListView { self.token_update = self.token_update.max(Update::Token); } - let offset = self.offset.extract(self.direction); - let first_data = usize::conv(u64::conv(offset) / u64::conv(self.skip)); + let offset: u64 = self.offset.extract(self.direction).cast(); + let size: u64 = self.rect().size.extract(self.direction).cast(); + let skip: u64 = self.skip.cast(); + let visible_start = usize::conv(offset / skip); + let visible_end = usize::conv((offset + size) / skip) + 1; + self.visible_range = (visible_start..visible_end).cast(); let alloc_len = self.widgets.len(); let data_len; if !self.len_is_known || changes != Changes::None { - let lbound = first_data + 2 * alloc_len; + let lbound = visible_end + alloc_len; let result = self.clerk.len(data, lbound); self.len_is_known = result.is_known(); data_len = result.len(); @@ -520,13 +526,14 @@ mod ListView { } else { data_len = self.data_len.cast(); } - let cur_len = data_len.min(alloc_len); - let first_data = first_data.min(data_len - cur_len); + let cur_len = data_len.min(visible_end) - visible_start; + let first_data = visible_start; let old_start = self.first_data.cast(); let old_end = old_start + usize::conv(self.cur_len); let (mut start, mut end) = (first_data, first_data + cur_len); + let offset = self.offset.extract(self.direction); let virtual_offset = -(offset & 0x7FF0_0000); if virtual_offset != self.virtual_offset { self.virtual_offset = virtual_offset; @@ -756,8 +763,11 @@ mod ListView { fn draw_with_offset(&self, mut draw: DrawCx, viewport: Rect, offset: Offset) { // We use a new pass to clip and offset scrolled content: draw.with_clip_region(viewport, offset + self.virtual_offset(), |mut draw| { - for child in &self.widgets { - if let Some(key) = child.key() { + let alloc_len = self.widgets.len(); + for di in self.visible_range.clone() { + if let Some(child) = self.widgets.get(usize::conv(di) % alloc_len) + && let Some(key) = child.key() + { if self.selection.contains(key) { draw.selection(child.item.rect(), self.sel_style); } @@ -850,8 +860,10 @@ mod ListView { fn probe(&self, coord: Coord) -> Id { let coord = coord + self.translation(0); - for child in &self.widgets { - if child.token.is_some() + let alloc_len = self.widgets.len(); + for di in self.visible_range.clone() { + if let Some(child) = self.widgets.get(usize::conv(di) % alloc_len) + && child.token.is_some() && let Some(id) = child.item.try_probe(coord) { return id; From 2102f643b885021878b02228933882a9d581ac4f Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 10 Feb 2026 11:03:02 +0000 Subject: [PATCH 3/5] Revise clerk module doc --- crates/kas-view/src/clerk.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/kas-view/src/clerk.rs b/crates/kas-view/src/clerk.rs index c8bd34281..4f94e19f9 100644 --- a/crates/kas-view/src/clerk.rs +++ b/crates/kas-view/src/clerk.rs @@ -3,32 +3,41 @@ // You may obtain a copy of the License in the LICENSE-APACHE file or at: // https://www.apache.org/licenses/LICENSE-2.0 -//! Data clerks +//! A clerk (pronounced "klark" or "klerk") is: +//! +//! > One who occupationally provides assistance by working with records, +//! > accounts, letters, etc.; an office worker. +//! +//! (See [clerk - Wiktionary](https://en.wiktionary.org/wiki/clerk).) //! //! # Interfaces //! -//! A clerk manages a view or query over a data set using an `Index` type -//! specified by the [view controller](crate#view-controller). For -//! [`ListView`](crate::ListView), `Index = usize`. +//! Each [view controller](crate#view-controller) uses a clerk to access data +//! records. Clerks are user-defined types and must implement [`Clerk`]: //! -//! All clerks must implement [`Clerk`]: +//! All clerks must implement [`Clerk`] as well as at least one of the other +//! traits below: //! //! - [`Clerk`] covers the base functionality required by all clerks //! -//! ## Data generators +//! The `Index` type parameter used by [`Clerk`] must correspond to the view +//! controller; for example [`ListView`](crate::ListView) requires +//! `Index = usize`. +//! +//! ## Generator Clerks //! -//! Generator clerks construct owned items. One of the following traits must be -//! implemented: +//! A generator clerk constructs owned data items on demand and must implement +//! one of the following traits: //! //! - [`IndexedGenerator`] provides a very simple interface: `update` and //! `generate`. //! - [`KeyedGenerator`] is slightly more complex, supporting a custom key //! type (thus allowing data items to be tracked through changing indices). //! -//! ## Async data access +//! ## Async Clerks //! -//! Async clerks allow borrowed access to locally cached items. Such clerks must -//! implement both [`AsyncClerk`] and [`TokenClerk`]: +//! An async clerk allows borrowed access to locally cached items. Such clerks +//! must implement both [`AsyncClerk`] and [`TokenClerk`]: //! //! - [`AsyncClerk`] supports async data access with a local cache and batched //! item retrieval. From 2833f0a4a7f8d62dad3493627abf56205fb38a61 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 10 Feb 2026 15:31:45 +0000 Subject: [PATCH 4/5] ListView: add fns with_load_ahead, with_min_alloc_len --- crates/kas-view/src/list_view.rs | 62 +++++++++++++++++++++--- examples/file-explorer/src/viewer/dir.rs | 14 +++++- examples/file-explorer/src/viewer/mod.rs | 2 +- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/kas-view/src/list_view.rs b/crates/kas-view/src/list_view.rs index dffb1be2d..d93fd6f9c 100644 --- a/crates/kas-view/src/list_view.rs +++ b/crates/kas-view/src/list_view.rs @@ -144,6 +144,8 @@ mod ListView { driver: V, widgets: Vec>, data_len: u32, + load_ahead: u32, + min_alloc_len: u32, token_update: Update, rect_update: bool, immediate_scroll_update: bool, @@ -238,6 +240,8 @@ mod ListView { driver, widgets: Default::default(), data_len: 0, + load_ahead: 0, + min_alloc_len: 0, token_update: Update::None, rect_update: false, immediate_scroll_update: false, @@ -454,6 +458,48 @@ mod ListView { self } + /// Set the number of items to load predictively + /// + /// `ListView` will construct and update sufficient view widgets to + /// cover all visible items plus this many items before and after the + /// range of visible items. + /// + /// By default this is zero since load-ahead is not useful where data is + /// available and view widgets are fast to update; it may even harm + /// performance. + /// + /// Where data must be retrieved (asynchronously) from a slow source, it + /// may be preferable not to use this setting but instead to + /// predictively load data items in the clerk: see + /// [`AsyncClerk::prepare_range`](super::clerk::AsyncClerk::prepare_range). + #[inline] + pub fn with_load_ahead(mut self, items: u32) -> Self { + self.load_ahead = items; + self + } + + /// Set the minimum allocation size + /// + /// The allocation size is always large enough to cover all visible data + /// items plus those required by [`Self::with_load_ahead`]. This method + /// allows a larger allocation size (i.e. a cache) to be used. + /// + /// When the allocation size is larger than required, previously-loaded + /// items outside of the visible + load-ahead range will be retained + /// until overwritten, potentially allowing faster scroll-back. + /// + /// By default this is zero since this is not useful where data is + /// available and view widgets are fast to update; it may even harm + /// performance since cached widgets are also updated as required. + /// + /// Where data access is slow, it may be preferable to instead cache + /// data in a clerk (see [Async Clerks](super::clerk#async-clerks)). + #[inline] + pub fn with_min_alloc_len(mut self, len: u32) -> Self { + self.min_alloc_len = len; + self + } + #[inline] fn virtual_offset(&self) -> Offset { match self.direction.is_vertical() { @@ -526,12 +572,15 @@ mod ListView { } else { data_len = self.data_len.cast(); } - let cur_len = data_len.min(visible_end) - visible_start; - let first_data = visible_start; + + let load_ahead: usize = self.load_ahead.cast(); + let data_start = data_len.min(visible_start.saturating_sub(load_ahead)); + let data_end = data_len.min(visible_end.saturating_add(load_ahead)); + let cur_len = data_end - data_start; let old_start = self.first_data.cast(); let old_end = old_start + usize::conv(self.cur_len); - let (mut start, mut end) = (first_data, first_data + cur_len); + let (mut start, mut end) = (data_start, data_start + cur_len); let offset = self.offset.extract(self.direction); let virtual_offset = -(offset & 0x7FF0_0000); @@ -548,7 +597,7 @@ mod ListView { debug_assert!(cur_len <= self.widgets.len()); self.cur_len = cur_len.cast(); - self.first_data = first_data.cast(); + self.first_data = data_start.cast(); if start < end { self.map_view_widgets(cx, data, start..end, force_update); @@ -692,8 +741,9 @@ mod ListView { 0 } else { self.skip = skip; - let size = rect.size.extract(self.direction); - usize::conv(size).div_ceil(usize::conv(skip)) + 1 + let size: usize = rect.size.extract(self.direction).cast(); + usize::conv(self.min_alloc_len) + .max(size.div_ceil(usize::conv(skip)) + 1 + 2 * usize::conv(self.load_ahead)) }; let avail_widgets = self.widgets.len(); diff --git a/examples/file-explorer/src/viewer/dir.rs b/examples/file-explorer/src/viewer/dir.rs index cf437b1fd..1daa26bd5 100644 --- a/examples/file-explorer/src/viewer/dir.rs +++ b/examples/file-explorer/src/viewer/dir.rs @@ -129,13 +129,25 @@ mod DirView { .with_style(FrameStyle::EditBox) .with_margin_style(MarginStyle::None) )] - #[derive(Default)] pub struct DirView { core: widget_core!(), #[widget] list: ScrollRegion>, } + impl Self { + pub fn new() -> Self { + DirView { + core: Default::default(), + list: ScrollRegion::new_viewport( + ListView::default() + .with_load_ahead(2) + .with_min_alloc_len(100), + ), + } + } + } + impl Events for Self { type Data = Data; } diff --git a/examples/file-explorer/src/viewer/mod.rs b/examples/file-explorer/src/viewer/mod.rs index 1766c316d..b490416c1 100644 --- a/examples/file-explorer/src/viewer/mod.rs +++ b/examples/file-explorer/src/viewer/mod.rs @@ -5,5 +5,5 @@ mod dir; use kas::prelude::*; pub fn viewer() -> impl Widget { - dir::DirView::default() + dir::DirView::new() } From 2124afc0ab976fe3861445a6ef61ba8564b56c32 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 10 Feb 2026 15:32:21 +0000 Subject: [PATCH 5/5] ListView fix: clear cached widgets on forced update --- crates/kas-view/src/list_view.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/kas-view/src/list_view.rs b/crates/kas-view/src/list_view.rs index d93fd6f9c..09ece9876 100644 --- a/crates/kas-view/src/list_view.rs +++ b/crates/kas-view/src/list_view.rs @@ -587,8 +587,26 @@ mod ListView { if virtual_offset != self.virtual_offset { self.virtual_offset = virtual_offset; self.rect_update = true; - } else if force_update || self.rect_update || self.token_update != Update::None { - // This forces an update to all widgets + } + + if force_update || self.rect_update || self.token_update != Update::None { + // Not restricting start..end forces an update to all widgets + // We must also clear cached widgets not in start..end: + let a = start % alloc_len; + let b = end % alloc_len; + let range = if a == b { + 0..alloc_len + } else if a > b { + b..a + } else { + for i in 0..a { + self.widgets[i].token = None; + } + b..alloc_len + }; + for i in range { + self.widgets[i].token = None; + } } else if start >= old_start { start = start.max(old_end); } else if end <= old_end { @@ -725,6 +743,9 @@ mod ListView { } fn set_rect(&mut self, cx: &mut SizeCx, rect: Rect, hints: AlignHints) { + if rect == self.rect() && hints == self.align_hints { + return; + } self.core.set_rect(rect); self.align_hints = hints;