diff --git a/examples/data-list-view.rs b/examples/data-list-view.rs new file mode 100644 index 0000000..4a2a9b8 --- /dev/null +++ b/examples/data-list-view.rs @@ -0,0 +1,164 @@ +use kas::prelude::*; +use kas::view::{DataGenerator, DataLen, GeneratorChanges, GeneratorClerk}; +use kas::view::{Driver, ListView}; +use kas::widgets::{column, *}; +use std::collections::HashMap; + +#[derive(Clone, Debug)] +enum Control { + Select(usize), + Update(usize, String), +} + +#[derive(Debug)] +struct MyData { + last_change: GeneratorChanges, + last_key: usize, + active: usize, + strings: HashMap, +} +impl MyData { + fn new() -> Self { + MyData { + last_change: GeneratorChanges::None, + last_key: 0, + active: 0, + strings: HashMap::new(), + } + } + fn get_string(&self, index: usize) -> String { + self.strings + .get(&index) + .cloned() + .unwrap_or_else(|| format!("Entry #{}", index + 1)) + } + 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); + } + }; + } +} + +type MyItem = (usize, String); // (active index, entry's text) + +#[derive(Debug)] +struct ListEntryGuard(usize); +impl EditGuard for ListEntryGuard { + type Data = MyItem; + + fn update(edit: &mut EditField, cx: &mut ConfigCx, data: &MyItem) { + if !edit.has_edit_focus() { + edit.set_string(cx, data.1.to_string()); + } + } + + fn activate(edit: &mut EditField, cx: &mut EventCx, _: &MyItem) -> IsUsed { + cx.push(Control::Select(edit.guard.0)); + Used + } + + fn edit(edit: &mut EditField, cx: &mut EventCx, _: &MyItem) { + cx.push(Control::Update(edit.guard.0, edit.clone_string())); + } +} + +#[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, + #[widget] + radio: RadioButton, + #[widget] + edit: EditBox, + } + + impl Events for Self { + type Data = MyItem; + } +} + +struct ListEntryDriver; +impl Driver 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 + } +} + +#[derive(Default)] +struct Generator; +impl DataGenerator for Generator { + type Data = MyData; + type Key = usize; + type Item = MyItem; + + fn update(&mut self, data: &Self::Data) -> GeneratorChanges { + // We assume that `MyData::handle` has only been called once since this + // method was last called. + data.last_change.clone() + } + + fn len(&self, data: &Self::Data, lbound: usize) -> DataLen { + DataLen::LBound((data.active.max(data.last_key) + 1).max(lbound)) + } + + fn key(&self, _: &Self::Data, index: usize) -> Option { + Some(index) + } + + fn generate(&self, data: &Self::Data, key: &usize) -> Self::Item { + (data.active, data.get_string(*key)) + } +} + +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() +} diff --git a/src/SUMMARY.md b/src/SUMMARY.md index ce212c3..3afd440 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -7,4 +7,4 @@ - [Configuration](config.md) - [Calculator: make_widget and grid](calculator.md) - [Custom widgets](custom-widget.md) -- [Data models](data-models.md) +- [Data list view](data-list-view.md) diff --git a/src/data-list-view.md b/src/data-list-view.md new file mode 100644 index 0000000..52c07b4 --- /dev/null +++ b/src/data-list-view.md @@ -0,0 +1,295 @@ +# Data list view + +*Topics: view widgets, `DataGenerator`* + +![Data list view](screenshots/data-list-view.png) + +## 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: +```rust +#[derive(Debug)] +struct MyData { + active: usize, + strings: HashMap, +} + +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: +```rust +#[derive(Clone, Debug)] +enum Control { + Select(usize), + Update(usize, String), +} +# struct MyData { +# active: usize, +# strings: HashMap, +# } + +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: +```rust +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`]: + +```rust +#[derive(Debug)] +struct ListEntryGuard(usize); +impl EditGuard for ListEntryGuard { + type Data = MyItem; + + fn update(edit: &mut EditField, cx: &mut ConfigCx, data: &MyItem) { + if !edit.has_edit_focus() { + edit.set_string(cx, data.1.to_string()); + } + } + + fn activate(edit: &mut EditField, cx: &mut EventCx, _: &MyItem) -> IsUsed { + cx.push(Control::Select(edit.guard.0)); + Used + } + + fn edit(edit: &mut EditField, 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: +```rust +#[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, + #[widget] + radio: RadioButton, + #[widget] + edit: EditBox, + } + + 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: +```rust +struct ListEntryDriver; +impl Driver 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>)` 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: +```rust +#[derive(Default)] +struct Generator; + +// We implement for Index=usize, as required by ListView: +impl DataGenerator for Generator { + type Data = MyData; + type Key = usize; + type Item = MyItem; + + fn update(&mut self, _: &Self::Data) -> GeneratorChanges { + todo!() + } + + fn len(&self, data: &Self::Data, lbound: usize) -> DataLen { + todo!() + } + + fn key(&self, _: &Self::Data, index: usize) -> Option { + 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): +```rust +pub enum DataLen { + 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: +```rust + fn len(&self, data: &Self::Data, lbound: usize) -> DataLen { + DataLen::LBound((data.active.max(data.last_key + 1).max(lbound)) + } +``` + +Right, lets update `MyData` with these additional capabilities: +```rust +#[derive(Debug)] +struct MyData { + last_change: GeneratorChanges, + last_key: usize, + active: usize, + strings: HashMap, +} + +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`: +```rust +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. + +[Full code can be found here](https://github.com/kas-gui/tutorials/blob/master/examples/data-list-view.rs). + +[`EditBox`]: https://docs.rs/kas/latest/kas/widgets/struct.EditBox.html +[`EditGuard`]: https://docs.rs/kas/latest/kas/widgets/trait.EditGuard.html +[`GeneratorChanges::Any`]: https://docs.rs/kas/latest/kas/view/enum.GeneratorChanges.html#variant.Any +[`DataGenerator`]: https://docs.rs/kas/latest/kas/view/trait.DataGenerator.html +[`DataGenerator::update`]: https://docs.rs/kas/latest/kas/view/trait.DataGenerator.html#tymethod.update +[`DataGenerator::len`]: https://docs.rs/kas/latest/kas/view/trait.DataGenerator.html#tymethod.len +[`DataLen`]: https://docs.rs/kas/latest/kas/view/enum.DataLen.html +[`DataLen::Known`]: https://docs.rs/kas/latest/kas/view/enum.DataLen.html +[`ListView`]: https://docs.rs/kas/latest/kas/view/struct.ListView.html +[`GeneratorClerk`]: https://docs.rs/kas/latest/kas/view/struct.GeneratorClerk.html +[`DataClerk`]: https://docs.rs/kas/latest/kas/view/trait.DataClerk.html +[`column!`]: https://docs.rs/kas/latest/kas/widgets/macro.column.html +[Column]: https://docs.rs/kas/latest/kas/widgets/type.Column.html +[`Collection`]: https://docs.rs/kas/latest/kas/trait.Collection.html diff --git a/src/data-models.md b/src/data-models.md deleted file mode 100644 index 6b8dc16..0000000 --- a/src/data-models.md +++ /dev/null @@ -1,25 +0,0 @@ -# Sync-counter: data models - -*Topics: data models and view widgets* - -TODO: - -- [`ListView`] and [`ListData`] -- [`Driver`], including predefined impls -- [`Filter`] and [`UnsafeFilteredList`]. This rather messy to use (improvable?). The latter should eventually be replaced with a safe variant. -- [`MatrixView`] and [`MatrixData`]. (Will possibly gain support for row/column labels and be renamed `TableView`.) - -For now, see the examples: - -- [`examples/ldata-list-view.rs`](https://github.com/kas-gui/kas/blob/master/examples/data-list-view.rs) uses [`ListView`] with custom [`ListData`] and [`Driver`] -- [`examples/gallery.rs`](https://github.com/kas-gui/kas/blob/master/examples/gallery.rs#L338)'s `filter_list` uses [`UnsafeFilteredList`] with a custom [`Driver`]. Less code but possibly more complex. -- [`examples/times-tables.rs`](https://github.com/kas-gui/kas/blob/master/examples/times-tables.rs) uses [`MatrixView`] with custom [`MatrixData`] and [`driver::NavView`]. Probably the easiest example. - -[`ListView`]: https://docs.rs/kas/latest/kas/view/struct.ListView.html -[`ListData`]: https://docs.rs/kas/latest/kas/view/trait.ListData.html -[`Driver`]: https://docs.rs/kas/latest/kas/view/trait.Driver.html -[`driver::NavView`]: https://docs.rs/kas/latest/kas/view/driver/struct.NavView.html -[`Filter`]: https://docs.rs/kas/latest/kas/view/filter/trait.Filter.html -[`UnsafeFilteredList`]: https://docs.rs/kas/latest/kas/view/filter/struct.UnsafeFilteredList.html -[`MatrixView`]: https://docs.rs/kas/latest/kas/view/struct.MatrixView.html -[`MatrixData`]: https://docs.rs/kas/latest/kas/view/trait.MatrixData.html diff --git a/src/screenshots/data-list-view.png b/src/screenshots/data-list-view.png new file mode 100644 index 0000000..aaedeb1 Binary files /dev/null and b/src/screenshots/data-list-view.png differ