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