Implement pure version of todos example 🎉

The `Widget` trait in `iced_pure` needed to change a bit to make the
implementation of `Element::map` possible.

Specifically, the `children` method has been split into `diff` and
`children_state`.
This commit is contained in:
Héctor Ramón Jiménez 2022-02-12 17:21:28 +07:00
parent e3108494e5
commit bd22cc0bc0
No known key found for this signature in database
GPG key ID: 140CC052C94F138E
18 changed files with 917 additions and 96 deletions

View file

@ -0,0 +1,19 @@
[package]
name = "pure_todos"
version = "0.1.0"
authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
edition = "2021"
publish = false
[dependencies]
iced = { path = "../../..", features = ["async-std", "debug", "pure"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
async-std = "1.0"
directories-next = "2.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Window", "Storage"] }
wasm-timer = "0.2"

View file

@ -0,0 +1,20 @@
## Todos
A todos tracker inspired by [TodoMVC]. It showcases dynamic layout, text input, checkboxes, scrollables, icons, and async actions! It automatically saves your tasks in the background, even if you did not finish typing them.
All the example code is located in the __[`main`]__ file.
<div align="center">
<a href="https://gfycat.com/littlesanehalicore">
<img src="https://thumbs.gfycat.com/LittleSaneHalicore-small.gif" height="400px">
</a>
</div>
You can run the native version with `cargo run`:
```
cargo run --package todos
```
We have not yet implemented a `LocalStorage` version of the auto-save feature. Therefore, it does not work on web _yet_!
[`main`]: src/main.rs
[TodoMVC]: http://todomvc.com/

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Todos - Iced</title>
<base data-trunk-public-url />
</head>
<body>
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" data-bin="todos" />
</body>
</html>

View file

