Expand description
§3. Layout and style
The list works, but every element has the same visual weight. Center it in a narrow column, make each row read as a checkbox and title, and derive the active count from the todos rather than storing a second value.
§Center a column
GPUI uses flexbox for the nested-box layout found in most interfaces. A container lays out its children along a main axis; here the window centers a column that is at most 480 pixels wide:
div()
.size_full()
.flex()
.justify_center()
.p_8()
.child(
div()
.w_full()
.max_w(px(480.))
.flex()
.flex_col()
.gap_4()
.child("Todos"),
)flex makes the outer box a flex container, and
justify_center centers its children along its main
axis, horizontally. The inner box asks for the full width with
w_full but caps it with max_w, so it is
480 pixels wide in a large window and shrinks with a small one.
flex_col stacks its own children vertically, and
gap_4 puts one rem of space between them.
px is a length in logical pixels. GPUI scales it to the display for you, so
480 logical pixels look the same size on a regular and a high-resolution screen.
§Style the rows
Each row is a small flex row of its own: a round checkbox, then the title, which
takes up the remaining width with flex_1.
fn row(&self, index: usize, todo: &Todo, colors: &Colors, cx: &mut Context<Self>) -> impl IntoElement + use<> {
div()
.id(index)
.flex()
.items_center()
.gap_3()
.px_2()
.py_2()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(colors.container))
.child(
div()
.size_5()
.rounded_full()
.border_1()
.border_color(colors.border)
.when(todo.done, |checkbox| checkbox.bg(colors.selected)),
)
.child(
div()
.flex_1()
.when(todo.done, |title| title.line_through().text_color(colors.disabled))
.child(todo.title.clone()),
)
.on_click(cx.listener(move |todos, _event, _window, cx| {
let todo = &mut todos.todos[index];
todo.done = !todo.done;
cx.notify();
}))
}Moving the row into a method keeps render focused on the page structure. A
method returning impl IntoElement is enough while the row belongs only to this
view; Chapter 5 compares that boundary with reusable components and child views.
The + use<> after the return type says that the element the method returns
doesn’t borrow any of its arguments: it owns everything it needs. Without it,
Rust assumes the element might still borrow cx, and won’t let you call row
for each todo inside the closure you pass to map.
hover takes the style changes to apply while the
pointer is over the row. GPUI tracks the hover state and redraws the row for
you; there’s nothing to notify.
§Count what’s left
Finish render with a title, the rows, and a footer:
impl Render for Todos {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = Colors::for_appearance(window);
let left = self.todos.iter().filter(|todo| !todo.done).count();
div()
.size_full()
.flex()
.justify_center()
.p_8()
.bg(colors.background)
.text_color(colors.text)
.child(
div()
.w_full()
.max_w(px(480.))
.flex()
.flex_col()
.gap_4()
.child(div().text_3xl().font_weight(FontWeight::SEMIBOLD).child("Todos"))
.child(
div().flex().flex_col().children(
self.todos
.iter()
.enumerate()
.map(|(index, todo)| self.row(index, todo, &colors, cx)),
),
)
.child(div().text_sm().text_color(colors.disabled).child(format!("{left} left"))),
)
}
}The count is derived from todos on every render, so there is no second field to
keep in sync. A notification schedules the render that reads the changed list; the
rows and the count therefore come from the same snapshot of state.
§The program so far
src/main.rs
use gpui::{
App, Context, FontWeight, QuitMode, SharedString, Window, WindowOptions, colors::Colors,
div, prelude::*, px,
};
struct Todo {
title: SharedString,
done: bool,
}
struct Todos {
todos: Vec<Todo>,
}
impl Todos {
fn new() -> Self {
Self {
todos: vec![
Todo { title: "Buy milk".into(), done: false },
Todo { title: "Water the plants".into(), done: true },
Todo { title: "Call Sam".into(), done: false },
],
}
}
fn row(&self, index: usize, todo: &Todo, colors: &Colors, cx: &mut Context<Self>) -> impl IntoElement + use<> {
div()
.id(index)
.flex()
.items_center()
.gap_3()
.px_2()
.py_2()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(colors.container))
.child(
div()
.size_5()
.rounded_full()
.border_1()
.border_color(colors.border)
.when(todo.done, |checkbox| checkbox.bg(colors.selected)),
)
.child(
div()
.flex_1()
.when(todo.done, |title| title.line_through().text_color(colors.disabled))
.child(todo.title.clone()),
)
.on_click(cx.listener(move |todos, _event, _window, cx| {
let todo = &mut todos.todos[index];
todo.done = !todo.done;
cx.notify();
}))
}
}
impl Render for Todos {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = Colors::for_appearance(window);
let left = self.todos.iter().filter(|todo| !todo.done).count();
div()
.size_full()
.flex()
.justify_center()
.p_8()
.bg(colors.background)
.text_color(colors.text)
.child(
div()
.w_full()
.max_w(px(480.))
.flex()
.flex_col()
.gap_4()
.child(div().text_3xl().font_weight(FontWeight::SEMIBOLD).child("Todos"))
.child(
div().flex().flex_col().children(
self.todos
.iter()
.enumerate()
.map(|(index, todo)| self.row(index, todo, &colors, cx)),
),
)
.child(div().text_sm().text_color(colors.disabled).child(format!("{left} left"))),
)
}
}
fn main() {
gpui_platform::application()
.with_quit_mode(QuitMode::LastWindowClosed)
.run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |_window, cx| cx.new(|_cx| Todos::new()))
.unwrap();
cx.activate(true);
});
}The layout is ready for a control above the list. Chapter 4 adds a text field and uses focus and actions to let you add your own todos.