Fix UI blocking in Images tab (#461)

The Images tab would freeze when selecting shortcuts or correcting
grid IDs because block_on() was called on the main UI thread.

Changes:
- Convert handle_shortcut_selected() to spawn async SteamGridDB search
  in the background instead of blocking the UI thread
- Convert handle_correct_grid_request() to spawn async name search
  in the background instead of blocking
- Add poll_grid_id_search() and poll_name_search() to receive async
  results via watch channels
- Add grid_id_search and name_search fields to ImageSelectState
- Fix Windows file:// URL paths by normalizing backslashes to forward
  slashes (file:/// prefix for absolute paths)
- Remove blocking thread::sleep(100ms) after image selection, replace
  with request_repaint()

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
d-b-c-e
2026-02-02 19:10:31 +01:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 80695c2b95
commit e890ef940c
5 changed files with 107 additions and 34 deletions
+11
View File
@@ -7,6 +7,9 @@ use super::{ gamemode::GameMode, possible_image::PossibleImage, gametype::GameT
use tokio::sync::watch::{self, Receiver}; use tokio::sync::watch::{self, Receiver};
/// Result type for grid ID search operations
pub type GridIdSearchResult = Result<Option<usize>, String>;
pub struct ImageSelectState { pub struct ImageSelectState {
pub selected_shortcut: Option<GameType>, pub selected_shortcut: Option<GameType>,
pub grid_id: Option<usize>, pub grid_id: Option<usize>,
@@ -21,6 +24,12 @@ pub struct ImageSelectState {
pub steam_games: Option<Vec<crate::steam::SteamGameInfo>>, pub steam_games: Option<Vec<crate::steam::SteamGameInfo>>,
pub possible_names: Option<Vec<steamgriddb_api::search::SearchResult>>, pub possible_names: Option<Vec<steamgriddb_api::search::SearchResult>>,
/// Receiver for async grid ID search results
pub grid_id_search: Receiver<FetchStatus<GridIdSearchResult>>,
/// Receiver for async name search results (for "correct grid ID" feature)
pub name_search: Receiver<FetchStatus<Vec<steamgriddb_api::search::SearchResult>>>,
} }
@@ -39,6 +48,8 @@ impl Default for ImageSelectState {
possible_names: None, possible_names: None,
image_options: watch::channel(FetchStatus::NeedsFetched).1, image_options: watch::channel(FetchStatus::NeedsFetched).1,
steam_games: None, steam_games: None,
grid_id_search: watch::channel(FetchStatus::NeedsFetched).1,
name_search: watch::channel(FetchStatus::NeedsFetched).1,
} }
} }
} }
+20 -10
View File
@@ -1,8 +1,10 @@
use tokio::sync::watch;
use crate::{ use crate::{
steamgriddb::CachedSearch, steamgriddb::CachedSearch,
ui::{ ui::{
images::{image_select_state::ImageSelectState, useraction::UserAction}, images::{image_select_state::ImageSelectState, useraction::UserAction},
MyEguiApp, FetchStatus, MyEguiApp,
}, },
}; };
@@ -56,15 +58,23 @@ pub fn handle_correct_grid_request(app:&mut MyEguiApp) {
.image_selected_state .image_selected_state
.selected_shortcut .selected_shortcut
.as_ref() .as_ref()
.map(|s| s.name()) .map(|s| s.name().to_string())
.unwrap_or_default();
let auth_key = app
.settings
.steamgrid_db
.auth_key
.clone()
.unwrap_or_default(); .unwrap_or_default();
if let Some(auth_key) = app.settings.steamgrid_db.auth_key.clone() {
// Create channel to communicate results
let (tx, rx) = watch::channel(FetchStatus::Fetching);
app.image_selected_state.name_search = rx;
// Clear any existing possible_names so we show loading state
app.image_selected_state.possible_names = None;
// Spawn the search in the background instead of blocking the UI
app.rt.spawn(async move {
let client = steamgriddb_api::Client::new(auth_key); let client = steamgriddb_api::Client::new(auth_key);
let search_results = app.rt.block_on(client.search(app_name)); let search_results = client.search(&app_name).await;
app.image_selected_state.possible_names = search_results.ok(); let results = search_results.unwrap_or_default();
let _ = tx.send(FetchStatus::Fetched(results));
});
}
} }
+3 -1
View File
@@ -83,7 +83,9 @@ fn render_thumbnail(
) -> bool { ) -> bool {
let (_path, key) = shortcut.key(image_type, Path::new(&user_path)); let (_path, key) = shortcut.key(image_type, Path::new(&user_path));
let text = format!("Pick {} image", image_type.name()); let text = format!("Pick {} image", image_type.name());
let image = egui::Image::new(format!("file://{}", key)).max_width(MAX_WIDTH).shrink_to_fit(); // Convert Windows backslashes to forward slashes for file:// URL
let key_normalized = key.replace('\\', "/");
let image = egui::Image::new(format!("file:///{}", key_normalized)).max_width(MAX_WIDTH).shrink_to_fit();
let calced = image.calc_size(egui::Vec2 { x: MAX_WIDTH, y: f32::INFINITY }, image.size()); let calced = image.calc_size(egui::Vec2 { x: MAX_WIDTH, y: f32::INFINITY }, image.size());
let button = ImageButton::new(image); let button = ImageButton::new(image);
ui.add_sized(calced,button).on_hover_text(text).clicked() ui.add_sized(calced,button).on_hover_text(text).clicked()
+29 -11
View File
@@ -2,6 +2,7 @@ use std::path::Path;
use egui::ImageButton; use egui::ImageButton;
use steam_shortcuts_util::shortcut::ShortcutOwned; use steam_shortcuts_util::shortcut::ShortcutOwned;
use tokio::sync::watch;
use crate::{ use crate::{
steam::SteamUsersInfo, steam::SteamUsersInfo,
@@ -11,7 +12,7 @@ use crate::{
gametype::GameType, hasimagekey::HasImageKey, gametype::GameType, hasimagekey::HasImageKey,
useraction::UserAction, useraction::UserAction,
}, },
MyEguiApp, FetchStatus, MyEguiApp,
}, },
}; };
@@ -66,7 +67,9 @@ fn render_image(
&ImageType::Grid, &ImageType::Grid,
Path::new(&user_info.steam_user_data_folder), Path::new(&user_info.steam_user_data_folder),
); );
let image = egui::Image::new(format!("file://{}", key)).max_width(column_width).shrink_to_fit(); // Convert Windows backslashes to forward slashes for file:// URL
let key_normalized = key.replace('\\', "/");
let image = egui::Image::new(format!("file:///{}", key_normalized)).max_width(column_width).shrink_to_fit();
let calced = image.calc_size(egui::Vec2 { x: column_width, y: f32::INFINITY }, image.size()); let calced = image.calc_size(egui::Vec2 { x: column_width, y: f32::INFINITY }, image.size());
let button = ImageButton::new(image); let button = ImageButton::new(image);
@@ -78,16 +81,31 @@ fn render_image(
None None
} }
pub fn handle_shortcut_selected(app: &mut MyEguiApp, shortcut: GameType) { pub fn handle_shortcut_selected(app: &mut MyEguiApp, shortcut: GameType) {
let state = &mut app.image_selected_state; // Set the selected shortcut immediately so UI can show it
//We must have a user to get to this action; app.image_selected_state.selected_shortcut = Some(shortcut.clone());
if let Some(auth_key) = &app.settings.steamgrid_db.auth_key { app.image_selected_state.grid_id = None;
// We must have a user to get to this action
if let Some(auth_key) = app.settings.steamgrid_db.auth_key.clone() {
// Create channel to communicate results
let (tx, rx) = watch::channel(FetchStatus::Fetching);
app.image_selected_state.grid_id_search = rx;
let app_id = shortcut.app_id();
let app_name = shortcut.name().to_string();
// Spawn the search in the background instead of blocking the UI
app.rt.spawn(async move {
let client = steamgriddb_api::Client::new(auth_key); let client = steamgriddb_api::Client::new(auth_key);
let search = CachedSearch::new(&client); let search = CachedSearch::new(&client);
state.grid_id = app let result = search.search(app_id, &app_name).await;
.rt
.block_on(search.search(shortcut.app_id(), shortcut.name())) let search_result = match result {
.ok() Ok(grid_id) => Ok(grid_id),
.flatten(); Err(e) => Err(e.to_string()),
};
let _ = tx.send(FetchStatus::Fetched(search_result));
});
} }
state.selected_shortcut = Some(shortcut);
} }
+34 -2
View File
@@ -11,7 +11,7 @@ use super::{
useraction::UserAction, useraction::UserAction,
}; };
use std::{ path::Path, thread, time::Duration}; use std::path::Path;
use crate::{ use crate::{
config::get_thumbnails_folder, config::get_thumbnails_folder,
@@ -131,9 +131,39 @@ impl MyEguiApp {
} }
} }
/// Poll for async grid_id search results from background task
fn poll_grid_id_search(&mut self) {
if let FetchStatus::Fetched(result) = &*self.image_selected_state.grid_id_search.borrow() {
match result {
Ok(Some(grid_id)) => {
if self.image_selected_state.grid_id.is_none() {
self.image_selected_state.grid_id = Some(*grid_id);
}
}
Ok(None) => {}
Err(_) => {}
}
}
}
/// Poll for async name search results from background task
fn poll_name_search(&mut self) {
if let FetchStatus::Fetched(results) = &*self.image_selected_state.name_search.borrow() {
if self.image_selected_state.possible_names.is_none() {
self.image_selected_state.possible_names = Some(results.clone());
}
}
}
pub fn render_ui_images(&mut self, ui: &mut egui::Ui) { pub fn render_ui_images(&mut self, ui: &mut egui::Ui) {
self.ensure_steam_users_loaded(); self.ensure_steam_users_loaded();
// Poll for async grid_id search results
self.poll_grid_id_search();
// Poll for async name search results
self.poll_name_search();
if let Some(error_message) = &self.image_selected_state.settings_error { if let Some(error_message) = &self.image_selected_state.settings_error {
ui.label(error_message); ui.label(error_message);
return; return;
@@ -147,6 +177,7 @@ impl MyEguiApp {
ui.reset_style(); ui.reset_style();
action = self.render_ui_image_action(ui); action = self.render_ui_image_action(ui);
}); });
match action { match action {
UserAction::UserSelected(user) => { UserAction::UserSelected(user) => {
self.handle_user_selected(user); self.handle_user_selected(user);
@@ -159,7 +190,8 @@ impl MyEguiApp {
} }
UserAction::ImageSelected(image) => { UserAction::ImageSelected(image) => {
handle_image_selected(self, image); handle_image_selected(self, image);
thread::sleep(Duration::from_millis(100)); // Request repaint to refresh images after download starts
ui.ctx().request_repaint();
ui.ctx().forget_all_images(); ui.ctx().forget_all_images();
} }
UserAction::BackButton => { UserAction::BackButton => {