diff --git a/Cargo.lock b/Cargo.lock index 51d64ae..a183f2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -789,6 +795,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.24.0" @@ -1471,14 +1483,17 @@ name = "dash-frontend" version = "0.1.0" dependencies = [ "anyhow", + "base64", "chrono", "gio 0.21.5", "glam", "gtk", + "keyvalues-parser", "log", "rust-embed", "serde", "serde_json", + "steam_shortcuts_util", "wayvr-ipc", "wgui", "wlx-common", @@ -3026,6 +3041,14 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "keyvalues-parser" +version = "0.2.2" +source = "git+https://github.com/CosmicHorrorDev/vdf-rs.git?rev=fc6dcbea9eb13cacb98dea40063f6f56cde6e145#fc6dcbea9eb13cacb98dea40063f6f56cde6e145" +dependencies = [ + "pest", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -3446,6 +3469,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5269,6 +5303,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "steam_shortcuts_util" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6" +dependencies = [ + "ascii", + "crc32fast", + "nom 7.1.3", + "nom_locate", +] + [[package]] name = "strict-num" version = "0.1.1" diff --git a/dash-frontend/Cargo.toml b/dash-frontend/Cargo.toml index 9bed73f..c1bf475 100644 --- a/dash-frontend/Cargo.toml +++ b/dash-frontend/Cargo.toml @@ -16,3 +16,6 @@ serde.workspace = true serde_json.workspace = true wlx-common = { path = "../wlx-common" } wayvr-ipc = { path = "../wayvr-ipc", default-features = false } +base64 = "0.22.1" +keyvalues-parser = { git = "https://github.com/CosmicHorrorDev/vdf-rs.git", rev = "fc6dcbea9eb13cacb98dea40063f6f56cde6e145" } +steam_shortcuts_util = "1.1.8" diff --git a/dash-frontend/assets/gui/tab/games.xml b/dash-frontend/assets/gui/tab/games.xml index 4a1fde2..2ee00a0 100644 --- a/dash-frontend/assets/gui/tab/games.xml +++ b/dash-frontend/assets/gui/tab/games.xml @@ -3,5 +3,6 @@ +
\ No newline at end of file diff --git a/dash-frontend/assets/gui/view/game_list.xml b/dash-frontend/assets/gui/view/game_list.xml new file mode 100644 index 0000000..eee5646 --- /dev/null +++ b/dash-frontend/assets/gui/view/game_list.xml @@ -0,0 +1,7 @@ + + + + +
+ + \ No newline at end of file diff --git a/dash-frontend/assets/lang/de.json b/dash-frontend/assets/lang/de.json index dad7ee6..3ca23fa 100644 --- a/dash-frontend/assets/lang/de.json +++ b/dash-frontend/assets/lang/de.json @@ -64,5 +64,10 @@ "NO_WINDOWS_FOUND": "Keine Fenster gefunden", "WINDOW_OPTIONS": "Fensteroptionen", "APPLICATION_STARTED": "Anwendung gestartet", - "LIST_OF_WINDOWS": "Fensterliste" + "LIST_OF_WINDOWS": "Fensterliste", + "CLOSE_WINDOW": "Fenster schließen", + "GAME_LIST": { + "NO_GAMES_FOUND": "Keine Spiele gefunden" + }, + "TERMINATE_PROCESS": "Prozess beenden" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/en.json b/dash-frontend/assets/lang/en.json index f5e84f1..858bdf5 100644 --- a/dash-frontend/assets/lang/en.json +++ b/dash-frontend/assets/lang/en.json @@ -41,6 +41,9 @@ }, "CLOSE_WINDOW": "Close window", "FAILED_TO_LAUNCH_APPLICATION": "Failed to launcha application:", + "GAME_LIST": { + "NO_GAMES_FOUND": "No games found" + }, "GAMES": "Games", "GENERAL_SETTINGS": "General settings", "HEIGHT": "Height", diff --git a/dash-frontend/assets/lang/es.json b/dash-frontend/assets/lang/es.json index 194f80f..497fc6b 100644 --- a/dash-frontend/assets/lang/es.json +++ b/dash-frontend/assets/lang/es.json @@ -64,5 +64,10 @@ "NO_WINDOWS_FOUND": "No se encontraron ventanas", "WINDOW_OPTIONS": "Opciones de ventana", "APPLICATION_STARTED": "Aplicación iniciada", - "LIST_OF_WINDOWS": "Lista de ventanas" + "LIST_OF_WINDOWS": "Lista de ventanas", + "CLOSE_WINDOW": "Cerrar ventana", + "GAME_LIST": { + "NO_GAMES_FOUND": "No se encontraron juegos" + }, + "TERMINATE_PROCESS": "Finalizar proceso" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/ja.json b/dash-frontend/assets/lang/ja.json index 808dcd7..9be1c93 100644 --- a/dash-frontend/assets/lang/ja.json +++ b/dash-frontend/assets/lang/ja.json @@ -64,5 +64,10 @@ "NO_WINDOWS_FOUND": "ウィンドウが見つかりませんでした", "WINDOW_OPTIONS": "ウィンドウオプション", "APPLICATION_STARTED": "アプリケーションが起動しました", - "LIST_OF_WINDOWS": "ウィンドウ一覧" + "LIST_OF_WINDOWS": "ウィンドウ一覧", + "CLOSE_WINDOW": "ウィンドウを閉じる", + "GAME_LIST": { + "NO_GAMES_FOUND": "ゲームが見つかりませんでした" + }, + "TERMINATE_PROCESS": "プロセスを終了する" } \ No newline at end of file diff --git a/dash-frontend/assets/lang/pl.json b/dash-frontend/assets/lang/pl.json index aafb4fa..304d832 100644 --- a/dash-frontend/assets/lang/pl.json +++ b/dash-frontend/assets/lang/pl.json @@ -64,5 +64,10 @@ "NO_WINDOWS_FOUND": "Nie znaleziono okien", "WINDOW_OPTIONS": "Opcje okna", "APPLICATION_STARTED": "Aplikacja uruchomiona", - "LIST_OF_WINDOWS": "Lista okien" + "LIST_OF_WINDOWS": "Lista okien", + "CLOSE_WINDOW": "Zamknij okno", + "GAME_LIST": { + "NO_GAMES_FOUND": "Nie znaleziono gier" + }, + "TERMINATE_PROCESS": "Zakończ proces" } \ No newline at end of file diff --git a/dash-frontend/src/tab/games.rs b/dash-frontend/src/tab/games.rs index 1a304c7..4bab982 100644 --- a/dash-frontend/src/tab/games.rs +++ b/dash-frontend/src/tab/games.rs @@ -1,19 +1,30 @@ use wgui::{ assets::AssetPath, - parser::{ParseDocumentParams, ParserState}, + parser::{Fetchable, ParseDocumentParams, ParserState}, }; -use crate::tab::{Tab, TabParams, TabType}; +use crate::{ + tab::{Tab, TabParams, TabType}, + views::game_list, +}; pub struct TabGames { #[allow(dead_code)] pub state: ParserState, + + view_game_list: game_list::View, } impl Tab for TabGames { fn get_type(&self) -> TabType { TabType::Games } + + fn update(&mut self, params: super::TabUpdateParams) -> anyhow::Result<()> { + self.view_game_list.update(params.layout, params.interface)?; + + Ok(()) + } } impl TabGames { @@ -28,6 +39,15 @@ impl TabGames { params.parent_id, )?; - Ok(Self { state }) + let game_list_parent = state.get_widget_id("game_list_parent")?; + + let view_game_list = game_list::View::new(game_list::Params { + frontend_tasks: params.frontend_tasks.clone(), + globals: params.globals, + layout: params.layout, + parent_id: game_list_parent, + })?; + + Ok(Self { state, view_game_list }) } } diff --git a/dash-frontend/src/util/mod.rs b/dash-frontend/src/util/mod.rs index 49c84fc..fb9588a 100644 --- a/dash-frontend/src/util/mod.rs +++ b/dash-frontend/src/util/mod.rs @@ -1,5 +1,6 @@ pub mod desktop_finder; pub mod pactl_wrapper; pub mod popup_manager; +pub mod steam_utils; pub mod toast_manager; pub mod various; diff --git a/dash-frontend/src/util/steam_utils.rs b/dash-frontend/src/util/steam_utils.rs new file mode 100644 index 0000000..782ffae --- /dev/null +++ b/dash-frontend/src/util/steam_utils.rs @@ -0,0 +1,408 @@ +use base64::{Engine as _, engine::general_purpose}; +use keyvalues_parser::{Obj, Vdf}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use steam_shortcuts_util::parse_shortcuts; + +pub struct SteamUtils { + steam_root: PathBuf, +} + +fn get_steam_root() -> anyhow::Result { + let home = PathBuf::from(std::env::var("HOME")?); + + let steam_paths: [&str; 3] = [ + ".steam/steam", + ".steam/debian-installation", + ".var/app/com.valvesoftware.Steam/data/Steam", + ]; + let Some(steam_path) = steam_paths + .iter() + .map(|path| home.join(path)) + .filter(|p| p.exists()) + .next() + else { + anyhow::bail!("Couldn't find Steam installation in search paths"); + }; + + Ok(steam_path) +} + +pub type AppID = String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AppManifest { + pub app_id: AppID, + pub run_game_id: AppID, + pub name: String, + pub cover_b64: Option, + pub raw_state_flags: u64, // documentation: https://github.com/lutris/lutris/blob/master/docs/steam.rst + pub last_played: Option, // unix timestamp +} + +pub enum GameSortMethod { + NameAsc, + NameDesc, + PlayDateDesc, +} + +fn get_obj_first<'a>(obj: &'a Obj<'_>, key: &str) -> Option<&'a Obj<'a>> { + obj.get(key)?.first()?.get_obj() +} + +fn get_str_first<'a>(obj: &'a Obj<'_>, key: &str) -> Option<&'a str> { + obj.get(key)?.first()?.get_str() +} + +fn vdf_parse_libraryfolders<'a>(vdf_root: &'a Vdf<'a>) -> Option> { + let obj_libraryfolders = vdf_root.value.get_obj()?; + + let mut res = Vec::::new(); + + let mut num = 0; + loop { + let Some(library_folder) = get_obj_first(obj_libraryfolders, format!("{}", num).as_str()) else { + // no more libraries to find + break; + }; + + let Some(apps) = get_obj_first(library_folder, "apps") else { + // no apps? + num += 1; + continue; + }; + + let Some(path) = get_str_first(library_folder, "path") else { + // no path? + num += 1; + continue; + }; + + //log::trace!("path: {}", path); + + res.extend( + apps + .iter() + .filter_map(|item| item.0.parse::().ok()) + .map(|app_id| AppEntry { + app_id: app_id.to_string(), + root_path: String::from(path), + }), + ); + + num += 1; + } + + Some(res) +} + +fn vdf_parse_appstate<'a>(app_id: AppID, vdf_root: &'a Vdf<'a>) -> Option { + let app_state_obj = vdf_root.value.get_obj()?; + + let name = app_state_obj.get("name")?.first()?.get_str()?; + + let raw_state_flags = app_state_obj + .get("StateFlags")? + .first()? + .get_str()? + .parse::() + .ok()?; + + let last_played = match app_state_obj.get("LastPlayed") { + Some(s) => Some(s.first()?.get_str()?.parse::().ok()?), + None => None, + }; + + Some(AppManifest { + app_id: app_id.clone(), + run_game_id: app_id, + cover_b64: None, + name: String::from(name), + raw_state_flags, + last_played, + }) +} + +struct AppEntry { + pub root_path: String, + pub app_id: AppID, +} + +pub fn stop(app_id: AppID, force_kill: bool) -> anyhow::Result<()> { + log::info!("Stopping Steam game with AppID {}", app_id); + + for game in list_running_games()? { + if game.app_id != app_id { + continue; + } + + log::info!("Killing process with PID {} and its children", game.pid); + let _ = std::process::Command::new("pkill") + .arg(if force_kill { "-9" } else { "-11" }) + .arg("-P") + .arg(format!("{}", game.pid)) + .spawn()?; + } + Ok(()) +} + +pub fn launch(app_id: AppID) -> anyhow::Result<()> { + log::info!("Launching Steam game with AppID {}", app_id); + call_steam(&format!("steam://rungameid/{}", app_id))?; + Ok(()) +} + +#[derive(Serialize)] +pub struct RunningGame { + pub app_id: AppID, + pub pid: i32, +} + +#[derive(Serialize)] +struct Shortcut { + name: String, + exe: String, + run_game_id: u64, + app_id: u64, + cover_b64: Option, +} + +pub fn list_running_games() -> anyhow::Result> { + let mut res = Vec::::new(); + + let entries = std::fs::read_dir("/proc")?; + for entry in entries.into_iter().flatten() { + let path_cmdline = entry.path().join("cmdline"); + let Ok(cmdline) = std::fs::read(path_cmdline) else { + continue; + }; + + let proc_file_name = entry.file_name(); + let Some(pid) = proc_file_name.to_str() else { + continue; + }; + + let Ok(pid) = pid.parse::() else { + continue; + }; + + let args: Vec<&str> = cmdline + .split(|byte| *byte == 0x00) + .filter_map(|arg| match std::str::from_utf8(arg) { + Ok(arg) => Some(arg), + Err(_) => None, + }) + .collect(); + + let mut has_steam_launch = false; + for arg in &args { + if *arg == "SteamLaunch" { + has_steam_launch = true; + break; + } + } + + if !has_steam_launch { + continue; + } + + // Running game process found. Parse AppID + for arg in &args { + let pat = "AppId="; + let Some(pos) = arg.find(pat) else { + continue; + }; + + if pos != 0 { + continue; + } + + let Some((_, second)) = arg.split_at_checked(pat.len()) else { + continue; + }; + + let Ok(app_id_num) = second.parse::() else { + continue; + }; + + // AppID found. Add it to the list + res.push(RunningGame { + app_id: app_id_num.to_string(), + pid, + }); + + break; + } + } + + Ok(res) +} + +fn call_steam(arg: &str) -> anyhow::Result<()> { + match std::process::Command::new("xdg-open").arg(arg).spawn() { + Ok(_) => Ok(()), + Err(_) => { + std::process::Command::new("steam").arg(arg).spawn()?; + Ok(()) + } + } +} + +fn shortcut_to_fake_manifest(shortcut: &Shortcut) -> AppManifest { + AppManifest { + app_id: shortcut.app_id.to_string(), + run_game_id: shortcut.run_game_id.to_string(), + name: shortcut.name.clone(), + cover_b64: shortcut.cover_b64.clone(), + raw_state_flags: 0, // Not applicable for shortcuts, 0 by default + last_played: None, // Steam does not use this for shortcuts + } +} + +fn compute_rungameid(app_id: u32) -> u64 { + (app_id as u64) << 32 | 0x02000000 +} + +impl SteamUtils { + fn convert_cover_to_base64(app_id: &u32, original_path: &Path) -> std::io::Result> { + // List of supported extensions with their MIME types + let extensions = [ + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("webp", "image/webp"), + ("bmp", "image/bmp"), + ("gif", "image/gif"), + ]; + + for (ext, mime) in extensions.iter() { + let filepath = original_path.join("grid").join(format!("{}p.{}", app_id, ext)); + if filepath.exists() { + let mut file = fs::File::open(&filepath)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + + let base64_string = general_purpose::STANDARD.encode(&buffer); + let data_uri = format!("data:{};base64,{}", mime, base64_string); + return Ok(Some(data_uri)); + } + } + + Ok(None) + } + + fn list_shortcuts(&self) -> Result, Box> { + let userdata_dir = self.steam_root.join("userdata"); + let user_dirs = fs::read_dir(userdata_dir)?; + + let mut shortcuts: Vec = Vec::new(); + + for user in user_dirs.flatten() { + let config_path = user.path().join("config"); + let shortcut_path = config_path.join("shortcuts.vdf"); + + if !shortcut_path.exists() { + continue; + } + + let content = std::fs::read(&shortcut_path)?; + let shortcuts_data = parse_shortcuts(content.as_slice())?; + + for s in shortcuts_data { + let run_game_id = compute_rungameid(s.app_id); + let cover_base64 = match SteamUtils::convert_cover_to_base64(&s.app_id, &config_path) { + Ok(path) => path, // If successful, use the new path + Err(e) => { + log::error!("Error converting cover for app {}: {}", s.app_id, e); + None + } + }; + shortcuts.push(Shortcut { + name: s.app_name.to_string(), + exe: s.exe.to_string(), + run_game_id, + app_id: s.app_id as u64, + cover_b64: cover_base64, + }); + } + } + + Ok(shortcuts) + } + + fn get_dir_steamapps(&self) -> PathBuf { + self.steam_root.join("steamapps") + } + + pub fn new() -> anyhow::Result { + let steam_root = get_steam_root()?; + + Ok(Self { steam_root }) + } + + fn get_app_manifest(&self, app_entry: &AppEntry) -> anyhow::Result { + let manifest_path = + PathBuf::from(&app_entry.root_path).join(format!("steamapps/appmanifest_{}.acf", app_entry.app_id)); + + let vdf_data = std::fs::read_to_string(manifest_path)?; + let vdf_root = keyvalues_parser::Vdf::parse(&vdf_data)?; + + let Some(manifest) = vdf_parse_appstate(app_entry.app_id.clone(), &vdf_root) else { + anyhow::bail!("Failed to parse AppState"); + }; + + Ok(manifest) + } + + pub fn list_installed_games(&self, sort_method: GameSortMethod) -> anyhow::Result> { + let path = self.get_dir_steamapps().join("libraryfolders.vdf"); + let vdf_data = std::fs::read_to_string(path)?; + + let vdf_root = keyvalues_parser::Vdf::parse(&vdf_data)?; + + let Some(apps) = vdf_parse_libraryfolders(&vdf_root) else { + anyhow::bail!("Failed to fetch installed Steam apps"); + }; + + let mut games: Vec = apps + .iter() + .filter_map(|app_entry| { + let manifest = match self.get_app_manifest(app_entry) { + Ok(manifest) => manifest, + Err(e) => { + log::error!("Failed to get app manifest for AppID {}: {}", app_entry.app_id, e); + return None; + } + }; + Some(manifest) + }) + .collect(); + + if let Ok(shortcuts) = self.list_shortcuts() { + let mut fake_manifests = shortcuts + .iter() + .map(shortcut_to_fake_manifest) + .collect::>(); + games.append(&mut fake_manifests); + } else { + log::error!("Failed to read non-Steam shortcuts"); + } + + match sort_method { + GameSortMethod::NameAsc => { + games.sort_by(|a, b| a.name.cmp(&b.name)); + } + GameSortMethod::NameDesc => { + games.sort_by(|a, b| b.name.cmp(&a.name)); + } + GameSortMethod::PlayDateDesc => { + games.sort_by(|a, b| b.last_played.cmp(&a.last_played)); + } + } + + Ok(games) + } +} diff --git a/dash-frontend/src/views/game_list.rs b/dash-frontend/src/views/game_list.rs new file mode 100644 index 0000000..53a2c44 --- /dev/null +++ b/dash-frontend/src/views/game_list.rs @@ -0,0 +1,301 @@ +use std::{cell::RefCell, rc::Rc}; + +use wayvr_ipc::packet_server::{self, WvrWindowHandle}; +use wgui::{ + assets::AssetPath, + components::{ + self, + button::ComponentButton, + tooltip::{TooltipInfo, TooltipSide}, + }, + drawing::{self, GradientMode}, + globals::WguiGlobals, + i18n::Translation, + layout::{Layout, WidgetID, WidgetPair}, + parser::{Fetchable, ParseDocumentParams, ParserState}, + taffy::{ + self, + prelude::{length, percent}, + }, + widget::{ + ConstructEssentials, + label::{WidgetLabel, WidgetLabelParams}, + rectangle, + util::WLength, + }, +}; +use wlx_common::dash_interface::BoxDashInterface; + +use crate::{ + frontend::{FrontendTask, FrontendTasks}, + task::Tasks, + util::{ + popup_manager::MountPopupParams, + steam_utils::{self, SteamUtils}, + }, + views::window_options, +}; + +#[derive(Clone)] +enum Task { + AppManifestClicked(steam_utils::AppManifest), + Refresh, +} + +pub struct Params<'a> { + pub globals: &'a WguiGlobals, + pub frontend_tasks: FrontendTasks, + pub layout: &'a mut Layout, + pub parent_id: WidgetID, +} + +pub struct View { + #[allow(dead_code)] + pub parser_state: ParserState, + tasks: Tasks, + frontend_tasks: FrontendTasks, + globals: WguiGlobals, + id_list_parent: WidgetID, + steam_utils: steam_utils::SteamUtils, +} + +impl View { + pub fn new(params: Params) -> anyhow::Result { + let doc_params = &ParseDocumentParams { + globals: params.globals.clone(), + path: AssetPath::BuiltIn("gui/view/game_list.xml"), + extra: Default::default(), + }; + + let parser_state = wgui::parser::parse_from_assets(doc_params, params.layout, params.parent_id)?; + let list_parent = parser_state.fetch_widget(¶ms.layout.state, "list_parent")?; + + let tasks = Tasks::new(); + + let steam_utils = SteamUtils::new()?; + + tasks.push(Task::Refresh); + + Ok(Self { + parser_state, + tasks, + frontend_tasks: params.frontend_tasks, + globals: params.globals.clone(), + id_list_parent: list_parent.id, + steam_utils, + }) + } + + pub fn update(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> { + loop { + let tasks = self.tasks.drain(); + if tasks.is_empty() { + break; + } + for task in tasks { + match task { + Task::Refresh => self.refresh(layout, interface)?, + Task::AppManifestClicked(manifest) => self.action_app_manifest_clicked(manifest)?, + } + } + } + + Ok(()) + } +} + +pub struct Games { + manifests: Vec, +} + +const BORDER_COLOR_DEFAULT: drawing::Color = drawing::Color::new(1.0, 1.0, 1.0, 0.3); +const BORDER_COLOR_HOVERED: drawing::Color = drawing::Color::new(1.0, 1.0, 1.0, 1.0); + +const GAME_COVER_SIZE_X: f32 = 140.0; +const GAME_COVER_SIZE_Y: f32 = 210.0; + +pub fn construct_game_cover( + ess: &mut ConstructEssentials, + globals: &WguiGlobals, + manifest: &steam_utils::AppManifest, +) -> anyhow::Result<(WidgetPair, Rc)> { + let (widget_button, button) = components::button::construct( + ess, + components::button::Params { + color: Some(drawing::Color::new(1.0, 1.0, 1.0, 0.1)), + border_color: Some(BORDER_COLOR_DEFAULT), + hover_border_color: Some(BORDER_COLOR_HOVERED), + round: WLength::Units(12.0), + border: 2.0, + tooltip: Some(TooltipInfo { + side: TooltipSide::Bottom, + text: Translation::from_raw_text(&manifest.name), + }), + style: taffy::Style { + position: taffy::Position::Relative, + align_items: Some(taffy::AlignItems::Center), + justify_content: Some(taffy::JustifyContent::Center), + size: taffy::Size { + width: length(GAME_COVER_SIZE_X), + height: length(GAME_COVER_SIZE_Y), + }, + ..Default::default() + }, + ..Default::default() + }, + )?; + + let rect_gradient = |color: drawing::Color, color2: drawing::Color| { + rectangle::WidgetRectangle::create(rectangle::WidgetRectangleParams { + color, + color2, + round: WLength::Units(12.0), + gradient: GradientMode::Vertical, + ..Default::default() + }) + }; + + let rect_gradient_style = |align_self: taffy::AlignSelf, height: f32| taffy::Style { + position: taffy::Position::Absolute, + align_self: Some(align_self), + size: taffy::Size { + width: percent(1.0), + height: percent(height), + }, + ..Default::default() + }; + + // top shine + ess.layout.add_child( + widget_button.id, + rect_gradient( + drawing::Color::new(1.0, 1.0, 1.0, 0.25), + drawing::Color::new(1.0, 1.0, 1.0, 0.0), + ), + rect_gradient_style(taffy::AlignSelf::Baseline, 0.05), + )?; + + // top white gradient + ess.layout.add_child( + widget_button.id, + rect_gradient( + drawing::Color::new(1.0, 1.0, 1.0, 0.2), + drawing::Color::new(1.0, 1.0, 1.0, 0.0), + ), + rect_gradient_style(taffy::AlignSelf::Baseline, 0.5), + )?; + + // bottom black gradient + ess.layout.add_child( + widget_button.id, + rect_gradient( + drawing::Color::new(0.0, 0.0, 0.0, 0.0), + drawing::Color::new(0.0, 0.0, 0.0, 0.2), + ), + rect_gradient_style(taffy::AlignSelf::End, 0.5), + )?; + + // bottom shadow + ess.layout.add_child( + widget_button.id, + rect_gradient( + drawing::Color::new(0.0, 0.0, 0.0, 0.0), + drawing::Color::new(0.0, 0.0, 0.0, 0.2), + ), + rect_gradient_style(taffy::AlignSelf::End, 0.05), + )?; + + Ok((widget_button, button)) +} + +fn fill_game_list( + globals: &WguiGlobals, + ess: &mut ConstructEssentials, + interface: &mut BoxDashInterface, + games: &Games, + tasks: &Tasks, +) -> anyhow::Result<()> { + for manifest in &games.manifests { + let (_, button) = construct_game_cover(ess, globals, manifest)?; + + button.on_click({ + let tasks = tasks.clone(); + let manifest = manifest.clone(); + Box::new(move |_, _| { + tasks.push(Task::AppManifestClicked(manifest.clone())); + Ok(()) + }) + }); + } + + Ok(()) +} + +impl View { + fn game_list(&self) -> anyhow::Result { + let manifests = self + .steam_utils + .list_installed_games(steam_utils::GameSortMethod::PlayDateDesc)?; + + Ok(Games { manifests }) + } + + fn refresh(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> { + layout.remove_children(self.id_list_parent); + + let mut text: Option = None; + match self.game_list() { + Ok(list) => { + if list.manifests.is_empty() { + text = Some(Translation::from_translation_key("GAME_LIST.NO_GAMES_FOUND")) + } else { + fill_game_list( + &self.globals, + &mut ConstructEssentials { + layout, + parent: self.id_list_parent, + }, + interface, + &list, + &self.tasks, + )? + } + } + Err(e) => text = Some(Translation::from_raw_text(&format!("Error: {:?}", e))), + } + + if let Some(text) = text.take() { + layout.add_child( + self.id_list_parent, + WidgetLabel::create( + &mut self.globals.get(), + WidgetLabelParams { + content: text, + ..Default::default() + }, + ), + Default::default(), + )?; + } + + Ok(()) + } + + fn action_app_manifest_clicked(&mut self, manifest: steam_utils::AppManifest) -> anyhow::Result<()> { + self.frontend_tasks.push(FrontendTask::MountPopup(MountPopupParams { + title: Translation::from_raw_text(&manifest.name), + on_content: { + let frontend_tasks = self.frontend_tasks.clone(); + let globals = self.globals.clone(); + let tasks = self.tasks.clone(); + + Rc::new(move |data| { + // todo + Ok(()) + }) + }, + })); + + Ok(()) + } +} diff --git a/dash-frontend/src/views/mod.rs b/dash-frontend/src/views/mod.rs index 736d510..87c536a 100644 --- a/dash-frontend/src/views/mod.rs +++ b/dash-frontend/src/views/mod.rs @@ -1,5 +1,6 @@ pub mod app_launcher; pub mod audio_settings; +pub mod game_list; pub mod process_list; pub mod window_list; pub mod window_options;