summaryrefslogblamecommitdiffstats
path: root/examples/todos/src/main.rs
blob: 7faf742e3077583295f826ebf0a7a9cced8e0083 (plain) (tree)
1
2
3
4
5
6
7
8
9
                   
                   
                                                                        
                                       
  
                 
                                                                       
 
                                    
               
 
                               
                                       

                                    
                                                               
                                          
                               
                                    
                             

 
                




                  
                         
              
                        
                   
                     

                 

 
                       


                                          

                         
                          
                                    
                               
                                   

 
            
                                                                          
 




                                                                  

     
                               





                                                               

     
                                                                














                                                                
                 
 
                                             
             


                                      
                                             

                                                     

                                       







                                                                            

                                       


                                                       

                                       


                                                                     

                                       


                                                                    

                                                                          
 
                                                      

                                             




                                                                  




                                               

                         
                                                

                                             

                                       
                     






                                                      
                                                                           
                                                                                
                                                          
                  
 


                                       
 
                                                            


                                        
                                     
                                    


                                                                   

                                



                                       

                  
                                                   

             

     
                                        


                                                


                            

                   
                                         
                                
                              
                                           
                                     
 
                                                                             
                                   

                                                    
                                

                                     
 
                                                             



                                                                       
                                 




                                                                     






                                                                        
                     
                                
                           






                                                                            
                      
                  
 
                                                                    
                                

                                    
                                                                

             
     

                                                     








                                                                  
                                             
                   
                                                                      
                                                                             
                 
                                                                        
                                                                           

                          
             

          

 
                                               
             

                                      

                        

                  


                     
                       
                    

            

 

                            
                  


     






                              


           
                                                  
                                                

     

                                         
                               

                             
                                   


         





                                                  
                                                





                                                                
                                                 





                                     
                                                      

                                

                                                                          
                                
                             
                                                           
 




                                                    
                                             

                            
                                
                       
             
                                   


                                                                          
                                                                 

                                                              
 

                               
                           


                                                     


                                                  
                                          

                            
                                
                       

             


     



                                                                              
                                

                                                                      
                           
                
                        





                                                                  
              
                                   
                                                          
         
                     




                                                                           


                    
                    
           

 


                                                                       
                 
              




              
             
                                           







                                                
                                                  
                                                                          

 
                                                         
           
                     
                        
                     
                            
                                    
     
                

           

        
 
                                         
                             
                                                  

                        

 
                                 


                    
                                   

                    








                                               

                       

           

 

                       


           

 
                                   


                                                  
                                                                 


                                          
                                                       
          
 
                                
 



                                                      
                                  

                                         
 

                                                              
                                           

                                          
                  
                                           
 
                                                                      
     
 
                                                  
                                  
 
                                                      
                                             
 
                                
 
                                          

                                              
                                               
         
 


                                                            
                                               
 

                                           
                                                
         

                                                                         
                                                                        


              
 
 

                              





                                              
                                                      
                                                              


                              

                                          
 
                                                                      


                                                  
                                                              

                                                      
                                             


                                     
                                            



                                                                                

     




                 
                                

                                      

                                                       
                                 






                                                     

           
                                                     


                                                                           
                                       
                                           
 

                                                        
 
                                           


                                                 
                                       
                                                      
 
                                                  




                                                                   
              

     
use iced::keyboard;
use iced::widget::{
    self, button, center, center_x, checkbox, column, keyed_column, row,
    scrollable, text, text_input, Text,
};
use iced::window;
use iced::{Center, Element, Fill, Font, 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<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),
    TabPressed { shift: bool },
    ToggleFullscreen(window::Mode),
}

impl Todos {
    const ICON_FONT: &'static [u8] = include_bytes!("../fonts/icons.ttf");

    fn new() -> (Self, Command<Message>) {
        (
            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<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());
                    }
                    _ => {}
                }

                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<Message> {
        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(move |message| {
                                        Message::TaskMessage(i, message)
                                    }),
                                )
                            }),
                    )
                    .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<Message> {
        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<TaskMessage> {
        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<Message> {
    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<Task>,
}

#[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<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::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<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::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 _ = wasm_timer::Delay::new(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<Message> {
        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(())
    }
}