ruffle/desktop/src/gui.rs

504 lines
20 KiB
Rust

mod controller;
mod movie;
mod open_dialog;
pub use controller::GuiController;
pub use movie::MovieView;
use std::borrow::Cow;
use url::Url;
use crate::custom_event::RuffleEvent;
use crate::gui::open_dialog::OpenDialog;
use crate::player::PlayerOptions;
use chrono::DateTime;
use egui::*;
use fluent_templates::fluent_bundle::FluentValue;
use fluent_templates::{static_loader, Loader};
use rfd::FileDialog;
use ruffle_core::backend::ui::US_ENGLISH;
use ruffle_core::debug_ui::Message as DebugMessage;
use ruffle_core::Player;
use std::collections::HashMap;
use std::fs;
use sys_locale::get_locale;
use unic_langid::LanguageIdentifier;
use winit::event_loop::EventLoopProxy;
const VERGEN_UNKNOWN: &str = "VERGEN_IDEMPOTENT_OUTPUT";
static_loader! {
static TEXTS = {
locales: "./assets/texts",
fallback_language: "en-US"
};
}
pub fn text<'a>(locale: &LanguageIdentifier, id: &'a str) -> Cow<'a, str> {
TEXTS.lookup(locale, id).map(Cow::Owned).unwrap_or_else(|| {
tracing::error!("Unknown desktop text id '{id}'");
Cow::Borrowed(id)
})
}
#[allow(dead_code)]
pub fn text_with_args<'a, T: AsRef<str>>(
locale: &LanguageIdentifier,
id: &'a str,
args: &HashMap<T, FluentValue>,
) -> Cow<'a, str> {
TEXTS
.lookup_with_args(locale, id, args)
.map(Cow::Owned)
.unwrap_or_else(|| {
tracing::error!("Unknown desktop text id '{id}'");
Cow::Borrowed(id)
})
}
/// Size of the top menu bar in pixels.
/// This is the offset at which the movie will be shown,
/// and added to the window size if trying to match a movie.
pub const MENU_HEIGHT: u32 = 24;
/// The main controller for the Ruffle GUI.
pub struct RuffleGui {
event_loop: EventLoopProxy<RuffleEvent>,
is_about_visible: bool,
is_as3_warning_visible: bool,
is_open_dialog_visible: bool,
context_menu: Vec<ruffle_core::ContextMenuItem>,
open_dialog: OpenDialog,
locale: LanguageIdentifier,
default_player_options: PlayerOptions,
currently_opened: Option<(Url, PlayerOptions)>,
}
impl RuffleGui {
fn new(
event_loop: EventLoopProxy<RuffleEvent>,
default_path: Option<Url>,
default_player_options: PlayerOptions,
) -> Self {
// TODO: language negotiation + https://github.com/1Password/sys-locale/issues/14
// This should also be somewhere else so it can be supplied through UiBackend too
let preferred_locale = get_locale();
let locale = preferred_locale
.and_then(|l| l.parse().ok())
.unwrap_or_else(|| US_ENGLISH.clone());
Self {
is_about_visible: false,
is_as3_warning_visible: false,
is_open_dialog_visible: false,
context_menu: vec![],
open_dialog: OpenDialog::new(
default_player_options.clone(),
default_path,
event_loop.clone(),
locale.clone(),
),
event_loop,
locale,
default_player_options,
currently_opened: None,
}
}
/// Renders all of the main Ruffle UI, including the main menu and context menus.
fn update(
&mut self,
egui_ctx: &egui::Context,
show_menu: bool,
mut player: Option<&mut Player>,
) {
if show_menu {
self.main_menu_bar(egui_ctx, player.as_deref_mut());
}
self.about_window(egui_ctx);
self.open_dialog(egui_ctx);
self.as3_warning(egui_ctx);
if let Some(player) = player {
player.show_debug_ui(egui_ctx);
for item in player.debug_ui().items_to_save() {
std::thread::spawn(move || {
if let Some(path) = FileDialog::new()
.set_file_name(&item.suggested_name)
.save_file()
{
if let Err(e) = fs::write(&path, item.data) {
tracing::error!(
"Couldn't save {} to {path:?}: {e}",
item.suggested_name,
);
}
}
});
}
}
if !self.context_menu.is_empty() {
self.context_menu(egui_ctx);
}
}
pub fn show_context_menu(&mut self, menu: Vec<ruffle_core::ContextMenuItem>) {
self.context_menu = menu;
}
pub fn is_context_menu_visible(&self) -> bool {
!self.context_menu.is_empty()
}
pub fn display_unsupported_message(&mut self) {
self.is_as3_warning_visible = true;
}
/// Notifies the GUI that a new player was created.
fn on_player_created(&mut self, opt: PlayerOptions, movie_url: Url) {
self.currently_opened = Some((movie_url.clone(), opt.clone()));
// Update dialog state to reflect the newly-opened movie's options.
self.is_open_dialog_visible = false;
self.open_dialog = OpenDialog::new(
opt,
Some(movie_url),
self.event_loop.clone(),
self.locale.clone(),
);
}
/// Renders the main menu bar at the top of the window.
fn main_menu_bar(&mut self, egui_ctx: &egui::Context, mut player: Option<&mut Player>) {
egui::TopBottomPanel::top("menu_bar").show(egui_ctx, |ui| {
// TODO(mike): Make some MenuItem struct with shortcut info to handle this more cleanly.
if ui.ctx().input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::O))
}) {
self.open_file(ui);
}
if ui.ctx().input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::O))
}) {
self.open_file_advanced();
}
if ui.ctx().input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::Q))
}) {
self.request_exit(ui);
}
if ui.ctx().input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::P))
}) {
if let Some(player) = &mut player {
player.set_is_playing(!player.is_playing());
}
}
menu::bar(ui, |ui| {
menu::menu_button(ui, text(&self.locale, "file-menu"), |ui| {
let mut shortcut;
shortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::O);
if Button::new(text(&self.locale, "file-menu-open-quick"))
.shortcut_text(ui.ctx().format_shortcut(&shortcut))
.ui(ui)
.clicked()
{
self.open_file(ui);
}
shortcut = KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::O);
if Button::new(text(&self.locale, "file-menu-open-advanced"))
.shortcut_text(ui.ctx().format_shortcut(&shortcut))
.ui(ui).clicked() {
ui.close_menu();
self.open_file_advanced();
}
if ui.add_enabled(player.is_some(), Button::new(text(&self.locale, "file-menu-reload"))).clicked() {
self.reload_movie(ui);
}
if ui.add_enabled(player.is_some(), Button::new(text(&self.locale, "file-menu-close"))).clicked() {
self.close_movie(ui);
}
ui.separator();
shortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Q);
if Button::new(text(&self.locale, "file-menu-exit"))
.shortcut_text(ui.ctx().format_shortcut(&shortcut))
.ui(ui)
.clicked()
{
self.request_exit(ui);
}
});
menu::menu_button(ui, text(&self.locale, "controls-menu"), |ui| {
ui.add_enabled_ui(player.is_some(), |ui| {
let playing = player.as_ref().map(|p| p.is_playing()).unwrap_or_default();
let pause_shortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::P);
if Button::new(text(&self.locale, if playing { "controls-menu-suspend" } else { "controls-menu-resume" })).shortcut_text(ui.ctx().format_shortcut(&pause_shortcut)).ui(ui).clicked() {
ui.close_menu();
if let Some(player) = &mut player {
player.set_is_playing(!player.is_playing());
}
}
});
});
menu::menu_button(ui, text(&self.locale, "debug-menu"), |ui| {
ui.add_enabled_ui(player.is_some(), |ui| {
if Button::new(text(&self.locale, "debug-menu-open-stage")).ui(ui).clicked() {
ui.close_menu();
if let Some(player) = &mut player {
player.debug_ui().queue_message(DebugMessage::TrackStage);
}
}
if Button::new(text(&self.locale, "debug-menu-open-movie")).ui(ui).clicked() {
ui.close_menu();
if let Some(player) = &mut player {
player.debug_ui().queue_message(DebugMessage::TrackTopLevelMovie);
}
}
if Button::new(text(&self.locale, "debug-menu-open-movie-list")).ui(ui).clicked() {
ui.close_menu();
if let Some(player) = &mut player {
player.debug_ui().queue_message(DebugMessage::ShowKnownMovies);
}
}
});
});
menu::menu_button(ui, text(&self.locale, "help-menu"), |ui| {
if ui.button(text(&self.locale, "help-menu-join-discord")).clicked() {
self.launch_website(ui, "https://discord.gg/ruffle");
}
if ui.button(text(&self.locale, "help-menu-report-a-bug")).clicked() {
self.launch_website(ui, "https://github.com/ruffle-rs/ruffle/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml");
}
if ui.button(text(&self.locale, "help-menu-sponsor-development")).clicked() {
self.launch_website(ui, "https://opencollective.com/ruffle/");
}
if ui.button(text(&self.locale, "help-menu-translate-ruffle")).clicked() {
self.launch_website(ui, "https://crowdin.com/project/ruffle");
}
ui.separator();
if ui.button(text(&self.locale, "help-menu-about")).clicked() {
self.show_about_screen(ui);
}
});
});
});
}
fn as3_warning(&mut self, egui_ctx: &egui::Context) {
let mut keep_open = true;
egui::Window::new("AS3 warning")
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(Align2::CENTER_TOP, (0.0, 50.0))
.open(&mut self.is_as3_warning_visible)
.show(egui_ctx, |ui| {
ui.set_min_width(400.0);
ui.label("The Ruffle emulator may not yet fully support all of ActionScript 3 used by this content.");
ui.label("Some parts of the content may not work as expected.");
ui.label("See the following link for more info:");
ui.hyperlink_to(
"https://github.com/ruffle-rs/ruffle/wiki/Frequently-Asked-Questions-For-Users",
"https://github.com/ruffle-rs/ruffle/wiki/Frequently-Asked-Questions-For-Users",
);
ui.label("(click anywhere to continue)");
if ui.input(|i| i.key_pressed(Key::Escape) || i.pointer.any_click()) {
keep_open = false;
}
});
if !keep_open {
self.is_as3_warning_visible = false;
}
}
fn about_window(&mut self, egui_ctx: &egui::Context) {
egui::Window::new(text(&self.locale, "about-ruffle"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
.open(&mut self.is_about_visible)
.show(egui_ctx, |ui| {
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Ruffle")
.color(Color32::from_rgb(0xFF, 0xAD, 0x33))
.size(32.0),
);
Grid::new("about_ruffle_version_info")
.striped(true)
.show(ui, |ui| {
ui.label(text(&self.locale, "about-ruffle-version"));
ui.label(env!("CARGO_PKG_VERSION"));
ui.end_row();
ui.label(text(&self.locale, "about-ruffle-channel"));
ui.label(env!("CFG_RELEASE_CHANNEL"));
ui.end_row();
let build_time = env!("VERGEN_BUILD_TIMESTAMP");
if build_time != VERGEN_UNKNOWN {
ui.label(text(&self.locale, "about-ruffle-build-time"));
ui.label(
DateTime::parse_from_rfc3339(build_time)
.map(|t| t.format("%c").to_string())
.unwrap_or_else(|_| build_time.to_string()),
);
ui.end_row();
}
let sha = env!("VERGEN_GIT_SHA");
if sha != VERGEN_UNKNOWN {
ui.label(text(&self.locale, "about-ruffle-commit-ref"));
ui.hyperlink_to(
sha,
format!("https://github.com/ruffle-rs/ruffle/commit/{}", sha),
);
ui.end_row();
}
let commit_time = env!("VERGEN_GIT_COMMIT_TIMESTAMP");
if sha != VERGEN_UNKNOWN {
ui.label(text(&self.locale, "about-ruffle-commit-time"));
ui.label(
DateTime::parse_from_rfc3339(commit_time)
.map(|t| t.format("%c").to_string())
.unwrap_or_else(|_| commit_time.to_string()),
);
ui.end_row();
}
ui.label(text(&self.locale, "about-ruffle-build-features"));
ui.horizontal_wrapped(|ui| {
ui.label(env!("VERGEN_CARGO_FEATURES").replace(',', ", "));
});
ui.end_row();
});
ui.horizontal(|ui| {
ui.hyperlink_to(
text(&self.locale, "about-ruffle-visit-website"),
"https://ruffle.rs",
);
ui.hyperlink_to(
text(&self.locale, "about-ruffle-visit-github"),
"https://github.com/ruffle-rs/ruffle/",
);
ui.hyperlink_to(
text(&self.locale, "about-ruffle-visit-discord"),
"https://discord.gg/ruffle",
);
ui.hyperlink_to(
text(&self.locale, "about-ruffle-visit-sponsor"),
"https://opencollective.com/ruffle/",
);
ui.shrink_width_to_current();
});
})
});
}
/// Renders the right-click context menu.
fn context_menu(&mut self, egui_ctx: &egui::Context) {
let mut item_clicked = false;
let mut menu_visible = false;
// TODO: What is the proper way in egui to spawn a random context menu?
egui::CentralPanel::default()
.frame(Frame::none())
.show(egui_ctx, |_| {})
.response
.context_menu(|ui| {
menu_visible = true;
for (i, item) in self.context_menu.iter().enumerate() {
if i != 0 && item.separator_before {
ui.separator();
}
let clicked = if item.checked {
Checkbox::new(&mut true, &item.caption).ui(ui).clicked()
} else {
Button::new(&item.caption).ui(ui).clicked()
};
if clicked {
let _ = self
.event_loop
.send_event(RuffleEvent::ContextMenuItemClicked(i));
item_clicked = true;
}
}
});
if item_clicked
|| !menu_visible
|| egui_ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Escape))
{
// Hide menu.
self.context_menu.clear();
}
}
fn open_file(&mut self, ui: &mut egui::Ui) {
ui.close_menu();
let _ = self
.event_loop
.send_event(RuffleEvent::BrowseAndOpen(Box::new(
self.default_player_options.clone(),
)));
}
fn open_file_advanced(&mut self) {
self.is_open_dialog_visible = true;
}
fn close_movie(&mut self, ui: &mut egui::Ui) {
let _ = self.event_loop.send_event(RuffleEvent::CloseFile);
self.currently_opened = None;
ui.close_menu();
}
fn reload_movie(&mut self, ui: &mut egui::Ui) {
let _ = self.event_loop.send_event(RuffleEvent::CloseFile);
if let Some((movie_url, opts)) = self.currently_opened.take() {
let _ = self
.event_loop
.send_event(RuffleEvent::OpenURL(movie_url, opts.into()));
}
ui.close_menu();
}
fn open_dialog(&mut self, egui_ctx: &egui::Context) {
if self.is_open_dialog_visible {
let keep_open = self.open_dialog.show(egui_ctx);
self.is_open_dialog_visible = keep_open;
}
}
fn request_exit(&mut self, ui: &mut egui::Ui) {
let _ = self.event_loop.send_event(RuffleEvent::ExitRequested);
ui.close_menu();
}
fn launch_website(&mut self, ui: &mut egui::Ui, url: &str) {
let _ = webbrowser::open(url);
ui.close_menu();
}
fn show_about_screen(&mut self, ui: &mut egui::Ui) {
self.is_about_visible = true;
ui.close_menu();
}
}