diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bd965..c3da11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ * When the game list is filtered, the summary line (e.g., "1 of 10 games") now reflects the filtered totals. * The `enable/disable all` buttons are now constrained by the active filter. + * CLI: On Steam Deck, when game mode is active, + the `wrap --gui` command will use custom dialogs instead of native ones, + because native dialogs don't work properly in game mode. * Fixed: * If a custom game's title begins or ends with a space, that custom game will now be ignored. diff --git a/src/cli.rs b/src/cli.rs index f0baf7b..5847a35 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1021,6 +1021,12 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) }; println!("{serialized}"); } + Subcommand::Dialog { kind, message } => { + if let Err(e) = crate::gui::dialog::run(config.theme, kind, message) { + log::error!("Failed to run custom dialog: {e:?}"); + failed = true; + } + } } if failed { Err(Error::SomeEntriesFailed) diff --git a/src/cli/parse.rs b/src/cli/parse.rs index de1d251..c12cbf9 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -398,6 +398,15 @@ pub enum Subcommand { #[clap(subcommand)] kind: SchemaSubcommand, }, + /// For internal use; not a stable interface. + /// Show custom dialogs. + #[clap(hide = true)] + Dialog { + #[clap(long, value_parser = possible_values!(crate::gui::dialog::Kind, ALL_CLI))] + kind: crate::gui::dialog::Kind, + #[clap(long)] + message: String, + }, } #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] diff --git a/src/cli/ui.rs b/src/cli/ui.rs index bdbf58e..22d6bae 100644 --- a/src/cli/ui.rs +++ b/src/cli/ui.rs @@ -1,4 +1,23 @@ -use crate::{lang::TRANSLATOR, prelude::Error}; +use crate::{ + lang::TRANSLATOR, + prelude::{Error, STEAM_DECK_GAME_MODE}, +}; + +enum System { + Native, + Iced, +} + +impl System { + fn best() -> Self { + if *STEAM_DECK_GAME_MODE { + // Native dialogs don't work in game mode. + Self::Iced + } else { + Self::Native + } + } +} /// GUI looks nicer with an extra empty line as separator, but for terminals a single /// newline is sufficient @@ -45,12 +64,19 @@ pub fn alert_with_error(gui: bool, force: bool, msg: &str, error: &Error) -> Res pub fn alert(gui: bool, force: bool, msg: &str) -> Result<(), Error> { log::debug!("Showing alert to user (GUI={}, force={}): {}", gui, force, msg); if gui { - rfd::MessageDialog::new() - .set_title(TRANSLATOR.app_name()) - .set_description(msg) - .set_level(rfd::MessageLevel::Error) - .set_buttons(rfd::MessageButtons::Ok) - .show(); + match System::best() { + System::Native => { + rfd::MessageDialog::new() + .set_title(TRANSLATOR.app_name()) + .set_description(msg) + .set_level(rfd::MessageLevel::Error) + .set_buttons(rfd::MessageButtons::Ok) + .show(); + } + System::Iced => { + crate::gui::dialog::error(msg); + } + } Ok(()) } else if !force { // TODO: Dialoguer doesn't have an alert type. @@ -79,18 +105,23 @@ pub fn confirm(gui: bool, force: Option, msg: &str) -> Result } if gui { - let choice = match rfd::MessageDialog::new() - .set_title(TRANSLATOR.app_name()) - .set_description(msg) - .set_level(rfd::MessageLevel::Info) - .set_buttons(rfd::MessageButtons::YesNo) - .show() - { - rfd::MessageDialogResult::Yes => true, - rfd::MessageDialogResult::No => false, - rfd::MessageDialogResult::Ok => true, - rfd::MessageDialogResult::Cancel => false, - rfd::MessageDialogResult::Custom(_) => false, + let choice = match System::best() { + System::Native => { + match rfd::MessageDialog::new() + .set_title(TRANSLATOR.app_name()) + .set_description(msg) + .set_level(rfd::MessageLevel::Info) + .set_buttons(rfd::MessageButtons::YesNo) + .show() + { + rfd::MessageDialogResult::Yes => true, + rfd::MessageDialogResult::No => false, + rfd::MessageDialogResult::Ok => true, + rfd::MessageDialogResult::Cancel => false, + rfd::MessageDialogResult::Custom(_) => false, + } + } + System::Iced => crate::gui::dialog::confirm(msg), }; log::debug!("User responded: {}", choice); Ok(choice) diff --git a/src/gui.rs b/src/gui.rs index 594a46d..aecdbcc 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -2,6 +2,7 @@ mod app; mod badge; mod button; mod common; +pub mod dialog; mod editor; mod file_tree; mod font; diff --git a/src/gui/dialog.rs b/src/gui/dialog.rs new file mode 100644 index 0000000..7de2a2c --- /dev/null +++ b/src/gui/dialog.rs @@ -0,0 +1,225 @@ +use iced::{ + alignment, + widget::{button, text, Column, Container, Row}, + Alignment, Element, Length, Size, Task, +}; + +use crate::{ + gui::icon::Icon, + lang::TRANSLATOR, + prelude::{run_command, Privacy}, + resource::config, +}; + +const POSITIVE_CHOICE: &str = "::ludusavi-positive::"; + +#[allow(unused)] +pub fn info(message: &str) { + show(Kind::Info, message); +} + +pub fn error(message: &str) { + show(Kind::Error, message); +} + +pub fn confirm(message: &str) -> bool { + show(Kind::Confirm, message) +} + +pub fn show(kind: Kind, message: &str) -> bool { + let exe = std::env::current_exe().unwrap().to_string_lossy().to_string(); + match run_command( + &exe, + &["dialog", "--kind", kind.slug(), "--message", message], + &[0], + Privacy::Public, + ) { + Ok(info) => info.stdout.contains(POSITIVE_CHOICE), + Err(e) => { + log::error!("Failed to show custom dialog: {e:?}"); + false + } + } +} + +pub fn run(theme: config::Theme, kind: Kind, message: String) -> iced::Result { + let app = iced::application(DialogApp::title, DialogApp::update, DialogApp::view) + .theme(DialogApp::theme) + .settings(iced::Settings { + default_font: crate::gui::font::TEXT, + ..Default::default() + }) + .window(iced::window::Settings { + min_size: Some(Size::new(320.0, 180.0)), + exit_on_close_request: true, + position: iced::window::Position::Centered, + #[cfg(target_os = "linux")] + platform_specific: iced::window::settings::PlatformSpecific { + application_id: std::env::var(crate::prelude::ENV_LINUX_APP_ID) + .unwrap_or_else(|_| crate::prelude::LINUX_APP_ID.to_string()), + ..Default::default() + }, + icon: match image::load_from_memory(include_bytes!("../../assets/icon.png")) { + Ok(buffer) => { + let buffer = buffer.to_rgba8(); + let width = buffer.width(); + let height = buffer.height(); + let dynamic_image = image::DynamicImage::ImageRgba8(buffer); + match iced::window::icon::from_rgba(dynamic_image.into_bytes(), width, height) { + Ok(icon) => Some(icon), + Err(_) => None, + } + } + Err(_) => None, + }, + ..Default::default() + }); + + app.run_with(move || { + ( + DialogApp::new(theme, kind, message), + Task::batch([ + iced::font::load(std::borrow::Cow::Borrowed(crate::gui::font::TEXT_DATA)).map(|_| Message::Ignore), + iced::font::load(std::borrow::Cow::Borrowed(crate::gui::font::ICONS_DATA)).map(|_| Message::Ignore), + iced::window::get_oldest().and_then(iced::window::gain_focus), + iced::window::get_oldest().and_then(|id| iced::window::resize(id, iced::Size::new(320.0, 180.0))), + ]), + ) + }) +} + +fn icon<'a>(icon: Icon) -> Element<'a, Message, crate::gui::style::Theme> { + text(icon.as_char().to_string()) + .font(crate::gui::font::ICONS) + .size(40) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .into() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub enum Kind { + Info, + Error, + Confirm, +} + +impl Kind { + pub const ALL_CLI: &'static [&'static str] = &[Self::INFO, Self::ERROR, Self::CONFIRM]; + const INFO: &'static str = "info"; + const ERROR: &'static str = "error"; + const CONFIRM: &'static str = "confirm"; +} + +impl Kind { + pub fn slug(&self) -> &str { + match self { + Self::Info => Self::INFO, + Self::Error => Self::ERROR, + Self::Confirm => Self::CONFIRM, + } + } +} + +impl std::str::FromStr for Kind { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + Self::INFO => Ok(Self::Info), + Self::ERROR => Ok(Self::Error), + Self::CONFIRM => Ok(Self::Confirm), + _ => Err(format!("invalid dialog kind: {}", s)), + } + } +} + +struct DialogApp { + theme: config::Theme, + kind: Kind, + message: String, + positive: String, + negative: String, +} + +#[derive(Debug, Clone, Copy)] +enum Message { + Ignore, + Positive, + Negative, +} + +impl DialogApp { + fn new(theme: config::Theme, kind: Kind, message: String) -> Self { + let positive = match kind { + Kind::Info => TRANSLATOR.okay_button(), + Kind::Error => TRANSLATOR.okay_button(), + Kind::Confirm => TRANSLATOR.continue_button(), + }; + + let negative = TRANSLATOR.cancel_button(); + + Self { + theme, + kind, + message, + positive, + negative, + } + } + + fn theme(&self) -> crate::gui::style::Theme { + crate::gui::style::Theme::from(self.theme) + } + + fn title(&self) -> String { + TRANSLATOR.app_name() + } + + fn update(&mut self, message: Message) { + match message { + Message::Ignore => {} + Message::Positive => { + println!("{POSITIVE_CHOICE}"); + std::process::exit(0); + } + Message::Negative => { + std::process::exit(0); + } + } + } + + fn view(&self) -> Element { + Container::new( + Column::new() + .spacing(20) + .padding(20) + .width(Length::Fill) + .align_x(Alignment::Center) + .push( + Row::new() + .spacing(20) + .align_y(Alignment::Center) + .push(match self.kind { + Kind::Info => icon(Icon::Info), + Kind::Error => icon(Icon::Error), + Kind::Confirm => icon(Icon::Question), + }) + .push(text(&self.message)), + ) + .push( + Row::new() + .spacing(20) + .align_y(Alignment::Center) + .push(button(text(&self.positive)).on_press(Message::Positive)) + .push_maybe(match self.kind { + Kind::Info => None, + Kind::Error => None, + Kind::Confirm => Some(button(text(&self.negative)).on_press(Message::Negative)), + }), + ), + ) + .center(Length::Fill) + .into() + } +} diff --git a/src/gui/icon.rs b/src/gui/icon.rs index 46b26b0..461e28c 100644 --- a/src/gui/icon.rs +++ b/src/gui/icon.rs @@ -32,6 +32,7 @@ pub enum Icon { OpenInBrowser, OpenInNew, PlayCircleOutline, + Question, Refresh, Remove, RemoveCircle, @@ -72,6 +73,7 @@ impl Icon { Self::OpenInBrowser => '\u{e89d}', Self::OpenInNew => '\u{E89E}', Self::PlayCircleOutline => '\u{E039}', + Self::Question => '\u{e8fd}', Self::Refresh => '\u{E5D5}', Self::Remove => '\u{E15B}', Self::RemoveCircle => '\u{E15C}', diff --git a/src/prelude.rs b/src/prelude.rs index 4162fba..91749ec 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -36,6 +36,8 @@ pub const INVALID_FILE_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '> pub static STEAM_DECK: LazyLock = LazyLock::new(|| Os::HOST == Os::Linux && StrictPath::new("/home/deck".to_string()).exists()); +pub static STEAM_DECK_GAME_MODE: LazyLock = + LazyLock::new(|| Os::HOST == Os::Linux && std::env::var("SteamDeck").is_ok_and(|x| &x == "1")); pub static OS_USERNAME: LazyLock = LazyLock::new(whoami::username); pub static AVAILABLE_PARALELLISM: LazyLock> =