Data list view
Topics: view widgets, DataGenerator
Problem
This tutorial will concern building a front-end to a very simple database.
Context: we have a database consisting of a set of String
values, keyed by sequentially-assigned (but not necessarily contiguous) usize
numbers. One key is deemed active. In code:
#![allow(unused)] fn main() { #[derive(Debug)] struct MyData { active: usize, strings: HashMap<usize, String>, } impl MyData { fn new() -> Self { MyData { active: 0, strings: HashMap::new(), } } fn get_string(&self, index: usize) -> String { self.strings .get(&index) .cloned() .unwrap_or_else(|| format!("Entry #{}", index + 1)) } } }
Our very simple database supports two mutating operations: selecting a new key to be active and replacing the string value at a given key:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] enum Control { Select(usize), Update(usize, String), } struct MyData { active: usize, strings: HashMap<usize, String>, } impl MyData { fn handle(&mut self, control: Control) { match control { Control::Select(index) => { self.active = index; } Control::Update(index, text) => { self.strings.insert(index, text); } }; } } }
The view widget
We wish to display our database as a sequence of "view widgets", each tied to a single key. We will start by designing such a "view widget".
Input data
Each item consists of a key: usize
and value: String
. Additionally, an item may or may not be active. Since we don't need to pass static (unchanging) data on update, we will omit key
. Though we could pass is_active: bool
, it turns out to be just as easy to pass active: usize
.
The input data to our view widget will therefore be:
#![allow(unused)] fn main() { type MyItem = (usize, String); // (active index, entry's text) }
Edit fields and guards
We choose to display the String
value in an EditBox
, allowing direct editing of the value. To fine-tune behaviour of this EditBox
, we will implement a custom EditGuard
:
#![allow(unused)] fn main() { #[derive(Debug)] struct ListEntryGuard(usize); impl EditGuard for ListEntryGuard { type Data = MyItem; fn update(edit: &mut EditField<Self>, cx: &mut ConfigCx, data: &MyItem) { if !edit.has_edit_focus() { edit.set_string(cx, data.1.to_string()); } } fn activate(edit: &mut EditField<Self>, cx: &mut EventCx, _: &MyItem) -> IsUsed { cx.push(Control::Select(edit.guard.0)); Used } fn edit(edit: &mut EditField<Self>, cx: &mut EventCx, _: &MyItem) { cx.push(Control::Update(edit.guard.0, edit.clone_string())); } } }
The view widget
The view widget itself is a custom widget:
#![allow(unused)] fn main() { #[impl_self] mod ListEntry { // The list entry #[widget] #[layout(column! [ row! [self.label, self.radio], self.edit, ])] struct ListEntry { core: widget_core!(), #[widget(&())] label: Label<String>, #[widget] radio: RadioButton<MyItem>, #[widget] edit: EditBox<ListEntryGuard>, } impl Events for Self { type Data = MyItem; } } }
(In fact, the primary reason to use a custom widget here is to have a named widget type.)
The driver
To use ListEntry
as a view widget, we need a driver:
#![allow(unused)] fn main() { struct ListEntryDriver; impl Driver<usize, MyItem> for ListEntryDriver { type Widget = ListEntry; fn make(&mut self, key: &usize) -> Self::Widget { let n = *key; ListEntry { core: Default::default(), label: Label::new(format!("Entry number {}", n + 1)), radio: RadioButton::new_msg( "display this entry", move |_, data: &MyItem| data.0 == n, move || Control::Select(n), ), edit: EditBox::new(ListEntryGuard(n)).with_width_em(18.0, 30.0), } } fn navigable(_: &Self::Widget) -> bool { false } } }
A scrollable view over data entries
We've already seen the column!
macro which allows easy construction of a fixed-size vertical list. This macro constructs a Column<C>
widget over a synthesized Collection
type, C
If we instead use Column<Vec<ListEntry>>
we can extend the column dynamically.
Such an approach (directly representing each data entry with a widget) is scalable to at least 10'000 entries, assuming one is prepared for some delays when constructing and resizing the UI. If we wanted to scale this further, we could page results, or try building a façade which dymanically re-allocates view widgets as the view is scrolled ...
... but wait, Kas already has that. It's called ListView
. Lets use it.
Data clerks
To drive ListView
, we need an implementation of DataClerk
. This is a low-level interface designed to support custom caching of data using batched async
retrieval.
For our toy example, we can use GeneratorClerk
, which provides a higher-level interface over the DataGenerator
trait.
We determined our view widget's input data type above: type MyItem = (usize, String);
— our implementation just needs to generate values of this type on demand. (And since input data must be passed by a single reference, we cannot pass our data as (usize, &str)
here. We could instead pass (usize, Rc<Box<String>>)
to avoid deep-cloning String
s, but in this little example there is no need.)
Data generators
The DataGenerator
trait is fairly simple to implement:
#![allow(unused)] fn main() { #[derive(Default)] struct Generator; // We implement for Index=usize, as required by ListView: impl DataGenerator<usize> for Generator { type Data = MyData; type Key = usize; type Item = MyItem; fn update(&mut self, _: &Self::Data) -> GeneratorChanges<usize> { todo!() } fn len(&self, data: &Self::Data, lbound: usize) -> DataLen<usize> { todo!() } fn key(&self, _: &Self::Data, index: usize) -> Option<Self::Key> { Some(index) } fn generate(&self, data: &Self::Data, key: &usize) -> Self::Item { (data.active, data.get_string(*key)) } } }
Returning GeneratorChanges::Any
from fn DataGenerator::update
is never wrong, yet it may cause unnecessary work. It turns out that we can simply calculate necessary updates in fn MyData::handle
. (This assumes that MyData::handle
will not be called multiple times before DataGenerator::update
.)
Before we amend MyData
, we should look at fn DataGenerator::len
, which affects both the items our view controller might try to generate and the length of scroll bars. The return type is DataLen
(with Index=usize
in our case):
#![allow(unused)] fn main() { pub enum DataLen<Index> { Known(Index), LBound(Index), } }
MyData
does not have a limit on its data length (aside from usize::MAX
and the amount of memory available to HashMap
, both of which we shall ignore). We do have a known lower bound: the last (highest) key value used.
At this point, we could decide that the highest addressible key is data.last_key + 1
and therefore return DataLen::Known(data.last_key + 2)
. Instead, we'd like to support unlimited scrolling (like in spreadsheets); following the recommendations on DataGenerator::len
thus leads to the following implementation:
#![allow(unused)] fn main() { fn len(&self, data: &Self::Data, lbound: usize) -> DataLen<usize> { DataLen::LBound((data.active.max(data.last_key + 1).max(lbound)) } }
Right, lets update MyData
with these additional capabilities:
#![allow(unused)] fn main() { #[derive(Debug)] struct MyData { last_change: GeneratorChanges<usize>, last_key: usize, active: usize, strings: HashMap<usize, String>, } impl MyData { fn handle(&mut self, control: Control) { match control { Control::Select(index) => { self.last_change = GeneratorChanges::Any; self.active = index; } Control::Update(index, text) => { self.last_change = GeneratorChanges::Range(index..index + 1); self.last_key = self.last_key.max(index); self.strings.insert(index, text); } }; } } }
ListView
Now we can write fn main
:
fn main() -> kas::runner::Result<()> { env_logger::init(); let clerk = GeneratorClerk::new(Generator::default()); let list = ListView::down(clerk, ListEntryDriver); let tree = column![ "Contents of selected entry:", Text::new(|_, data: &MyData| data.get_string(data.active)), Separator::new(), ScrollBars::new(list).with_fixed_bars(false, true), ]; let ui = tree .with_state(MyData::new()) .on_message(|_, data, control| data.handle(control)); let window = Window::new(ui, "Data list view"); kas::runner::Runner::new(())?.with(window).run() }
The ListView
widget controls our view. We construct with direction down
, a GeneratorClerk
and our ListEntryDriver
. Done.