From 2a9f4577ad35115f612062b5ad237f62cd8f8650 Mon Sep 17 00:00:00 2001 From: isbool Date: Tue, 10 Feb 2026 18:20:12 +0200 Subject: [PATCH] Fix provider refresh state and improve popup/settings window placement - Fix stale loading/selection behavior when provider enablement changes - Show explicit empty states when no providers are selected or no data is available - Ensure settings changes are synced before refresh runs - Rework main popup sizing/placement to be taskbar-safe and less tall by default - Place settings window adjacent to the main popup based on available screen space --- rust/src/native_ui/app.rs | 284 ++++++++++++++++++++++++------ rust/src/native_ui/preferences.rs | 150 +++++++++++++++- 2 files changed, 378 insertions(+), 56 deletions(-) diff --git a/rust/src/native_ui/app.rs b/rust/src/native_ui/app.rs index 8388303c64..c43234e254 100644 --- a/rust/src/native_ui/app.rs +++ b/rust/src/native_ui/app.rs @@ -54,6 +54,33 @@ pub struct ProviderData { } impl ProviderData { + fn placeholder(id: ProviderId) -> Self { + Self { + name: id.cli_name().to_string(), + display_name: id.display_name().to_string(), + account: None, + session_percent: None, + session_reset: None, + weekly_percent: None, + weekly_reset: None, + model_percent: None, + model_name: None, + plan: None, + error: None, + dashboard_url: None, + pace_percent: None, + pace_lasts_to_reset: false, + cost_used: None, + credits_remaining: None, + credits_percent: None, + status_level: StatusLevel::Unknown, + status_description: None, + cost_history: Vec::new(), + credits_history: Vec::new(), + usage_breakdown: Vec::new(), + } + } + fn from_result(id: ProviderId, result: &ProviderFetchResult, metadata: &crate::core::ProviderMetadata, reset_time_relative: bool) -> Self { let snapshot = &result.usage; let (pace_percent, pace_lasts) = calculate_pace(&snapshot.primary); @@ -326,6 +353,8 @@ pub struct CodexBarApp { shortcut_manager: Option, icon_cache: ProviderIconCache, was_refreshing: bool, // Track previous frame's refresh state + pending_main_window_layout: bool, + anchor_main_window_to_pointer: bool, } impl CodexBarApp { @@ -349,30 +378,7 @@ impl CodexBarApp { let placeholders: Vec = enabled_ids .iter() - .map(|&id| ProviderData { - name: id.cli_name().to_string(), - display_name: id.display_name().to_string(), - account: None, - session_percent: None, - session_reset: None, - weekly_percent: None, - weekly_reset: None, - model_percent: None, - model_name: None, - plan: None, - error: None, - dashboard_url: None, - pace_percent: None, - pace_lasts_to_reset: false, - cost_used: None, - credits_remaining: None, - credits_percent: None, - status_level: StatusLevel::Unknown, - status_description: None, - cost_history: Vec::new(), - credits_history: Vec::new(), - usage_breakdown: Vec::new(), - }) + .map(|&id| ProviderData::placeholder(id)) .collect(); let state = Arc::new(Mutex::new(SharedState { @@ -500,9 +506,77 @@ impl CodexBarApp { shortcut_manager, icon_cache: ProviderIconCache::new(), was_refreshing: false, + pending_main_window_layout: true, + anchor_main_window_to_pointer: false, } } + fn layout_main_window(&mut self, ctx: &egui::Context, anchor_to_pointer: bool) { + let Some(outer_rect) = ctx.input(|i| i.viewport().outer_rect) else { + return; + }; + let Some(work_area) = work_area_rect(ctx) else { + return; + }; + + let margin = 12.0; + let gap = 10.0; + let min_size = egui::vec2(320.0, 320.0); + let max_w = (work_area.width() - margin * 2.0).max(min_size.x); + let max_h = (work_area.height() - margin * 2.0).max(min_size.y); + let target_size = egui::vec2(360.0_f32.min(max_w), 500.0_f32.min(max_h)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(target_size)); + + let anchor = if anchor_to_pointer { + ctx.input(|i| i.pointer.latest_pos()) + } else { + None + } + .unwrap_or_else(|| outer_rect.center()); + + // For tray/shortcut opens, keep the popup on the left side and vertically centered + // so it doesn't appear pinned to the taskbar area. + let (target_x, target_y) = if anchor_to_pointer { + let center_x = work_area.min.x + work_area.width() * 0.22; + ( + center_x - target_size.x * 0.5, + work_area.min.y + (work_area.height() - target_size.y) * 0.5, + ) + } else { + let space_above = anchor.y - work_area.min.y - margin; + let space_below = work_area.max.y - anchor.y - margin; + let x = anchor.x - target_size.x * 0.5; + let y = if space_below >= target_size.y + gap || space_below >= space_above { + anchor.y + gap + } else { + anchor.y - target_size.y - gap + }; + (x, y) + }; + + let min_x = work_area.min.x + margin; + let min_y = work_area.min.y + margin; + let max_x = (work_area.max.x - target_size.x - margin).max(min_x); + let max_y = (work_area.max.y - target_size.y - margin).max(min_y); + let x = if max_x <= min_x { + min_x + } else { + target_x.clamp(min_x, max_x) + }; + let y = if max_y <= min_y { + min_y + } else { + target_y.clamp(min_y, max_y) + }; + + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(x, y))); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + + self.pending_main_window_layout = false; + self.anchor_main_window_to_pointer = false; + } + fn refresh_providers(&self) { let state = Arc::clone(&self.state); let enabled_ids = self.settings.get_enabled_provider_ids(); @@ -517,6 +591,13 @@ impl CodexBarApp { s.is_refreshing = true; s.loading_pattern = LoadingPattern::random(); s.loading_phase = 0.0; + s.providers = enabled_ids + .iter() + .map(|&id| ProviderData::placeholder(id)) + .collect(); + if s.selected_provider_idx >= s.providers.len() { + s.selected_provider_idx = 0; + } } let rt = match tokio::runtime::Runtime::new() { @@ -673,6 +754,41 @@ impl CodexBarApp { } } +fn work_area_rect(ctx: &egui::Context) -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::Foundation::RECT as WinRect; + use windows::Win32::UI::WindowsAndMessaging::{ + SystemParametersInfoW, SPI_GETWORKAREA, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, + }; + + let mut rect = WinRect::default(); + let ok = unsafe { + SystemParametersInfoW( + SPI_GETWORKAREA, + 0, + Some((&mut rect as *mut WinRect).cast()), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + .is_ok() + }; + + if ok { + let pixels_per_point = ctx.pixels_per_point().max(0.1); + return Some(Rect::from_min_max( + egui::pos2(rect.left as f32 / pixels_per_point, rect.top as f32 / pixels_per_point), + egui::pos2(rect.right as f32 / pixels_per_point, rect.bottom as f32 / pixels_per_point), + )); + } + } + + ctx.input(|i| { + i.viewport() + .monitor_size + .map(|size| Rect::from_min_size(egui::pos2(0.0, 0.0), size)) + }) +} + fn create_provider(id: ProviderId) -> Box { match id { ProviderId::Claude => Box::new(ClaudeProvider::new()), @@ -698,14 +814,24 @@ fn create_provider(id: ProviderId) -> Box { impl eframe::App for CodexBarApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Check keyboard shortcuts - if let Some(ref shortcut_mgr) = self.shortcut_manager { + if self.pending_main_window_layout { + self.layout_main_window(ctx, self.anchor_main_window_to_pointer); + } + + // Check keyboard shortcuts without holding an immutable borrow of self + // while triggering layout changes. + let mut shortcut_triggered = false; + if let Some(shortcut_mgr) = self.shortcut_manager.as_ref() { while shortcut_mgr.check_events() { - tracing::info!("Keyboard shortcut triggered - focusing window"); - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + shortcut_triggered = true; } } + if shortcut_triggered { + tracing::info!("Keyboard shortcut triggered - focusing window"); + self.pending_main_window_layout = true; + self.anchor_main_window_to_pointer = true; + self.layout_main_window(ctx, true); + } // Auto-refresh check let should_refresh = { @@ -849,7 +975,9 @@ impl eframe::App for CodexBarApp { match action { TrayMenuAction::Quit => std::process::exit(0), TrayMenuAction::Open => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + self.pending_main_window_layout = true; + self.anchor_main_window_to_pointer = true; + self.layout_main_window(ctx, true); } TrayMenuAction::Refresh => { if !is_refreshing { @@ -857,8 +985,10 @@ impl eframe::App for CodexBarApp { } } TrayMenuAction::Settings => { + self.pending_main_window_layout = true; + self.anchor_main_window_to_pointer = true; + self.layout_main_window(ctx, true); self.preferences_window.open(); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } TrayMenuAction::CheckForUpdates => { // Trigger update check in background @@ -1268,9 +1398,41 @@ impl eframe::App for CodexBarApp { self.preferences_window.open(); } } + } else if is_refreshing { + egui::Frame::none() + .fill(Theme::CARD_BG) + .rounding(Rounding::same(Radius::LG)) + .inner_margin(Spacing::XXL) + .stroke(Stroke::new(1.0, Theme::CARD_BORDER)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.spinner(); + ui.add_space(Spacing::SM); + ui.label( + RichText::new("Loading providers...") + .size(FontSize::BASE) + .color(Theme::TEXT_MUTED), + ); + }); + }); + } else { + egui::Frame::none() + .fill(Theme::CARD_BG) + .rounding(Rounding::same(Radius::LG)) + .inner_margin(Spacing::XXL) + .stroke(Stroke::new(1.0, Theme::CARD_BORDER)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("No provider data available.") + .size(FontSize::BASE) + .color(Theme::TEXT_MUTED), + ); + }); + }); } } else { - // Loading state + let has_enabled_providers = !self.settings.get_enabled_provider_ids().is_empty(); egui::Frame::none() .fill(Theme::CARD_BG) .rounding(Rounding::same(Radius::LG)) @@ -1278,13 +1440,26 @@ impl eframe::App for CodexBarApp { .stroke(Stroke::new(1.0, Theme::CARD_BORDER)) .show(ui, |ui| { ui.vertical_centered(|ui| { - ui.spinner(); - ui.add_space(Spacing::SM); - ui.label( - RichText::new("Loading providers...") - .size(FontSize::BASE) - .color(Theme::TEXT_MUTED), - ); + if has_enabled_providers { + ui.spinner(); + ui.add_space(Spacing::SM); + ui.label( + RichText::new("Loading providers...") + .size(FontSize::BASE) + .color(Theme::TEXT_MUTED), + ); + } else { + ui.label( + RichText::new("No providers selected.") + .size(FontSize::BASE) + .color(Theme::TEXT_MUTED), + ); + ui.add_space(Spacing::SM); + if ui.button("Open Provider Settings").clicked() { + self.preferences_window.active_tab = super::preferences::PreferencesTab::Providers; + self.preferences_window.open(); + } + } }); }); } @@ -1313,8 +1488,23 @@ impl eframe::App for CodexBarApp { // Show preferences window self.preferences_window.show(ctx); + let mut refresh_requested = self.preferences_window.take_refresh_requested(); + let previous_enabled_provider_ids = self.settings.get_enabled_provider_ids(); + + // Sync settings first, then refresh so refresh always uses current settings. + if self.preferences_window.settings_changed { + self.settings = self.preferences_window.settings.clone(); + if let Err(e) = self.settings.save() { + tracing::error!("Failed to save settings: {}", e); + } + if previous_enabled_provider_ids != self.settings.get_enabled_provider_ids() { + refresh_requested = true; + } + self.preferences_window.settings_changed = false; + } + // Check if preferences window requested a provider refresh - if self.preferences_window.take_refresh_requested() { + if refresh_requested { self.refresh_providers(); } @@ -1327,15 +1517,6 @@ impl eframe::App for CodexBarApp { } self.was_refreshing = is_refreshing; } - - // Sync settings - save immediately when changed - if self.preferences_window.settings_changed { - self.settings = self.preferences_window.settings.clone(); - if let Err(e) = self.settings.save() { - tracing::error!("Failed to save settings: {}", e); - } - self.preferences_window.settings_changed = false; - } } } @@ -1937,8 +2118,9 @@ pub fn run() -> anyhow::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([360.0, 820.0]) - .with_min_inner_size([320.0, 400.0]) + .with_inner_size([360.0, 500.0]) + .with_min_inner_size([320.0, 320.0]) + .with_clamp_size_to_monitor_size(true) .with_resizable(true) .with_decorations(true) .with_transparent(false) diff --git a/rust/src/native_ui/preferences.rs b/rust/src/native_ui/preferences.rs index c24c3cb21c..38b0a6f5e3 100644 --- a/rust/src/native_ui/preferences.rs +++ b/rust/src/native_ui/preferences.rs @@ -87,6 +87,8 @@ pub struct PreferencesWindow { icon_cache: ProviderIconCache, // Shared state for viewport shared_state: Arc>, + // Place settings viewport beside the main menu window on next show() + needs_viewport_placement: bool, } /// Shared state that can be accessed from viewport @@ -175,6 +177,7 @@ impl Default for PreferencesWindow { browser_import_status: None, icon_cache: ProviderIconCache::new(), shared_state, + needs_viewport_placement: false, } } } @@ -186,6 +189,7 @@ impl PreferencesWindow { pub fn open(&mut self) { self.is_open = true; + self.needs_viewport_placement = true; self.settings = Settings::load(); self.cookies = ManualCookies::load(); self.api_keys = ApiKeys::load(); @@ -225,6 +229,7 @@ impl PreferencesWindow { if let Ok(mut state) = self.shared_state.lock() { state.is_open = false; } + self.needs_viewport_placement = false; } /// Check if a refresh was requested and reset the flag @@ -253,14 +258,45 @@ impl PreferencesWindow { let shared_state = Arc::clone(&self.shared_state); let settings_viewport_id = egui::ViewportId::from_hash_of("settings_viewport"); + let work_area = work_area_rect(ctx); + let main_outer_rect = ctx.input(|i| i.viewport().outer_rect); + + let preferred_size = egui::vec2(720.0, 740.0); + let default_min_size = egui::vec2(520.0, 420.0); + let margin = 12.0; + let settings_size = if let Some(area) = work_area { + let max_w = (area.width() - margin * 2.0).max(360.0); + let max_h = (area.height() - margin * 2.0).max(360.0); + egui::vec2(preferred_size.x.min(max_w), preferred_size.y.min(max_h)) + } else { + preferred_size + }; + let settings_min_size = egui::vec2( + default_min_size.x.min(settings_size.x), + default_min_size.y.min(settings_size.y), + ); + let settings_position = if self.needs_viewport_placement { + match (main_outer_rect, work_area) { + (Some(main_rect), Some(area)) => Some(settings_position_near_main_window(main_rect, settings_size, area)), + _ => None, + } + } else { + None + }; + + let mut builder = egui::ViewportBuilder::default() + .with_title("CodexBar Settings") + .with_inner_size([settings_size.x, settings_size.y]) + .with_min_inner_size([settings_min_size.x, settings_min_size.y]) + .with_clamp_size_to_monitor_size(true) + .with_resizable(true); + if let Some(position) = settings_position { + builder = builder.with_position(position); + } ctx.show_viewport_immediate( settings_viewport_id, - egui::ViewportBuilder::default() - .with_title("CodexBar Settings") - .with_inner_size([720.0, 740.0]) - .with_min_inner_size([650.0, 580.0]) - .with_resizable(true), + builder, |ctx, _class| { // Check if window was closed if ctx.input(|i| i.viewport().close_requested()) { @@ -289,6 +325,11 @@ impl PreferencesWindow { }, ); + if settings_position.is_some() { + ctx.send_viewport_cmd_to(settings_viewport_id, egui::ViewportCommand::Focus); + self.needs_viewport_placement = false; + } + // Sync state back from shared state if let Ok(state) = self.shared_state.lock() { self.is_open = state.is_open; @@ -1622,6 +1663,105 @@ impl PreferencesWindow { ); }); } + +} + +fn settings_position_near_main_window(main_rect: Rect, settings_size: Vec2, monitor_size: Rect) -> egui::Pos2 { + let margin = 12.0; + let gap = 12.0; + + let right_space = monitor_size.max.x - main_rect.max.x - gap - margin; + let left_space = main_rect.min.x - monitor_size.min.x - gap - margin; + let bottom_space = monitor_size.max.y - main_rect.max.y - gap - margin; + let top_space = main_rect.min.y - monitor_size.min.y - gap - margin; + + let mut best_side = "right"; + let mut best_space = right_space; + for (side, space) in [ + ("left", left_space), + ("bottom", bottom_space), + ("top", top_space), + ] { + if space > best_space { + best_side = side; + best_space = space; + } + } + + let min_x = monitor_size.min.x + margin; + let min_y = monitor_size.min.y + margin; + let max_x = (monitor_size.max.x - settings_size.x - margin).max(min_x); + let max_y = (monitor_size.max.y - settings_size.y - margin).max(min_y); + let clamp_x = |value: f32| { + if max_x <= min_x { + min_x + } else { + value.clamp(min_x, max_x) + } + }; + let clamp_y = |value: f32| { + if max_y <= min_y { + min_y + } else { + value.clamp(min_y, max_y) + } + }; + + let (x, y) = match best_side { + "right" => ( + clamp_x(main_rect.max.x + gap), + clamp_y(main_rect.min.y), + ), + "left" => ( + clamp_x(main_rect.min.x - settings_size.x - gap), + clamp_y(main_rect.min.y), + ), + "bottom" => ( + clamp_x(main_rect.min.x), + clamp_y(main_rect.max.y + gap), + ), + _ => ( + clamp_x(main_rect.min.x), + clamp_y(main_rect.min.y - settings_size.y - gap), + ), + }; + + egui::pos2(x, y) +} + +fn work_area_rect(ctx: &egui::Context) -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::Foundation::RECT as WinRect; + use windows::Win32::UI::WindowsAndMessaging::{ + SystemParametersInfoW, SPI_GETWORKAREA, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, + }; + + let mut rect = WinRect::default(); + let ok = unsafe { + SystemParametersInfoW( + SPI_GETWORKAREA, + 0, + Some((&mut rect as *mut WinRect).cast()), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + .is_ok() + }; + + if ok { + let pixels_per_point = ctx.pixels_per_point().max(0.1); + return Some(Rect::from_min_max( + egui::pos2(rect.left as f32 / pixels_per_point, rect.top as f32 / pixels_per_point), + egui::pos2(rect.right as f32 / pixels_per_point, rect.bottom as f32 / pixels_per_point), + )); + } + } + + ctx.input(|i| { + i.viewport() + .monitor_size + .map(|size| Rect::from_min_size(egui::pos2(0.0, 0.0), size)) + }) } // ════════════════════════════════════════════════════════════════════════════════