1. Project Repository
2. Introduction to egui
egui2.1. How to get Started
-
eguihas official github page: https://github.com/emilk/egui -
For beginner it is suggested to start with their eframe_template. Just clone it and
cargo runto see a very basic single component: -
We can check out their web demo, where you can play around with the well-classified components. For component that appeals to you, you can view the source code and study how to achieve the ui.

2.2. Entrypoint
2.2.1. main.rs, for resource initialization
main.rs, for resource initializationNote that main.rs is the binary crate, which starts as the entrypoint of our egui application.
It is a good place to start initialization of resources that is not related to egui framework such as
- database connection;
- spawn of
tokioruntime (for doing async tasks), etc.
2.2.2. lib.rs, for app state initialization
lib.rs, for app state initializationFor our application logic, we will "enable" custom packages in lib.rs by using mod <package-name> several times. We can also define static resources, or methods that manage static resources, in lib.rs.
2.3. Integration with SQLite Database via Prisma
Prisma2.3.1. prisma.rs
prisma.rsWe have mentioned how to install prisma in any project in Prisma with SQLite for GUI Application in Rust.
A successful installation of prisma into the project will create you a prisma.rs in src/ via cargo build. After that, add a line mod prisma in lib.rs and we are all set to use prisma.
2.3.2. Connect to database on the launch of the application
Now back to eframe_tempalte project (mentioned in section 2.1. How to get Started), in main.rs we have the following block of code:
fn main() -> eframe::Result<()> { // Choose database location based on build mode let db_path = if cfg!(debug_assertions) { // In debug mode, use current directory for easier development std::env::current_dir().unwrap().join("database.db") } else { // In release mode, use proper app data directory let app_data_dir = dirs::data_dir() .unwrap_or_else(|| std::env::current_dir().unwrap()) .join("ShellScriptManager"); // Create directory if it doesn't exist std::fs::create_dir_all(&app_data_dir).ok(); app_data_dir.join("database.db") }; let db_url = format!("file:{}", db_path.display()); let rt = tokio::runtime::Runtime::new().unwrap(); shell_script_manager::RT_HANDLE .set(rt.handle().clone()) .unwrap(); rt.block_on(async { match shell_script_manager::prisma::new_client_with_url(&db_url).await { Ok(client) => { // Initialize database schema automatically for desktop app if let Err(e) = shell_script_manager::db::get_db::initialize_database(&client).await { eprintln!("Failed to initialize database: {}", e); eprintln!("Please check database permissions or file path"); std::process::exit(1); } shell_script_manager::PRISMA_CLIENT.set(client).unwrap(); println!("Database connection established successfully"); } Err(e) => { eprintln!("Failed to connect to database: {}", e); eprintln!("Please ensure the database exists by running: npm run migrate:dev"); eprintln!( "If deploying to production, run migrations as part of your deployment process." ); std::process::exit(1); } } }); // Spawn a task to keep the runtime alive std::thread::spawn(move || { rt.block_on(async { // Keep alive until signal let _ = tokio::signal::ctrl_c().await; }); }); // Initialize event system let (tx, rx) = crossbeam::channel::unbounded(); shell_script_manager::EVENT_SENDER.set(tx).unwrap(); shell_script_manager::EVENT_RECEIVER.set(rx).unwrap(); let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 720.0]) .with_min_inner_size([800.0, 400.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( "Shell Script Managers", native_options, Box::new(|cc| Ok(Box::new(shell_script_manager::App::new(cc)))), ) }
The highlighted lines are new code added to fn main, the intention is clearly stated in the comment, where we have:
-
Connected to database;
-
Kept the
tokioruntime, i.e., executor polls, alive to schedule futures (async tasks).
2.4. Initialize Static Resources
In 2.3.2. Connect to database on the launch of the application we have the following:
let (tx, rx) = crossbeam::channel::unbounded(); shell_script_manager::EVENT_SENDER.set(tx).unwrap(); shell_script_manager::EVENT_RECEIVER.set(rx).unwrap();
These are the static resources that we have declared in lib.rs:
// lib.rs #[derive(Debug)] pub enum AppCommand { Folder(FolderCommand), } #[derive(Debug)] pub enum AppEvent { Folder(FolderEvent), } #[derive(Debug)] pub enum AppMessage { Command(AppCommand), Event(AppEvent), } pub static EVENT_SENDER: OnceLock<Sender<AppMessage>> = OnceLock::new(); pub static EVENT_RECEIVER: OnceLock<Receiver<AppMessage>> = OnceLock::new(); pub fn send_event(message: AppMessage) { let _ = EVENT_SENDER.get().unwrap().send(message); } pub fn dispatch_folder_event(event: FolderEvent) { println!("Dispatching folder event: {:?}", event); send_event(AppMessage::Event(AppEvent::Folder(event))); } pub fn dispatch_folder_command(command: FolderCommand) { println!("Dispatching folder command: {:?}", command); send_event(AppMessage::Command(AppCommand::Folder(command))); }
There are many more static resources by searching pub static in the project.
3. Architecture
3.1. State Management
Script Folders Management is the major domain (and possibly the only domain) in our application. Let's organize our state in a separate folder:
3.1.1. Declare global state
Since our state will be accessed in both main thread running UI as well as threads used by tokio runtime. For thread safty most of our state are wrapped inside an atomic reference count pointer Arc, avoiding our states being killed when parent resource gets dropped:
In addition to Arc, we also protect our data by Read-Write Lock Rwlock:
-
On one hand, we don't want to block access for reading data during other
read; -
On the other hand, we don't want to allow read access to a data that is being written (use of write lock), or vice versa (no write while reading by read lock).
i.e.,
ReadandWriteare exclusive to each other.
1#[derive(Default)] 2pub struct FoldersState { 3 pub selected_folder_id: RwLock<Option<i32>>, 4 pub app_state: RwLock<Arc<Option<prisma::application_state::Data>>>, 5 pub folder_list: RwLock<Arc<Vec<prisma::scripts_folder::Data>>>, 6 pub scripts_of_selected_folder: RwLock<Arc<Vec<prisma::shell_script::Data>>>, 7 pub folder_to_delete: RwLock<Option<Arc<prisma::scripts_folder::Data>>>, 8 pub folder_to_rename: RwLock<Option<Arc<prisma::scripts_folder::Data>>>, 9 pub rename_text: RwLock<Option<String>>, 10 pub script_to_edit: RwLock<Option<Arc<prisma::shell_script::Data>>>, 11} 12 13pub static FOLDER_STATE: LazyLock<FoldersState> = LazyLock::new(|| FoldersState::default());
3.1.2. Reducer
For sure we can mutate the state by dereferencing the pointer and assign it a value anywhere. This is both good and bad for immidiate mode gui application.
As in redux, we should restrict outself to mutate the state only via reducer. In this way we have decoupled the state-update logic from ui-view logic. Which easily causes scope issue by directly
14pub struct FolderReducer<'a> { 15 pub state: &'a FoldersState, 16} 17 18impl<'a> FolderReducer<'a> { 19 pub fn select_folder(&self, id: i32) { 20 *self.state.selected_folder_id.write().unwrap() = Some(id); 21 } 22 23 pub fn delete_folder(&self, id: i32) { 24 let mut folders = self.state.folder_list.write().unwrap(); 25 let updated_folders: Vec<_> = folders.iter().filter(|f| f.id != id).cloned().collect(); 26 *folders = Arc::new(updated_folders); 27 } 28 29 pub fn rename_folder(&self, id: i32, new_name: &str) { 30 let mut folders = self.state.folder_list.write().unwrap(); 31 let folders_vec = Arc::make_mut(&mut *folders); 32 for folder in folders_vec.iter_mut() { 33 if folder.id == id { 34 folder.name = new_name.to_string(); 35 break; 36 } 37 } 38 } 39 40 pub fn set_folder_list(&self, folders: Vec<prisma::scripts_folder::Data>) { 41 *self.state.folder_list.write().unwrap() = Arc::new(folders); 42 } 43 44 pub fn set_scripts_of_selected_folder(&self, scripts: Vec<prisma::shell_script::Data>) { 45 *self.state.scripts_of_selected_folder.write().unwrap() = Arc::new(scripts); 46 } 47 48 pub fn set_app_state(&self, app_state: Option<prisma::application_state::Data>) { 49 *self.state.app_state.write().unwrap() = Arc::new(app_state); 50 } 51}
3.1.3. The with_ hook via closure
with_ hook via closureIn react repeated pattern of state extraction can be reused in react via hook, which is always of the form
const { someState } = useSomeHook(someParam);
However, in Rust, we need to consider the lifespan of a RwLockReadGuard, it would be much better to get access to a state via closure to ensure the guard is released at an appropriate time to prevent locking:
// src/lib.rs pub fn with_folder_state<F, R>(f: F) -> R where F: FnOnce(&crate::state::folder_state::FoldersState) -> R, { f(&crate::state::folder_state::FOLDER_STATE) } pub fn with_folder_state_reducer<F, R>(f: F) -> R where F: FnOnce(&crate::state::folder_state::FolderReducer<'static>) -> R, { // FOLDER_STATE is a 'static LazyLock, so we can create a FolderReducer<'static> safely let reducer = crate::state::folder_state::FolderReducer { state: &crate::state::folder_state::FOLDER_STATE, }; f(&reducer) }
3.2. Event-Driven Design based on Rust Messaging System
3.2.1. Initialize messaging system
In src/lib.rs we define static variable for our own project library:
pub static EVENT_SENDER: OnceLock<Sender<AppMessage>> = OnceLock::new(); pub static EVENT_RECEIVER: OnceLock<Receiver<AppMessage>> = OnceLock::new();
In src/main.rs we initialize resources before we start the egui application:
// Initialize event system let (tx, rx) = crossbeam::channel::unbounded(); shell_script_manager::EVENT_SENDER.set(tx).unwrap(); shell_script_manager::EVENT_RECEIVER.set(rx).unwrap();
3.2.2. Backend State Part I: Commands
As in usual architecture in DDD, our backend state changes are all handled by Command handlers (single responsibility), which (they) as a whole serve as an application layer that interacts directly with our UI interface.
For UI state change we will be delegating it to our event handlers, again for the purpose of separation of concerns.
1#[derive(Debug)] 2pub enum FolderCommand { 3 CreateFolder {}, 4 SelectFolder { 5 folder_id: i32, 6 }, 7 DeleteFolder { 8 folder_id: i32, 9 }, 10 AddScriptToFolder { 11 folder_id: i32, 12 name: String, 13 command: String, 14 }, 15 UpdateScript { 16 script_id: i32, 17 new_command: String, 18 }, 19 UpdateScriptName { 20 script_id: i32, 21 new_name: String, 22 }, 23 RenameFolder { 24 folder_id: i32, 25 new_name: String, 26 }, 27} 28 29pub struct FolderCommandHandler { 30 folder_repository: Arc<FolderRepository>, 31 script_repository: Arc<ScriptRepository>, 32}
Here we need Arc pointer as we need to move pointers into threads created by tokio runtime.
33impl FolderCommandHandler { 34 pub fn new() -> Self { 35 Self { 36 folder_repository: Arc::new(FolderRepository::new()), 37 script_repository: Arc::new(ScriptRepository::new()), 38 } 39 } 40 41 pub fn handle(&self, command: FolderCommand) { 42 let folder_repository = self.folder_repository.clone(); 43 let script_repository = self.script_repository.clone(); 44 match command { 45 FolderCommand::CreateFolder {} => { 46 crate::spawn_task(async move { 47 let total_num_folders = 48 folder_repository.get_folder_count().await.to_i64().unwrap(); 49 let folder_name = format!("Folder {}", total_num_folders + 1); 50 51 match folder_repository.create_script_folder(&folder_name).await { 52 Ok(_) => { 53 crate::dispatch_folder_event(FolderEvent::FolderAdded { 54 name: folder_name.clone(), 55 }); 56 } 57 Err(e) => eprintln!("Failed to add folder: {:?}", e), 58 } 59 }); 60 }
Here once our backend state change has been finished, we dispatch the corresponding event to notice our system. We will be updating our UI state when the relevant event is received.
When our application gets more complicated, we might also handle additional side effects from our event handler.
61 FolderCommand::SelectFolder { folder_id } => crate::spawn_task(async move { 62 match folder_repository 63 .upsert_app_state_last_folder_id(folder_id) 64 .await 65 { 66 Ok(_) => { 67 crate::dispatch_folder_event(FolderEvent::FolderSelected { folder_id }); 68 println!( 69 "Successfully updated last opened folder id to {}", 70 folder_id 71 ); 72 } 73 Err(e) => eprintln!("Failed to update last opened folder id: {:?}", e), 74 } 75 }), 76 // many more ... 77 } 78}
3.2.3. Backend State Part II: Repository
To interact with database, we create a special kind of service for this purpose:
pub struct FolderRepository { db: &'static PrismaClient, }
We have declared a
pub static PRISMA_CLIENT: OnceLock<prisma::PrismaClient> = OnceLock::new();
in lib.rs and instantiated one in main.rs. Therefore it is accessible in the whole life-time of the application.
Next let's list out a few simple methods in this repository:
impl FolderRepository { pub fn new() -> Self { let db = crate::db::get_db::get_db(); Self { db } } pub async fn get_folder_count(&self) -> i64 { let total_num_folders = self.db.scripts_folder().count(vec![]).exec().await.unwrap(); total_num_folders } pub async fn get_all_folders(&self) -> prisma_client_rust::Result<Vec<Data>> { self.db.scripts_folder().find_many(vec![]).exec().await } pub async fn create_script_folder( &self, folder_name: &String, ) -> prisma_client_rust::Result<Data> { self.db .scripts_folder() .create(folder_name.clone(), 0, vec![]) .exec() .await } ... }
3.2.4. UI State: Events
In folder_event_handler.rs we have defined the following events:
1pub enum FolderEvent { 2 FolderAdded { name: String }, 3 FolderSelected { folder_id: i32 }, 4 FolderDeleted { folder_id: i32 }, 5 ScriptAdded { folder_id: i32 }, 6 ScriptUpdated { script_id: i32 }, 7 FolderRenamed { folder_id: i32, new_name: String }, 8}
Basically they correspond to AddFolderCommand, SelectFolderCommand, ..., the verbs at the heads are moved to the tails in past tense.
Once a command is finished, we receive an event and do the subsequent action for the application:
9pub struct FolderEventHandler { 10 folder_repository: Arc<FolderRepository>, 11 script_repository: Arc<ScriptRepository>, 12} 13 14impl FolderEventHandler { 15 pub fn new() -> Self { 16 Self { 17 folder_repository: Arc::new(FolderRepository::new()), 18 script_repository: Arc::new(ScriptRepository::new()), 19 } 20 } 21 22 pub fn handle(&self, event: FolderEvent) { 23 let folder_repository = self.folder_repository.clone(); 24 let script_repository = self.script_repository.clone(); 25 match event { 26 FolderEvent::FolderAdded { name } => { 27 // fetch all folder and set it into the state 28 println!( 29 "Folder added event received for folder: {}, now refetch all folders", 30 name 31 ); 32 crate::spawn_task(async move { 33 match folder_repository.get_all_folders().await { 34 Ok(folders) => { 35 crate::with_folder_state_reducer(|r| r.set_folder_list(folders)); 36 } 37 Err(e) => eprintln!("Failed to load folders: {:?}", e), 38 } 39 }); 40 } 41 ... 42 } 43 } 44}
Our event handlers have a single responsibility of maintaining UI state by fetching the data from our database.
Note that in traditional backend, event handlers (in policies) are responsible only to determine whether we need to dispatch another command for the reaction to domain business rules.

















