use iced::keyboard; use iced::widget::{ self, Text, button, center, center_x, checkbox, column, keyed_column, row, scrollable, text, text_input, }; use iced::window; use iced::{ Center, Element, Fill, Font, Function, Subscription, Task as Command, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; pub fn main() -> iced::Result { #[cfg(not(target_arch = "wasm32"))] tracing_subscriber::fmt::init(); iced::application(Todos::title, Todos::update, Todos::view) .subscription(Todos::subscription) .font(Todos::ICON_FONT) .window_size((500.0, 800.0)) .run_with(Todos::new) } #[derive(Debug)] enum Todos { Loading, Loaded(State), } #[derive(Debug, Default)] struct State { input_value: String, filter: Filter, tasks: Vec, dirty: bool, saving: bool, } #[derive(Debug, Clone)] enum Message { Loaded(Result), Saved(Result<(), SaveError>), InputChanged(String), CreateTask, FilterChanged(Filter), TaskMessage(usize, TaskMessage), TabPressed { shift: bool }, ToggleFullscreen(window::Mode), } impl Todos { const ICON_FONT: &'static [u8] = include_bytes!("../fonts/icons.ttf"); fn new() -> (Self, Command) { ( Self::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 { 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()); } _ => {} } text_input::focus("new-task") } Todos::Loaded(state) => { let mut saved = false; let command = match message { Message::InputChanged(value) => { state.input_value = value; Command::none() } Message::CreateTask => { if !state.input_value.is_empty() { state .tasks .push(Task::new(state.input_value.clone())); state.input_value.clear(); } Command::none() } Message::FilterChanged(filter) => { state.filter = filter; Command::none() } Message::TaskMessage(i, TaskMessage::Delete) => { state.tasks.remove(i); Command::none() } Message::TaskMessage(i, task_message) => { if let Some(task) = state.tasks.get_mut(i) { let should_focus = matches!(task_message, TaskMessage::Edit); task.update(task_message); if should_focus { let id = Task::text_input_id(i); Command::batch(vec![ text_input::focus(id.clone()), text_input::select_all(id), ]) } else { Command::none() } } else { Command::none() } } Message::Saved(_result) => { state.saving = false; saved = true; Command::none() } Message::TabPressed { shift } => { if shift { widget::focus_previous() } else { widget::focus_next() } } Message::ToggleFullscreen(mode) => window::get_latest() .and_then(move |window| window::set_mode(window, mode)), Message::Loaded(_) => Command::none(), }; if !saved { state.dirty = true; } let save = 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() }; Command::batch(vec![command, save]) } } } fn view(&self) -> Element { match self { Todos::Loading => loading_message(), Todos::Loaded(State { input_value, filter, tasks, .. }) => { let title = text("todos") .width(Fill) .size(100) .color([0.5, 0.5, 0.5]) .align_x(Center); let input = text_input("What needs to be done?", input_value) .id("new-task") .on_input(Message::InputChanged) .on_submit(Message::CreateTask) .padding(15) .size(30) .align_x(Center); let controls = view_controls(tasks, *filter); let filtered_tasks = tasks.iter().filter(|task| filter.matches(task)); let tasks: Element<_> = if filtered_tasks.count() > 0 { keyed_column( tasks .iter() .enumerate() .filter(|(_, task)| filter.matches(task)) .map(|(i, task)| { ( task.id, task.view(i) .map(Message::TaskMessage.with(i)), ) }), ) .spacing(10) .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![title, input, controls, tasks] .spacing(20) .max_width(800); scrollable(center_x(content).padding(40)).into() } } } fn subscription(&self) -> Subscription { use keyboard::key; keyboard::on_key_press(|key, modifiers| { let keyboard::Key::Named(key) = key else { return None; }; match (key, modifiers) { (key::Named::Tab, _) => Some(Message::TabPressed { shift: modifiers.shift(), }), (key::Named::ArrowUp, keyboard::Modifiers::SHIFT) => { Some(Message::ToggleFullscreen(window::Mode::Fullscreen)) } (key::Named::ArrowDown, keyboard::Modifiers::SHIFT) => { Some(Message::ToggleFullscreen(window::Mode::Windowed)) } _ => None, } }) } } #[derive(Debug, Clone, Serialize, Deserialize)] struct Task { #[serde(default = "Uuid::new_v4")] id: Uuid, 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 text_input_id(i: usize) -> text_input::Id { text_input::Id::new(format!("task-{i}")) } fn new(description: String) -> Self { Task { id: Uuid::new_v4(), 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, i: usize) -> Element { match &self.state { TaskState::Idle => { let checkbox = checkbox(&self.description, self.completed) .on_toggle(TaskMessage::Completed) .width(Fill) .size(17) .text_shaping(text::Shaping::Advanced); row![ checkbox, button(edit_icon()) .on_press(TaskMessage::Edit) .padding(10) .style(button::text), ] .spacing(20) .align_y(Center) .into() } TaskState::Editing => { let text_input = text_input("Describe your task...", &self.description) .id(Self::text_input_id(i)) .on_input(TaskMessage::DescriptionEdited) .on_submit(TaskMessage::FinishEdition) .padding(10); row![ text_input, button( row![delete_icon(), "Delete"] .spacing(10) .align_y(Center) ) .on_press(TaskMessage::Delete) .padding(10) .style(button::danger) ] .spacing(20) .align_y(Center) .into() } } } } fn view_controls(tasks: &[Task], current_filter: Filter) -> Element { let tasks_left = tasks.iter().filter(|task| !task.completed).count(); let filter_button = |label, filter, current_filter| { let label = text(label); let button = button(label).style(if filter == current_filter { button::primary } else { button::text }); button.on_press(Message::FilterChanged(filter)).padding(8) }; row![ text!( "{tasks_left} {} left", if tasks_left == 1 { "task" } else { "tasks" } ) .width(Fill), row![ filter_button("All", Filter::All, current_filter), filter_button("Active", Filter::Active, current_filter), filter_button("Completed", Filter::Completed, current_filter,), ] .spacing(10) ] .spacing(20) .align_y(Center) .into() } #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, )] pub enum Filter { #[default] All, Active, Completed, } 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> { center(text("Loading...").width(Fill).align_x(Center).size(50)).into() } fn empty_message(message: &str) -> Element<'_, Message> { center( text(message) .width(Fill) .size(25) .align_x(Center) .color([0.7, 0.7, 0.7]), ) .height(200) .into() } // Fonts fn icon(unicode: char) -> Text<'static> { text(unicode.to_string()) .font(Font::with_name("Iced-Todos-Icons")) .width(20) .align_x(Center) } fn edit_icon() -> Text<'static> { icon('\u{F303}') } fn delete_icon() -> Text<'static> { icon('\u{F1F8}') } // Persistence #[derive(Debug, Clone, Serialize, Deserialize)] struct SavedState { input_value: String, filter: Filter, tasks: Vec, } #[derive(Debug, Clone)] enum LoadError { File, Format, } #[derive(Debug, Clone)] enum SaveError { File, Write, Format, } #[cfg(not(target_arch = "wasm32"))] impl SavedState { fn path() -> std::path::PathBuf { let mut path = if let Some(project_dirs) = directories::ProjectDirs::from("rs", "Iced", "Todos") { project_dirs.data_dir().into() } else { std::env::current_dir().unwrap_or_default() }; path.push("todos.json"); path } async fn load() -> Result { use async_std::prelude::*; let mut contents = String::new(); let mut file = async_std::fs::File::open(Self::path()) .await .map_err(|_| LoadError::File)?; file.read_to_string(&mut contents) .await .map_err(|_| LoadError::File)?; serde_json::from_str(&contents).map_err(|_| LoadError::Format) } async fn save(self) -> Result<(), SaveError> { use async_std::prelude::*; let json = serde_json::to_string_pretty(&self) .map_err(|_| SaveError::Format)?; let path = Self::path(); if let Some(dir) = path.parent() { async_std::fs::create_dir_all(dir) .await .map_err(|_| SaveError::File)?; } { let mut file = async_std::fs::File::create(path) .await .map_err(|_| SaveError::File)?; file.write_all(json.as_bytes()) .await .map_err(|_| SaveError::Write)?; } // 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 { let window = web_sys::window()?; window.local_storage().ok()? } async fn load() -> Result { let storage = Self::storage().ok_or(LoadError::File)?; let contents = storage .get_item("state") .map_err(|_| LoadError::File)? .ok_or(LoadError::File)?; serde_json::from_str(&contents).map_err(|_| LoadError::Format) } async fn save(self) -> Result<(), SaveError> { let storage = Self::storage().ok_or(SaveError::File)?; let json = serde_json::to_string_pretty(&self) .map_err(|_| SaveError::Format)?; storage .set_item("state", &json) .map_err(|_| SaveError::Write)?; let _ = wasmtimer::tokio::sleep(std::time::Duration::from_secs(2)).await; Ok(()) } } #[cfg(test)] mod tests { use super::*; use iced::{Settings, Theme}; use iced_test::selector::text; use iced_test::{Error, Simulator}; fn simulator(todos: &Todos) -> Simulator { Simulator::with_settings( Settings { fonts: vec![Todos::ICON_FONT.into()], ..Settings::default() }, todos.view(), ) } #[test] fn it_creates_a_new_task() -> Result<(), Error> { let (mut todos, _command) = Todos::new(); let _command = todos.update(Message::Loaded(Err(LoadError::File))); let mut ui = simulator(&todos); let _input = ui.click("new-task")?; let _ = ui.typewrite("Create the universe"); let _ = ui.tap_key(keyboard::key::Named::Enter); for message in ui.into_messages() { let _command = todos.update(message); } let mut ui = simulator(&todos); let _ = ui.find(text("Create the universe"))?; let snapshot = ui.snapshot(&Theme::Dark)?; assert!( snapshot.matches_hash("snapshots/creates_a_new_task")?, "snapshots should match!" ); Ok(()) } }