iced-yoda/examples/todos/src/main.rs

620 lines
17 KiB
Rust
Raw Normal View History

use iced::keyboard;
use iced::widget::{
2025-02-21 01:28:47 +01:00
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,
};
2022-07-28 03:54:02 +02:00
use serde::{Deserialize, Serialize};
2023-09-04 02:55:09 +02:00
use uuid::Uuid;
pub fn main() -> iced::Result {
2023-09-10 00:43:38 +02:00
#[cfg(not(target_arch = "wasm32"))]
2023-09-04 02:55:09 +02:00
tracing_subscriber::fmt::init();
iced::application(Todos::new, Todos::update, Todos::view)
2024-03-17 17:57:24 +01:00
.subscription(Todos::subscription)
.title(Todos::title)
.font(Todos::ICON_FONT)
2024-03-17 17:57:24 +01:00
.window_size((500.0, 800.0))
.run()
}
#[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),
2022-08-04 03:24:44 +02:00
TabPressed { shift: bool },
ToggleFullscreen(window::Mode),
}
2024-03-17 17:57:24 +01:00
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),
)
2019-11-17 07:09:46 +01:00
}
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);
2022-07-28 03:54:02 +02:00
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()
}
}
2024-02-05 00:51:51 +01:00
Message::Saved(_result) => {
state.saving = false;
saved = true;
Command::none()
}
2022-08-04 03:24:44 +02:00
Message::TabPressed { shift } => {
if shift {
widget::focus_previous()
} else {
widget::focus_next()
}
}
2024-06-20 01:13:42 +02:00
Message::ToggleFullscreen(mode) => window::get_latest()
.and_then(move |window| window::set_mode(window, mode)),
Message::Loaded(_) => Command::none(),
};
if !saved {
state.dirty = true;
}
2019-11-17 07:09:46 +01:00
2022-07-28 03:54:02 +02:00
let save = if state.dirty && !state.saving {
state.dirty = false;
state.saving = true;
Command::perform(
2019-11-18 00:13:18 +01:00
SavedState {
input_value: state.input_value.clone(),
filter: state.filter,
tasks: state.tasks.clone(),
2019-11-18 00:13:18 +01:00
}
.save(),
Message::Saved,
)
} else {
Command::none()
2022-07-28 03:54:02 +02:00
};
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)
2024-03-04 19:31:26 +01:00
.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 {
2023-09-04 02:55:09 +02:00
keyed_column(
tasks
.iter()
.enumerate()
.filter(|(_, task)| filter.matches(task))
.map(|(i, task)| {
2023-09-04 02:55:09 +02:00
(
task.id,
task.view(i)
.map(Message::TaskMessage.with(i)),
2023-09-04 02:55:09 +02:00
)
}),
)
2022-07-28 03:54:02 +02:00
.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 {
2023-09-04 02:55:09 +02:00
#[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 {
2022-07-28 03:54:02 +02:00
fn text_input_id(i: usize) -> text_input::Id {
text_input::Id::new(format!("task-{i}"))
2022-07-28 03:54:02 +02:00
}
fn new(description: String) -> Self {
Task {
2023-09-04 02:55:09 +02:00
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 => {}
}
}
2022-07-28 03:54:02 +02:00
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)
2024-03-04 20:42:37 +01:00
.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 {
2024-03-04 20:42:37 +01:00
button::primary
} else {
2024-03-04 20:42:37 +01:00
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()
}
2023-03-14 11:11:17 +01:00
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
)]
pub enum Filter {
2023-03-14 11:11:17 +01:00
#[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,
}
}
}
2020-04-14 12:11:10 +02:00
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)
2024-03-04 19:31:26 +01:00
.color([0.7, 0.7, 0.7]),
)
.height(200)
.into()
}
// Fonts
2021-11-01 23:59:04 +09:00
fn icon(unicode: char) -> Text<'static> {
text(unicode.to_string())
.font(Font::with_name("Iced-Todos-Icons"))
.width(20)
.align_x(Center)
}
2021-11-01 23:59:04 +09:00
fn edit_icon() -> Text<'static> {
icon('\u{F303}')
}
2021-11-01 23:59:04 +09:00
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 {
Write,
Format,
}
#[cfg(not(target_arch = "wasm32"))]
2019-11-18 00:13:18 +01:00
impl SavedState {
fn path() -> std::path::PathBuf {
let mut path = if let Some(project_dirs) =
directories::ProjectDirs::from("rs", "Iced", "Todos")
2019-11-18 00:13:18 +01:00
{
project_dirs.data_dir().into()
} else {
std::env::current_dir().unwrap_or_default()
2019-11-18 00:13:18 +01:00
};
2019-11-18 00:13:18 +01:00
path.push("todos.json");
2019-11-18 00:13:18 +01:00
path
}
async fn load() -> Result<SavedState, LoadError> {
2025-04-02 10:45:27 +02:00
let contents = tokio::fs::read_to_string(Self::path())
2019-12-14 20:48:32 +01:00
.await
.map_err(|_| LoadError::File)?;
2019-11-18 00:13:18 +01:00
serde_json::from_str(&contents).map_err(|_| LoadError::Format)
2019-11-18 00:13:18 +01:00
}
2019-11-18 00:13:18 +01:00
async fn save(self) -> Result<(), SaveError> {
let json = serde_json::to_string_pretty(&self)
.map_err(|_| SaveError::Format)?;
2019-11-18 00:13:18 +01:00
let path = Self::path();
2019-11-24 11:55:46 +01:00
if let Some(dir) = path.parent() {
2025-04-02 10:45:27 +02:00
tokio::fs::create_dir_all(dir)
2019-12-14 20:48:32 +01:00
.await
2025-04-02 10:45:27 +02:00
.map_err(|_| SaveError::Write)?;
2019-11-24 11:55:46 +01:00
}
2019-11-18 00:13:18 +01:00
2019-12-14 20:48:32 +01:00
{
2025-04-02 10:45:27 +02:00
tokio::fs::write(path, json.as_bytes())
2019-12-14 20:48:32 +01:00
.await
.map_err(|_| SaveError::Write)?;
2019-12-14 20:48:32 +01:00
}
2019-11-18 00:13:18 +01:00
// This is a simple way to save at most once every couple seconds
2025-04-02 10:45:27 +02:00
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2019-11-18 00:13:18 +01:00
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> {
2022-07-09 18:49:50 +02:00
let storage = Self::storage().ok_or(LoadError::File)?;
let contents = storage
.get_item("state")
2022-07-09 18:49:50 +02:00
.map_err(|_| LoadError::File)?
.ok_or(LoadError::File)?;
2022-07-09 18:49:50 +02:00
serde_json::from_str(&contents).map_err(|_| LoadError::Format)
}
async fn save(self) -> Result<(), SaveError> {
2025-04-02 10:45:27 +02:00
let storage = Self::storage().ok_or(SaveError::Write)?;
let json = serde_json::to_string_pretty(&self)
2022-07-09 18:49:50 +02:00
.map_err(|_| SaveError::Format)?;
storage
.set_item("state", &json)
2022-07-09 18:49:50 +02:00
.map_err(|_| SaveError::Write)?;
2025-02-02 22:44:05 -03:00
let _ =
wasmtimer::tokio::sleep(std::time::Duration::from_secs(2)).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::{Settings, Theme};
2024-12-17 04:13:19 +01:00
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]
2024-12-10 04:56:37 +01:00
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);
2024-12-10 04:56:37 +01:00
let _input = ui.click("new-task")?;
let _ = ui.typewrite("Create the universe");
let _ = ui.tap_key(keyboard::key::Named::Enter);
2024-12-10 04:56:37 +01:00
for message in ui.into_messages() {
let _command = todos.update(message);
}
let mut ui = simulator(&todos);
2024-12-17 04:13:19 +01:00
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!"
);
2024-12-04 00:29:36 +01:00
Ok(())
}
}