| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512 |
- use eframe::egui;
- use std::collections::HashMap;
- use std::sync::{mpsc, Arc};
- use tokio::sync::Mutex;
- use super::audits::AuditsView;
- use super::borrowing::BorrowingView;
- use super::categories::CategoriesView;
- use super::dashboard::DashboardView;
- use super::inventory::InventoryView;
- use super::issues::IssuesView;
- use super::label_templates::LabelTemplatesView;
- use super::login::LoginScreen;
- use super::printers::PrintersView;
- use super::ribbon::RibbonUI;
- use super::suppliers::SuppliersView;
- use super::templates::TemplatesView;
- use super::zones::ZonesView;
- use crate::api::ApiClient;
- use crate::config::AppConfig;
- use crate::models::{LoginResponse, UserInfo};
- use crate::session::{SessionData, SessionManager};
- pub struct BeepZoneApp {
- // Session management
- session_manager: Arc<Mutex<SessionManager>>,
- api_client: Option<ApiClient>,
- // Current view state
- current_view: AppView,
- previous_view: Option<AppView>,
- current_user: Option<UserInfo>,
- current_permissions: Option<serde_json::Value>,
- // Per-view filter state storage
- view_filter_states: HashMap<AppView, crate::core::components::filter_builder::FilterGroup>,
- // UI components
- login_screen: LoginScreen,
- dashboard: DashboardView,
- inventory: InventoryView,
- categories: CategoriesView,
- zones: ZonesView,
- borrowing: BorrowingView,
- audits: AuditsView,
- templates: TemplatesView,
- suppliers: SuppliersView,
- issues: IssuesView,
- printers: PrintersView,
- label_templates: LabelTemplatesView,
- ribbon_ui: Option<RibbonUI>,
- // Configuration
- #[allow(dead_code)]
- app_config: Option<AppConfig>,
- // State
- login_success: Option<(String, LoginResponse)>,
- show_about: bool,
- pub should_exit_to_kiosk: bool,
-
- // Kiosk integration
- pub is_kiosk_mode: bool,
- pub enable_full_osk_button: bool,
- pub show_osk: bool,
- pub osk_shift_mode: bool,
- last_focused_id: Option<egui::Id>,
- osk_event_queue: Vec<egui::Event>,
- // Status bar state
- server_status: ServerStatus,
- last_health_check: std::time::Instant,
- health_check_in_progress: bool,
- health_check_rx: Option<mpsc::Receiver<HealthCheckResult>>,
- // Re-authentication prompt state
- reauth_needed: bool,
- reauth_password: String,
- // Database outage tracking
- db_offline_latch: bool,
- last_timeout_at: Option<std::time::Instant>,
- consecutive_healthy_checks: u8,
- }
- #[derive(Debug, Clone, Copy, PartialEq)]
- pub enum ServerStatus {
- Unknown,
- Connected,
- Disconnected,
- Checking,
- }
- #[derive(Debug, Clone, Copy)]
- struct HealthCheckResult {
- status: ServerStatus,
- reauth_required: bool,
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
- pub enum AppView {
- Login,
- Dashboard,
- Inventory,
- Categories,
- Zones,
- Borrowing,
- Audits,
- Templates,
- Suppliers,
- IssueTracker,
- Printers,
- LabelTemplates,
- }
- impl BeepZoneApp {
- pub fn new(
- _cc: &eframe::CreationContext<'_>,
- session_manager: Arc<Mutex<SessionManager>>,
- ) -> Self {
- let session_manager_blocking = session_manager.blocking_lock();
- let login_screen = LoginScreen::new(&session_manager_blocking);
- // Try to restore session on startup
- let (api_client, current_view, current_user, current_permissions) =
- if let Some(session) = session_manager_blocking.get_session() {
- log::info!("Found saved session, attempting to restore...");
- // Create API client with saved token
- match ApiClient::new(session.server_url.clone()) {
- Ok(mut client) => {
- client.set_token(session.token.clone());
- // Verify session is still valid (tolerant)
- match client.check_session_valid() {
- Ok(true) => {
- log::info!(
- "Session restored successfully for user: {}",
- session.user.username
- );
- (
- Some(client),
- AppView::Dashboard,
- Some(session.user.clone()),
- session.permissions.clone(),
- )
- }
- Ok(false) => {
- log::warn!("Saved session check returned invalid");
- (None, AppView::Login, None, None)
- }
- Err(e) => {
- log::warn!("Saved session validity check error: {}", e);
- // Be forgiving on startup: keep client and let periodic checks refine
- (
- Some(client),
- AppView::Dashboard,
- Some(session.user.clone()),
- session.permissions.clone(),
- )
- }
- }
- }
- Err(e) => {
- log::error!("Failed to create API client: {}", e);
- (None, AppView::Login, None, None)
- }
- }
- } else {
- log::info!("No saved session found");
- (None, AppView::Login, None, None)
- };
- drop(session_manager_blocking);
- // Load configuration and initialize ribbon UI
- let ribbon_ui = Some(RibbonUI::default());
- let app_config = None;
- let mut app = Self {
- session_manager,
- api_client,
- current_view,
- previous_view: None,
- view_filter_states: HashMap::new(),
- current_user,
- current_permissions,
- login_screen,
- dashboard: DashboardView::new(),
- inventory: InventoryView::new(),
- categories: CategoriesView::new(),
- zones: ZonesView::new(),
- borrowing: BorrowingView::new(),
- audits: AuditsView::new(),
- templates: TemplatesView::new(),
- suppliers: SuppliersView::new(),
- issues: IssuesView::new(),
- printers: PrintersView::new(),
- label_templates: LabelTemplatesView::new(),
- ribbon_ui,
- app_config,
- login_success: None,
- show_about: false,
- should_exit_to_kiosk: false,
- is_kiosk_mode: false,
- enable_full_osk_button: false,
- show_osk: false,
- osk_shift_mode: false,
- last_focused_id: None,
- osk_event_queue: Vec::new(),
- server_status: ServerStatus::Unknown,
- last_health_check: std::time::Instant::now(),
- health_check_in_progress: false,
- health_check_rx: None,
- reauth_needed: false,
- reauth_password: String::new(),
- db_offline_latch: false,
- last_timeout_at: None,
- consecutive_healthy_checks: 0,
- };
- // Do initial health check if we have an API client
- if app.api_client.is_some() {
- app.request_health_check();
- }
- app
- }
- pub fn handle_login_success(&mut self, server_url: String, response: LoginResponse) {
- // Ensure we have token and user
- let token = match response.token {
- Some(t) => t,
- None => {
- log::error!("Login successful but no token returned");
- return;
- }
- };
-
- let user = match response.user {
- Some(u) => u,
- None => {
- log::error!("Login successful but no user returned");
- return;
- }
- };
- // Capture username for logging before moving fields out of response
- let username = user.username.clone();
- log::info!("Login successful for user: {}", username);
- // Create API client with token
- let mut api_client = match ApiClient::new(server_url.clone()) {
- Ok(client) => client,
- Err(e) => {
- log::error!("Failed to create API client: {}", e);
- // This shouldn't happen in normal operation, so just log and continue without client
- return;
- }
- };
- api_client.set_token(token.clone());
- self.api_client = Some(api_client.clone());
- self.current_user = Some(user.clone());
- // Fetch permissions
- let permissions = match api_client.get_permissions() {
- Ok(resp) => {
- if resp.success {
- log::info!("Permissions fetched successfully: {:?}", resp.permissions);
- Some(resp.permissions)
- } else {
- log::warn!("Failed to fetch permissions: {:?}", resp.error);
- None
- }
- }
- Err(e) => {
- log::warn!("Error fetching permissions: {}", e);
- None
- }
- };
-
- self.current_permissions = permissions.clone();
- // Save session (blocking is fine here, it's just writing a small JSON file)
- let session_data = SessionData {
- server_url,
- token,
- user,
- remember_server: true,
- remember_username: true,
- saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
- permissions,
- default_printer_id: None,
- last_printer_id: None,
- };
- let mut session_manager = self.session_manager.blocking_lock();
- if let Err(e) = session_manager.save_session(session_data) {
- log::error!("Failed to save session: {}", e);
- }
- // Switch to dashboard
- self.current_view = AppView::Dashboard;
- self.reauth_needed = false;
- self.reauth_password.clear();
- // Load dashboard data
- if let Some(client) = self.api_client.as_ref() {
- self.dashboard.refresh_data(client);
- }
- }
- fn handle_reauth_success(&mut self, server_url: String, response: LoginResponse) {
- // Ensure we have token and user
- let token = match response.token {
- Some(t) => t,
- None => {
- log::error!("Reauth successful but no token returned");
- return;
- }
- };
-
- let user = match response.user {
- Some(u) => u,
- None => {
- log::error!("Reauth successful but no user returned");
- return;
- }
- };
- // Preserve current view but refresh token and user
- let mut new_client = match ApiClient::new(server_url.clone()) {
- Ok(client) => client,
- Err(e) => {
- log::error!("Failed to create API client during reauth: {}", e);
- self.reauth_needed = true;
- return;
- }
- };
- new_client.set_token(token.clone());
- // Replace client and user
- self.api_client = Some(new_client.clone());
- self.current_user = Some(user.clone());
- // Fetch permissions
- let permissions = match new_client.get_permissions() {
- Ok(resp) => {
- if resp.success {
- Some(resp.permissions)
- } else {
- log::warn!("Failed to fetch permissions: {:?}", resp.error);
- None
- }
- }
- Err(e) => {
- log::warn!("Error fetching permissions: {}", e);
- None
- }
- };
-
- self.current_permissions = permissions.clone();
- // Save updated session
- let session_data = SessionData {
- server_url,
- token,
- user,
- remember_server: true,
- remember_username: true,
- saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
- permissions,
- default_printer_id: None,
- last_printer_id: None,
- };
- let mut session_manager = self.session_manager.blocking_lock();
- if let Err(e) = session_manager.save_session(session_data) {
- log::error!("Failed to save session after reauth: {}", e);
- }
- }
- fn show_top_bar(&mut self, ctx: &egui::Context, disable_actions: bool) {
- egui::TopBottomPanel::top("top_bar")
- .exact_height(45.0)
- .show_separator_line(false)
- .frame(
- egui::Frame::new()
- .fill(ctx.style().visuals.window_fill)
- .stroke(egui::Stroke::NONE)
- .inner_margin(egui::vec2(16.0, 5.0)),
- )
- .show(ctx, |ui| {
- // Horizontal layout for title and controls
- ui.horizontal(|ui| {
- ui.heading("BeepZone");
- ui.separator();
- // User info
- if let Some(user) = &self.current_user {
- ui.label(format!("User: {} ({})", user.username, user.role));
- ui.label(format!("Powah: {}", user.power));
- }
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- ui.add_enabled_ui(!disable_actions, |ui| {
- if ui.button("About").clicked() {
- self.show_about = true;
- }
- if self.is_kiosk_mode && self.enable_full_osk_button {
- let btn_text = if self.show_osk { "Hide Keyboard" } else { "Keyboard" };
- if ui.button(btn_text).clicked() {
- self.show_osk = !self.show_osk;
- }
- }
- if ui.button("Bye").clicked() {
- self.handle_logout();
- }
- });
- });
- });
- });
- }
- fn show_ribbon(&mut self, ctx: &egui::Context) -> Option<String> {
- let mut action_triggered = None;
- if let Some(ribbon_ui) = &mut self.ribbon_ui {
- let min_height = ribbon_ui.preferred_height();
- // Outer container panel with normal background
- egui::TopBottomPanel::top("ribbon_container")
- .min_height(min_height + 16.0)
- .max_height(min_height + 96.0)
- .show_separator_line(false)
- .frame(egui::Frame::new().fill(if ctx.style().visuals.dark_mode {
- ctx.style().visuals.panel_fill
- } else {
- // Darker background in light mode
- egui::Color32::from_rgb(210, 210, 210)
- }))
- .show(ctx, |ui| {
- ui.add_space(0.0);
- let side_margin: f32 = 16.0;
- let inner_pad: f32 = 8.0;
- ui.horizontal(|ui| {
- // Left margin
- ui.add_space(side_margin);
- // Remaining width after left margin
- let remaining = ui.available_width();
- // Leave room for right margin and inner padding on both sides of the frame
- let content_width = (remaining - side_margin - inner_pad * 2.0).max(0.0);
- // Custom ribbon background color based on theme
- let is_dark_mode = ctx.style().visuals.dark_mode;
- let ribbon_bg_color = if is_dark_mode {
- // Lighter gray for dark mode - more visible contrast
- egui::Color32::from_rgb(45, 45, 45)
- } else {
- // Lighter/white ribbon in light mode
- egui::Color32::from_rgb(248, 248, 248)
- };
- egui::Frame::new()
- .fill(ribbon_bg_color)
- .inner_margin(inner_pad)
- .corner_radius(6.0)
- .show(ui, |ui| {
- // Constrain to the computed content width so right margin remains
- ui.set_width(content_width);
- ui.scope(|ui| {
- ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
- action_triggered = ribbon_ui.show(ctx, ui, self.current_permissions.as_ref());
- });
- // Update current view based on active ribbon tab
- if let Some(view_name) = ribbon_ui.get_active_view() {
- self.current_view = match view_name.to_lowercase().as_str() {
- "dashboard" => AppView::Dashboard,
- "inventory" => AppView::Inventory,
- "categories" => AppView::Categories,
- "zones" => AppView::Zones,
- "borrowing" => AppView::Borrowing,
- "audits" => AppView::Audits,
- "item templates" => AppView::Templates,
- "templates" => AppView::Templates, // Backwards compat
- "suppliers" => AppView::Suppliers,
- "issues" | "issue_tracker" => AppView::IssueTracker,
- "printers" => AppView::Printers,
- "label templates" => AppView::LabelTemplates,
- _ => self.current_view,
- };
- }
- });
- // Right margin
- ui.add_space(side_margin);
- });
- ui.add_space(8.0);
- });
- } else {
- // Fallback to simple ribbon if config failed to load
- egui::TopBottomPanel::top("ribbon")
- .exact_height(38.0)
- .show_separator_line(false)
- .show(ctx, |ui| {
- ui.add_space(2.0);
- ui.horizontal_wrapped(|ui| {
- ui.selectable_value(
- &mut self.current_view,
- AppView::Dashboard,
- "Dashboard",
- );
- ui.selectable_value(
- &mut self.current_view,
- AppView::Inventory,
- "Inventory",
- );
- ui.selectable_value(
- &mut self.current_view,
- AppView::Categories,
- "Categories",
- );
- ui.selectable_value(&mut self.current_view, AppView::Zones, "Zones");
- ui.selectable_value(
- &mut self.current_view,
- AppView::Borrowing,
- "Borrowing",
- );
- ui.selectable_value(&mut self.current_view, AppView::Audits, "Audits");
- ui.selectable_value(
- &mut self.current_view,
- AppView::Templates,
- "Templates",
- );
- ui.selectable_value(
- &mut self.current_view,
- AppView::Suppliers,
- "Suppliers",
- );
- ui.selectable_value(
- &mut self.current_view,
- AppView::IssueTracker,
- "Issues",
- );
- });
- });
- }
- action_triggered
- }
- pub fn handle_logout(&mut self) {
- log::info!("Taking myself out");
- // Logout from API
- if let Some(api_client) = &self.api_client {
- let _ = api_client.logout();
- }
- // Clear session and reset login screen (do both while holding the lock once)
- {
- let mut session_manager = self.session_manager.blocking_lock();
- if let Err(e) = session_manager.clear_session() {
- log::error!("Failed to clear session: {}", e);
- }
- // Reset login screen while we still have the lock
- self.login_screen = LoginScreen::new(&session_manager);
- } // Lock is dropped here
- // Reset state
- self.api_client = None;
- self.current_user = None;
- self.current_view = AppView::Login;
- self.should_exit_to_kiosk = true;
- self.server_status = ServerStatus::Unknown;
- // Reset ribbon state to default
- if let Some(ribbon) = &mut self.ribbon_ui {
- ribbon.active_tab = "Dashboard".to_string();
- }
- }
- /// Force an immediate health check (used when timeout errors detected)
- pub fn force_health_check(&mut self) {
- self.last_health_check = std::time::Instant::now() - std::time::Duration::from_secs(10);
- self.request_health_check();
- }
- fn request_health_check(&mut self) {
- if self.api_client.is_none() || self.health_check_in_progress {
- return;
- }
- if let Some(client) = &self.api_client {
- let api_client = client.clone();
- let reauth_needed = self.reauth_needed;
- let (tx, rx) = mpsc::channel();
- self.health_check_rx = Some(rx);
- self.health_check_in_progress = true;
-
- // Only show "Checking..." if we aren't already connected to avoid UI flickering
- if !matches!(self.server_status, ServerStatus::Connected) {
- self.server_status = ServerStatus::Checking;
- }
-
- self.last_health_check = std::time::Instant::now();
- std::thread::spawn(move || {
- let result = Self::run_health_check(api_client, reauth_needed);
- let _ = tx.send(result);
- });
- }
- }
- fn desired_health_interval(&self, predicted_block: bool) -> f32 {
- if predicted_block || self.db_offline_latch {
- 0.75
- } else if matches!(self.server_status, ServerStatus::Connected) {
- 1.5
- } else {
- 2.5
- }
- }
- fn poll_health_check(&mut self) {
- if let Some(rx) = &self.health_check_rx {
- match rx.try_recv() {
- Ok(result) => {
- self.apply_health_result(result);
- self.health_check_rx = None;
- self.health_check_in_progress = false;
- self.last_health_check = std::time::Instant::now();
- }
- Err(mpsc::TryRecvError::Empty) => {}
- Err(mpsc::TryRecvError::Disconnected) => {
- log::warn!("Health check worker disconnected unexpectedly");
- self.health_check_rx = None;
- self.health_check_in_progress = false;
- }
- }
- }
- }
- fn apply_health_result(&mut self, result: HealthCheckResult) {
- if self.reauth_needed != result.reauth_required {
- if self.reauth_needed && !result.reauth_required {
- log::info!("Session valid again; clearing re-auth requirement");
- } else if !self.reauth_needed && result.reauth_required {
- log::info!("Session invalid/expired; prompting re-auth");
- }
- self.reauth_needed = result.reauth_required;
- }
- match result.status {
- ServerStatus::Disconnected => {
- self.db_offline_latch = true;
- self.last_timeout_at = Some(std::time::Instant::now());
- self.consecutive_healthy_checks = 0;
- }
- ServerStatus::Connected => {
- self.consecutive_healthy_checks = self.consecutive_healthy_checks.saturating_add(1);
- if self.db_offline_latch {
- let timeout_cleared = self
- .last_timeout_at
- .map(|t| t.elapsed() > std::time::Duration::from_secs(2))
- .unwrap_or(true);
- if timeout_cleared && self.consecutive_healthy_checks >= 2 {
- log::info!("Health checks stable; clearing database offline latch");
- self.db_offline_latch = false;
- }
- }
- }
- _ => {
- self.consecutive_healthy_checks = 0;
- }
- }
- if self.db_offline_latch {
- self.server_status = ServerStatus::Disconnected;
- } else {
- self.server_status = result.status;
- }
- }
- fn run_health_check(api_client: ApiClient, mut reauth_needed: bool) -> HealthCheckResult {
- let connected = match api_client.check_session_valid() {
- Ok(true) => {
- reauth_needed = false;
- true
- }
- Ok(false) => {
- reauth_needed = true;
- true
- }
- Err(e) => {
- log::warn!("Session status check error: {}", e);
- false
- }
- };
- if connected {
- let mut db_disconnected = false;
- if let Ok(true) = api_client.health_check() {
- if let Ok(info_opt) = api_client.health_info() {
- if let Some(info) = info_opt {
- let db_down = info.get("database").and_then(|v| v.as_str())
- .map(|s| s.eq_ignore_ascii_case("disconnected"))
- .unwrap_or(false)
- || info.get("database_connected").and_then(|v| v.as_bool())
- == Some(false)
- || info.get("db_connected").and_then(|v| v.as_bool())
- == Some(false)
- || info
- .get("db")
- .and_then(|v| v.as_str())
- .map(|s| s.eq_ignore_ascii_case("down"))
- .unwrap_or(false)
- || info
- .get("database")
- .and_then(|v| v.as_str())
- .map(|s| s.eq_ignore_ascii_case("down"))
- .unwrap_or(false);
- if db_down {
- db_disconnected = true;
- }
- }
- }
- }
- if db_disconnected {
- log::warn!("Database disconnected; treating as offline");
- HealthCheckResult {
- status: ServerStatus::Disconnected,
- reauth_required: reauth_needed,
- }
- } else {
- HealthCheckResult {
- status: ServerStatus::Connected,
- reauth_required: reauth_needed,
- }
- }
- } else {
- HealthCheckResult {
- status: ServerStatus::Disconnected,
- reauth_required: reauth_needed,
- }
- }
- }
- fn handle_ribbon_action(&mut self, action: String) {
- log::info!("Ribbon action triggered: {}", action);
- // Handle different action types
- if action.starts_with("search:") {
- let search_query = action.strip_prefix("search:").unwrap_or("");
- log::info!("Search action: {}", search_query);
- // TODO: Implement search functionality
- } else {
- match action.as_str() {
- // Dashboard actions
- "refresh_dashboard" => {
- if let Some(api_client) = &self.api_client {
- self.dashboard.refresh_data(api_client);
- }
- }
- "customize_dashboard" => {
- log::info!("Customize dashboard - TODO");
- }
- // Inventory actions
- "add_item" => {
- log::info!("Add item - TODO");
- }
- "edit_item" => {
- log::info!("Edit item - TODO");
- }
- "delete_item" => {
- log::info!("Delete item - TODO");
- }
- "print_label" => {
- log::info!("Print label - TODO");
- }
- // Quick actions
- "inventarize_quick" => {
- log::info!("Quick inventarize - TODO");
- }
- "checkout_checkin" => {
- log::info!("Check-out/in - TODO");
- }
- "start_room_audit" => {
- log::info!("Start room audit - TODO");
- }
- "start_spot_check" => {
- log::info!("Start spot-check - TODO");
- }
- _ => {
- log::info!("Unhandled action: {}", action);
- }
- }
- }
- }
- fn show_status_bar(&self, ctx: &egui::Context) {
- egui::TopBottomPanel::bottom("status_bar")
- .exact_height(24.0)
- .show_separator_line(false)
- .frame(
- egui::Frame::new()
- .fill(ctx.style().visuals.window_fill)
- .stroke(egui::Stroke::NONE),
- )
- .show(ctx, |ui| {
- ui.horizontal(|ui| {
- // Seqkel inikator
- let (icon, text, color) = match self.server_status {
- ServerStatus::Connected => (
- "-",
- if self.reauth_needed {
- "Server Connected • Re-auth required"
- } else {
- "Server Connected"
- },
- egui::Color32::from_rgb(76, 175, 80),
- ),
- ServerStatus::Disconnected => {
- // Check if we detected database timeout recently
- let timeout_detected = self.dashboard.has_timeout_error();
- let text = if timeout_detected {
- "Database Timeout - Retrying..."
- } else {
- "Server Disconnected"
- };
- ("x", text, egui::Color32::from_rgb(244, 67, 54))
- },
- ServerStatus::Checking => {
- ("~", "Checking...", egui::Color32::from_rgb(255, 152, 0))
- }
- ServerStatus::Unknown => (
- "??????????? -",
- "I don't know maybe connected maybe not ???",
- egui::Color32::GRAY,
- ),
- };
- ui.label(egui::RichText::new(icon).color(color).size(16.0));
- ui.label(egui::RichText::new(text).color(color).size(12.0));
- ui.separator();
- // Server URL
- if let Some(client) = &self.api_client {
- ui.label(
- egui::RichText::new(format!("Server: {}", client.base_url()))
- .size(11.0)
- .color(egui::Color32::GRAY),
- );
- }
- // User info on the right
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if let Some(user) = &self.current_user {
- ui.label(
- egui::RichText::new(format!("User: {}", user.username))
- .size(11.0)
- .color(egui::Color32::GRAY),
- );
- }
- });
- });
- });
- }
- fn show_reconnect_overlay(&self, ctx: &egui::Context) {
- let screen_rect = ctx.viewport_rect();
- let visuals = ctx.style().visuals.clone();
- let dim_color = if visuals.dark_mode {
- egui::Color32::from_black_alpha(180)
- } else {
- egui::Color32::from_white_alpha(200)
- };
- // Dim the entire interface
- let layer_id = egui::LayerId::new(
- egui::Order::Foreground,
- egui::Id::new("reconnect_overlay_bg"),
- );
- ctx.layer_painter(layer_id)
- .rect_filled(screen_rect, 0.0, dim_color);
- // Capture input so underlying widgets don't receive clicks or keypresses
- egui::Area::new(egui::Id::new("reconnect_overlay_blocker"))
- .order(egui::Order::Foreground)
- .movable(false)
- .interactable(true)
- .fixed_pos(screen_rect.left_top())
- .show(ctx, |ui| {
- ui.set_min_size(screen_rect.size());
- ui.allocate_rect(ui.max_rect(), egui::Sense::click_and_drag());
- });
- let timeout_detected = self.dashboard.has_timeout_error();
- let message = if timeout_detected {
- "Database temporarily unavailable. Waiting for heartbeat…"
- } else {
- "Connection to the backend was lost. Retrying…"
- };
- // Foreground card with spinner and message
- egui::Area::new(egui::Id::new("reconnect_overlay_card"))
- .order(egui::Order::Foreground)
- .movable(false)
- .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
- .show(ctx, |ui| {
- ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
- ui.set_min_size(egui::vec2(360.0, 200.0));
- egui::Frame::default()
- .fill(visuals.panel_fill)
- .stroke(egui::Stroke::new(1.0, visuals.weak_text_color()))
- .corner_radius(12.0)
- .inner_margin(egui::Margin::symmetric(32, 24))
- .show(ui, |ui| {
- ui.vertical_centered(|ui| {
- ui.heading(
- egui::RichText::new("Reconnecting…")
- .color(visuals.strong_text_color())
- .size(20.0),
- );
- ui.add_space(8.0);
- ui.spinner();
- ui.label(
- egui::RichText::new(message)
- .color(visuals.text_color())
- .size(15.0),
- );
- ui.label(
- egui::RichText::new(
- "All actions are paused until the backend recovers.",
- )
- .color(visuals.weak_text_color())
- .size(13.0),
- );
- });
- });
- });
- // Keep spinner animating while offline
- ctx.request_repaint_after(std::time::Duration::from_millis(250));
- }
- fn should_block_interaction(&self) -> bool {
- self.api_client.is_some()
- && self.current_view != AppView::Login
- && (matches!(self.server_status, ServerStatus::Disconnected)
- || self.db_offline_latch)
- }
- /// Save current filter state before switching views
- fn save_filter_state_for_view(&mut self, view: AppView) {
- if let Some(ribbon) = &self.ribbon_ui {
- // Only save filter state for views that use filters
- if matches!(
- view,
- AppView::Inventory | AppView::Zones | AppView::Borrowing
- ) {
- self.view_filter_states
- .insert(view, ribbon.filter_builder.filter_group.clone());
- }
- }
- }
- /// Restore filter state when switching to a view
- fn restore_filter_state_for_view(&mut self, view: AppView) {
- if let Some(ribbon) = &mut self.ribbon_ui {
- // Check if we have saved state for this view
- if let Some(saved_state) = self.view_filter_states.get(&view) {
- ribbon.filter_builder.filter_group = saved_state.clone();
- } else {
- // No saved state - clear filters for this view (fresh start)
- ribbon.filter_builder.filter_group =
- crate::core::components::filter_builder::FilterGroup::new();
- }
- }
- }
- }
- impl eframe::App for BeepZoneApp {
- fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
- // Inject queued OSK events at the start of the frame
- if !self.osk_event_queue.is_empty() {
- let events = std::mem::take(&mut self.osk_event_queue);
- ctx.input_mut(|i| i.events.extend(events));
- }
- // Track focus for OSK
- if let Some(id) = ctx.memory(|m| m.focused()) {
- self.last_focused_id = Some(id);
- }
- // Show OSK first so it reserves space at the bottom (preventing click-through and overlap)
- self.show_osk_overlay(ctx);
- // Detect view changes and save/restore filter state
- if let Some(prev_view) = self.previous_view {
- if prev_view != self.current_view {
- // Save filter state for the view we're leaving
- self.save_filter_state_for_view(prev_view);
- // Restore filter state for the view we're entering
- self.restore_filter_state_for_view(self.current_view);
- // Update available columns for the new view
- if let Some(ribbon) = &mut self.ribbon_ui {
- match self.current_view {
- AppView::Inventory => {
- // Ensure Inventory uses asset columns in the FilterBuilder
- ribbon.filter_builder.set_columns_for_context("assets");
- }
- AppView::Zones => {
- ribbon.filter_builder.available_columns = vec![
- ("Any".to_string(), "Any".to_string()),
- ("Zone Code".to_string(), "zones.zone_code".to_string()),
- ("Zone Name".to_string(), "zones.zone_name".to_string()),
- ];
- }
- AppView::Borrowing => {
- ribbon.filter_builder.available_columns =
- crate::ui::borrowing::BorrowingView::get_filter_columns();
- }
- _ => {}
- }
- }
- }
- }
- // Update previous view for next frame
- self.previous_view = Some(self.current_view);
- // Customize background color for light mode
- if !ctx.style().visuals.dark_mode {
- let mut style = (*ctx.style()).clone();
- style.visuals.panel_fill = egui::Color32::from_rgb(210, 210, 210);
- style.visuals.window_fill = egui::Color32::from_rgb(210, 210, 210);
- ctx.set_style(style);
- }
- // Check for login success
- if let Some((server_url, response)) = self.login_success.take() {
- self.handle_login_success(server_url, response);
- }
- // Process any completed health checks and schedule new ones
- self.poll_health_check();
- let predicted_block = self.should_block_interaction();
- let health_interval = self.desired_health_interval(predicted_block);
- if self.api_client.is_some()
- && !self.health_check_in_progress
- && self.last_health_check.elapsed().as_secs_f32() > health_interval
- {
- self.request_health_check();
- }
- // Show appropriate view
- if self.current_view == AppView::Login {
- self.login_screen.show(ctx, &mut self.login_success);
- } else {
- let mut block_interaction = self.should_block_interaction();
- if let Some(client) = &self.api_client {
- if client.take_timeout_signal() {
- log::warn!("Backend timeout detected via API client; entering reconnect mode");
- self.server_status = ServerStatus::Disconnected;
- self.db_offline_latch = true;
- self.last_timeout_at = Some(std::time::Instant::now());
- self.consecutive_healthy_checks = 0;
- block_interaction = true;
- // Force an immediate health re-check
- self.last_health_check = std::time::Instant::now()
- - std::time::Duration::from_secs(10);
- if !self.health_check_in_progress {
- self.request_health_check();
- }
- }
- }
- // When we're blocked, ensure a health check is queued so we recover ASAP
- if block_interaction
- && !self.health_check_in_progress
- && self.last_health_check.elapsed().as_secs_f32() > 1.0
- {
- self.request_health_check();
- }
- self.show_top_bar(ctx, block_interaction);
- let ribbon_action = if block_interaction || self.current_view == AppView::Login {
- None
- } else {
- self.show_ribbon(ctx)
- };
- self.show_status_bar(ctx);
- if !block_interaction {
- if let Some(action) = ribbon_action {
- self.handle_ribbon_action(action);
- }
- egui::CentralPanel::default().show(ctx, |ui| match self.current_view {
- AppView::Dashboard => {
- self.dashboard.show(ui, self.api_client.as_ref());
-
- // Check if dashboard has timeout error and trigger health check
- if self.dashboard.has_timeout_error() {
- self.force_health_check();
- }
- }
- AppView::Inventory => {
- // Handle FilterBuilder popup BEFORE showing inventory
- // This ensures filter changes are processed in the current frame
- if let Some(ribbon) = &mut self.ribbon_ui {
- let filter_changed = ribbon.filter_builder.show_popup(ctx);
- if filter_changed {
- ribbon
- .checkboxes
- .insert("inventory_filter_changed".to_string(), true);
- }
- }
- self.inventory.show(
- ui,
- self.api_client.as_ref(),
- self.ribbon_ui.as_mut(),
- &self.session_manager,
- self.current_permissions.as_ref(),
- );
- }
- AppView::Categories => {
- self.categories
- .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut(), self.current_permissions.as_ref());
- }
- AppView::Zones => {
- if let Some(ribbon) = self.ribbon_ui.as_mut() {
- // Handle FilterBuilder popup BEFORE showing zones view so changes are applied in the same frame
- let filter_changed = ribbon.filter_builder.show_popup(ctx);
- if filter_changed {
- ribbon
- .checkboxes
- .insert("zones_filter_changed".to_string(), true);
- }
- self.zones.show(ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
- // Handle zone navigation request to inventory
- if let Some((zone_code, zone_id)) = self.zones.switch_to_inventory_with_zone.take() {
- log::info!("Switching to inventory with zone filter: {} (ID: {})", zone_code, zone_id);
- // Save current Zones filter state
- let zones_filter_state = ribbon.filter_builder.filter_group.clone();
- self.view_filter_states
- .insert(AppView::Zones, zones_filter_state);
- // Set zone filter using the ID which is safer and faster
- ribbon.filter_builder.set_single_filter(
- "assets.zone_id".to_string(),
- crate::core::components::filter_builder::FilterOperator::Is,
- zone_id.to_string(),
- );
- // Switch to inventory view
- self.current_view = AppView::Inventory;
- ribbon.active_tab = "Inventory".to_string();
- ribbon
- .checkboxes
- .insert("inventory_filter_changed".to_string(), true);
- // Update previous_view to match so next frame doesn't restore old inventory filters
- self.previous_view = Some(AppView::Inventory);
- // Request repaint to ensure the filter is applied on the next frame
- ctx.request_repaint();
- }
- } else {
- // Fallback if no ribbon (shouldn't happen)
- log::warn!("No ribbon available for zones view");
- }
- }
- AppView::Borrowing => {
- if let Some(ribbon) = self.ribbon_ui.as_mut() {
- // Handle FilterBuilder popup
- let filter_changed = ribbon.filter_builder.show_popup(ctx);
- if filter_changed {
- ribbon
- .checkboxes
- .insert("borrowing_filter_changed".to_string(), true);
- }
- self.borrowing
- .show(ctx, ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
- // Handle borrower navigation request to inventory
- if let Some(borrower_id) =
- self.borrowing.switch_to_inventory_with_borrower.take()
- {
- log::info!(
- "Switching to inventory with borrower filter: {}",
- borrower_id
- );
- // Save current Borrowing filter state
- let borrowing_filter_state = ribbon.filter_builder.filter_group.clone();
- self.view_filter_states
- .insert(AppView::Borrowing, borrowing_filter_state);
- // Set borrower filter using the current_borrower_id from assets table
- ribbon.filter_builder.set_single_filter(
- "assets.current_borrower_id".to_string(),
- crate::core::components::filter_builder::FilterOperator::Is,
- borrower_id.to_string(),
- );
- // Switch to inventory view
- self.current_view = AppView::Inventory;
- ribbon.active_tab = "Inventory".to_string();
- ribbon
- .checkboxes
- .insert("inventory_filter_changed".to_string(), true);
- // Update previous_view to match so next frame doesn't restore old inventory filters
- self.previous_view = Some(AppView::Inventory);
- // Request repaint to ensure the filter is applied on the next frame
- ctx.request_repaint();
- }
- } else {
- // Fallback if no ribbon (shouldn't happen)
- log::warn!("No ribbon available for borrowing view");
- }
- }
- AppView::Audits => {
- let user_id = self.current_user.as_ref().map(|u| u.id);
- self.audits.show(ctx, ui, self.api_client.as_ref(), user_id, self.current_permissions.as_ref());
- }
- AppView::Templates => {
- if let Some(ribbon) = self.ribbon_ui.as_mut() {
- // Handle FilterBuilder popup BEFORE showing templates view
- let filter_changed = ribbon.filter_builder.show_popup(ctx);
- if filter_changed {
- ribbon
- .checkboxes
- .insert("templates_filter_changed".to_string(), true);
- }
- let flags = self
- .templates
- .show(ui, self.api_client.as_ref(), Some(ribbon), self.current_permissions.as_ref());
- for flag in flags {
- ribbon.checkboxes.insert(flag, false);
- }
- } else {
- self.templates.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
- }
- }
- AppView::Suppliers => {
- if let Some(ribbon_ui) = self.ribbon_ui.as_mut() {
- let flags = self.suppliers.show(
- ui,
- self.api_client.as_ref(),
- Some(&mut *ribbon_ui),
- self.current_permissions.as_ref(),
- );
- for flag in flags {
- ribbon_ui.checkboxes.insert(flag, false);
- }
- } else {
- let _ = self.suppliers.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
- }
- }
- AppView::IssueTracker => {
- self.issues.show(ui, self.api_client.as_ref(), self.current_permissions.as_ref());
- }
- AppView::Printers => {
- // Render printers dropdown in ribbon if we're on printers tab
- if let Some(ribbon) = self.ribbon_ui.as_mut() {
- if ribbon.active_tab == "Printers" {
- self.printers
- .inject_dropdown_into_ribbon(ribbon, &self.session_manager);
- }
- }
- self.printers.render(
- ui,
- self.api_client.as_ref(),
- self.ribbon_ui.as_mut(),
- &self.session_manager,
- self.current_permissions.as_ref(),
- );
- }
- AppView::LabelTemplates => {
- self.label_templates.render(
- ui,
- self.api_client.as_ref(),
- self.ribbon_ui.as_mut(),
- self.current_permissions.as_ref(),
- );
- }
- AppView::Login => {
- // Do nothing, we are transitioning to logout
- }
- });
- } else {
- self.show_reconnect_overlay(ctx);
- }
- }
- // Re-authentication modal when needed (only outside of Login view)
- if self.reauth_needed && self.current_view != AppView::Login {
- let mut keep_open = true;
- egui::Window::new("Session expired")
- .collapsible(false)
- .resizable(false)
- .movable(true)
- .open(&mut keep_open)
- .show(ctx, |ui| {
- ui.label("Your session has expired or is invalid. Reenter your password to continue.");
- ui.add_space(8.0);
- let mut pw = std::mem::take(&mut self.reauth_password);
- let response = ui.add(
- egui::TextEdit::singleline(&mut pw)
- .password(true)
- .hint_text("Password")
- .desired_width(260.0),
- );
- self.reauth_password = pw;
- ui.add_space(8.0);
- ui.horizontal(|ui| {
- let mut try_login = ui.button("Reauthenticate").clicked();
- // Allow Enter to submit
- try_login |= response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
- if try_login {
- if let (Some(client), Some(user)) = (self.api_client.as_mut(), self.current_user.as_ref()) {
- // Attempt password login to refresh token
- match client.login_password(&user.username, &self.reauth_password) {
- Ok(resp) => {
- let server_url = client.base_url().to_string();
- self.handle_reauth_success(server_url, resp);
- self.reauth_needed = false;
- self.reauth_password.clear();
- // Avoid immediate re-flagging by pushing out the next health check
- self.last_health_check = std::time::Instant::now();
- }
- Err(e) => {
- log::error!("Reauth failed: {}", e);
- }
- }
- }
- }
- if ui.button("Go to Login").clicked() {
- self.handle_logout();
- self.reauth_needed = false;
- self.reauth_password.clear();
- }
- });
- });
- if !keep_open {
- // Close button pressed: just dismiss (will reappear on next check if still invalid)
- self.reauth_needed = false;
- self.reauth_password.clear();
- self.last_health_check = std::time::Instant::now();
- }
- }
- // About dialog
- if self.show_about {
- egui::Window::new("About BeepZone")
- .collapsible(false)
- .resizable(false)
- .show(ctx, |ui| {
- ui.heading("BeepZone Desktop Client");
- ui.heading("- eGUI EMO Edition");
- ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION")));
- ui.separator();
- ui.label("A crude inventory system meant to run on any potato!");
- ui.label("- Fueled by peanut butter and caffeine");
- ui.label("- Backed by Spaghetti codebase supreme pro plus ultra");
- ui.label("- Running at all thanks to vibe coding and sheer willpower");
- ui.label("- Oles Approved");
- ui.label("- Atleast tries to be a good fucking inventory system!");
- ui.separator();
- ui.label("Made with love (and some hatred) by crt ");
- ui.separator();
- if ui.button("Close this goofy ah panel").clicked() {
- self.show_about = false;
- }
- });
- }
- }
- }
- impl BeepZoneApp {
- fn show_osk_overlay(&mut self, ctx: &egui::Context) {
- if !self.show_osk { return; }
- let height = 340.0;
- egui::TopBottomPanel::bottom("osk_panel")
- .resizable(false)
- .min_height(height)
- .show(ctx, |ui| {
- ui.vertical_centered(|ui| {
- ui.add_space(10.0);
-
- // Styling
- let btn_size = egui::vec2(50.0, 50.0);
- let spacing = 6.0;
- ui.style_mut().spacing.item_spacing = egui::vec2(spacing, spacing);
- // Layouts
- let rows_lower = [
- vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "="],
- vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]"],
- vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "\\"],
- vec!["z", "x", "c", "v", "b", "n", "m", ",", ".", "/"],
- ];
- let rows_upper = [
- vec!["!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+"],
- vec!["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{", "}"],
- vec!["A", "S", "D", "F", "G", "H", "J", "K", "L", ":", "\"", "|"],
- vec!["Z", "X", "C", "V", "B", "N", "M", "<", ">", "?"],
- ];
- let rows = if self.osk_shift_mode { rows_upper } else { rows_lower };
- for row in rows {
- ui.horizontal(|ui| {
- // Center row
- let width = row.len() as f32 * (btn_size.x + spacing) - spacing;
- let margin = (ui.available_width() - width) / 2.0;
- if margin > 0.0 { ui.add_space(margin); }
- for key in row {
- if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new(key).size(24.0))).clicked() {
- // Queue text event for next frame
- self.osk_event_queue.push(egui::Event::Text(key.to_string()));
-
- // Restore focus immediately
- if let Some(id) = self.last_focused_id {
- ctx.memory_mut(|m| m.request_focus(id));
- }
- }
- }
- });
- }
-
- // Modifiers and Actions
- ui.horizontal(|ui| {
- let shift_width = 100.0;
- let space_width = 300.0;
- let back_width = 100.0;
- let total_width = shift_width + space_width + back_width + (spacing * 2.0);
- let margin = (ui.available_width() - total_width) / 2.0;
- if margin > 0.0 { ui.add_space(margin); }
-
- // Shift
- let shift_text = if self.osk_shift_mode { "SHIFT (ON)" } else { "SHIFT" };
- let shift_btn = egui::Button::new(egui::RichText::new(shift_text).size(20.0))
- .fill(if self.osk_shift_mode { egui::Color32::from_rgb(100, 100, 255) } else { ui.visuals().widgets.inactive.bg_fill });
-
- if ui.add_sized(egui::vec2(shift_width, 50.0), shift_btn).clicked() {
- self.osk_shift_mode = !self.osk_shift_mode;
- if let Some(id) = self.last_focused_id {
- ctx.memory_mut(|m| m.request_focus(id));
- }
- }
- // Space
- if ui.add_sized(egui::vec2(space_width, 50.0), egui::Button::new(egui::RichText::new("SPACE").size(20.0))).clicked() {
- self.osk_event_queue.push(egui::Event::Text(" ".to_string()));
- if let Some(id) = self.last_focused_id {
- ctx.memory_mut(|m| m.request_focus(id));
- }
- }
- // Backspace
- if ui.add_sized(egui::vec2(back_width, 50.0), egui::Button::new(egui::RichText::new("BACK").size(20.0))).clicked() {
- self.osk_event_queue.push(egui::Event::Key {
- key: egui::Key::Backspace,
- pressed: true,
- modifiers: egui::Modifiers::NONE,
- repeat: false,
- physical_key: None,
- });
- if let Some(id) = self.last_focused_id {
- ctx.memory_mut(|m| m.request_focus(id));
- }
- }
- });
- ui.add_space(10.0);
- });
- });
- }
- }
|