Moved client to iced-rs, todos example

This commit is contained in:
2024-06-19 13:35:39 -04:00
Unverified
parent bafbdc9b36
commit 66849dba55
14 changed files with 571 additions and 191 deletions

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
client/assets/icons.ttf Normal file

Binary file not shown.

View File

@@ -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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -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);
})
);
});

View File

@@ -0,0 +1,4 @@
[Desktop Entry]
Name=Todos - Iced
Exec=iced-todos
Type=Application

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Todos - Iced</title>
<base data-trunk-public-url />
</head>
<body>
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" data-bin="todos" />
</body>
</html>

View File

@@ -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<text_input::Id> = Lazy::new(text_input::Id::unique);
#[derive(Default, Debug)]
pub enum Realm {
#[default]
Loading,
Loaded(State),
}
impl Default for RealmApp {
#[derive(Debug, Default)]
pub struct State {
input_value: String,
filter: Filter,
tasks: Vec<Task>,
dirty: bool,
saving: bool,
}
#[derive(Debug, Clone)]
pub enum Message {
Loaded(Result<SavedState, LoadError>),
Saved(Result<(), SaveError>),
InputChanged(String),
CreateTask,
FilterChanged(Filter),
TaskMessage(usize, TaskMessage),
TabPressed { shift: bool },
ToggleFullscreen(window::Mode),
}
impl Realm {
pub fn load() -> Command<Message> {
Command::perform(SavedState::load(), Message::Loaded)
}
pub fn title(&self) -> String {
let dirty = match self {
Realm::Loading => false,
Realm::Loaded(state) => state.dirty,
};
format!("Todos{} - Iced", if dirty { "*" } else { "" })
}
pub fn update(&mut self, message: Message) -> Command<Message> {
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());
}
_ => {}
}
text_input::focus(INPUT_ID.clone())
}
Realm::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::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])
}
}
}
pub fn view(&self) -> Element<Message> {
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<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 {
// Example stuff:
label: "Hello World!".to_owned(),
selected: false
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(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()
}
}
}
}
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`.
fn view_controls(tasks: &[Task], current_filter: Filter) -> Element<Message> {
let tasks_left = tasks.iter().filter(|task| !task.completed).count();
// 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();
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,
}
Default::default()
}
}
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
fn loading_message<'a>() -> Element<'a, Message> {
center(
text("Loading...")
.horizontal_alignment(alignment::Horizontal::Center)
.size(50),
)
.into()
}
// egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// egui::menu::bar(ui, |ui| {
// egui::widgets::global_dark_light_mode_buttons(ui);
// });
// });
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()
}
//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;
}
});
// Fonts
const ICONS: Font = Font::with_name("Iced-Todos-Icons");
//Channels
egui::SidePanel::left("inner_left_panel").show(ctx, |ui| {
ui.label("inner");
});
fn icon(unicode: char) -> Text<'static> {
text(unicode.to_string())
.font(ICONS)
.width(20)
.horizontal_alignment(alignment::Horizontal::Center)
}
//Conversation
egui::CentralPanel::default().show(ctx, |ui| {
//TODO: Messages
fn edit_icon() -> Text<'static> {
icon('\u{F303}')
}
//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() {
fn delete_icon() -> Text<'static> {
icon('\u{F1F8}')
}
}
ui.separator();
// Persistence
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SavedState {
input_value: String,
filter: Filter,
tasks: Vec<Task>,
}
});
#[derive(Debug, Clone)]
enum LoadError {
File,
Format,
}
// ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
// egui::warn_if_debug_build(ui);
// });
});
#[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
}
/// 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);
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(())
}
}

View File

@@ -1,7 +1,3 @@
#![warn(clippy::all, rust_2018_idioms)]
mod app;
mod components;
mod types;
pub use app::RealmApp;
pub use app::Realm;

View File

@@ -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()
}