diff --git a/client/Cargo.toml b/client/Cargo.toml index 014cb20..ad90271 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -9,27 +9,21 @@ targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [dependencies] realm_server = { version = "0.1", path = "../server" } -egui = "0.27.2" -eframe = { version = "0.27.2", default-features = false, features = [ - "accesskit", # Make egui comptaible with screen readers. NOTE: adds a lot of dependencies. - "default_fonts", # Embed the default egui fonts. - "glow", # Use the glow rendering backend. Alternative: "wgpu". - "persistence", # Enable restoring app state when restarting the app. -] } + log = "0.4" tarpc = { version = "0.34.0", features = ["full"] } - -# You only need serde if you want app persistence: -serde = { version = "1", features = ["derive"] } - -# native: -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.8", features = ["v4", "fast-rng", "serde"] } +directories-next = "2.0" +tracing-subscriber = "0.3" +async-std = "1.12.0" +once_cell = "1.19.0" env_logger = "0.11.3" -# web: -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures = "0.4" - +[dependencies.iced] +git = "https://github.com/iced-rs/iced.git" +rev = "e6d0b3bda5042a1017a5944a5227c97e0ed6caf9" [profile.release] opt-level = 2 # fast and small wasm diff --git a/client/assets/favicon.ico b/client/assets/favicon.ico deleted file mode 100644 index 61ad031..0000000 Binary files a/client/assets/favicon.ico and /dev/null differ diff --git a/client/assets/icon-1024.png b/client/assets/icon-1024.png deleted file mode 100644 index 1b5868a..0000000 Binary files a/client/assets/icon-1024.png and /dev/null differ diff --git a/client/assets/icon-256.png b/client/assets/icon-256.png deleted file mode 100644 index ae72287..0000000 Binary files a/client/assets/icon-256.png and /dev/null differ diff --git a/client/assets/icon_ios_touch_192.png b/client/assets/icon_ios_touch_192.png deleted file mode 100644 index 8472802..0000000 Binary files a/client/assets/icon_ios_touch_192.png and /dev/null differ diff --git a/client/assets/icons.ttf b/client/assets/icons.ttf new file mode 100644 index 0000000..7b65fd3 Binary files /dev/null and b/client/assets/icons.ttf differ diff --git a/client/assets/manifest.json b/client/assets/manifest.json deleted file mode 100644 index c1edc8e..0000000 --- a/client/assets/manifest.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Realm Chat", - "short_name": "realm-chat-pwa", - "icons": [ - { - "src": "./icon-256.png", - "sizes": "256x256", - "type": "image/png" - }, - { - "src": "./maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - }, - { - "src": "./icon-1024.png", - "sizes": "1024x1024", - "type": "image/png" - } - ], - "lang": "en-US", - "id": "/index.html", - "start_url": "./index.html", - "display": "standalone", - "background_color": "white", - "theme_color": "white" -} diff --git a/client/assets/maskable_icon_x512.png b/client/assets/maskable_icon_x512.png deleted file mode 100644 index db8df3e..0000000 Binary files a/client/assets/maskable_icon_x512.png and /dev/null differ diff --git a/client/assets/sw.js b/client/assets/sw.js deleted file mode 100644 index 7ecd229..0000000 --- a/client/assets/sw.js +++ /dev/null @@ -1,25 +0,0 @@ -var cacheName = 'egui-template-pwa'; -var filesToCache = [ - './', - './index.html', - './eframe_template.js', - './eframe_template_bg.wasm', -]; - -/* Start the service worker and cache all of the app's content */ -self.addEventListener('install', function (e) { - e.waitUntil( - caches.open(cacheName).then(function (cache) { - return cache.addAll(filesToCache); - }) - ); -}); - -/* Serve cached content when offline */ -self.addEventListener('fetch', function (e) { - e.respondWith( - caches.match(e.request).then(function (response) { - return response || fetch(e.request); - }) - ); -}); diff --git a/client/iced-todos.desktop b/client/iced-todos.desktop new file mode 100644 index 0000000..dd7ce53 --- /dev/null +++ b/client/iced-todos.desktop @@ -0,0 +1,4 @@ +[Desktop Entry] +Name=Todos - Iced +Exec=iced-todos +Type=Application diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..ee5570f --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Todos - Iced + + + + + + diff --git a/client/src/app.rs b/client/src/app.rs index 42b9bee..197473c 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,89 +1,549 @@ -/// We derive Deserialize/Serialize so we can persist app state on shutdown. -#[derive(serde::Deserialize, serde::Serialize)] -#[serde(default)] // if we add new fields, give them default values when deserializing old state -pub struct RealmApp { - // Example stuff: - label: String, - selected: bool, +use iced::alignment::{self, Alignment}; +use iced::keyboard; +use iced::widget::{ + self, button, center, checkbox, column, container, keyed_column, row, + scrollable, text, text_input, Text, +}; +use iced::window; +use iced::{Command, Element, Font, Length, Subscription}; - // #[serde(skip)] // This how you opt-out of serialization of a field - // value: f32, +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +static INPUT_ID: Lazy = Lazy::new(text_input::Id::unique); + +#[derive(Default, Debug)] +pub enum Realm { + #[default] + Loading, + Loaded(State), } -impl Default for RealmApp { - fn default() -> Self { - Self { - // Example stuff: - label: "Hello World!".to_owned(), - selected: false - } +#[derive(Debug, Default)] +pub struct State { + input_value: String, + filter: Filter, + tasks: Vec, + dirty: bool, + saving: bool, +} + +#[derive(Debug, Clone)] +pub enum Message { + Loaded(Result), + Saved(Result<(), SaveError>), + InputChanged(String), + CreateTask, + FilterChanged(Filter), + TaskMessage(usize, TaskMessage), + TabPressed { shift: bool }, + ToggleFullscreen(window::Mode), +} + +impl Realm { + pub fn load() -> Command { + Command::perform(SavedState::load(), Message::Loaded) } -} -impl RealmApp { - /// Called once before the first frame. - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - // This is also where you can customize the look and feel of egui using - // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. + pub fn title(&self) -> String { + let dirty = match self { + Realm::Loading => false, + Realm::Loaded(state) => state.dirty, + }; - // Load previous app state (if any). - // Note that you must enable the `persistence` feature for this to work. - if let Some(storage) = cc.storage { - return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); - } - - Default::default() + format!("Todos{} - Iced", if dirty { "*" } else { "" }) } -} - -impl eframe::App for RealmApp { - /// Called each time the UI needs repainting, which may be many times per second. - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Put your widgets into a `SidePanel`, `TopBottomPanel`, `CentralPanel`, `Window` or `Area`. - // For inspiration and more examples, go to https://emilk.github.io/egui - - // egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - // egui::menu::bar(ui, |ui| { - // egui::widgets::global_dark_light_mode_buttons(ui); - // }); - // }); - - //Servers - egui::SidePanel::left("left_panel").show(ctx, |ui| { - ui.label("test"); - let response = ui.selectable_label(self.selected, "bruh"); - if response.clicked() { - self.selected = !self.selected; - } - }); - - //Channels - egui::SidePanel::left("inner_left_panel").show(ctx, |ui| { - ui.label("inner"); - }); - - //Conversation - egui::CentralPanel::default().show(ctx, |ui| { - //TODO: Messages - - //Message Box - ui.with_layout(egui::Layout::bottom_up(egui::Align::BOTTOM).with_cross_justify(true), |ui| { - let response = ui.add(egui::TextEdit::multiline(&mut self.label).desired_rows(1)); - if response.changed() { + pub fn update(&mut self, message: Message) -> Command { + match self { + Realm::Loading => { + match message { + Message::Loaded(Ok(state)) => { + *self = Realm::Loaded(State { + input_value: state.input_value, + filter: state.filter, + tasks: state.tasks, + ..State::default() + }); + } + Message::Loaded(Err(_)) => { + *self = Realm::Loaded(State::default()); + } + _ => {} } - ui.separator(); - }); + text_input::focus(INPUT_ID.clone()) + } + Realm::Loaded(state) => { + let mut saved = false; - // ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { - // egui::warn_if_debug_build(ui); - // }); - }); + 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::change_mode(window::Id::MAIN, 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]) + } + } } - /// Called by the framework to save state before shutdown. - fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, eframe::APP_KEY, self); + pub fn view(&self) -> Element { + match self { + Realm::Loading => loading_message(), + Realm::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) + .id(INPUT_ID.clone()) + .on_input(Message::InputChanged) + .on_submit(Message::CreateTask) + .padding(15) + .size(30); + + 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( + container(content).center_x(Length::Fill).padding(40), + ) + .into() + } + } + } + + pub 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(Length::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_items(Alignment::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_items(Alignment::Center) + ) + .on_press(TaskMessage::Delete) + .padding(10) + .style(button::danger) + ] + .spacing(20) + .align_items(Alignment::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(Length::Fill), + row![ + filter_button("All", Filter::All, current_filter), + filter_button("Active", Filter::Active, current_filter), + filter_button("Completed", Filter::Completed, current_filter,), + ] + .width(Length::Shrink) + .spacing(10) + ] + .spacing(20) + .align_items(Alignment::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...") + .horizontal_alignment(alignment::Horizontal::Center) + .size(50), + ) + .into() +} + +fn empty_message(message: &str) -> Element<'_, Message> { + center( + text(message) + .width(Length::Fill) + .size(25) + .horizontal_alignment(alignment::Horizontal::Center) + .color([0.7, 0.7, 0.7]), + ) + .height(200) + .into() +} + +// Fonts +const ICONS: Font = Font::with_name("Iced-Todos-Icons"); + +fn icon(unicode: char) -> Text<'static> { + text(unicode.to_string()) + .font(ICONS) + .width(20) + .horizontal_alignment(alignment::Horizontal::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_next::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(()) } } \ No newline at end of file diff --git a/client/src/lib.rs b/client/src/lib.rs index 3b63385..4e8f131 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,7 +1,3 @@ -#![warn(clippy::all, rust_2018_idioms)] - mod app; mod components; -mod types; - -pub use app::RealmApp; \ No newline at end of file +pub use app::Realm; \ No newline at end of file diff --git a/client/src/main.rs b/client/src/main.rs index fbcc6dc..b194f55 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,45 +1,12 @@ -#![warn(clippy::all, rust_2018_idioms)] -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +use realm_client::Realm; -// When compiling natively: -#[cfg(not(target_arch = "wasm32"))] -fn main() -> eframe::Result<()> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). +pub fn main() -> iced::Result { + tracing_subscriber::fmt::init(); - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1280.0, 720.0]) - .with_min_inner_size([300.0, 220.0]) - .with_icon( - // NOTE: Adding an icon is optional - eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..]) - .expect("Failed to load icon"), - ), - ..Default::default() - }; - eframe::run_native( - "Realm Chat", - native_options, - Box::new(|cc| Box::new(realm_client::RealmApp::new(cc))), - ) -} - -// When compiling to web using trunk: -#[cfg(target_arch = "wasm32")] -fn main() { - // Redirect `log` message to `console.log` and friends: - eframe::WebLogger::init(log::LevelFilter::Debug).ok(); - - let web_options = eframe::WebOptions::default(); - - wasm_bindgen_futures::spawn_local(async { - eframe::WebRunner::new() - .start( - "the_canvas_id", // hardcode it - web_options, - Box::new(|cc| Box::new(eframe_template::TemplateApp::new(cc))), - ) - .await - .expect("failed to start eframe"); - }); + iced::program(Realm::title, Realm::update, Realm::view) + .load(Realm::load) + .subscription(Realm::subscription) + .font(include_bytes!("../assets/icons.ttf").as_slice()) + .window_size((1280.0, 720.0)) + .run() } \ No newline at end of file