@ -0,0 +1,599 @@
use iced::alignment::{self, Alignment};
use iced::pure::widget::{
button, checkbox, column, container, row, scrollable, text, text_input,
};
use iced::pure::{Application, Element, Text};
use iced::{Command, Font, Length, Settings};
use serde::{Deserialize, Serialize};
pub fn main() -> iced::Result {
Todos::run(Settings::default())
}
#[derive(Debug)]
enum Todos {
Loading,
Loaded(State),
}
#[derive(Debug, Default)]
struct State {
input_value: String,
filter: Filter,
tasks: Vec<Task>,
dirty: bool,
saving: bool,
}
#[derive(Debug, Clone)]
enum Message {
Loaded(Result<SavedState, LoadError>),
Saved(Result<(), SaveError>),
InputChanged(String),
CreateTask,
FilterChanged(Filter),
TaskMessage(usize, TaskMessage),
}
impl Application for Todos {
type Executor = iced::executor::Default;
type Message = Message;
type Flags = ();
fn new(_flags: ()) -> (Todos, Command<Message>) {
(
Todos::Loading,
Command::perform(SavedState::load(), Message::Loaded),
)
}
fn title(&self) -> String {
let dirty = match self {
Todos::Loading => false,
Todos::Loaded(state) => state.dirty,
};
format!("Todos{} - Iced", if dirty { "*" } else { "" })
}
fn update(&mut self, message: Message) -> Command<Message> {
match self {
Todos::Loading => {
match message {
Message::Loaded(Ok(state)) => {
*self = Todos::Loaded(State {
input_value: state.input_value,
filter: state.filter,
tasks: state.tasks,
..State::default()
});
}
Message::Loaded(Err(_)) => {
*self = Todos::Loaded(State::default());
}
_ => {}
}
Command::none()
}
Todos::Loaded(state) => {
let mut saved = false;
match message {
Message::InputChanged(value) => {
state.input_value = value;
}
Message::CreateTask => {
if !state.input_value.is_empty() {
state
.tasks
.push(Task::new(state.input_value.clone()));
state.input_value.clear();
}
}
Message::FilterChanged(filter) => {
state.filter = filter;
}
Message::TaskMessage(i, TaskMessage::Delete) => {
state.tasks.remove(i);
}
Message::TaskMessage(i, task_message) => {
if let Some(task) = state.tasks.get_mut(i) {
task.update(task_message);
}
}
Message::Saved(_) => {
state.saving = false;
saved = true;
}
_ => {}
}
if !saved {
state.dirty = true;
}
if state.dirty && !state.saving {
state.dirty = false;
state.saving = true;
Command::perform(
SavedState {
input_value: state.input_value.clone(),
filter: state.filter,
tasks: state.tasks.clone(),
}
.save(),
Message::Saved,
)
} else {
Command::none()
}
}
}
}
fn view(&self) -> Element<Message> {
match self {
Todos::Loading => loading_message(),
Todos::Loaded(State {
input_value,
filter,
tasks,
..
}) => {
let title = text("todos")
.width(Length::Fill)
.size(100)
.color([0.5, 0.5, 0.5])
.horizontal_alignment(alignment::Horizontal::Center);
let input = text_input(
"What needs to be done?",
input_value,
Message::InputChanged,
)
.padding(15)
.size(30)
.on_submit(Message::CreateTask);
let controls = view_controls(&tasks, *filter);
let filtered_tasks =
tasks.iter().filter(|task| filter.matches(task));
let tasks: Element<_> = if filtered_tasks.count() > 0 {
tasks
.iter()
.enumerate()
.filter(|(_, task)| filter.matches(task))
.fold(column().spacing(20), |column, (i, task)| {
column.push(task.view().map(move |message| {
Message::TaskMessage(i, message)
}))
})
.into()
} else {
empty_message(match filter {
Filter::All => "You have not created a task yet...",
Filter::Active => "All your tasks are done! :D",
Filter::Completed => {
"You have not completed a task yet..."
}
})
};
let content = column()
.spacing(20)
.push(title)
.push(input)
.push(controls)
.push(tasks);
scrollable(
container(content)
.width(Length::Fill)
.padding(40)
.center_x(),
)
.into()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
description: String,
completed: bool,
#[serde(skip)]
state: TaskState,
}
#[derive(Debug, Clone)]
pub enum TaskState {
Idle,
Editing,
}
impl Default for TaskState {
fn default() -> Self {
Self::Idle
}
}
#[derive(Debug, Clone)]
pub enum TaskMessage {
Completed(bool),
Edit,
DescriptionEdited(String),
FinishEdition,
Delete,
}
impl Task {
fn new(description: String) -> Self {
Task {
description,
completed: false,
state: TaskState::Idle,
}
}
fn update(&mut self, message: TaskMessage) {
match message {
TaskMessage::Completed(completed) => {
self.completed = completed;
}
TaskMessage::Edit => {
self.state = TaskState::Editing;
}
TaskMessage::DescriptionEdited(new_description) => {
self.description = new_description;
}
TaskMessage::FinishEdition => {
if !self.description.is_empty() {
self.state = TaskState::Idle;
}
}
TaskMessage::Delete => {}
}
}
fn view(&self) -> Element<TaskMessage> {
match &self.state {
TaskState::Idle => {
let checkbox = checkbox(
&self.description,
self.completed,
TaskMessage::Completed,
)
.width(Length::Fill);
row()
.spacing(20)
.align_items(Alignment::Center)
.push(checkbox)
.push(
button(edit_icon())
.on_press(TaskMessage::Edit)
.padding(10)
.style(style::Button::Icon),
)
.into()
}
TaskState::Editing => {
let text_input = text_input(
"Describe your task...",
&self.description,
TaskMessage::DescriptionEdited,
)
.on_submit(TaskMessage::FinishEdition)
.padding(10);
row()
.spacing(20)
.align_items(Alignment::Center)
.push(text_input)
.push(
button(
row()
.spacing(10)
.push(delete_icon())
.push("Delete"),
)
.on_press(TaskMessage::Delete)
.padding(10)
.style(style::Button::Destructive),
)
.into()
}
}
}
}
fn view_controls(tasks: &[Task], current_filter: Filter) -> Element<Message> {
let tasks_left = tasks.iter().filter(|task| !task.completed).count();
let filter_button = |label, filter, current_filter| {
let label = text(label).size(16);
let button = button(label).style(if filter == current_filter {
style::Button::FilterSelected
} else {
style::Button::FilterActive
});
button.on_press(Message::FilterChanged(filter)).padding(8)
};
row()
.spacing(20)
.align_items(Alignment::Center)
.push(
text(format!(
"{} {} left",
tasks_left,
if tasks_left == 1 { "task" } else { "tasks" }
))
.width(Length::Fill)
.size(16),
)
.push(
row()
.width(Length::Shrink)
.spacing(10)
.push(filter_button("All", Filter::All, current_filter))
.push(filter_button("Active", Filter::Active, current_filter))
.push(filter_button(
"Completed",
Filter::Completed,
current_filter,
)),
)
.into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Filter {
All,
Active,
Completed,
}
impl Default for Filter {
fn default() -> Self {
Filter::All
}
}
impl Filter {
fn matches(&self, task: &Task) -> bool {
match self {
Filter::All => true,
Filter::Active => !task.completed,
Filter::Completed => task.completed,
}
}
}
fn loading_message<'a>() -> Element<'a, Message> {
container(
text("Loading...")
.horizontal_alignment(alignment::Horizontal::Center)
.size(50),
)
.width(Length::Fill)
.height(Length::Fill)
.center_y()
.into()
}
fn empty_message(message: &str) -> Element<'_, Message> {
container(
text(message)
.width(Length::Fill)
.size(25)
.horizontal_alignment(alignment::Horizontal::Center)
.color([0.7, 0.7, 0.7]),
)
.width(Length::Fill)
.height(Length::Units(200))
.center_y()
.into()
}
// Fonts
const ICONS: Font = Font::External {
name: "Icons",
bytes: include_bytes!("../../../todos/fonts/icons.ttf"),
};
fn icon(unicode: char) -> Text {
Text::new(unicode.to_string())
.font(ICONS)
.width(Length::Units(20))
.horizontal_alignment(alignment::Horizontal::Center)
.size(20)
}
fn edit_icon() -> Text {
icon('\u{F303}')
}
fn delete_icon() -> Text {
icon('\u{F1F8}')
}
// Persistence
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SavedState {
input_value: String,
filter: Filter,
tasks: Vec<Task>,
}
#[derive(Debug, Clone)]
enum LoadError {
FileError,
FormatError,
}
#[derive(Debug, Clone)]
enum SaveError {
FileError,
WriteError,
FormatError,
}
#[cfg(not(target_arch = "wasm32"))]
impl SavedState {
fn path() -> std::path::PathBuf {
let mut path = if let Some(project_dirs) =
directories_next::ProjectDirs::from("rs", "Iced", "Todos")
{
project_dirs.data_dir().into()
} else {
std::env::current_dir().unwrap_or(std::path::PathBuf::new())
};
path.push("todos.json");
path
}
async fn load() -> Result<SavedState, LoadError> {
use async_std::prelude::*;
let mut contents = String::new();
let mut file = async_std::fs::File::open(Self::path())
.await
.map_err(|_| LoadError::FileError)?;
file.read_to_string(&mut contents)
.await
.map_err(|_| LoadError::FileError)?;
serde_json::from_str(&contents).map_err(|_| LoadError::FormatError)
}
async fn save(self) -> Result<(), SaveError> {
use async_std::prelude::*;
let json = serde_json::to_string_pretty(&self)
.map_err(|_| SaveError::FormatError)?;
let path = Self::path();
if let Some(dir) = path.parent() {
async_std::fs::create_dir_all(dir)
.await
.map_err(|_| SaveError::FileError)?;
}
{
let mut file = async_std::fs::File::create(path)
.await
.map_err(|_| SaveError::FileError)?;
file.write_all(json.as_bytes())
.await
.map_err(|_| SaveError::WriteError)?;
}
// This is a simple way to save at most once every couple seconds
async_std::task::sleep(std::time::Duration::from_secs(2)).await;
Ok(())
}
}
#[cfg(target_arch = "wasm32")]
impl SavedState {
fn storage() -> Option<web_sys::Storage> {
let window = web_sys::window()?;
window.local_storage().ok()?
}
async fn load() -> Result<SavedState, LoadError> {
let storage = Self::storage().ok_or(LoadError::FileError)?;
let contents = storage
.get_item("state")
.map_err(|_| LoadError::FileError)?
.ok_or(LoadError::FileError)?;
serde_json::from_str(&contents).map_err(|_| LoadError::FormatError)
}
async fn save(self) -> Result<(), SaveError> {
let storage = Self::storage().ok_or(SaveError::FileError)?;
let json = serde_json::to_string_pretty(&self)
.map_err(|_| SaveError::FormatError)?;
storage
.set_item("state", &json)
.map_err(|_| SaveError::WriteError)?;
let _ = wasm_timer::Delay::new(std::time::Duration::from_secs(2)).await;
Ok(())
}
}
mod style {
use iced::{button, Background, Color, Vector};
pub enum Button {
FilterActive,
FilterSelected,
Icon,
Destructive,
}
impl button::StyleSheet for Button {
fn active(&self) -> button::Style {
match self {
Button::FilterActive => button::Style::default(),
Button::FilterSelected => button::Style {
background: Some(Background::Color(Color::from_rgb(
0.2, 0.2, 0.7,
))),
border_radius: 10.0,
text_color: Color::WHITE,
..button::Style::default()
},
Button::Icon => button::Style {
text_color: Color::from_rgb(0.5, 0.5, 0.5),
..button::Style::default()
},
Button::Destructive => button::Style {
background: Some(Background::Color(Color::from_rgb(
0.8, 0.2, 0.2,
))),
border_radius: 5.0,
text_color: Color::WHITE,
shadow_offset: Vector::new(1.0, 1.0),
..button::Style::default()
},
}
}
fn hovered(&self) -> button::Style {
let active = self.active();
button::Style {
text_color: match self {
Button::Icon => Color::from_rgb(0.2, 0.2, 0.7),
Button::FilterActive => Color::from_rgb(0.2, 0.2, 0.7),
_ => active.text_color,
},
shadow_offset: active.shadow_offset + Vector::new(0.0, 1.0),
..active
}
}
}
}