Lists and keys
At the end of this chapter, a to-do list adds, ticks, removes and filters rows, in a box that scrolls.
Rows from a Vec
A list is a loop in view. For each item in the model, declare a row.
That is all.
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
Keys
Here is the question keys answer. Frame 1 declares rows A, B, C. Frame 2 declares B, C — A was removed. Is frame 2’s first row a new row, or is it B moved up?
kui needs to know, because a row carries things between frames: its hover, its focus, its scroll position, a transition in flight. If the first row is “B moved up”, B keeps its hover. If it is “a new row”, everything resets.
By default a child’s key is its position, so frame 2’s first row would be taken for A. That is wrong for a list. So each row gets a key of its own, from the data:
ui.with_indexed(id, spec, ..)— the key is a number you own: an id, a database row.ui.with_keyed("name", spec, ..)— the key is a string. The stock widgets use their text this way, which is why a button is found by its label.ui.with(spec, ..)— the key is the position. Fine for a layout that does not change shape.
Two rows with the same key in one frame is a mistake, and kui says so
with a duplicate-key warning.
Messages with fields
Each row’s checkbox and remove button need to say which row. A message variant carries the id:
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
Scrolling
.scroll_y() on a box with a fixed height makes its content scroll.
The wheel moves it, a scrollbar appears when it overflows, and the
scroll position is kept between frames — under the box’s key, which is
why the list box would need a key of its own if it ever moved.
When the list is long
A loop over ten thousand rows declares ten thousand rows every frame.
For that, widgets::uniform_list declares only the rows in view and
two spacers, from a row height and a count. It is the same idea with
the loop inverted; see
widgets/virtual_list.rs
when you need it.
Try this
- Remove
with_indexedand useui.with(..)for the rows. Hover a row, then remove the row above it. The hover jumps. - Add a “Clear done” button. One
retain.
Where this is decided
- Keys, and the
duplicate-keywarning:docs/props.md, warnings.