diff options
| author | 2022-02-12 17:21:28 +0700 | |
|---|---|---|
| committer | 2022-02-12 17:21:28 +0700 | |
| commit | bd22cc0bc0f7551d29cf2acd22520f4a906f253c (patch) | |
| tree | fae6435d0e1500204fca73fa7872fb99a41b6eb4 /examples/pure/todos | |
| parent | e3108494e5886c34312184292ec05dddeb8bf3ca (diff) | |
| download | iced-bd22cc0bc0f7551d29cf2acd22520f4a906f253c.tar.gz iced-bd22cc0bc0f7551d29cf2acd22520f4a906f253c.tar.bz2 iced-bd22cc0bc0f7551d29cf2acd22520f4a906f253c.zip | |
Implement pure version of `todos` example :tada:
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`.
Diffstat (limited to 'examples/pure/todos')
| -rw-r--r-- | examples/pure/todos/Cargo.toml | 19 | ||||
| -rw-r--r-- | examples/pure/todos/README.md | 20 | ||||
| -rw-r--r-- | examples/pure/todos/index.html | 12 | ||||
| -rw-r--r-- | examples/pure/todos/src/main.rs | 599 | 
4 files changed, 650 insertions, 0 deletions
| diff --git a/examples/pure/todos/Cargo.toml b/examples/pure/todos/Cargo.toml new file mode 100644 index 00000000..fa14ff6a --- /dev/null +++ b/examples/pure/todos/Cargo.toml @@ -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" diff --git a/examples/pure/todos/README.md b/examples/pure/todos/README.md new file mode 100644 index 00000000..9c2598b9 --- /dev/null +++ b/examples/pure/todos/README.md @@ -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/ diff --git a/examples/pure/todos/index.html b/examples/pure/todos/index.html new file mode 100644 index 00000000..ee5570fb --- /dev/null +++ b/examples/pure/todos/index.html @@ -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> diff --git a/examples/pure/todos/src/main.rs b/examples/pure/todos/src/main.rs new file mode 100644 index 00000000..9acc9fcc --- /dev/null +++ b/examples/pure/todos/src/main.rs @@ -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 +            } +        } +    } +} | 
