Rename things to ush for brevity. Add Tree system.

This commit is contained in:
Michael Mikovsky
2026-02-09 10:27:15 -07:00
parent ebeaa29d5b
commit 2a18639d84
86 changed files with 368 additions and 419 deletions
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "ush-gui"
version.workspace = true
edition.workspace = true
authors.workspace = true
include.workspace = true
[lib]
crate-type = ["cdylib"]
[package.metadata.docs.rs]
all-features = true
targets = [
# "x86_64-unknown-linux-gnu",
"wasm32-unknown-unknown"
]
[dependencies]
unshell = { path="../" }
# log = "0.4.27"
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = {version = "0.12.26", features=["json"]}
log = "0.4.29"
# Stuff for app functionality
egui = "0.33.0"
eframe = { version = "0.33.0", default-features = false, features = [
# "accesskit", # Make egui compatible 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.
"wayland", # To support Linux (and CI)
"x11", # To support older Linux distributions (restores one of the default features)
] }
egui_extras = "0.33.2"
egui_tiles = "0.14.0"
egui-async = "0.2.6"
# Web Stuff
wasm-bindgen-futures = "0.4.50"
wasm-bindgen = "0.2.108"
web-sys = { version = "0.3.85", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "Window"] }
+20
View File
@@ -0,0 +1,20 @@
[build]
filehash = false
# # Proxy /api/* to backend
# [[proxy]]
# backend = "http://localhost:8081"
# rewrite = "/api/"
# # Proxy /auth/* to backend
# [[proxy]]
# backend = "http://localhost:8081"
# rewrite = "/auth/"
[[proxy]]
backend = "http://localhost:3000/api" # Address to proxy requests to
# ws = false # Use WebSocket for this proxy
# insecure = true # Disable certificate validation
# no_system_proxy = false # Disable system proxy
# rewrite = "" # Strip the given prefix off paths
# no_redirect = false # Disable following redirects of proxy responses
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"name": "egui Template PWA",
"short_name": "egui-template-pwa",
"icons": [],
"lang": "en-US",
"id": "/index.html",
"start_url": "./index.html",
"display": "standalone",
"background_color": "white",
"theme_color": "white"
}
+77
View File
@@ -0,0 +1,77 @@
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);
}),
);
});
// export function httpGet(theUrl) {
// var xmlHttp = new XMLHttpRequest();
// xmlHttp.open("GET", theUrl, false); // false for synchronous request
// xmlHttp.send(null);
// return xmlHttp.responseText;
// }
function startHttpRequest(callback) {
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function () {
if (xmlHttp.readyState !== 4) return;
if (xmlHttp.status == 200) callback(true, xmlHttp.responseText);
else if (xmlHttp.status == 401) callback(false, "Unauthorized");
else if (xmlHttp.status == 500) callback(false, "Internal Server Error");
else callback(false, xmlHttp.responseText);
};
return xmlHttp;
}
export function httpGet(theUrl, callback) {
var xmlHttp = startHttpRequest(callback);
xmlHttp.open("GET", theUrl, true); // true for asynchronous
xmlHttp.setRequestHeader("Content-Type", "application/json");
xmlHttp.send(null);
}
export function httpPost(url, body, callback) {
var xmlHttp = startHttpRequest(callback);
xmlHttp.open("POST", url, true);
xmlHttp.setRequestHeader("Content-Type", "application/json");
// var data = JSON.stringify({ email: "[email protected]", password: "101010" });
xmlHttp.send(body);
}
export function httpGetAuth(theUrl, auth, callback) {
var xmlHttp = startHttpRequest(callback);
xmlHttp.open("GET", theUrl, true); // true for asynchronous
xmlHttp.setRequestHeader("Content-Type", "application/json");
xmlHttp.setRequestHeader("authorization", auth);
xmlHttp.send(null);
}
export function httpPostAuth(url, auth, body, callback) {
var xmlHttp = startHttpRequest(callback);
xmlHttp.open("POST", url, true);
xmlHttp.setRequestHeader("Content-Type", "application/json");
xmlHttp.setRequestHeader("authorization", auth);
// var data = JSON.stringify({ email: "[email protected]", password: "101010" });
xmlHttp.send(body);
}
+178
View File
@@ -0,0 +1,178 @@
<!doctype html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- Disable zooming: -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<head>
<!-- change this to your project name -->
<title>eframe template</title>
<!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
<link data-trunk rel="rust" data-wasm-opt="2" />
<!-- this is the base url relative to which other urls will be constructed. trunk will insert this from the public-url option -->
<base data-trunk-public-url />
<link data-trunk rel="icon" href="assets/favicon.ico" />
<link data-trunk rel="copy-file" href="assets/sw.js" />
<link data-trunk rel="copy-file" href="assets/manifest.json" />
<!--<link
data-trunk
rel="copy-file"
href="assets/icon-1024.png"
data-target-path="assets"
/>
<link
data-trunk
rel="copy-file"
href="assets/icon-256.png"
data-target-path="assets"
/>
<link
data-trunk
rel="copy-file"
href="assets/icon_ios_touch_192.png"
data-target-path="assets"
/>
<link
data-trunk
rel="copy-file"
href="assets/maskable_icon_x512.png"
data-target-path="assets"
/>-->
<link rel="manifest" href="manifest.json" />
<link rel="apple-touch-icon" href="assets/icon_ios_touch_192.png" />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="white"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#404040"
/>
<style>
html {
/* Remove touch delay: */
touch-action: manipulation;
}
body {
/* Light mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #909090;
}
@media (prefers-color-scheme: dark) {
body {
/* Dark mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #404040;
}
}
/* Allow canvas to fill entire web page: */
html,
body {
overflow: hidden;
margin: 0 !important;
padding: 0 !important;
height: 100%;
width: 100%;
}
/* Make canvas fill entire document: */
canvas {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.centered {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #f0f0f0;
font-size: 24px;
font-family: Ubuntu-Light, Helvetica, sans-serif;
text-align: center;
}
/* ---------------------------------------------- */
/* Loading animation from https://loading.io/css/ */
.lds-dual-ring {
display: inline-block;
width: 24px;
height: 24px;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 24px;
height: 24px;
margin: 0px;
border-radius: 50%;
border: 3px solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<!-- The WASM code will resize the canvas dynamically -->
<!-- the id is hardcoded in main.rs . so, make sure both match. -->
<canvas id="the_canvas_id"></canvas>
<!-- the loading spinner will be removed in main.rs -->
<div class="centered" id="loading_text">
<noscript>You need javascript to use this website</noscript>
<p style="font-size: 16px">Loading…</p>
<div class="lds-dual-ring"></div>
</div>
<!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
<!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files -->
<script>
// We disable caching during development so that we always view the latest version.
if (
"serviceWorker" in navigator &&
window.location.hash !== "#dev"
) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("sw.js");
});
}
</script>
</body>
</html>
<!-- Powered by egui: https://github.com/emilk/egui/ -->
+94
View File
@@ -0,0 +1,94 @@
use egui::Frame;
use egui_tiles::Tree;
use crate::{
FORCE_REDRAW_DELAY,
app::{AppState, windows::WindowWrapper},
};
/// 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 TemplateApp {
// tab: Tab,
state: AppState,
tree: Tree<WindowWrapper>,
}
impl Default for TemplateApp {
fn default() -> Self {
Self {
state: AppState::default(),
tree: egui_tiles::Tree::new_horizontal("tree_root", Vec::new()),
}
}
}
impl TemplateApp {
/// 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`.
// Load previous app state (if any).
// Note that you must enable the `persistence` feature for this to work.
if let Some(storage) = cc.storage {
eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
} else {
Default::default()
}
}
}
impl eframe::App for TemplateApp {
/// 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);
}
/// 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) {
ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>();
if !self.state.auth.logged_in() {
egui::CentralPanel::default()
.frame(Frame::central_panel(&ctx.style()).inner_margin(0))
.show(ctx, |ui| self.state.auth.update(ui));
} else {
egui::TopBottomPanel::top("tab_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
ui.label("File");
});
ui.menu_button("View", |ui| {
ui.label("View");
self.state.labels(&mut self.tree, ui);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
egui::widgets::global_theme_preference_switch(ui);
});
// ui.style
});
});
egui::TopBottomPanel::bottom("tab_panel").show(ctx, |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("UnShell UI {}", env!("CARGO_PKG_VERSION")));
egui::warn_if_debug_build(ui);
});
});
egui::CentralPanel::default()
.frame(Frame::central_panel(&ctx.style()).inner_margin(0))
.show(ctx, |ui| self.tree.ui(&mut self.state, ui));
}
ctx.request_repaint_after(FORCE_REDRAW_DELAY);
}
}
+135
View File
@@ -0,0 +1,135 @@
mod app;
mod windows;
use std::collections::HashSet;
use crate::{
app::windows::WindowWrapper, auth::Auth, config::Config, flowchart::FlowChart,
interface::InterfaceWindow, log_viewer::LogViewer, payload_config::PayloadConfig,
};
pub use app::TemplateApp;
use egui_tiles::{TileId, Tree};
#[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct AppState {
pub auth: Auth,
pub open_windows: HashSet<AppWindow>,
pub flowchart: FlowChart,
pub config: Config,
pub payload_config: PayloadConfig,
pub log_viewer: LogViewer,
pub interface: InterfaceWindow,
}
impl AppState {
pub fn labels(&mut self, tree: &mut Tree<WindowWrapper>, ui: &mut egui::Ui) {
for (_, (key, name)) in (vec![
(AppWindow::Flowchart, "Flowchart"),
(AppWindow::PayloadConfig, "Payload Config"),
(AppWindow::Config, "Config"),
(AppWindow::LogViewer, "Log Viewer"),
(AppWindow::InterfaceWindow, "Tree Test"),
])
.iter()
.enumerate()
{
let enabled = self.open_windows.contains(&key);
if ui.selectable_label(enabled, *name).clicked() {
if enabled {
self.close_window(tree, key);
} else {
self.open_window(tree, key, name);
}
}
}
}
pub fn close_window(&mut self, tree: &mut Tree<WindowWrapper>, key: &AppWindow) {
match Self::find_pane_id(*key, tree) {
Some(tid) => {
let tid = tid.clone();
tree.remove_recursively(tid);
tree.tiles.remove(tid);
self.open_windows.remove(&key);
}
None => unreachable!(),
}
}
pub fn open_window(&mut self, tree: &mut Tree<WindowWrapper>, key: &AppWindow, name: &str) {
let tid = tree.tiles.insert_pane(WindowWrapper {
name: name.to_string(),
window: *key,
});
match self.open_windows.len() {
0 => {
tree.root = Some(tid);
}
1 => {
let old_root = tree.root.unwrap();
let tab_id = tree.tiles.insert_tab_tile(vec![old_root, tid]);
tree.root = Some(tab_id);
tree.make_active(|t, _| t == tid);
}
_ => {
let root = tree.root().unwrap();
let n = tree.tiles.get_container(root).unwrap().num_children();
tree.move_tile_to_container(tid, tree.root.unwrap().clone(), n, true);
}
}
self.open_windows.insert(key.clone());
}
fn find_pane_id(window_type: AppWindow, tree: &Tree<WindowWrapper>) -> Option<&TileId> {
for (tid, window) in tree.tiles.iter() {
match window {
egui_tiles::Tile::Pane(pane) => {
if pane.window == window_type {
return Some(tid);
}
}
egui_tiles::Tile::Container(_) => {}
}
}
None
}
}
#[derive(Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq, Hash)]
pub enum AppWindow {
Flowchart,
Config,
PayloadConfig,
LogViewer,
InterfaceWindow,
}
impl AppWindow {
fn update(&self, state: &mut AppState, ui: &mut egui::Ui) {
match self {
AppWindow::Flowchart => state.flowchart.paint(ui),
AppWindow::Config => state.config.update(&mut state.auth, ui),
AppWindow::PayloadConfig => state.payload_config.update(ui),
AppWindow::LogViewer => state.log_viewer.update(&mut state.auth, ui),
AppWindow::InterfaceWindow => state.interface.update(&mut state.auth, ui),
}
}
fn render_title_buttons(&self, state: &mut AppState, ui: &mut egui::Ui) {
match self {
AppWindow::Flowchart => {
state.flowchart.titlebar_buttons(ui);
}
AppWindow::Config => {
state.config.titlebar_buttons(ui);
}
_ => {
ui.label("");
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
use egui::Rect;
use crate::app::{AppState, AppWindow};
#[derive(serde::Deserialize, serde::Serialize)]
pub struct WindowWrapper {
pub name: String,
pub window: AppWindow,
}
impl egui_tiles::Behavior<WindowWrapper> for AppState {
fn tab_title_for_pane(&mut self, pane: &WindowWrapper) -> egui::WidgetText {
format!("{}", pane.name).into()
}
fn pane_ui(
&mut self,
ui: &mut egui::Ui,
_tile_id: egui_tiles::TileId,
pane: &mut WindowWrapper,
) -> egui_tiles::UiResponse {
let mut ret = egui_tiles::UiResponse::None;
let mut rect = Rect::NOTHING;
ui.horizontal(|ui| {
rect = ui.max_rect();
let bg_color = ui.style().visuals.extreme_bg_color;
ui.painter().rect_filled(rect, 0.0, bg_color);
ui.vertical_centered(|ui| {
ui.label(&pane.name);
});
});
let mut open_space = Rect::NOTHING;
#[allow(deprecated)]
ui.allocate_ui_at_rect(rect, |ui| {
ui.horizontal(|ui| {
pane.window.render_title_buttons(self, ui);
open_space = ui.available_rect_before_wrap();
})
});
let drag_sense = ui.interact(
open_space,
ui.id().with(&format!("Pane_{}_sense", pane.name)),
egui::Sense::drag(),
);
if drag_sense.drag_started() {
ret = egui_tiles::UiResponse::DragStarted;
}
if drag_sense.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
pane.window.update(self, ui);
ret
}
fn is_tab_closable(
&self,
tiles: &egui_tiles::Tiles<WindowWrapper>,
tile_id: egui_tiles::TileId,
) -> bool {
match tiles.get(tile_id).unwrap() {
egui_tiles::Tile::Pane(_) => true,
egui_tiles::Tile::Container(_) => false,
}
}
fn on_tab_close(
&mut self,
tiles: &mut egui_tiles::Tiles<WindowWrapper>,
tile_id: egui_tiles::TileId,
) -> bool {
match tiles.get(tile_id).unwrap() {
egui_tiles::Tile::Pane(pane) => self.open_windows.remove(&pane.window),
egui_tiles::Tile::Container(_) => false,
}
}
fn tab_bar_color(&self, visuals: &egui::Visuals) -> egui::Color32 {
visuals.panel_fill // same as the tab contents
}
}
+108
View File
@@ -0,0 +1,108 @@
use serde::de::DeserializeOwned;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::js_sys::Promise;
pub struct PromiseWrapper<T> {
state: Rc<RefCell<PromiseState<T>>>,
}
enum PromiseState<T> {
Pending,
Resolved(T),
Rejected(String),
}
impl<T: 'static> PromiseWrapper<T>
where
T: DeserializeOwned,
{
/// Create a new PromiseWrapper from a JavaScript Promise
/// The promise should resolve to a Response object (from fetch)
pub fn new(promise: Promise) -> Self {
let state = Rc::new(RefCell::new(PromiseState::Pending));
let state_clone = state.clone();
let state_clone2 = state.clone();
// Success callback
let success = Closure::once(move |value: JsValue| {
if let Ok(response) = value.dyn_into::<web_sys::Response>() {
if let Ok(json_promise) = response.json() {
let state_inner = state_clone.clone();
let json_success = Closure::once(move |json_value: JsValue| {
*state_inner.borrow_mut() = if let Some(body) = json_value.as_string() {
match serde_json::from_str(&body) {
Ok(data) => PromiseState::Resolved(data),
Err(e) => PromiseState::Rejected(format!("{:?}", e)),
}
} else {
PromiseState::Rejected(format!("Response was not a string"))
};
match serde_wasm_bindgen::from_value::<T>(json_value) {
Ok(data) => *state_inner.borrow_mut() = PromiseState::Resolved(data),
Err(e) => {
*state_inner.borrow_mut() =
PromiseState::Rejected(format!("{:?}", e))
}
}
});
let _ = json_promise.then(&json_success);
json_success.forget();
}
}
});
// Error callback
let error = Closure::once(move |err: JsValue| {
*state_clone2.borrow_mut() = PromiseState::Rejected(
err.as_string()
.unwrap_or_else(|| "Unknown error".to_string()),
);
});
let _ = promise.then2(&success, &error);
success.forget();
error.forget();
Self { state }
}
/// Poll the promise to check if it has completed
/// Returns Some(T) if resolved successfully, None if still pending or rejected
/// This is a lightweight check that's safe to call every frame
#[inline]
pub fn poll(&self) -> Option<T>
where
T: Clone,
{
match &*self.state.borrow() {
PromiseState::Resolved(value) => Some(value.clone()),
_ => None,
}
}
/// Check if the promise is still pending (lightweight)
#[inline]
pub fn is_pending(&self) -> bool {
matches!(&*self.state.borrow(), PromiseState::Pending)
}
/// Check if the promise was rejected (lightweight)
#[inline]
pub fn is_rejected(&self) -> bool {
matches!(&*self.state.borrow(), PromiseState::Rejected(_))
}
/// Get the error message if rejected
pub fn error(&self) -> Option<String> {
match &*self.state.borrow() {
PromiseState::Rejected(err) => Some(err.clone()),
_ => None,
}
}
}
+222
View File
@@ -0,0 +1,222 @@
use egui::{Align2, Area, Color32, Frame, Order, Sense, UiKind, Vec2, mutex::Mutex};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::json;
use std::sync::Arc;
use wasm_bindgen::prelude::Closure;
use unshell::Result;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Auth {
// Auth Stuff
token: Option<Token>,
#[serde(skip)]
auth_state: Arc<Mutex<AuthState>>,
// UI Stuff
username: String,
#[serde(skip)]
password: String,
show_password: bool,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
struct Token {
expiration: u128,
token: String,
}
#[derive(Debug, PartialEq, Eq)]
enum AuthState {
Unset,
NotLoggedIn,
RequestSent,
Authorised(Token),
Error(String),
}
impl Default for AuthState {
fn default() -> Self {
Self::Unset
}
}
impl Auth {
/// Refresh the authentication state
pub fn logged_in(&mut self) -> bool {
match (self.token.is_some(), &*self.auth_state.lock()) {
// The client is actually authorized
(true, AuthState::Authorised(_)) => true,
// If the user has just reloaded the session,
// the AuthState is not automatically set by any other process
(true, AuthState::Unset) => true,
// If the authentication state has been updated to unauthorized, delete the token
(true, _) => {
self.token = None;
false
}
// If the authentication state has been updated to authorized, set the token
(false, AuthState::Authorised(token)) => {
self.token = Some(token.clone());
// Also clear the password because it is bad to store it
self.password.clear();
true
}
// The client is actually unauthorized
(false, _) => false,
}
}
pub fn update(&mut self, ui: &mut egui::Ui) {
Area::new("Auth".into())
.kind(UiKind::Modal)
.sense(Sense::hover())
.anchor(Align2::CENTER_CENTER, Vec2::ZERO)
.order(Order::Foreground)
.interactable(true)
.show(ui.ctx(), |ui| {
Frame::popup(ui.style()).show(ui, |ui| {
ui.heading("UnShell Login");
ui.horizontal(|ui| {
ui.label("Username");
ui.text_edit_singleline(&mut self.username);
});
ui.horizontal(|ui| {
ui.label("Password");
let _ = ui.add(
// [ui.available_width(), 24.],
egui::TextEdit::singleline(&mut self.password)
.password(!self.show_password),
);
self.show_password = ui.button("Show").is_pointer_button_down_on();
// ui.toggle_value(&mut self.show_password, "Show");
});
ui.horizontal(|ui| {
let (show_spinner, err_text) = match *self.auth_state.lock() {
AuthState::Error(ref e) => {
// self.login_button(ui);
(false, e.clone())
}
AuthState::RequestSent => (true, "".into()),
_ => (false, "".into()),
};
if !show_spinner {
if ui.button("Login").clicked() {
let state = self.auth_state.clone();
crate::httpPost(
"/api/auth",
&json!({
"username": self.username.clone(),
"password": self.password.clone()
})
.to_string(),
Closure::once_into_js(move |ok: bool, response: String| {
*(state.lock()) = if ok {
if let Ok(token) =
serde_json::from_str::<Token>(&response)
{
AuthState::Authorised(token)
} else {
AuthState::Error("Malformed Response".into())
}
} else {
AuthState::Error(response)
}
}),
);
*(self.auth_state.lock()) = AuthState::RequestSent;
}
} else {
ui.spinner();
}
ui.colored_label(Color32::RED, err_text);
});
});
});
// });
// });
}
pub fn get<R, F>(&self, path: &str, ret: F) -> Result<()>
where
F: FnOnce(R) + 'static,
R: DeserializeOwned,
{
if let Some(ref token) = self.token {
let state = self.auth_state.clone();
crate::httpGetAuth(
path,
format!("Bearer {}", token.token),
Closure::once_into_js(move |ok: bool, response: String| {
if ok {
if let Ok(value) = serde_json::from_str::<R>(&response) {
ret(value)
} else {
*(state.lock()) = AuthState::Error("Malformed Response".into());
}
} else {
*(state.lock()) = AuthState::Error(response);
}
}),
);
}
Ok(())
}
pub fn post<R, T, F>(&self, path: &str, data: &T, ret: F) -> Result<()>
where
R: DeserializeOwned,
T: Serialize,
F: FnOnce(R) + 'static,
{
if let Some(ref token) = self.token {
let state = self.auth_state.clone();
crate::httpPostAuth(
path,
format!("Bearer {}", token.token),
&serde_json::to_string(data)?,
Closure::once_into_js(move |ok: bool, response: String| {
if ok {
if let Ok(value) = serde_json::from_str::<R>(&response) {
ret(value)
} else {
*(state.lock()) = AuthState::Error("Malformed Response".into());
}
} else {
*(state.lock()) = AuthState::Error(response);
}
}),
);
}
Ok(())
}
}
impl Default for Auth {
fn default() -> Self {
Self {
token: Default::default(),
auth_state: Arc::new(Mutex::new(AuthState::NotLoggedIn)),
username: Default::default(),
password: Default::default(),
show_password: Default::default(),
}
}
}
+113
View File
@@ -0,0 +1,113 @@
impl Auth {
// pub fn get_test(url: String) -> Promise {
// let fut = Self::get_async(&url);
// // wasm_bindgen_futures::(fut)
// }
// pub fn get_async<R>(&self, url: &str) -> PromiseWrapper<R>
// where
// // F: FnOnce(R) + 'static,
// R: DeserializeOwned + 'static,
// {
// let token = self.token.as_ref().unwrap();
// let opts = RequestInit::new();
// opts.set_method("GET");
// let request = Request::new_with_str_and_init(url, &opts).unwrap();
// let headers = request.headers();
// headers.set("content-type", "application/json").unwrap();
// headers
// .set("Authorization", &format!("Bearer {}", token.token))
// .unwrap();
// let window = web_sys::window().unwrap();
// let promise = window.fetch_with_request(&request);
// // wasm_bindgen_futures::spawn_local(async move {
// // let resp_value = JsFuture::from(window.fetch_with_request(&request))
// // .await
// // .unwrap();
// // // `resp_value` is a `Response` object.
// // assert!(resp_value.is_instance_of::<Response>());
// // let resp: Response = resp_value.dyn_into().unwrap();
// // // Convert this other `Promise` into a rust `Future`.
// // if let Ok(json) = resp.json() {
// // if let Ok(json) = JsFuture::from(json).await {
// // crate::log(&format!("{json:?}"));
// // // let json = ;
// // }
// // }
// // // crate::log(text);
// // // Any follow-up work here
// // });
// PromiseWrapper::new(promise)
// // Ok(())
// }
// pub fn get_async_callback<R, F>(&self, url: &str, callback: F) -> Result<()>
// where
// F: FnOnce(R) + 'static,
// R: DeserializeOwned + 'static,
// {
// if self.token.is_none() {
// return Err(ModuleError::Error("Not authenticated".into()));
// }
// let token = self.token.clone().unwrap();
// let url = url.to_string();
// let state_clone = self.auth_state.clone();
// wasm_bindgen_futures::future_to_promise(async move {
// let result = Self::get_async(&url, &token).await;
// match result {
// Ok(result) => callback(result),
// Err(err) => (*state_clone.lock()) = AuthState::Error(err.into()),
// }
// Ok(JsValue::NULL)
// });
// Ok(())
// }
// async fn get_async<R>(url: &str, token: &Token) -> Result<R>
// where
// R: DeserializeOwned + 'static,
// {
// let res = reqwest::Client::new()
// .get(format!("http://localhost:8080{url}"))
// .bearer_auth(&token.token)
// .send()
// .await
// .map_err(|e| ModuleError::Error(e.to_string()))?;
// match res.error_for_status() {
// Ok(res) => res
// .json()
// .await
// .map_err(|e| ModuleError::Error(e.to_string())),
// Err(err) => Err(ModuleError::Error(format!(
// "Server returned error: {err:?}"
// ))),
// }
// // .json::<R>()
// // .await
// // .map_err(|e| ModuleError::Error(e.to_string()))?;
// // serde_json::from_str(&res).map_err(|e| ModuleError::SerdeJsonError(e.to_string()))
// }
}
View File
+190
View File
@@ -0,0 +1,190 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use egui_extras::{Column, TableBuilder};
use crate::auth::Auth;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Config {
tree_option: String,
#[serde(skip)]
state: Arc<Mutex<ConfigState>>,
// trees: Arc<Mutex<Option<Vec<String>>>>,
// #[serde(skip)]
// is_requesting: Arc<AtomicBool>,
}
#[derive(Default)]
struct ConfigState {
trees: Option<Vec<String>>,
tree_keys: Option<HashMap<String, String>>,
is_requesting: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
tree_option: "".into(),
state: Arc::new(Mutex::new(ConfigState {
trees: None,
tree_keys: None,
is_requesting: false,
})), // trees: Arc::new(Mutex::new(None)),
// is_requesting: Arc::new(AtomicBool::new(false)),
}
}
}
impl Config {
pub fn update(&mut self, auth: &mut Auth, ui: &mut egui::Ui) {
let state_lock = self.state.lock().unwrap();
let tree_list_none = state_lock.trees.is_none();
let key_list_none = state_lock.tree_keys.is_none();
let is_requesting = state_lock.is_requesting;
if !tree_list_none
&& !state_lock
.trees
.as_ref()
.unwrap()
.contains(&self.tree_option)
{
self.tree_option.clear();
}
drop(state_lock);
ui.horizontal(|ui| {
if tree_list_none && !is_requesting {
self.state.lock().unwrap().is_requesting = true;
let state_clone = self.state.clone();
auth.get("/api/trees", move |response: Vec<String>| {
let mut state_lock = state_clone.lock().unwrap();
state_lock.trees = Some(response);
state_lock.is_requesting = false;
drop(state_lock);
})
.unwrap();
} else if tree_list_none && is_requesting {
ui.spinner();
}
// let state_lock = self.state.lock().unwrap();
// // This might have changed since the above api call
// tree_list_none = state_lock.trees.is_none();
if !self.tree_option.is_empty() && !tree_list_none {
// ui.horizontal(|ui| {
// ui.label(&format!("Tree: {}", self.tree_option));
// });
if key_list_none && !is_requesting {
self.state.lock().unwrap().is_requesting = true;
let state_clone = self.state.clone();
auth.get(
&format!("/api/values/{}", self.tree_option),
move |response: Result<HashMap<String, String>, String>| {
let mut state_lock = state_clone.lock().unwrap();
state_lock.tree_keys = Some(response.unwrap());
state_lock.is_requesting = false;
},
)
.unwrap();
} else if key_list_none && is_requesting {
ui.spinner();
}
}
});
if !key_list_none {
// let body_text_size = TextStyle::Body.resolve(ui.style()).size;
// use egui_extras::{Size, StripBuilder};
// StripBuilder::new(ui)
// .size(Size::remainder().at_least(100.0)) // for the table
// .size(Size::exact(body_text_size))
// .vertical(|mut strip| {
// strip.cell(|ui| {
egui::ScrollArea::both().show(ui, |ui| {
let table = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto())
.column(Column::auto())
.min_scrolled_height(0.0)
.sense(egui::Sense::click());
table
.header(20., |mut header| {
header.col(|ui| {
ui.strong("key");
});
header.col(|ui| {
ui.strong("value");
});
})
.body(|mut body| {
let state_lock = self.state.lock().unwrap();
let map = state_lock.tree_keys.as_ref().unwrap();
for (key, value) in (map).iter() {
// // let runtime = self.current_runtimes
body.row(18., |mut row| {
row.col(|ui| {
ui.label(key);
});
row.col(|ui| {
ui.label(value);
});
});
}
});
});
// });
// });
}
}
pub fn titlebar_buttons(&mut self, ui: &mut egui::Ui) {
let state_lock = self.state.lock().unwrap();
let mut tree_list_none = state_lock.trees.is_none();
drop(state_lock);
if ui.button("Refresh").clicked() {
let mut state_lock = self.state.lock().unwrap();
(*state_lock).trees = None;
(*state_lock).tree_keys = None;
drop(state_lock);
tree_list_none = true;
}
if !tree_list_none {
let state_lock = self.state.lock().unwrap();
let trees = state_lock.trees.as_ref().unwrap().clone();
drop(state_lock);
let before = &self.tree_option.clone();
egui::ComboBox::from_id_salt("Select Tree")
.selected_text(&self.tree_option)
.show_ui(ui, |ui| {
for tree in trees {
ui.selectable_value(&mut self.tree_option, tree.clone(), tree);
}
});
if before.ne(&self.tree_option) {
(*self.state.lock().unwrap()).tree_keys = None;
}
}
}
}
+230
View File
@@ -0,0 +1,230 @@
use egui::{TextStyle, Ui};
use egui_extras::{Column, TableBuilder};
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Config {
state: ConfigState,
current_payloads: Vec<PayloadConfig>,
}
#[derive(serde::Deserialize, serde::Serialize)]
enum ConfigState {
Base,
NewConfig(PayloadConfig),
EditConfig(usize, PayloadConfig),
}
impl Default for Config {
fn default() -> Self {
Self {
state: ConfigState::Base,
current_payloads: vec![PayloadConfig {
name: "Test".to_string(),
components: vec!["server".to_string()],
// runtimes: Vec::new(),
}],
}
}
}
impl Config {
// pub fn title(&self) -> &str {
// match self.state {
// ConfigState::Base => "Config",
// ConfigState::NewConfig(..) => "Config/New",
// ConfigState::EditConfig(..) => "Config/Edit",
// }
// }
pub fn update(&mut self, ui: &mut Ui) {
match &mut self.state {
ConfigState::Base => self.table_ui(ui),
ConfigState::EditConfig(_, config) => {
ui.heading("Edit Payload");
match Self::edit_ui(ui, config) {
Some(true) => {
if let ConfigState::EditConfig(n, config) =
std::mem::replace(&mut self.state, ConfigState::Base)
{
self.current_payloads[n] = config;
}
}
Some(false) => {
self.state = ConfigState::Base;
}
_ => {}
}
}
ConfigState::NewConfig(config) => {
ui.heading("Edit Payload");
match Self::edit_ui(ui, config) {
Some(true) => {
if let ConfigState::NewConfig(config) =
std::mem::replace(&mut self.state, ConfigState::Base)
{
self.current_payloads.push(config);
}
}
Some(false) => {
self.state = ConfigState::Base;
}
_ => {}
}
}
}
}
fn table_ui(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.heading("Payloads");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("New").clicked() {
self.state = ConfigState::NewConfig(PayloadConfig::new());
}
});
});
let body_text_size = TextStyle::Body.resolve(ui.style()).size;
use egui_extras::{Size, StripBuilder};
StripBuilder::new(ui)
.size(Size::remainder().at_least(100.0)) // for the table
.size(Size::exact(body_text_size)) // for the source code link
.vertical(|mut strip| {
strip.cell(|ui| {
egui::ScrollArea::horizontal().show(ui, |ui| {
let table = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto().resizable(false))
.column(Column::remainder())
.column(Column::remainder())
.column(Column::remainder())
.column(Column::auto().resizable(false))
.min_scrolled_height(0.0)
.sense(egui::Sense::click());
table
.header(20., |mut header| {
header.col(|ui| {
ui.strong("#");
});
header.col(|ui| {
ui.strong("Name");
});
header.col(|ui| {
ui.strong("Components");
});
header.col(|ui| {
ui.strong("Runtimes");
});
header.col(|_| {});
})
.body(|mut body| {
for i in 0..self.current_payloads.len() {
// let runtime = self.current_runtimes
body.row(18., |mut row| {
row.col(|ui| {
ui.label(i.to_string());
});
row.col(|ui| {
ui.label(self.current_payloads[i].name.clone());
});
row.col(|ui| {
ui.label(format!(
"{:?}",
self.current_payloads[i].components.clone()
));
});
row.col(|ui| {
ui.label("A");
});
row.col(|ui| {
if ui.button("Edit").clicked() {
self.state = ConfigState::EditConfig(
i,
self.current_payloads[i].clone(),
);
}
// if ui.button("Delete").clicked() {
// self.state = ConfigState::EditConfig(
// i,
// self.current_payloads[i].clone(),
// );
// }
});
if row.response().clicked() {
self.state = ConfigState::EditConfig(
i,
self.current_payloads[i].clone(),
);
}
});
}
});
});
});
});
}
fn edit_ui(ui: &mut Ui, config: &mut PayloadConfig) -> Option<bool> {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(&mut config.name);
});
ui.horizontal(|ui| {
ui.label("Components: ");
for component in vec!["client", "server"] {
let enabled = config
.components
.iter()
.enumerate()
.find(|s| s.1.eq(component));
if ui.selectable_label(enabled.is_some(), component).clicked() {
if let Some((i, _)) = enabled {
let _ = config.components.remove(i);
} else {
config.components.push(component.to_string());
}
}
}
});
let mut ret = None;
ui.horizontal(|ui| {
ret = if ui.button("Back").clicked() {
Some(false)
} else if ui.button("Save").clicked() {
Some(true)
} else {
None
};
});
ret
// return None;
}
}
#[derive(Clone, serde::Deserialize, serde::Serialize)]
struct PayloadConfig {
name: String,
components: Vec<String>,
// runtimes: Vec<RuntimeConfig>,
}
impl PayloadConfig {
pub fn new() -> Self {
Self {
name: "New Payload".to_string(),
components: Vec::new(),
// runtimes: Vec::new(),
}
}
}
+93
View File
@@ -0,0 +1,93 @@
use egui::{Pos2, Rect, UiBuilder, Vec2};
#[derive(serde::Deserialize, serde::Serialize)]
pub struct DraggableContainer {
pub pos: egui::Vec2, // Offset from center of clip_rect
pub size: egui::Vec2,
is_dragging: bool,
drag_offset: egui::Vec2,
drag_id: String,
pub vel: egui::Vec2,
}
impl DraggableContainer {
// pub fn new(center_offset: egui::Vec2, size: egui::Vec2, id: usize) -> Self {
// Self {
// pos: center_offset,
// size,
// is_dragging: false,
// drag_offset: egui::Vec2::ZERO,
// drag_id: format!("flowchart_drag_area{}", id),
// vel: Vec2::ZERO,
// }
// }
pub fn new_zero(id: usize) -> Self {
Self {
pos: Vec2::ZERO,
size: Vec2 { x: 100., y: 100. },
is_dragging: false,
drag_offset: egui::Vec2::ZERO,
drag_id: format!("flowchart_drag_area{}", id),
vel: Vec2::ZERO,
}
}
pub fn show<R>(
&mut self,
ui: &mut egui::Ui,
add_contents: impl FnOnce(&mut egui::Ui, &Rect) -> R,
) -> R {
// Calculate center of the clip rect
let clip_center = Pos2::ZERO;
// Calculate actual position from center offset
let center_pos = clip_center + self.pos;
let pos = center_pos - self.size / 2.0; // Top-left corner from center
let rect = egui::Rect::from_min_size(pos, self.size);
// Handle dragging logic
let response = ui.interact(rect, ui.id().with(&self.drag_id), egui::Sense::drag());
// if response.secondary_clicked() {
// }
if response.drag_started() {
self.is_dragging = true;
if let Some(pointer_pos) = ui.input(|i| i.pointer.latest_pos()) {
let pointer_pos = ui
.ctx()
.layer_transform_from_global(ui.painter().layer_id())
.unwrap_or_default()
* pointer_pos;
self.drag_offset = center_pos - pointer_pos;
}
}
if response.dragged() && self.is_dragging {
// Pointer code from https://github.com/emilk/egui/pull/7149
if let Some(pointer_pos) = ui.input(|i| i.pointer.latest_pos()) {
let pointer_pos = ui
.ctx()
.layer_transform_from_global(ui.painter().layer_id())
.unwrap_or_default()
* pointer_pos;
let new_center = pointer_pos + self.drag_offset;
self.pos = new_center - clip_center;
}
}
if response.drag_stopped() {
self.is_dragging = false;
}
// Create a child UI at the specified position
let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect));
// Add contents
add_contents(&mut child_ui, &rect)
}
}
+154
View File
@@ -0,0 +1,154 @@
use egui::{Color32, Painter, Pos2, Rect, Scene, Shape, Ui};
use crate::flowchart::CONNECTION_STROKE;
use crate::flowchart::GROUP_BORDER_MARGIN;
use crate::flowchart::ITERATIONS;
use crate::flowchart::RESOLUTION;
use crate::flowchart::container::DraggableContainer;
use crate::flowchart::group::convex_hull;
use crate::flowchart::{BG_STROKE, TARGET_LINE_GAP};
#[derive(serde::Deserialize, serde::Serialize)]
pub struct FlowChart {
scene_rect: Rect,
pub containers: Vec<DraggableContainer>,
pub connections: Vec<(usize, usize)>,
pub groups: Vec<Vec<usize>>,
}
impl Default for FlowChart {
fn default() -> Self {
let mut this = Self {
scene_rect: Rect::ZERO,
containers: vec![
DraggableContainer::new_zero(0),
DraggableContainer::new_zero(1),
DraggableContainer::new_zero(2),
DraggableContainer::new_zero(3),
DraggableContainer::new_zero(4),
DraggableContainer::new_zero(5),
DraggableContainer::new_zero(6),
DraggableContainer::new_zero(7),
],
connections: vec![(0, 1), (1, 2), (1, 3), (1, 4), (3, 5), (3, 6), (3, 7)],
groups: vec![vec![1, 3, 5, 7]],
};
this.arrange_circle();
this.arrange();
this
}
}
impl FlowChart {
pub fn arrange(&mut self) {
for _ in 0..ITERATIONS {
self.force(RESOLUTION);
}
}
fn paint_bg(rect: &Rect, painter: &Painter) {
let h_start = (rect.min.x / TARGET_LINE_GAP).round() as i32;
let h_end = ((rect.min.x + rect.width()) / TARGET_LINE_GAP).round() as i32 + 1;
for n in h_start..h_end {
painter.vline(n as f32 * TARGET_LINE_GAP, rect.y_range(), BG_STROKE);
}
let v_start = (rect.min.y / TARGET_LINE_GAP).round() as i32;
let v_end = ((rect.min.y + rect.height()) / TARGET_LINE_GAP).round() as i32 + 1;
for n in v_start..v_end {
painter.hline(rect.x_range(), n as f32 * TARGET_LINE_GAP, BG_STROKE);
}
}
fn paint_groups(groups: &Vec<Vec<usize>>, containers: &Vec<DraggableContainer>, ui: &mut Ui) {
for group in groups {
let mut points = Vec::new();
for n in group {
let container = &containers[*n];
let pos = container.pos.to_pos2();
let size = container.size;
points.append(&mut vec![
Pos2 {
x: pos.x - size.x / 2. - GROUP_BORDER_MARGIN,
y: pos.y - size.x / 2. - GROUP_BORDER_MARGIN,
},
Pos2 {
x: pos.x + size.x / 2. + GROUP_BORDER_MARGIN,
y: pos.y - size.y / 2. - GROUP_BORDER_MARGIN,
},
Pos2 {
x: pos.x - size.x / 2. - GROUP_BORDER_MARGIN,
y: pos.y + size.y / 2. + GROUP_BORDER_MARGIN,
},
Pos2 {
x: pos.x + size.x / 2. + GROUP_BORDER_MARGIN,
y: pos.y + size.y / 2. + GROUP_BORDER_MARGIN,
},
]);
}
let points = convex_hull(&points);
ui.painter().add(Shape::convex_polygon(
points,
Color32::DEBUG_COLOR,
BG_STROKE,
));
}
}
pub fn paint(&mut self, ui: &mut Ui) {
let scene = Scene::new()
// .max_inner_size([350.0, 1000.0])
.zoom_range(0.1..=2.0);
let containers = &mut self.containers;
let groups = &self.groups;
let mut inner_rect = Rect::NAN;
let rect = &self.scene_rect.clone();
let response = scene
.show(ui, &mut self.scene_rect, |mut ui| {
Self::paint_bg(rect, ui.painter());
Self::paint_groups(groups, containers, &mut ui);
for (a, b) in &self.connections {
ui.painter().line_segment(
[containers[*a].pos.to_pos2(), containers[*b].pos.to_pos2()],
CONNECTION_STROKE,
);
}
for container in containers {
container.show(&mut ui, |ui, rect| {
ui.painter().rect(
// ui.top
*rect,
0.,
Color32::PURPLE,
BG_STROKE,
egui::StrokeKind::Outside,
);
});
}
inner_rect = ui.min_rect();
})
.response;
if response.double_clicked() {
self.scene_rect = inner_rect;
}
}
pub fn titlebar_buttons(&mut self, ui: &mut egui::Ui) {
if ui.button("Arrange").clicked() {
self.arrange();
}
}
}
+106
View File
@@ -0,0 +1,106 @@
use std::f32::consts::TAU;
use egui::Vec2;
use crate::flowchart::{
ATTRACTION_STRENGTH, CENTER_ATTRACTION_STRENGTH, DAMPING, FlowChart, GROUP_ATTRACTION_STRENGTH,
REPULSION_STRENGTH, REST_LENGTH,
};
pub fn normalize(v: &Vec2) -> Vec2 {
let len = v.length();
if len > 0.0 {
Vec2 {
x: v.x / len,
y: v.y / len,
}
} else {
Vec2 { x: 0.0, y: 0.0 }
}
}
impl FlowChart {
pub fn force(&mut self, delta_time: f32) {
let num_nodes = self.containers.len();
let mut forces = vec![Vec2::new(0.0, 0.0); num_nodes];
// Calculate repulsive forces between all nodes
for i in 0..num_nodes {
for j in (i + 1)..num_nodes {
let diff = self.containers[i].pos - self.containers[j].pos;
let dist = diff.length().max(0.1); // Prevent division by zero
let force = normalize(&diff) * (REPULSION_STRENGTH / (dist * dist));
forces[i] = forces[i] + force;
forces[j] = forces[j] + (force * -1.0);
}
}
// Calculate attractive forces along connections
for &(i, j) in &self.connections {
let diff = self.containers[j].pos - self.containers[i].pos;
let dist = diff.length();
let displacement = dist - REST_LENGTH;
let force = normalize(&diff) * (displacement * ATTRACTION_STRENGTH);
forces[i] = forces[i] + force;
forces[j] = forces[j] + (force * -1.0);
}
// Apply force to center
for i in 0..num_nodes {
let diff = self.containers[i].pos;
let dist = diff.length();
let displacement = dist - REST_LENGTH;
let force = normalize(&diff) * (displacement * CENTER_ATTRACTION_STRENGTH);
forces[i] = forces[i] + force * -1.;
}
let group_avg = &self
.groups
.iter()
.map(|group| {
let mut sum = Vec2::ZERO;
for n in group {
sum += self.containers[*n].pos;
}
sum / group.len() as f32
})
.collect::<Vec<Vec2>>();
for (group, group_avg) in self.groups.iter().zip(group_avg) {
for i in 0..num_nodes {
let diff = self.containers[i].pos - *group_avg;
let dist = diff.length();
let displacement = dist - REST_LENGTH;
let force = normalize(&diff) * (displacement * GROUP_ATTRACTION_STRENGTH);
if group.contains(&i) {
forces[i] = forces[i] + force * -1.;
} else {
forces[i] = forces[i] + force;
}
}
}
// Update velocities and positions
for i in 0..num_nodes {
let c = &mut self.containers[i];
c.vel = (c.vel + forces[i] * delta_time) * DAMPING;
c.pos += c.vel * delta_time;
}
}
pub fn arrange_circle(&mut self) {
let node_count = self.containers.len() as f32;
for (i, m) in self.containers.iter_mut().enumerate() {
let ang = -(i as f32 / node_count) * TAU;
m.pos = Vec2 {
x: 300. * ang.sin(),
y: 300. * ang.cos(),
};
m.vel = Vec2::ZERO;
}
}
}
+59
View File
@@ -0,0 +1,59 @@
use egui::Pos2;
/// Calculate the convex hull of a set of points using Graham scan
pub fn convex_hull(points: &[Pos2]) -> Vec<Pos2> {
if points.len() < 3 {
return points.to_vec();
}
let mut pts = points.to_vec();
// Find the point with lowest y-coordinate (and leftmost if tie)
let start_idx = pts
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
a.y.partial_cmp(&b.y)
.unwrap()
.then(a.x.partial_cmp(&b.x).unwrap())
})
.unwrap()
.0;
pts.swap(0, start_idx);
let start = pts[0];
// Sort points by polar angle with respect to start point
pts[1..].sort_by(|a, b| {
let angle_a = polar_angle_to(&start, a);
let angle_b = polar_angle_to(&start, b);
angle_a.partial_cmp(&angle_b).unwrap()
});
// Build convex hull
let mut hull = Vec::new();
hull.push(pts[0]);
hull.push(pts[1]);
for i in 2..pts.len() {
while hull.len() > 1
&& cross_product(&hull[hull.len() - 2], &hull[hull.len() - 1], &pts[i]) <= 0.0
{
hull.pop();
}
hull.push(pts[i]);
}
hull
}
/// Calculate cross product of vectors (self->p2) and (self->p3)
/// Positive if counter-clockwise, negative if clockwise, zero if collinear
fn cross_product(p1: &Pos2, p2: &Pos2, p3: &Pos2) -> f32 {
(p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x)
}
/// Calculate polar angle from self to other point
fn polar_angle_to(a: &Pos2, b: &Pos2) -> f32 {
(b.y - a.y).atan2(b.x - a.x)
}
+31
View File
@@ -0,0 +1,31 @@
mod container;
mod flowchart;
mod force;
mod group;
use egui::{Color32, Stroke};
pub use flowchart::FlowChart;
const ITERATIONS: usize = 1_000;
const RESOLUTION: f32 = 0.6;
const TARGET_LINE_GAP: f32 = 80.;
const BG_STROKE: Stroke = Stroke {
width: 0.3,
color: Color32::GRAY,
};
const CONNECTION_STROKE: Stroke = Stroke {
width: 3.,
color: Color32::WHITE,
};
const GROUP_BORDER_MARGIN: f32 = 20.;
static REPULSION_STRENGTH: f32 = 100000.0; // repulsion_strength
static ATTRACTION_STRENGTH: f32 = 0.01; // attraction_strength
static CENTER_ATTRACTION_STRENGTH: f32 = 0.01; // attraction_strength
static GROUP_ATTRACTION_STRENGTH: f32 = 0.01; // attraction_strength
static REST_LENGTH: f32 = 50.0; // rest_length
static DAMPING: f32 = 0.9; // damping
+82
View File
@@ -0,0 +1,82 @@
use egui::{Color32, TextEdit};
use unshell::config::{ConfigStructField, InterfaceData, InterfaceStruct, config_struct};
use crate::config::Config;
struct ConfigInterface(Config);
pub fn render(
ui: &mut egui::Ui,
interface_struct: &InterfaceStruct,
interface_data: &mut InterfaceData,
) {
match (interface_struct, interface_data) {
(InterfaceStruct::ConfigStruct(interface), InterfaceData::ConfigStruct(data)) => {
render_config_struct(ui, interface, data);
}
}
}
fn render_config_struct(
ui: &mut egui::Ui,
interface: &config_struct::ConfigStructKeys,
data: &mut config_struct::ConfigStructValues,
) {
for (interface, data) in interface.iter().zip(data) {
match (interface, data) {
(ConfigStructField::Header(text), serde_json::Value::Null) => {
ui.heading(text);
}
(ConfigStructField::Text(text), serde_json::Value::Null) => {
ui.label(text);
}
(
ConfigStructField::String {
default: _,
max_length,
protected,
},
serde_json::Value::String(value),
) => {
ui.horizontal(|ui| {
let mut widget = TextEdit::singleline(value);
if let Some(limit) = &max_length {
widget = widget.char_limit(*limit);
}
let password = *protected && !ui.button("👁").is_pointer_button_down_on();
widget = widget.password(password);
// if protected
// ui.selectable_label(show_plaintext, "👁")
// .on_hover_text("Show/hide password")
// .clicked();
// {
// widget = widget.password(true);
// }
ui.add(widget);
if let Some(limit) = max_length {
ui.label(format!("{}/{}", value.len(), limit));
}
});
}
(
ConfigStructField::Integer { default, min, max },
serde_json::Value::Number(number),
) => todo!(),
(interface, data) => {
ui.colored_label(
Color32::RED,
&format!("Incorrect type and value! {interface:?} and {data:?}"),
);
}
}
}
}
+173
View File
@@ -0,0 +1,173 @@
mod interface;
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use unshell::{Result, config::TreeMessage};
use crate::auth::Auth;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct InterfaceWindow {
path: PathBuf,
// #[serde(skip)]
// data_bind: Bind<String, ModuleError>,
#[serde(skip)]
state: Arc<Mutex<InterfaceWindowState>>,
// #[serde(skip)]
// promise: Option<PromiseWrapper<Result<TreeMessage>>>,
}
pub struct InterfaceWindowState {
is_request: bool,
is_error: bool,
branch: Option<TreeMessage>,
}
impl InterfaceWindow {
pub fn update(&mut self, auth: &mut Auth, ui: &mut egui::Ui) {
// let data_bind = Bind::<InterfaceWindowState, ModuleError>::new(false);
ui.heading("Interface");
ui.label(self.path.to_string_lossy());
{
if !self.path.eq("/") && ui.button("Go up").clicked() {
self.go_up();
self.state.lock().unwrap().branch = None;
}
let state_lock = self.state.lock().unwrap();
let (is_request, is_error, is_some) = (
state_lock.is_request,
state_lock.is_error,
state_lock.branch.is_some(),
);
drop(state_lock);
if is_request {
ui.spinner();
} else if is_error {
self.reset_path();
let mut state_lock = self.state.lock().unwrap();
state_lock.is_error = false;
state_lock.is_request = false;
state_lock.branch = None;
drop(state_lock)
} else if !is_some {
{
let mut state_lock = self.state.lock().unwrap();
state_lock.is_request = true;
}
let state_clone = self.state.clone();
auth.get(
&format!("/api/interface{}", self.path.display()),
move |response: Result<TreeMessage>| {
let mut state_lock = state_clone.lock().unwrap();
match response {
Ok(item) => {
state_lock.branch = Some(item);
}
Err(err) => {
crate::log(&format!("Got error {err:?}"));
state_lock.is_error = true;
}
}
state_lock.is_request = false;
drop(state_lock);
},
)
.unwrap();
} else {
let state_clone = self.state.clone();
let mut state_lock = state_clone.lock().unwrap();
let branch = (state_lock.branch).as_mut().unwrap();
let clear = match branch {
TreeMessage::InterfaceAndValue(interface_struct, interface_data) => {
interface::render(ui, interface_struct, interface_data);
if ui.button("Save").clicked() {
auth.post(
&format!("/api/interface{}", self.path.display()),
&TreeMessage::State(interface_data.clone()),
move |response: Result<TreeMessage>| {
// debug!("{response:?}");
},
)
.unwrap();
}
false
}
TreeMessage::Folder(items) => {
let mut clear = false;
for item in items {
if ui.button(&format!("Item {}", item)).clicked() {
self.go_down(item.clone());
clear = true;
break;
}
}
clear
}
_ => false,
};
if clear {
state_lock.branch = None;
}
drop(state_lock)
}
}
}
fn reset_path(&mut self) {
self.path = PathBuf::from("/");
}
fn go_up(&mut self) {
if let Some(parent) = self.path.parent() {
self.path = PathBuf::from(parent);
}
}
fn go_down(&mut self, path: String) {
self.path = self.path.join(path);
}
}
impl Default for InterfaceWindow {
fn default() -> Self {
Self {
path: "/".into(),
// promise: None,
state: Arc::new(Mutex::new(InterfaceWindowState::default())),
// data_bind: Bind::new(false),
}
}
}
impl Default for InterfaceWindowState {
fn default() -> Self {
Self {
is_request: false,
branch: None,
is_error: false,
}
}
}
+90
View File
@@ -0,0 +1,90 @@
#![warn(clippy::all, rust_2018_idioms)]
#![macro_use]
// #[allow(unused_extern_crates)]
// extern crate log;
pub mod app;
mod auth;
mod blobs;
mod config;
mod flowchart;
mod interface;
mod log_viewer;
mod payload_config;
use std::time::Duration;
const FORCE_REDRAW_DELAY: Duration = Duration::from_millis(300);
// mod JsFunc {
// use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
pub fn log(text: &str);
}
#[wasm_bindgen(module = "/assets/sw.js")]
extern "C" {
pub fn httpGet(url: &str, ok_callback: JsValue);
pub fn httpPost(url: &str, data: &str, ok_callback: JsValue);
pub fn httpGetAuth(url: &str, auth: String, ok_callback: JsValue);
pub fn httpPostAuth(url: &str, auth: String, data: &str, ok_callback: JsValue);
}
// When compiling to web using trunk:
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen(start)]
pub async fn run() {
use wasm_bindgen::JsCast as _;
use app::TemplateApp;
// Redirect `log` message to `console.log` and friends:
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
let web_options = eframe::WebOptions::default();
let document = web_sys::window()
.expect("No window")
.document()
.expect("No document");
let canvas = document
.get_element_by_id("the_canvas_id")
.expect("Failed to find the_canvas_id")
.dyn_into::<web_sys::HtmlCanvasElement>()
.expect("the_canvas_id was not a HtmlCanvasElement");
let start_result = eframe::WebRunner::new()
.start(
canvas,
web_options,
Box::new(|cc| Ok(Box::new(TemplateApp::new(cc)))),
)
.await;
// Remove the loading text and spinner:
if let Some(loading_text) = document.get_element_by_id("loading_text") {
match start_result {
Ok(_) => {
loading_text.remove();
}
Err(e) => {
loading_text.set_inner_html(
"<p> The app has crashed. See the developer console for details. </p>",
);
panic!("Failed to start eframe: {e:?}");
}
}
}
}
// }
// #[cfg(not(target_arch = "wasm32"))]
// mod JsFunc {
// pub fn httpGet(url: &str, callback: fn() -> {}) {}
// }
+207
View File
@@ -0,0 +1,207 @@
use chrono::DateTime;
use chrono::Utc;
use egui::Color32;
use egui::TextStyle;
use egui_extras::Column;
use egui_extras::TableBuilder;
use crate::auth::Auth;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::SystemTime;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct LogViewer {
enable_location: bool,
#[serde(skip)]
state: Arc<Mutex<LogState>>,
}
#[derive(Default)]
struct LogState {
logs: Vec<Record>,
// trees: Option<Vec<String>>,
// tree_keys: Option<HashMap<String, String>>,
is_requesting: bool,
requested_data: bool,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Record {
log_level: LogLevel,
location: Option<String>,
time: SystemTime,
message: String,
}
impl Record {
pub fn display_level(&self, ui: &mut egui::Ui) {
match self.log_level {
LogLevel::Debug => ui.colored_label(Color32::LIGHT_BLUE, "DBUG"),
LogLevel::Info => ui.colored_label(Color32::DARK_GREEN, "INFO"),
LogLevel::Warn => ui.colored_label(Color32::YELLOW, "WARN"),
LogLevel::Error => ui.colored_label(Color32::RED, "ERR!"),
};
}
pub fn display_location(&self, ui: &mut egui::Ui) {
if let Some(ref location) = self.location {
ui.label(location);
}
}
pub fn display_time(&self, ui: &mut egui::Ui) {
let date: DateTime<Utc> = self.time.into();
let date = date.to_rfc2822().to_string();
ui.label(date);
}
pub fn display_message(&self, ui: &mut egui::Ui) {
ui.strong(&self.message);
}
}
impl LogViewer {
pub fn update(&mut self, auth: &mut Auth, ui: &mut egui::Ui) {
ui.heading("Log Viewer");
ui.horizontal(|ui| {
if ui.button("Refresh").clicked() {
self.refresh_logs(auth);
}
ui.checkbox(&mut self.enable_location, "Enable Location");
});
// let logs = ;
let body_text_size = TextStyle::Body.resolve(ui.style()).size;
use egui_extras::{Size, StripBuilder};
StripBuilder::new(ui)
.size(Size::remainder().at_least(100.0)) // for the table
.size(Size::exact(body_text_size))
.vertical(|mut strip| {
strip.cell(|ui| {
egui::ScrollArea::both()
.stick_to_bottom(true)
.show(ui, |ui| {
let table = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.stick_to_bottom(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto())
.column(Column::auto())
.column(Column::auto())
.min_scrolled_height(0.0)
.sense(egui::Sense::click());
let table = if self.enable_location {
table.column(Column::auto())
} else {
table
};
table
.header(20., |mut header| {
header.col(|ui| {
ui.strong("Time");
});
header.col(|ui| {
ui.strong("Level");
});
header.col(|ui| {
ui.strong("Message");
});
if self.enable_location {
header.col(|ui| {
ui.strong("Location");
});
}
})
.body(|mut body| {
let state_lock = self.state.lock().unwrap();
// let logs = state_lock.logs.as_ref();
for log in state_lock.logs.iter() {
// // let runtime = self.current_runtimes
body.row(18., |mut row| {
row.col(|ui| log.display_time(ui));
row.col(|ui| log.display_level(ui));
row.col(|ui| log.display_message(ui));
if self.enable_location {
row.col(|ui| log.display_location(ui));
}
});
}
});
});
});
});
{
let state_lock = self.state.lock().unwrap();
match (
state_lock.is_requesting,
state_lock.logs.len() == 0,
state_lock.requested_data,
) {
(true, _, _) => {
drop(state_lock);
ui.spinner();
}
(false, true, true) => {
drop(state_lock);
ui.label("There are no logs");
}
(false, true, false) => {
drop(state_lock);
self.refresh_logs(auth);
}
_ => {
drop(state_lock);
}
}
}
}
fn refresh_logs(&self, auth: &mut Auth) {
let state_clone = self.state.clone();
{
let mut state_lock = self.state.lock().unwrap();
state_lock.logs.clear();
state_lock.is_requesting = true;
}
auth.get(&format!("/api/log/{}", 0), move |e: Vec<String>| {
let mut state_lock = state_clone.lock().unwrap();
state_lock.logs.append(
&mut e
.iter()
.map(|log| serde_json::from_str(log).unwrap())
.collect(),
);
state_lock.is_requesting = false;
state_lock.requested_data = true;
// crate::log(&format!("{e:?}"));
})
.unwrap();
}
}
impl Default for LogViewer {
fn default() -> Self {
Self {
enable_location: false,
state: Arc::new(Mutex::new(LogState::default())),
}
}
}
+21
View File
@@ -0,0 +1,21 @@
#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
// pretty_env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
// let native_options = eframe::NativeOptions {
// viewport: egui::ViewportBuilder::default()
// .with_inner_size([400.0, 300.0])
// .with_min_inner_size([300.0, 220.0]),
// ..Default::default()
// };
// eframe::run_native(
// "eframe template",
// native_options,
// Box::new(|cc| Ok(Box::new(TemplateApp::new(cc)))),
// )
todo!()
}
+30
View File
@@ -0,0 +1,30 @@
// use crate::payload_config::structs::ConfigStructField;
mod structs;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct PayloadConfig {
config_struct: structs::Config,
}
// struct ServerConfigState {
// // config: Vec<PayloadConfig>
// }
impl PayloadConfig {
pub fn update(&mut self, ui: &mut egui::Ui) {
if ui.button("export").clicked() {
crate::log(&self.config_struct.export());
}
// ui.heading("Test");
self.config_struct.update(ui);
}
}
impl Default for PayloadConfig {
fn default() -> Self {
Self {
config_struct: structs::default_configurable(),
}
}
}
+126
View File
@@ -0,0 +1,126 @@
use std::collections::HashMap;
use egui::TextEdit;
use serde_json::Value;
#[derive(serde::Deserialize, serde::Serialize)]
enum ConfigStructField {
Header(String),
Text(String),
String {
default: Option<String>,
max_length: Option<usize>,
protected: bool,
},
Integer {
default: i32,
min: Option<i32>,
max: Option<i32>,
},
// Checkbox
// Dropdown
// Collapsing header
// Slider
// ...
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct Config {
config: Vec<(String, ConfigStructField)>,
state: HashMap<String, Value>,
}
impl Config {
fn new(config: Vec<(String, ConfigStructField)>) -> Self {
Self {
config,
state: HashMap::default(),
}
}
pub fn update(&mut self, ui: &mut egui::Ui) {
for (id, field) in &self.config {
match field {
ConfigStructField::Header(text) => {
ui.heading(text);
}
ConfigStructField::Text(text) => {
ui.label(text);
}
ConfigStructField::String {
default,
max_length,
protected,
} => {
let value = if let Some(Value::String(value)) = self.state.get_mut(id) {
value
} else {
self.state.insert(
id.clone(),
Value::String(default.clone().unwrap_or(String::new())),
);
if let Some(Value::String(value)) = self.state.get_mut(id) {
value
} else {
unreachable!()
}
};
let mut widget = TextEdit::singleline(value).password(*protected);
if let Some(limit) = &max_length {
widget = widget.char_limit(*limit);
}
ui.add(widget);
}
_ => {} // ConfigStructField::Integer { default, min, max } => todo!(),
}
}
// match &self.field {
// ConfigStructField::Header(text) => {
// ui.heading(text);
// }
// ConfigStructField::Text(text) => {
// ui.label(text);
// }
// ConfigStructField::String {
// default,
// max_length,
// protected,
// } => ui.text_edit_singleline(),
// ConfigStructField::Integer { default, min, max } => todo!(),
// }
}
pub fn export(&self) -> String {
serde_json::to_string(&self.config).unwrap()
}
}
pub fn default_configurable() -> Config {
Config::new(vec![
(
"Header".into(),
ConfigStructField::Header("Test Header!".into()),
),
("text".into(), ConfigStructField::Text("Test Text!".into())),
(
"Config".into(),
ConfigStructField::String {
default: Some("Test String".into()),
max_length: Some(30),
protected: false,
},
),
(
"Protected".into(),
ConfigStructField::String {
default: Some("Protected String".into()),
max_length: None,
protected: true,
},
),
])
}