86c4a11321
Pause preview updates when the workspace config is not saveable, surface that state in the Live Preview panel, and treat incomplete threshold color setups as real blocking errors so MangoHud preview never sees the bad intermediate state.
3208 lines
109 KiB
Rust
3208 lines
109 KiB
Rust
use crate::ui::pages::{self, PageBuildContext};
|
|
use crate::ui::widgets::tool_page;
|
|
use gio::prelude::*;
|
|
use gtk4::prelude::*;
|
|
use libadwaita::prelude::*;
|
|
use mangotune::config::normalize::normalize_legacy_option_values;
|
|
use mangotune::config::parser::Parser;
|
|
use mangotune::config::resolver::{LayerSource, Resolver};
|
|
use mangotune::config::types::{AnnotatedConfig, ConfigLine, ValidationResult};
|
|
use mangotune::config::validator;
|
|
use mangotune::integrations::search_game_config_hints;
|
|
use mangotune::preview::PreviewController;
|
|
use mangotune::system::detect::{self, SystemInfo};
|
|
use mangotune::system::paths::XdgPaths;
|
|
use notify::{
|
|
event::EventKind, Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
|
|
};
|
|
use std::cell::{Cell, RefCell};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
use std::sync::{mpsc, Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Shared mutable application state, passed via Arc<Mutex<>> to editable pages.
|
|
pub struct AppState {
|
|
pub config: AnnotatedConfig,
|
|
pub validation: HashMap<String, ValidationResult>,
|
|
pub dirty: bool,
|
|
pub saved_snapshot: AnnotatedConfig,
|
|
pub redo_snapshot: Option<AnnotatedConfig>,
|
|
pub auto_disabled_dependents: HashMap<String, HashSet<String>>,
|
|
}
|
|
|
|
pub struct MainWindow {
|
|
pub window: libadwaita::ApplicationWindow,
|
|
_config_watcher: Option<RecommendedWatcher>,
|
|
_preview_controller: PreviewController,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct LayerStackBarWidgets {
|
|
bar: gtk4::Box,
|
|
summary_label: gtk4::Label,
|
|
summary_shell: gtk4::Button,
|
|
conflict_badge: gtk4::Label,
|
|
}
|
|
|
|
impl MainWindow {
|
|
pub fn new(app: &libadwaita::Application, system_info: SystemInfo) -> Self {
|
|
const WINDOW_WIDTH_DRIFT_WORKAROUND: i32 = 1300;
|
|
let settings = app_settings();
|
|
let initial_config = load_initial_config(settings.as_ref());
|
|
let requested_width = settings
|
|
.as_ref()
|
|
.map(|s| match s.int("window-width") {
|
|
1120 | 1040 | 980 => 860,
|
|
width if width > 1040 => 900,
|
|
width => width.clamp(680, 920),
|
|
})
|
|
.filter(|w| *w >= 640)
|
|
.unwrap_or(860)
|
|
.max(WINDOW_WIDTH_DRIFT_WORKAROUND);
|
|
let requested_height = settings
|
|
.as_ref()
|
|
.map(|s| s.int("window-height"))
|
|
.filter(|h| *h >= 480)
|
|
.unwrap_or(780);
|
|
let (default_width, default_height) =
|
|
clamp_initial_window_size(requested_width, requested_height);
|
|
|
|
let window = libadwaita::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("MangoTune")
|
|
.default_width(default_width)
|
|
.default_height(default_height)
|
|
.width_request(700)
|
|
.height_request(600)
|
|
.build();
|
|
window.add_css_class("app-window");
|
|
|
|
let state = Arc::new(Mutex::new(AppState {
|
|
config: initial_config.clone(),
|
|
validation: HashMap::new(),
|
|
dirty: false,
|
|
saved_snapshot: initial_config,
|
|
redo_snapshot: None,
|
|
auto_disabled_dependents: HashMap::new(),
|
|
}));
|
|
let preview = PreviewController::new();
|
|
|
|
let save_split = libadwaita::SplitButton::builder()
|
|
.label("Save Config")
|
|
.sensitive(false)
|
|
.build();
|
|
let toast_overlay = libadwaita::ToastOverlay::new();
|
|
toast_overlay.add_css_class("app-content-shell");
|
|
|
|
let reload_banner =
|
|
libadwaita::Banner::new("Config file changed externally. Reload to see changes.");
|
|
reload_banner.set_button_label(Some("Reload"));
|
|
reload_banner.set_revealed(false);
|
|
|
|
let page_ctx = PageBuildContext {
|
|
state: state.clone(),
|
|
preview: preview.clone(),
|
|
preview_reload_source: Rc::new(RefCell::new(None)),
|
|
validation_rows: Rc::new(RefCell::new(HashMap::new())),
|
|
option_rows: Rc::new(RefCell::new(HashMap::new())),
|
|
pending_search_target: Rc::new(RefCell::new(None)),
|
|
current_search_query: Rc::new(RefCell::new(String::new())),
|
|
save_button: save_split.clone(),
|
|
toast_overlay: toast_overlay.clone(),
|
|
parent_window: window.clone(),
|
|
system_info: system_info.clone(),
|
|
};
|
|
|
|
let navigation_view = build_navigation_view(&page_ctx);
|
|
restore_active_page(&navigation_view, settings.as_ref());
|
|
let split_view = build_split_view(&navigation_view, &page_ctx, settings.as_ref());
|
|
let config_bar = build_config_bar(&navigation_view, &state, &toast_overlay);
|
|
let header = build_header_bar(&window, &save_split, settings.as_ref());
|
|
|
|
wire_save_button(&save_split, &state, &toast_overlay, settings.as_ref());
|
|
refresh_save_button(&state, &save_split);
|
|
|
|
install_window_actions(
|
|
&window,
|
|
&state,
|
|
&save_split,
|
|
&toast_overlay,
|
|
&navigation_view,
|
|
&reload_banner,
|
|
&page_ctx,
|
|
&config_bar,
|
|
settings.as_ref(),
|
|
);
|
|
install_close_guard(
|
|
&window,
|
|
&state,
|
|
&preview,
|
|
&save_split,
|
|
&toast_overlay,
|
|
settings.as_ref(),
|
|
);
|
|
|
|
if let (Some(settings), Ok(state)) = (settings.as_ref(), state.lock()) {
|
|
if let Some(path) = &state.config.path {
|
|
let _ = settings.set_string("last-config-path", &path.display().to_string());
|
|
}
|
|
}
|
|
|
|
let watcher = install_external_config_watcher(
|
|
&state,
|
|
&reload_banner,
|
|
&toast_overlay,
|
|
&save_split,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
);
|
|
|
|
toast_overlay.set_child(Some(&split_view));
|
|
|
|
let toolbar_view = libadwaita::ToolbarView::new();
|
|
toolbar_view.add_css_class("app-toolbar-view");
|
|
toolbar_view.add_top_bar(&header);
|
|
toolbar_view.add_top_bar(&config_bar.bar);
|
|
toolbar_view.add_top_bar(&reload_banner);
|
|
toolbar_view.set_content(Some(&toast_overlay));
|
|
|
|
if !system_info.mangohud.installed {
|
|
window.set_title(Some("MangoTune (MangoHud not detected)"));
|
|
}
|
|
|
|
window.set_content(Some(&toolbar_view));
|
|
Self {
|
|
window,
|
|
_config_watcher: watcher,
|
|
_preview_controller: preview,
|
|
}
|
|
}
|
|
|
|
pub fn present(&self) {
|
|
self.window.present();
|
|
}
|
|
}
|
|
|
|
fn clamp_initial_window_size(width: i32, height: i32) -> (i32, i32) {
|
|
let min_width = 680;
|
|
let min_height = 480;
|
|
|
|
let Some(display) = gtk4::gdk::Display::default() else {
|
|
return (width.max(min_width), height.max(min_height));
|
|
};
|
|
let monitors = display.monitors();
|
|
let Some(monitor_obj) = monitors.item(0) else {
|
|
return (width.max(min_width), height.max(min_height));
|
|
};
|
|
let Ok(monitor) = monitor_obj.downcast::<gtk4::gdk::Monitor>() else {
|
|
return (width.max(min_width), height.max(min_height));
|
|
};
|
|
|
|
let geometry = monitor.geometry();
|
|
let area_width = geometry.width().max(0);
|
|
let area_height = geometry.height().max(0);
|
|
if area_width <= 0 || area_height <= 0 {
|
|
return (width.max(min_width), height.max(min_height));
|
|
}
|
|
|
|
let width_cap = (area_width - 48).max(min_width);
|
|
let height_cap = (area_height - 48).max(min_height);
|
|
(
|
|
width.clamp(min_width, width_cap),
|
|
height.clamp(min_height, height_cap),
|
|
)
|
|
}
|
|
|
|
fn debug_log(message: &str) {
|
|
mangotune::debug_log::record(message);
|
|
}
|
|
|
|
pub fn refresh_save_button(state: &Arc<Mutex<AppState>>, save_button: &libadwaita::SplitButton) {
|
|
let Ok(state) = state.lock() else {
|
|
save_button.set_sensitive(false);
|
|
return;
|
|
};
|
|
let errors = validation_errors(&state.validation);
|
|
save_button.set_sensitive(true);
|
|
if errors.is_empty() {
|
|
if state.dirty {
|
|
save_button.set_tooltip_text(Some(
|
|
"Save your current MangoHud config, or open the menu for Save a Copy, Discard Changes, or Backup.",
|
|
));
|
|
} else {
|
|
save_button.set_tooltip_text(Some(
|
|
"No unsaved changes right now. The menu still contains Save a Copy, Discard Changes, and Backup actions.",
|
|
));
|
|
}
|
|
} else {
|
|
save_button.set_tooltip_text(Some(&format!(
|
|
"Cannot save yet: {} validation error(s). Fix the highlighted issues first, or open the menu for other actions.",
|
|
errors.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
pub fn recompute_validation(state: &Arc<Mutex<AppState>>) {
|
|
let Ok(mut state) = state.lock() else {
|
|
return;
|
|
};
|
|
state.validation = validator::validate_all(&state.config);
|
|
state.dirty = state.config.dirty;
|
|
}
|
|
|
|
fn wire_save_button(
|
|
save_split: &libadwaita::SplitButton,
|
|
state: &Arc<Mutex<AppState>>,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let state_clone = state.clone();
|
|
let save_button_clone = save_split.clone();
|
|
let toast_overlay_clone = toast_overlay.clone();
|
|
let settings = settings.cloned();
|
|
|
|
save_split.connect_clicked(move |_| {
|
|
let _ = save_current_config(
|
|
&state_clone,
|
|
&save_button_clone,
|
|
&toast_overlay_clone,
|
|
settings.as_ref(),
|
|
);
|
|
});
|
|
}
|
|
|
|
fn save_current_config(
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
settings: Option<&gio::Settings>,
|
|
) -> bool {
|
|
debug_log("save: begin");
|
|
let config_to_write = {
|
|
let Ok(mut state_guard) = state.lock() else {
|
|
debug_log("save: failed to lock app state");
|
|
crate::ui::toast::show_toast(toast_overlay, "Could not acquire config state");
|
|
refresh_save_button(state, save_button);
|
|
return false;
|
|
};
|
|
|
|
let normalized = normalize_legacy_option_values(&mut state_guard.config);
|
|
if normalized > 0 {
|
|
debug_log(&format!(
|
|
"save: normalized {normalized} legacy option value(s) before validation"
|
|
));
|
|
state_guard.dirty = state_guard.config.dirty;
|
|
}
|
|
|
|
debug_log(&format!(
|
|
"save: validating {} options",
|
|
state_guard.config.options.len()
|
|
));
|
|
state_guard.validation = validator::validate_all(&state_guard.config);
|
|
let errors = validation_errors(&state_guard.validation);
|
|
|
|
if !errors.is_empty() {
|
|
debug_log(&format!(
|
|
"save: blocked by {} validation errors",
|
|
errors.len()
|
|
));
|
|
for (key, message) in &errors {
|
|
debug_log(&format!("save: validation error {key}: {message}"));
|
|
}
|
|
|
|
let preview = errors
|
|
.iter()
|
|
.take(3)
|
|
.map(|(key, message)| format!("{key}: {message}"))
|
|
.collect::<Vec<_>>()
|
|
.join(" | ");
|
|
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
&format!(
|
|
"Cannot save: {} validation error(s). {}",
|
|
errors.len(),
|
|
preview
|
|
),
|
|
);
|
|
|
|
drop(state_guard);
|
|
refresh_save_button(state, save_button);
|
|
return false;
|
|
}
|
|
|
|
state_guard.config.clone()
|
|
};
|
|
|
|
if let Some(path) = &config_to_write.path {
|
|
debug_log(&format!("save: target path {}", path.display()));
|
|
if let Some(parent) = path.parent() {
|
|
if let Err(err) = std::fs::create_dir_all(parent) {
|
|
debug_log(&format!("save: failed to create parent dir: {err}"));
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
&format!("Failed to create config directory: {err}"),
|
|
);
|
|
refresh_save_button(state, save_button);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if setting_bool(settings, "auto-backup-on-save", true) && path.exists() {
|
|
debug_log("save: creating autosave backup copy");
|
|
if let Err(err) = create_backup_copy_for_path(path, "autosave") {
|
|
debug_log(&format!("save: autosave backup warning: {err}"));
|
|
crate::ui::toast::show_toast(toast_overlay, &format!("Backup warning: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
let write_started = Instant::now();
|
|
debug_log("save: writing config to disk");
|
|
match Parser::write(&config_to_write) {
|
|
Ok(()) => {
|
|
debug_log(&format!(
|
|
"save: write completed in {} ms",
|
|
write_started.elapsed().as_millis()
|
|
));
|
|
if let Ok(mut state) = state.lock() {
|
|
state.config.dirty = false;
|
|
state.dirty = false;
|
|
state.validation.clear();
|
|
state.saved_snapshot = state.config.clone();
|
|
state.redo_snapshot = None;
|
|
|
|
if let (Some(settings), Some(path)) = (settings, state.config.path.as_ref()) {
|
|
let _ = settings.set_string("last-config-path", &path.display().to_string());
|
|
}
|
|
}
|
|
crate::ui::toast::show_toast(toast_overlay, "Config saved");
|
|
refresh_save_button(state, save_button);
|
|
debug_log("save: success");
|
|
true
|
|
}
|
|
Err(err) => {
|
|
debug_log(&format!(
|
|
"save: write failed after {} ms: {err}",
|
|
write_started.elapsed().as_millis()
|
|
));
|
|
crate::ui::toast::show_toast(toast_overlay, &format!("Save failed: {err}"));
|
|
refresh_save_button(state, save_button);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_initial_config(settings: Option<&gio::Settings>) -> AnnotatedConfig {
|
|
let preferred_path = settings
|
|
.map(|s| s.string("last-config-path"))
|
|
.map(|s| s.to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from);
|
|
|
|
if let Some(path) = preferred_path {
|
|
if path.exists() {
|
|
if let Ok(mut parsed) = Parser::read(&path) {
|
|
normalize_legacy_option_values(&mut parsed);
|
|
return parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(xdg) = XdgPaths::resolve() {
|
|
if xdg.global_config.exists() {
|
|
if let Ok(mut parsed) = Parser::read(&xdg.global_config) {
|
|
normalize_legacy_option_values(&mut parsed);
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
let _ = std::fs::create_dir_all(&xdg.mangohud_dir);
|
|
return AnnotatedConfig {
|
|
lines: vec![
|
|
ConfigLine::Comment(
|
|
"### MangoHud configuration - managed by MangoTune".to_string(),
|
|
),
|
|
ConfigLine::Comment("### App: global".to_string()),
|
|
ConfigLine::Blank,
|
|
],
|
|
options: indexmap::IndexMap::new(),
|
|
path: Some(xdg.global_config),
|
|
dirty: false,
|
|
};
|
|
}
|
|
|
|
AnnotatedConfig {
|
|
lines: vec![],
|
|
options: indexmap::IndexMap::new(),
|
|
path: None,
|
|
dirty: false,
|
|
}
|
|
}
|
|
|
|
fn build_header_bar(
|
|
window: &libadwaita::ApplicationWindow,
|
|
save_split: &libadwaita::SplitButton,
|
|
settings: Option<&gio::Settings>,
|
|
) -> libadwaita::HeaderBar {
|
|
let header = libadwaita::HeaderBar::new();
|
|
header.add_css_class("app-headerbar");
|
|
let title = libadwaita::WindowTitle::new("MangoTune", "No config loaded");
|
|
title.add_css_class("shell-window-title");
|
|
header.set_title_widget(Some(&title));
|
|
save_split.add_css_class("shell-save-button");
|
|
save_split.set_popover(Some(&build_save_popover(window, settings)));
|
|
header.pack_start(save_split);
|
|
|
|
let gear_button = gtk4::MenuButton::new();
|
|
gear_button.set_icon_name("emblem-system-symbolic");
|
|
gear_button.set_popover(Some(&build_gear_popover(window)));
|
|
gear_button.add_css_class("shell-menu-button");
|
|
header.pack_end(&gear_button);
|
|
|
|
header
|
|
}
|
|
|
|
fn build_compact_popover() -> (gtk4::Popover, gtk4::Box) {
|
|
let popover = gtk4::Popover::new();
|
|
popover.add_css_class("compact-menu-popover");
|
|
popover.set_has_arrow(false);
|
|
popover.set_autohide(true);
|
|
popover.set_halign(gtk4::Align::Start);
|
|
popover.set_valign(gtk4::Align::Start);
|
|
|
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
content.add_css_class("compact-menu-box");
|
|
content.set_halign(gtk4::Align::Start);
|
|
content.set_valign(gtk4::Align::Start);
|
|
content.set_hexpand(false);
|
|
content.set_vexpand(false);
|
|
popover.set_child(Some(&content));
|
|
|
|
(popover, content)
|
|
}
|
|
|
|
fn build_compact_popover_row(
|
|
title: &str,
|
|
subtitle: Option<&str>,
|
|
trailing: Option<&str>,
|
|
leading_mark: bool,
|
|
) -> gtk4::Button {
|
|
let button = gtk4::Button::new();
|
|
button.add_css_class("flat");
|
|
button.add_css_class("compact-menu-row");
|
|
button.set_halign(gtk4::Align::Start);
|
|
button.set_hexpand(false);
|
|
button.set_vexpand(false);
|
|
|
|
let shell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
|
shell.set_margin_start(10);
|
|
shell.set_margin_end(10);
|
|
shell.set_margin_top(2);
|
|
shell.set_margin_bottom(2);
|
|
shell.set_halign(gtk4::Align::Start);
|
|
shell.set_valign(gtk4::Align::Center);
|
|
shell.set_hexpand(false);
|
|
|
|
let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
|
|
text_box.set_hexpand(false);
|
|
|
|
let title_label = gtk4::Label::new(Some(title));
|
|
title_label.add_css_class("compact-menu-row-title");
|
|
title_label.set_xalign(0.0);
|
|
title_label.set_halign(gtk4::Align::Start);
|
|
title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
|
title_label.set_max_width_chars(38);
|
|
text_box.append(&title_label);
|
|
|
|
if let Some(subtitle) = subtitle {
|
|
let subtitle_label = gtk4::Label::new(Some(subtitle));
|
|
subtitle_label.add_css_class("compact-menu-row-subtitle");
|
|
subtitle_label.add_css_class("dim-label");
|
|
subtitle_label.set_xalign(0.0);
|
|
subtitle_label.set_halign(gtk4::Align::Start);
|
|
subtitle_label.set_wrap(false);
|
|
subtitle_label.set_max_width_chars(38);
|
|
subtitle_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
|
text_box.append(&subtitle_label);
|
|
}
|
|
|
|
shell.append(&text_box);
|
|
|
|
if trailing.is_none() {
|
|
let mark = gtk4::Label::new(Some("✓"));
|
|
mark.add_css_class("compact-menu-row-mark");
|
|
mark.add_css_class("dim-label");
|
|
mark.set_xalign(1.0);
|
|
mark.set_halign(gtk4::Align::End);
|
|
mark.set_visible(leading_mark);
|
|
shell.append(&mark);
|
|
} else if let Some(trailing) = trailing {
|
|
let trailing_label = gtk4::Label::new(Some(trailing));
|
|
trailing_label.add_css_class("compact-menu-row-trailing");
|
|
trailing_label.add_css_class("dim-label");
|
|
trailing_label.set_xalign(1.0);
|
|
trailing_label.set_halign(gtk4::Align::End);
|
|
trailing_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
|
shell.append(&trailing_label);
|
|
}
|
|
|
|
button.set_child(Some(&shell));
|
|
button
|
|
}
|
|
|
|
fn build_compact_popover_separator() -> gtk4::Separator {
|
|
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
|
separator.add_css_class("compact-menu-separator");
|
|
separator
|
|
}
|
|
|
|
fn activate_window_action(
|
|
window: &libadwaita::ApplicationWindow,
|
|
action_name: &str,
|
|
target: Option<&glib::Variant>,
|
|
) {
|
|
let _ = gtk4::prelude::WidgetExt::activate_action(window, action_name, target);
|
|
}
|
|
|
|
fn window_action_bool_state(
|
|
window: &libadwaita::ApplicationWindow,
|
|
action_name: &str,
|
|
fallback: bool,
|
|
) -> bool {
|
|
window
|
|
.lookup_action(action_name)
|
|
.and_then(|action| action.state())
|
|
.and_then(|state| bool::from_variant(&state))
|
|
.unwrap_or(fallback)
|
|
}
|
|
|
|
fn toggle_window_bool_action(window: &libadwaita::ApplicationWindow, action_name: &str) {
|
|
if let Some(action) = window.lookup_action(action_name) {
|
|
let current = action
|
|
.state()
|
|
.and_then(|state| bool::from_variant(&state))
|
|
.unwrap_or(false);
|
|
action.change_state(&(!current).to_variant());
|
|
}
|
|
}
|
|
|
|
fn build_save_popover(
|
|
window: &libadwaita::ApplicationWindow,
|
|
settings: Option<&gio::Settings>,
|
|
) -> gtk4::Popover {
|
|
let (popover, content) = build_compact_popover();
|
|
|
|
let save_as = build_compact_popover_row("Save a Copy…", None, None, false);
|
|
{
|
|
let window = window.clone();
|
|
let popover = popover.clone();
|
|
save_as.connect_clicked(move |_| {
|
|
activate_window_action(&window, "win.save-as", None);
|
|
popover.popdown();
|
|
});
|
|
}
|
|
content.append(&save_as);
|
|
|
|
let auto_backup = build_compact_popover_row(
|
|
"Auto backup on save",
|
|
None,
|
|
None,
|
|
setting_bool(settings, "auto-backup-on-save", true),
|
|
);
|
|
{
|
|
let window = window.clone();
|
|
let popover = popover.clone();
|
|
auto_backup.connect_clicked(move |_| {
|
|
toggle_window_bool_action(&window, "auto-backup-on-save");
|
|
popover.popdown();
|
|
});
|
|
}
|
|
content.append(&auto_backup);
|
|
|
|
content.append(&build_compact_popover_separator());
|
|
|
|
for (title, action) in [
|
|
("Restore Latest Safety Backup", "win.restore-backup"),
|
|
("Reset to Defaults", "win.reset-defaults"),
|
|
("Discard Unsaved Changes", "win.revert"),
|
|
("Create Safety Backup", "win.backup"),
|
|
] {
|
|
let row = build_compact_popover_row(title, None, None, false);
|
|
let window = window.clone();
|
|
let popover = popover.clone();
|
|
row.connect_clicked(move |_| {
|
|
activate_window_action(&window, action, None);
|
|
popover.popdown();
|
|
});
|
|
content.append(&row);
|
|
}
|
|
|
|
popover.connect_show({
|
|
let window = window.clone();
|
|
let settings = settings.cloned();
|
|
move |popover| {
|
|
if let Some(container) = popover
|
|
.child()
|
|
.and_then(|child| child.downcast::<gtk4::Box>().ok())
|
|
{
|
|
if let Some(row) = container
|
|
.first_child()
|
|
.and_then(|child| child.next_sibling())
|
|
.and_then(|child| child.downcast::<gtk4::Button>().ok())
|
|
{
|
|
let active = window_action_bool_state(
|
|
&window,
|
|
"auto-backup-on-save",
|
|
setting_bool(settings.as_ref(), "auto-backup-on-save", true),
|
|
);
|
|
if let Some(shell) = row
|
|
.child()
|
|
.and_then(|child| child.downcast::<gtk4::Box>().ok())
|
|
{
|
|
if let Some(mark) = shell
|
|
.last_child()
|
|
.and_then(|child| child.downcast::<gtk4::Label>().ok())
|
|
{
|
|
mark.set_visible(active);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
popover
|
|
}
|
|
|
|
fn build_gear_popover(window: &libadwaita::ApplicationWindow) -> gtk4::Popover {
|
|
let (popover, content) = build_compact_popover();
|
|
for (title, action) in [
|
|
("Keyboard Shortcuts", "win.shortcuts"),
|
|
("About MangoTune", "win.about"),
|
|
] {
|
|
let row = build_compact_popover_row(title, None, None, false);
|
|
let window = window.clone();
|
|
let popover = popover.clone();
|
|
row.connect_clicked(move |_| {
|
|
activate_window_action(&window, action, None);
|
|
popover.popdown();
|
|
});
|
|
content.append(&row);
|
|
}
|
|
popover
|
|
}
|
|
|
|
fn build_config_bar(
|
|
navigation_view: &libadwaita::NavigationView,
|
|
state: &Arc<Mutex<AppState>>,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
) -> LayerStackBarWidgets {
|
|
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
bar.add_css_class("config-bar");
|
|
bar.add_css_class("shell-strip");
|
|
bar.set_margin_start(12);
|
|
bar.set_margin_end(12);
|
|
bar.set_margin_top(6);
|
|
bar.set_margin_bottom(6);
|
|
|
|
let icon = gtk4::Image::from_icon_name("globe-symbolic");
|
|
icon.add_css_class("shell-strip-icon");
|
|
bar.append(&icon);
|
|
|
|
let editing_label = gtk4::Label::new(Some("Layer stack"));
|
|
editing_label.set_xalign(0.0);
|
|
editing_label.add_css_class("shell-strip-label");
|
|
bar.append(&editing_label);
|
|
|
|
let stack_summary = gtk4::Button::new();
|
|
stack_summary.set_hexpand(true);
|
|
stack_summary.set_halign(gtk4::Align::Fill);
|
|
stack_summary.add_css_class("flat");
|
|
stack_summary.add_css_class("shell-target-summary");
|
|
stack_summary.set_tooltip_text(Some(
|
|
"Click to switch which MangoHud config file MangoTune is actively editing. View Layers still shows the full detected stack.",
|
|
));
|
|
|
|
let stack_summary_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
stack_summary_box.set_hexpand(true);
|
|
|
|
let summary_label = gtk4::Label::new(Some("Discovering MangoHud layer stack..."));
|
|
summary_label.set_xalign(0.0);
|
|
summary_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
|
summary_label.set_width_chars(1);
|
|
summary_label.set_max_width_chars(28);
|
|
summary_label.set_hexpand(true);
|
|
summary_label.add_css_class("shell-target-summary-label");
|
|
stack_summary_box.append(&summary_label);
|
|
|
|
let summary_chevron = gtk4::Image::from_icon_name("pan-down-symbolic");
|
|
summary_chevron.add_css_class("dim-label");
|
|
stack_summary_box.append(&summary_chevron);
|
|
stack_summary.set_child(Some(&stack_summary_box));
|
|
bar.append(&stack_summary);
|
|
|
|
{
|
|
let state = state.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let anchor = stack_summary.clone();
|
|
stack_summary.connect_clicked(move |_| {
|
|
let toast_overlay = toast_overlay.clone();
|
|
let current_path = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone());
|
|
let anchor = anchor.clone();
|
|
glib::MainContext::default().spawn_local(async move {
|
|
let targets = discover_switchable_config_targets(current_path.clone()).await;
|
|
if targets.is_empty() {
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
"No switchable MangoHud config targets were found",
|
|
);
|
|
return;
|
|
}
|
|
show_switch_config_menu(&anchor, &targets);
|
|
});
|
|
});
|
|
}
|
|
|
|
let conflict_badge = gtk4::Label::new(Some("No conflicts detected"));
|
|
conflict_badge.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
|
conflict_badge.set_width_chars(1);
|
|
conflict_badge.set_max_width_chars(20);
|
|
conflict_badge.add_css_class("dim-label");
|
|
conflict_badge.add_css_class("shell-status-label");
|
|
conflict_badge.add_css_class("shell-conflict-label");
|
|
bar.append(&conflict_badge);
|
|
|
|
let view_layers = gtk4::Button::with_label("View Layers");
|
|
view_layers.add_css_class("shell-strip-button");
|
|
let nav_clone = navigation_view.clone();
|
|
view_layers.connect_clicked(move |_| {
|
|
navigate_to_tag(&nav_clone, "conflicts");
|
|
});
|
|
bar.append(&view_layers);
|
|
|
|
refresh_layer_stack_widgets(&summary_label, &stack_summary, &conflict_badge, state);
|
|
|
|
let summary_label_clone = summary_label.clone();
|
|
let stack_summary_clone = stack_summary.clone();
|
|
let conflict_badge_clone = conflict_badge.clone();
|
|
let state_clone = state.clone();
|
|
glib::timeout_add_seconds_local(5, move || {
|
|
refresh_layer_stack_widgets(
|
|
&summary_label_clone,
|
|
&stack_summary_clone,
|
|
&conflict_badge_clone,
|
|
&state_clone,
|
|
);
|
|
glib::ControlFlow::Continue
|
|
});
|
|
|
|
LayerStackBarWidgets {
|
|
bar,
|
|
summary_label,
|
|
summary_shell: stack_summary,
|
|
conflict_badge,
|
|
}
|
|
}
|
|
|
|
fn build_split_view(
|
|
navigation_view: &libadwaita::NavigationView,
|
|
ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) -> libadwaita::OverlaySplitView {
|
|
let split = libadwaita::OverlaySplitView::new();
|
|
split.add_css_class("app-split-view");
|
|
split.set_sidebar_width_fraction(0.18);
|
|
split.set_min_sidebar_width(156.0);
|
|
split.set_max_sidebar_width(208.0);
|
|
split.set_show_sidebar(true);
|
|
|
|
let sidebar = build_sidebar(navigation_view, ctx, settings);
|
|
split.set_sidebar(Some(&sidebar));
|
|
split.set_content(Some(navigation_view));
|
|
split
|
|
}
|
|
|
|
fn build_sidebar(
|
|
navigation_view: &libadwaita::NavigationView,
|
|
ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) -> gtk4::Box {
|
|
let shell = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
|
shell.add_css_class("navigation-shell");
|
|
|
|
let search = gtk4::SearchEntry::new();
|
|
search.set_placeholder_text(Some("Search pages"));
|
|
search.add_css_class("navigation-search");
|
|
shell.append(&search);
|
|
|
|
let sidebar = gtk4::ListBox::new();
|
|
sidebar.add_css_class("navigation-sidebar");
|
|
sidebar.set_selection_mode(gtk4::SelectionMode::Single);
|
|
sidebar.set_vexpand(true);
|
|
shell.append(&sidebar);
|
|
|
|
let collapsed_sections = Rc::new(RefCell::new(default_collapsed_sections()));
|
|
|
|
let mut last_section: Option<&'static str> = None;
|
|
for item in pages::SIDEBAR_ITEMS {
|
|
if last_section != Some(item.section) {
|
|
last_section = Some(item.section);
|
|
let section_row = gtk4::ListBoxRow::new();
|
|
section_row.add_css_class("navigation-section-row");
|
|
section_row.set_activatable(false);
|
|
section_row.set_selectable(false);
|
|
section_row.set_widget_name(&format!("section:{}", item.section));
|
|
|
|
let button = gtk4::Button::new();
|
|
button.add_css_class("flat");
|
|
button.add_css_class("navigation-section-button");
|
|
|
|
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
header.set_margin_start(12);
|
|
header.set_margin_end(12);
|
|
header.set_margin_top(12);
|
|
header.set_margin_bottom(4);
|
|
|
|
let arrow = gtk4::Image::from_icon_name(
|
|
if collapsed_sections.borrow().contains(item.section) {
|
|
"pan-end-symbolic"
|
|
} else {
|
|
"pan-down-symbolic"
|
|
},
|
|
);
|
|
arrow.set_pixel_size(14);
|
|
arrow.add_css_class("dim-label");
|
|
header.append(&arrow);
|
|
|
|
let label = gtk4::Label::new(Some(item.section));
|
|
label.add_css_class("heading");
|
|
label.add_css_class("dim-label");
|
|
label.set_xalign(0.0);
|
|
label.set_hexpand(true);
|
|
header.append(&label);
|
|
|
|
button.set_child(Some(&header));
|
|
section_row.set_child(Some(&button));
|
|
sidebar.append(§ion_row);
|
|
|
|
let list = sidebar.clone();
|
|
let search = search.clone();
|
|
let collapsed_sections = collapsed_sections.clone();
|
|
let section_name = item.section.to_string();
|
|
button.connect_clicked(move |_| {
|
|
{
|
|
let mut collapsed = collapsed_sections.borrow_mut();
|
|
if collapsed.contains(§ion_name) {
|
|
*collapsed = all_sidebar_sections();
|
|
collapsed.remove(§ion_name);
|
|
} else {
|
|
collapsed.insert(section_name.clone());
|
|
}
|
|
}
|
|
refresh_sidebar_section_icons(&list, &collapsed_sections.borrow());
|
|
let query = search.text().to_string().to_ascii_lowercase();
|
|
filter_sidebar_rows(&list, &query, &collapsed_sections.borrow());
|
|
});
|
|
}
|
|
|
|
let row = gtk4::ListBoxRow::new();
|
|
row.add_css_class("navigation-row");
|
|
row.set_widget_name(item.id);
|
|
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
row_box.add_css_class("navigation-row-box");
|
|
row_box.set_margin_start(12);
|
|
row_box.set_margin_end(12);
|
|
row_box.set_margin_top(6);
|
|
row_box.set_margin_bottom(6);
|
|
|
|
let icon = gtk4::Image::from_icon_name(item.icon_name);
|
|
icon.add_css_class("navigation-row-icon");
|
|
icon.set_pixel_size(16);
|
|
row_box.append(&icon);
|
|
|
|
let label = gtk4::Label::new(Some(item.title));
|
|
label.add_css_class("navigation-row-label");
|
|
label.set_xalign(0.0);
|
|
label.set_hexpand(true);
|
|
row_box.append(&label);
|
|
|
|
row.set_child(Some(&row_box));
|
|
sidebar.append(&row);
|
|
}
|
|
|
|
let nav_clone = navigation_view.clone();
|
|
let _settings = settings.cloned();
|
|
sidebar.connect_row_activated(move |_, row| {
|
|
if !row.is_activatable() {
|
|
return;
|
|
}
|
|
let raw_id = row.widget_name();
|
|
if raw_id.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let raw_id = raw_id.as_str();
|
|
let Some(item) = pages::SIDEBAR_ITEMS.iter().find(|item| {
|
|
item.id.eq_ignore_ascii_case(raw_id) || item.title.eq_ignore_ascii_case(raw_id)
|
|
}) else {
|
|
return;
|
|
};
|
|
let id = item.id;
|
|
|
|
if let Some(page) = nav_clone.visible_page() {
|
|
if page.tag().as_deref() == Some(id) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
debug_log(&format!("nav: row activation raw='{raw_id}' mapped='{id}'"));
|
|
navigate_to_tag(&nav_clone, id);
|
|
});
|
|
|
|
{
|
|
let list = sidebar.clone();
|
|
let collapsed_sections = collapsed_sections.clone();
|
|
let navigation_view = navigation_view.clone();
|
|
let page_ctx = Rc::new(ctx.clone());
|
|
search.connect_search_changed(move |entry| {
|
|
let query = entry.text().to_string().to_ascii_lowercase();
|
|
*page_ctx.current_search_query.borrow_mut() = query.clone();
|
|
*page_ctx.pending_search_target.borrow_mut() = None;
|
|
filter_sidebar_rows(&list, &query, &collapsed_sections.borrow());
|
|
if query.trim().is_empty() {
|
|
if navigation_view
|
|
.visible_page()
|
|
.and_then(|page| page.tag())
|
|
.is_some_and(|tag| tag.as_str() == "search_results")
|
|
{
|
|
navigate_to_tag(&navigation_view, "overview");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if navigation_view
|
|
.visible_page()
|
|
.and_then(|page| page.tag())
|
|
.is_some_and(|tag| tag.as_str() == "search_results")
|
|
{
|
|
refresh_visible_page(&navigation_view, &page_ctx);
|
|
} else {
|
|
navigate_to_tag(&navigation_view, "search_results");
|
|
}
|
|
});
|
|
}
|
|
|
|
filter_sidebar_rows(&sidebar, "", &collapsed_sections.borrow());
|
|
refresh_sidebar_section_icons(&sidebar, &collapsed_sections.borrow());
|
|
|
|
shell
|
|
}
|
|
|
|
fn build_navigation_view(ctx: &PageBuildContext) -> libadwaita::NavigationView {
|
|
let navigation_view = libadwaita::NavigationView::new();
|
|
navigation_view.add_css_class("app-navigation-view");
|
|
navigation_view.set_hexpand(true);
|
|
navigation_view.set_halign(gtk4::Align::Fill);
|
|
navigation_view.set_vexpand(true);
|
|
|
|
for item in pages::SIDEBAR_ITEMS {
|
|
if let Some(page) = pages::build_navigation_page(item.id, ctx) {
|
|
navigation_view.add(&page);
|
|
debug_log(&format!(
|
|
"nav: added page id='{}' title='{}'",
|
|
item.id, item.title
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(page) = pages::build_navigation_page("search_results", ctx) {
|
|
navigation_view.add(&page);
|
|
}
|
|
|
|
if app_settings().is_some() {
|
|
let ctx = ctx.clone();
|
|
navigation_view.connect_visible_page_notify(move |view| {
|
|
if let Some(page) = view.visible_page() {
|
|
if page.tag().is_some() {
|
|
refresh_visible_page(view, &ctx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
navigation_view
|
|
}
|
|
|
|
fn navigate_to_tag(navigation_view: &libadwaita::NavigationView, tag: &str) {
|
|
if navigation_view.find_page(tag).is_none() {
|
|
debug_log(&format!("nav: requested missing tag '{tag}'"));
|
|
return;
|
|
}
|
|
navigation_view.replace_with_tags(&[tag]);
|
|
}
|
|
|
|
fn refresh_visible_page(navigation_view: &libadwaita::NavigationView, ctx: &PageBuildContext) {
|
|
let Some(page) = navigation_view.visible_page() else {
|
|
return;
|
|
};
|
|
let Some(tag) = page.tag() else {
|
|
return;
|
|
};
|
|
let stable_width = ctx.parent_window.width();
|
|
let stable_height = ctx.parent_window.height();
|
|
if let Some(existing) = page.child() {
|
|
if pages::refresh_page_widget_in_place(tag.as_str(), &existing, ctx) {
|
|
let ctx_clone = ctx.clone();
|
|
glib::idle_add_local_once(move || {
|
|
pages::focus_pending_search_target(&ctx_clone);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
let preserve_scroll = ctx.pending_search_target.borrow().is_none();
|
|
let scroll_position = if preserve_scroll {
|
|
page.child()
|
|
.and_then(|child| child.downcast::<gtk4::ScrolledWindow>().ok())
|
|
.map(|scroll| scroll.vadjustment().value())
|
|
} else {
|
|
None
|
|
};
|
|
let Some(widget) = pages::build_page_widget(tag.as_str(), ctx) else {
|
|
return;
|
|
};
|
|
ctx.option_rows.borrow_mut().clear();
|
|
page.set_child(Some(&widget));
|
|
|
|
let window = ctx.parent_window.clone();
|
|
let ctx_clone = ctx.clone();
|
|
glib::idle_add_local_once(move || {
|
|
if stable_width > 0 && stable_height > 0 {
|
|
let (locked_width, locked_height) =
|
|
clamp_initial_window_size(stable_width, stable_height);
|
|
window.set_default_size(locked_width, locked_height);
|
|
}
|
|
if let Some(value) = scroll_position {
|
|
if let Some(child) = page.child() {
|
|
if let Ok(scroll) = child.downcast::<gtk4::ScrolledWindow>() {
|
|
scroll.vadjustment().set_value(value);
|
|
}
|
|
}
|
|
}
|
|
pages::focus_pending_search_target(&ctx_clone);
|
|
});
|
|
}
|
|
|
|
fn filter_sidebar_rows(sidebar: >k4::ListBox, query: &str, collapsed_sections: &HashSet<String>) {
|
|
let query = query.trim();
|
|
let mut section_has_visible_items = false;
|
|
let mut current_section_row: Option<gtk4::ListBoxRow> = None;
|
|
let mut current_section_name: Option<String> = None;
|
|
|
|
let mut child = sidebar.first_child();
|
|
while let Some(widget) = child {
|
|
let next = widget.next_sibling();
|
|
let Some(row) = widget.downcast_ref::<gtk4::ListBoxRow>() else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
|
|
if !row.is_activatable() {
|
|
if let Some(previous_section) = current_section_row.replace(row.clone()) {
|
|
previous_section.set_visible(section_has_visible_items || query.is_empty());
|
|
}
|
|
current_section_name = row
|
|
.widget_name()
|
|
.strip_prefix("section:")
|
|
.map(ToString::to_string);
|
|
section_has_visible_items = false;
|
|
row.set_visible(query.is_empty());
|
|
child = next;
|
|
continue;
|
|
}
|
|
|
|
let id = row.widget_name();
|
|
let item = pages::SIDEBAR_ITEMS
|
|
.iter()
|
|
.find(|item| item.id.eq_ignore_ascii_case(id.as_str()));
|
|
let matches = query.is_empty()
|
|
|| item.is_some_and(|item| pages::sidebar_search_text(item).contains(query));
|
|
let collapsed = current_section_name
|
|
.as_ref()
|
|
.is_some_and(|section| collapsed_sections.contains(section));
|
|
let visible = if query.is_empty() {
|
|
matches && !collapsed
|
|
} else {
|
|
matches
|
|
};
|
|
row.set_visible(visible);
|
|
section_has_visible_items |= visible;
|
|
child = next;
|
|
}
|
|
|
|
if let Some(section_row) = current_section_row {
|
|
section_row.set_visible(section_has_visible_items || query.is_empty());
|
|
}
|
|
}
|
|
|
|
fn default_collapsed_sections() -> HashSet<String> {
|
|
let mut collapsed = all_sidebar_sections();
|
|
collapsed.remove("Start");
|
|
collapsed
|
|
}
|
|
|
|
fn all_sidebar_sections() -> HashSet<String> {
|
|
pages::SIDEBAR_ITEMS
|
|
.iter()
|
|
.map(|item| item.section.to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn refresh_sidebar_section_icons(sidebar: >k4::ListBox, collapsed_sections: &HashSet<String>) {
|
|
let mut child = sidebar.first_child();
|
|
while let Some(widget) = child {
|
|
let next = widget.next_sibling();
|
|
let Some(row) = widget.downcast_ref::<gtk4::ListBoxRow>() else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
let section_name = row.widget_name();
|
|
let Some(section) = section_name.strip_prefix("section:") else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
let Some(button) = row
|
|
.child()
|
|
.and_then(|child| child.downcast::<gtk4::Button>().ok())
|
|
else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
let Some(header) = button
|
|
.child()
|
|
.and_then(|child| child.downcast::<gtk4::Box>().ok())
|
|
else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
let Some(icon) = header
|
|
.first_child()
|
|
.and_then(|child| child.downcast::<gtk4::Image>().ok())
|
|
else {
|
|
child = next;
|
|
continue;
|
|
};
|
|
icon.set_icon_name(Some(if collapsed_sections.contains(section) {
|
|
"pan-end-symbolic"
|
|
} else {
|
|
"pan-down-symbolic"
|
|
}));
|
|
child = next;
|
|
}
|
|
}
|
|
|
|
fn restore_active_page(
|
|
navigation_view: &libadwaita::NavigationView,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let _ = (navigation_view, settings);
|
|
}
|
|
|
|
struct LayerStackSummary {
|
|
primary_label: String,
|
|
labels: Vec<String>,
|
|
conflict_count: usize,
|
|
}
|
|
|
|
async fn discover_layer_summary(active_path: Option<PathBuf>) -> LayerStackSummary {
|
|
let mut labels = Vec::new();
|
|
let mut conflict_count = 0;
|
|
let mut primary_label = None;
|
|
if let Ok(xdg) = XdgPaths::resolve() {
|
|
if let Ok(layers) = Resolver::discover(&xdg).await {
|
|
conflict_count = Resolver::find_conflicts(&layers).len();
|
|
for layer in layers {
|
|
let label = format!(
|
|
"[{}] {}",
|
|
layer.priority,
|
|
Resolver::layer_label(&layer.source_type)
|
|
);
|
|
if active_path.as_ref() == layer.path.as_ref() {
|
|
primary_label = Some(label.clone());
|
|
}
|
|
labels.push(label);
|
|
}
|
|
}
|
|
|
|
if labels.is_empty() && xdg.global_config.exists() {
|
|
let fallback = format!("[●] {} (global)", xdg.global_config.display());
|
|
if active_path.as_ref() == Some(&xdg.global_config) {
|
|
primary_label = Some(fallback.clone());
|
|
}
|
|
labels.push(fallback);
|
|
}
|
|
}
|
|
|
|
if labels.is_empty() {
|
|
labels.push("Built-in MangoHud defaults only".to_string());
|
|
}
|
|
|
|
let primary_label =
|
|
primary_label.unwrap_or_else(|| labels.first().cloned().unwrap_or_default());
|
|
|
|
LayerStackSummary {
|
|
primary_label,
|
|
labels,
|
|
conflict_count,
|
|
}
|
|
}
|
|
|
|
fn refresh_layer_stack_widgets(
|
|
summary_label: >k4::Label,
|
|
summary_shell: >k4::Button,
|
|
conflict_badge: >k4::Label,
|
|
state: &Arc<Mutex<AppState>>,
|
|
) {
|
|
let summary_label = summary_label.clone();
|
|
let summary_shell = summary_shell.clone();
|
|
let conflict_badge = conflict_badge.clone();
|
|
let active_path = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone());
|
|
glib::MainContext::default().spawn_local(async move {
|
|
let summary = discover_layer_summary(active_path).await;
|
|
summary_label.set_text(&summary.primary_label);
|
|
summary_shell.set_tooltip_text(Some(&format!(
|
|
"Detected MangoHud layer stack:\\n{}",
|
|
summary.labels.join("\n")
|
|
)));
|
|
|
|
if summary.conflict_count == 0 {
|
|
conflict_badge.set_text("No conflicts detected");
|
|
conflict_badge.remove_css_class("shell-conflict-active");
|
|
} else {
|
|
conflict_badge.set_text(&format!(
|
|
"{} conflict{} detected",
|
|
summary.conflict_count,
|
|
if summary.conflict_count == 1 { "" } else { "s" }
|
|
));
|
|
conflict_badge.add_css_class("shell-conflict-active");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn install_window_actions(
|
|
window: &libadwaita::ApplicationWindow,
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
navigation_view: &libadwaita::NavigationView,
|
|
reload_banner: &libadwaita::Banner,
|
|
page_ctx: &PageBuildContext,
|
|
layer_stack: &LayerStackBarWidgets,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let navigate_action =
|
|
gio::SimpleAction::new("navigate-page", Some(&String::static_variant_type()));
|
|
{
|
|
let navigation_view = navigation_view.clone();
|
|
navigate_action.connect_activate(move |_, parameter| {
|
|
let Some(parameter) = parameter else {
|
|
return;
|
|
};
|
|
let Some(tag) = parameter.get::<String>() else {
|
|
return;
|
|
};
|
|
navigate_to_tag(&navigation_view, &tag);
|
|
});
|
|
}
|
|
window.add_action(&navigate_action);
|
|
|
|
let refresh_page_action = gio::SimpleAction::new("refresh-current-page", None);
|
|
{
|
|
let navigation_view = navigation_view.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
refresh_page_action.connect_activate(move |_, _| {
|
|
refresh_visible_page(&navigation_view, &page_ctx);
|
|
});
|
|
}
|
|
window.add_action(&refresh_page_action);
|
|
|
|
let refresh_layer_stack_action = gio::SimpleAction::new("refresh-layer-stack", None);
|
|
{
|
|
let state = state.clone();
|
|
let summary_label = layer_stack.summary_label.clone();
|
|
let summary_shell = layer_stack.summary_shell.clone();
|
|
let conflict_badge = layer_stack.conflict_badge.clone();
|
|
refresh_layer_stack_action.connect_activate(move |_, _| {
|
|
refresh_layer_stack_widgets(&summary_label, &summary_shell, &conflict_badge, &state);
|
|
});
|
|
}
|
|
window.add_action(&refresh_layer_stack_action);
|
|
|
|
let save_action = gio::SimpleAction::new("save", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.cloned();
|
|
save_action.connect_activate(move |_, _| {
|
|
let _ = save_current_config(&state, &save_button, &toast_overlay, settings.as_ref());
|
|
});
|
|
}
|
|
window.add_action(&save_action);
|
|
|
|
let revert_action = gio::SimpleAction::new("revert", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
revert_action.connect_activate(move |_, _| {
|
|
run_workspace_bool_action(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
|| restore_saved_snapshot(&state),
|
|
"Discarded unsaved changes and restored the last saved state",
|
|
"No unsaved changes to revert",
|
|
);
|
|
});
|
|
}
|
|
window.add_action(&revert_action);
|
|
|
|
let undo_action = gio::SimpleAction::new("undo", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
undo_action.connect_activate(move |_, _| {
|
|
run_workspace_bool_action(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
|| restore_saved_snapshot(&state),
|
|
"Discarded unsaved changes and restored the last saved state",
|
|
"Nothing to undo",
|
|
);
|
|
});
|
|
}
|
|
window.add_action(&undo_action);
|
|
|
|
let redo_action = gio::SimpleAction::new("redo", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
redo_action.connect_activate(move |_, _| {
|
|
let mut changed = false;
|
|
if let Ok(mut state) = state.lock() {
|
|
if let Some(snapshot) = state.redo_snapshot.take() {
|
|
state.config = snapshot;
|
|
state.config.dirty = true;
|
|
state.dirty = true;
|
|
state.validation.clear();
|
|
state.auto_disabled_dependents.clear();
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
refresh_workspace_after_config_change(&state, &save_button, &page_ctx);
|
|
crate::ui::toast::show_toast(&toast_overlay, "Redo applied");
|
|
} else {
|
|
crate::ui::toast::show_toast(&toast_overlay, "Nothing to redo");
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&redo_action);
|
|
|
|
let reload_action = gio::SimpleAction::new("reload-config", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.cloned();
|
|
let reload_banner = reload_banner.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
reload_action.connect_activate(move |_, _| {
|
|
let _ = reload_config_from_disk(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
);
|
|
reload_banner.set_revealed(false);
|
|
});
|
|
}
|
|
window.add_action(&reload_action);
|
|
|
|
let close_action = gio::SimpleAction::new("close-window", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
close_action.connect_activate(move |_, _| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
window.close();
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&close_action);
|
|
|
|
let shortcuts_action = gio::SimpleAction::new("shortcuts", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
shortcuts_action.connect_activate(move |_, _| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
show_shortcuts_window(&window);
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&shortcuts_action);
|
|
|
|
let auto_backup_action = gio::SimpleAction::new_stateful(
|
|
"auto-backup-on-save",
|
|
None,
|
|
&setting_bool(settings, "auto-backup-on-save", true).to_variant(),
|
|
);
|
|
{
|
|
let settings = settings.cloned();
|
|
auto_backup_action.connect_change_state(move |action, value| {
|
|
let Some(value) = value.and_then(bool::from_variant) else {
|
|
return;
|
|
};
|
|
action.set_state(&value.to_variant());
|
|
if let Some(settings) = settings.as_ref() {
|
|
let _ = settings.set_boolean("auto-backup-on-save", value);
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&auto_backup_action);
|
|
|
|
let backup_action = gio::SimpleAction::new("backup", None);
|
|
{
|
|
let state = state.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
backup_action.connect_activate(move |_, _| match backup_current_config(&state) {
|
|
Ok(path) => crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Backup created: {}", path.display()),
|
|
),
|
|
Err(err) => {
|
|
crate::ui::toast::show_toast(&toast_overlay, &format!("Backup failed: {err}"))
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&backup_action);
|
|
|
|
let restore_backup_action = gio::SimpleAction::new("restore-backup", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
restore_backup_action.connect_activate(move |_, _| match restore_latest_backup_into_state(
|
|
&state,
|
|
) {
|
|
Ok(path) => {
|
|
refresh_workspace_after_config_change(&state, &save_button, &page_ctx);
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Loaded safety backup from {}", path.display()),
|
|
);
|
|
}
|
|
Err(err) => crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Restore safety backup failed: {err}"),
|
|
),
|
|
});
|
|
}
|
|
window.add_action(&restore_backup_action);
|
|
|
|
let reset_defaults_action = gio::SimpleAction::new("reset-defaults", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.cloned();
|
|
reset_defaults_action.connect_activate(move |_, _| {
|
|
run_workspace_bool_action(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
|| reset_config_to_defaults(&state, settings.as_ref()),
|
|
"Reset the current config and preview defaults",
|
|
"Could not reset the current config",
|
|
);
|
|
});
|
|
}
|
|
window.add_action(&reset_defaults_action);
|
|
|
|
let switch_target_action =
|
|
gio::SimpleAction::new("switch-config-target", Some(&String::static_variant_type()));
|
|
{
|
|
let window_weak = window.downgrade();
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.cloned();
|
|
switch_target_action.connect_activate(move |_, parameter| {
|
|
let Some(window) = window_weak.upgrade() else {
|
|
return;
|
|
};
|
|
let Some(target_path) = parameter.and_then(|value| value.get::<String>()) else {
|
|
return;
|
|
};
|
|
let current_path = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone());
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.clone();
|
|
glib::MainContext::default().spawn_local(async move {
|
|
let targets = discover_switchable_config_targets(current_path.clone()).await;
|
|
let Some(target) = targets
|
|
.into_iter()
|
|
.find(|item| item.path.display().to_string() == target_path)
|
|
else {
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
"That config target is no longer available",
|
|
);
|
|
return;
|
|
};
|
|
switch_active_config_with_guard(
|
|
&window,
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
target,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
window.add_action(&switch_target_action);
|
|
|
|
let create_per_app_action = gio::SimpleAction::new("create-per-app-config", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.cloned();
|
|
create_per_app_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else {
|
|
return;
|
|
};
|
|
let window_for_create = window.clone();
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.clone();
|
|
show_create_per_app_dialog(&window, move |app_name| {
|
|
let Some(xdg) = XdgPaths::resolve().ok() else {
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
"Could not resolve the MangoHud config directory",
|
|
);
|
|
return;
|
|
};
|
|
|
|
match Resolver::create_per_app_config(&app_name, &xdg) {
|
|
Ok(path) => {
|
|
let target = ConfigSwitchTarget {
|
|
label: format!("Per-app ({app_name})"),
|
|
subtitle: path.display().to_string(),
|
|
current: false,
|
|
path,
|
|
};
|
|
switch_active_config_with_guard(
|
|
&window_for_create,
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
target,
|
|
);
|
|
}
|
|
Err(err) => crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Could not create per-app config: {err}"),
|
|
),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
window.add_action(&create_per_app_action);
|
|
|
|
let delete_current_per_app_action =
|
|
gio::SimpleAction::new("delete-current-per-app-config", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.cloned();
|
|
delete_current_per_app_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else {
|
|
return;
|
|
};
|
|
delete_current_per_app_config_with_guard(
|
|
&window,
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
);
|
|
});
|
|
}
|
|
window.add_action(&delete_current_per_app_action);
|
|
|
|
let save_as_action = gio::SimpleAction::new("save-as", None);
|
|
{
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.cloned();
|
|
let window_weak = window.downgrade();
|
|
save_as_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
let dialog = gtk4::FileDialog::builder()
|
|
.title("Save Config As")
|
|
.accept_label("Save")
|
|
.modal(true)
|
|
.initial_name("MangoHud.conf")
|
|
.build();
|
|
|
|
let initial_folder = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone())
|
|
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
|
|
.or_else(|| XdgPaths::resolve().ok().map(|xdg| xdg.mangohud_dir));
|
|
if let Some(folder) = initial_folder {
|
|
let folder_file = gio::File::for_path(folder);
|
|
dialog.set_initial_folder(Some(&folder_file));
|
|
}
|
|
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.clone();
|
|
glib::MainContext::default().spawn_local(async move {
|
|
match dialog.save_future(Some(&window)).await {
|
|
Ok(file) => {
|
|
let Some(path) = file.path() else {
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
"Selected save location has no local file path",
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Ok(mut guard) = state.lock() {
|
|
guard.config.path = Some(path);
|
|
guard.config.dirty = true;
|
|
guard.dirty = true;
|
|
}
|
|
recompute_validation(&state);
|
|
let _ = save_current_config(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
settings.as_ref(),
|
|
);
|
|
}
|
|
Err(err) => {
|
|
if !err.to_string().contains("Dismissed") {
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Save As failed: {err}"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
window.add_action(&save_as_action);
|
|
|
|
let show_conflicts = gio::SimpleAction::new("show-conflicts", None);
|
|
{
|
|
let navigation_view = navigation_view.clone();
|
|
show_conflicts.connect_activate(move |_, _| {
|
|
navigate_to_tag(&navigation_view, "conflicts");
|
|
});
|
|
}
|
|
window.add_action(&show_conflicts);
|
|
|
|
let refresh_detection = gio::SimpleAction::new("refresh-detection", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
let toast_overlay = toast_overlay.clone();
|
|
refresh_detection.connect_activate(move |_, _| {
|
|
let window_weak = window_weak.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
glib::MainContext::default().spawn_local(async move {
|
|
match detect::detect_system().await {
|
|
Ok(info) => {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
let title = if info.mangohud.installed {
|
|
"MangoTune"
|
|
} else {
|
|
"MangoTune (MangoHud not detected)"
|
|
};
|
|
window.set_title(Some(title));
|
|
}
|
|
crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!(
|
|
"Detection refreshed: display={:?}, gpu={:?}",
|
|
info.display_server, info.gpu.vendor
|
|
),
|
|
);
|
|
}
|
|
Err(err) => crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Refresh failed: {err}"),
|
|
),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
window.add_action(&refresh_detection);
|
|
|
|
let about_action = gio::SimpleAction::new("about", None);
|
|
{
|
|
let window_weak = window.downgrade();
|
|
about_action.connect_activate(move |_, _| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
show_about_dialog(&window);
|
|
}
|
|
});
|
|
}
|
|
window.add_action(&about_action);
|
|
}
|
|
|
|
fn install_close_guard(
|
|
window: &libadwaita::ApplicationWindow,
|
|
state: &Arc<Mutex<AppState>>,
|
|
preview: &PreviewController,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let bypass = Rc::new(Cell::new(false));
|
|
let state = state.clone();
|
|
let preview = preview.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.cloned();
|
|
|
|
window.connect_close_request(move |window| {
|
|
if bypass.get() {
|
|
bypass.set(false);
|
|
let _ = preview.stop();
|
|
persist_window_geometry(window, settings.as_ref());
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
|
|
let dirty = state.lock().map(|s| s.dirty).unwrap_or(false);
|
|
if !dirty {
|
|
let _ = preview.stop();
|
|
persist_window_geometry(window, settings.as_ref());
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
|
|
let body = format!(
|
|
"You have unsaved changes to {}. What would you like to do?",
|
|
active_config_label(&state)
|
|
);
|
|
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let settings = settings.clone();
|
|
let bypass_for_async = bypass.clone();
|
|
let win = window.clone();
|
|
let win_for_discard = win.clone();
|
|
let settings_for_discard = settings.clone();
|
|
let bypass_for_discard = bypass_for_async.clone();
|
|
let win_for_save = win.clone();
|
|
let settings_for_save = settings.clone();
|
|
let bypass_for_save = bypass_for_async.clone();
|
|
let state_for_save = state.clone();
|
|
let save_button_for_save = save_button.clone();
|
|
let toast_overlay_for_save = toast_overlay.clone();
|
|
show_unsaved_changes_dialog(
|
|
&win,
|
|
&body,
|
|
move || {
|
|
bypass_for_discard.set(true);
|
|
persist_window_geometry(&win_for_discard, settings_for_discard.as_ref());
|
|
win_for_discard.close();
|
|
},
|
|
move || {
|
|
if save_current_config(
|
|
&state_for_save,
|
|
&save_button_for_save,
|
|
&toast_overlay_for_save,
|
|
settings_for_save.as_ref(),
|
|
) {
|
|
bypass_for_save.set(true);
|
|
persist_window_geometry(&win_for_save, settings_for_save.as_ref());
|
|
win_for_save.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
glib::Propagation::Stop
|
|
});
|
|
}
|
|
|
|
fn show_unsaved_changes_dialog<FDiscard, FSave>(
|
|
parent: &libadwaita::ApplicationWindow,
|
|
body: &str,
|
|
on_discard: FDiscard,
|
|
on_save: FSave,
|
|
) where
|
|
FDiscard: Fn() + 'static,
|
|
FSave: Fn() + 'static,
|
|
{
|
|
let dialog = libadwaita::AlertDialog::builder()
|
|
.heading("Unsaved Changes")
|
|
.body(body)
|
|
.build();
|
|
dialog.add_responses(&[
|
|
("save", "Save"),
|
|
("cancel", "Cancel"),
|
|
("discard", "Discard Changes"),
|
|
]);
|
|
dialog.set_default_response(Some("save"));
|
|
dialog.set_close_response("cancel");
|
|
dialog.set_response_appearance("save", libadwaita::ResponseAppearance::Suggested);
|
|
dialog.set_response_appearance("discard", libadwaita::ResponseAppearance::Destructive);
|
|
dialog.choose(
|
|
parent,
|
|
None::<&gio::Cancellable>,
|
|
move |response| match response.as_str() {
|
|
"save" => on_save(),
|
|
"discard" => on_discard(),
|
|
_ => {}
|
|
},
|
|
);
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ConfigSwitchTarget {
|
|
label: String,
|
|
path: PathBuf,
|
|
subtitle: String,
|
|
current: bool,
|
|
}
|
|
|
|
async fn discover_switchable_config_targets(
|
|
current_path: Option<PathBuf>,
|
|
) -> Vec<ConfigSwitchTarget> {
|
|
let Some(xdg) = XdgPaths::resolve().ok() else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let Ok(layers) = Resolver::discover(&xdg).await else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let current_path_ref = current_path.as_ref();
|
|
let mut targets = Vec::new();
|
|
for layer in layers {
|
|
let Some(path) = layer.path.clone() else {
|
|
continue;
|
|
};
|
|
if !layer.is_editable {
|
|
continue;
|
|
}
|
|
if !matches!(
|
|
layer.source_type,
|
|
LayerSource::GlobalXdg | LayerSource::PerAppXdg(_)
|
|
) {
|
|
continue;
|
|
}
|
|
targets.push(ConfigSwitchTarget {
|
|
label: Resolver::layer_label(&layer.source_type),
|
|
subtitle: path.display().to_string(),
|
|
current: current_path_ref == Some(&path),
|
|
path,
|
|
});
|
|
}
|
|
|
|
targets.sort_by(|a, b| a.label.cmp(&b.label));
|
|
if let Some(global_idx) = targets
|
|
.iter()
|
|
.position(|target| target.label == "Saved global config")
|
|
{
|
|
let global = targets.remove(global_idx);
|
|
targets.insert(0, global);
|
|
}
|
|
targets
|
|
}
|
|
|
|
fn show_switch_config_menu(anchor: >k4::Button, targets: &[ConfigSwitchTarget]) {
|
|
let current_target = targets.iter().find(|target| target.current);
|
|
let (popover, content) = build_compact_popover();
|
|
popover.set_position(gtk4::PositionType::Bottom);
|
|
popover.set_parent(anchor);
|
|
|
|
for target in targets {
|
|
let title = if target.label == "Saved global config" {
|
|
target.label.clone()
|
|
} else {
|
|
target
|
|
.path
|
|
.file_stem()
|
|
.and_then(|stem| stem.to_str())
|
|
.map(ToOwned::to_owned)
|
|
.unwrap_or_else(|| target.label.clone())
|
|
};
|
|
let row = build_compact_popover_row(&title, None, None, target.current);
|
|
row.set_tooltip_text(Some(&target.subtitle));
|
|
let popover_clone = popover.clone();
|
|
let anchor = anchor.clone();
|
|
let target_value = target.path.display().to_string();
|
|
row.connect_clicked(move |_| {
|
|
activate_window_action(
|
|
&anchor
|
|
.root()
|
|
.and_then(|root| root.downcast::<libadwaita::ApplicationWindow>().ok())
|
|
.expect("window root"),
|
|
"win.switch-config-target",
|
|
Some(&target_value.to_variant()),
|
|
);
|
|
popover_clone.popdown();
|
|
});
|
|
content.append(&row);
|
|
}
|
|
|
|
content.append(&build_compact_popover_separator());
|
|
|
|
let create_row = build_compact_popover_row("Create New Per-App Config…", None, None, false);
|
|
{
|
|
let popover = popover.clone();
|
|
let anchor = anchor.clone();
|
|
create_row.connect_clicked(move |_| {
|
|
activate_window_action(
|
|
&anchor
|
|
.root()
|
|
.and_then(|root| root.downcast::<libadwaita::ApplicationWindow>().ok())
|
|
.expect("window root"),
|
|
"win.create-per-app-config",
|
|
None,
|
|
);
|
|
popover.popdown();
|
|
});
|
|
}
|
|
content.append(&create_row);
|
|
|
|
if let Some(current) = current_target {
|
|
if current.label.starts_with("Per-app (") {
|
|
let delete_row =
|
|
build_compact_popover_row("Delete Current Per-App Config…", None, None, false);
|
|
let popover = popover.clone();
|
|
let anchor = anchor.clone();
|
|
delete_row.connect_clicked(move |_| {
|
|
activate_window_action(
|
|
&anchor
|
|
.root()
|
|
.and_then(|root| root.downcast::<libadwaita::ApplicationWindow>().ok())
|
|
.expect("window root"),
|
|
"win.delete-current-per-app-config",
|
|
None,
|
|
);
|
|
popover.popdown();
|
|
});
|
|
content.append(&delete_row);
|
|
}
|
|
}
|
|
|
|
popover.connect_closed(|popover| {
|
|
popover.unparent();
|
|
});
|
|
popover.popup();
|
|
}
|
|
|
|
fn show_create_per_app_dialog(
|
|
parent: &libadwaita::ApplicationWindow,
|
|
on_create: impl Fn(String) + 'static,
|
|
) {
|
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
content.set_margin_top(8);
|
|
content.set_margin_bottom(4);
|
|
|
|
let help = gtk4::Label::new(Some(
|
|
"Search by game title or type the executable directly. Click a result to fill the config name.",
|
|
));
|
|
help.set_wrap(true);
|
|
help.set_xalign(0.0);
|
|
help.add_css_class("dim-label");
|
|
content.append(&help);
|
|
|
|
let entry = gtk4::Entry::new();
|
|
entry.set_placeholder_text(Some("cs2, valheim, vkcube"));
|
|
content.append(&entry);
|
|
|
|
let results_hint = gtk4::Label::new(Some(
|
|
"Start typing a game title or executable name to see matching suggestions.",
|
|
));
|
|
results_hint.set_wrap(true);
|
|
results_hint.set_xalign(0.0);
|
|
results_hint.add_css_class("dim-label");
|
|
content.append(&results_hint);
|
|
|
|
let results_scroll = gtk4::ScrolledWindow::new();
|
|
results_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
|
|
results_scroll.set_min_content_height(148);
|
|
results_scroll.add_css_class("config-hint-results-scroll");
|
|
results_scroll.set_visible(false);
|
|
|
|
let results_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
|
results_box.add_css_class("config-hint-results");
|
|
results_scroll.set_child(Some(&results_box));
|
|
content.append(&results_scroll);
|
|
|
|
let error_label = gtk4::Label::new(None);
|
|
error_label.set_xalign(0.0);
|
|
error_label.add_css_class("error");
|
|
error_label.set_visible(false);
|
|
content.append(&error_label);
|
|
|
|
let refresh_results = {
|
|
let entry = entry.clone();
|
|
let results_box = results_box.clone();
|
|
let results_hint = results_hint.clone();
|
|
let results_scroll = results_scroll.clone();
|
|
Rc::new(move || {
|
|
while let Some(child) = results_box.first_child() {
|
|
results_box.remove(&child);
|
|
}
|
|
|
|
let query = entry.text();
|
|
let query = query.trim();
|
|
if query.is_empty() {
|
|
results_scroll.set_visible(false);
|
|
results_hint.set_label(
|
|
"Start typing a game title or executable name to see matching suggestions.",
|
|
);
|
|
results_hint.set_visible(true);
|
|
return;
|
|
}
|
|
|
|
let matches = search_game_config_hints(query, 10);
|
|
if matches.is_empty() {
|
|
results_scroll.set_visible(false);
|
|
results_hint.set_label(
|
|
"No suggestions found. You can still create the config using the text you typed above.",
|
|
);
|
|
results_hint.set_visible(true);
|
|
return;
|
|
}
|
|
|
|
results_hint.set_visible(false);
|
|
results_scroll.set_visible(true);
|
|
|
|
for hint in matches {
|
|
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
|
row_box.add_css_class("config-hint-row");
|
|
row_box.set_hexpand(true);
|
|
row_box.set_halign(gtk4::Align::Fill);
|
|
|
|
let title = gtk4::Label::new(Some(&hint.title));
|
|
title.set_xalign(0.0);
|
|
title.add_css_class("config-hint-title");
|
|
|
|
let mut subtitle_text = if hint.verification == "verified" {
|
|
"Verified executable hint".to_string()
|
|
} else {
|
|
"Heuristic executable hint".to_string()
|
|
};
|
|
if let Some(appid) = hint.appid {
|
|
subtitle_text.push_str(&format!(" • AppID {appid}"));
|
|
}
|
|
let subtitle = gtk4::Label::new(Some(&subtitle_text));
|
|
subtitle.set_xalign(0.0);
|
|
subtitle.set_wrap(true);
|
|
subtitle.add_css_class("config-hint-subtitle");
|
|
|
|
let candidate_row = gtk4::FlowBox::new();
|
|
candidate_row.add_css_class("config-hint-candidates");
|
|
candidate_row.set_selection_mode(gtk4::SelectionMode::None);
|
|
candidate_row.set_activate_on_single_click(false);
|
|
candidate_row.set_halign(gtk4::Align::Start);
|
|
candidate_row.set_row_spacing(6);
|
|
candidate_row.set_column_spacing(6);
|
|
let candidates = std::iter::once(hint.preferred.clone())
|
|
.chain(
|
|
hint.candidates
|
|
.iter()
|
|
.filter(|candidate| candidate.as_str() != hint.preferred)
|
|
.cloned(),
|
|
)
|
|
.take(4)
|
|
.collect::<Vec<_>>();
|
|
let unique_candidates = {
|
|
let mut seen = std::collections::HashSet::new();
|
|
candidates
|
|
.into_iter()
|
|
.filter(|candidate| seen.insert(candidate.to_lowercase()))
|
|
.collect::<Vec<_>>()
|
|
};
|
|
for candidate in unique_candidates {
|
|
let chip = gtk4::Button::with_label(&candidate);
|
|
chip.add_css_class("flat");
|
|
chip.add_css_class("config-hint-candidate");
|
|
if candidate == hint.preferred {
|
|
chip.add_css_class("config-hint-candidate-primary");
|
|
}
|
|
let entry = entry.clone();
|
|
let candidate_value = candidate.clone();
|
|
chip.connect_clicked(move |_| {
|
|
entry.set_text(&candidate_value);
|
|
});
|
|
candidate_row.insert(&chip, -1);
|
|
}
|
|
|
|
let alternatives = hint
|
|
.candidates
|
|
.iter()
|
|
.filter(|candidate| candidate.as_str() != hint.preferred)
|
|
.take(2)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let detail_text = if alternatives.is_empty() {
|
|
format!("Suggested config name: {}", hint.preferred)
|
|
} else {
|
|
format!("Suggested config name: {}.", hint.preferred)
|
|
};
|
|
let details = gtk4::Label::new(Some(&detail_text));
|
|
details.set_xalign(0.0);
|
|
details.set_wrap(true);
|
|
details.add_css_class("config-hint-subtitle");
|
|
|
|
row_box.append(&title);
|
|
row_box.append(&subtitle);
|
|
row_box.append(&candidate_row);
|
|
row_box.append(&details);
|
|
|
|
results_box.append(&row_box);
|
|
}
|
|
})
|
|
};
|
|
refresh_results();
|
|
{
|
|
let refresh_results = refresh_results.clone();
|
|
entry.connect_changed(move |_| {
|
|
refresh_results();
|
|
});
|
|
}
|
|
|
|
let dialog = libadwaita::AlertDialog::builder()
|
|
.heading("Create New Per-App Config")
|
|
.body("Create a MangoHud config for one executable or process name.")
|
|
.extra_child(&content)
|
|
.build();
|
|
dialog.add_responses(&[("create", "Create"), ("cancel", "Cancel")]);
|
|
dialog.set_default_response(Some("create"));
|
|
dialog.set_close_response("cancel");
|
|
dialog.set_response_appearance("create", libadwaita::ResponseAppearance::Suggested);
|
|
|
|
let entry_for_response = entry.clone();
|
|
let error_for_response = error_label.clone();
|
|
dialog.connect_response(Some("create"), move |_dialog, response| {
|
|
if response != "create" {
|
|
return;
|
|
}
|
|
|
|
let name = entry_for_response.text().trim().to_string();
|
|
if name.is_empty() {
|
|
error_for_response.set_label("Enter an app name first.");
|
|
error_for_response.set_visible(true);
|
|
return;
|
|
}
|
|
|
|
on_create(name);
|
|
});
|
|
|
|
dialog.present(Some(parent));
|
|
glib::idle_add_local_once(move || {
|
|
entry.grab_focus();
|
|
});
|
|
}
|
|
|
|
fn show_delete_per_app_dialog(
|
|
parent: &libadwaita::ApplicationWindow,
|
|
label: &str,
|
|
on_delete: impl Fn() + 'static,
|
|
) {
|
|
let dialog = libadwaita::AlertDialog::builder()
|
|
.heading("Delete Per-App Config?")
|
|
.body(format!(
|
|
"{label} will be removed from ~/.config/MangoHud and MangoTune will switch back to the saved global config."
|
|
))
|
|
.build();
|
|
dialog.add_responses(&[("delete", "Delete"), ("cancel", "Cancel")]);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
dialog.set_response_appearance("delete", libadwaita::ResponseAppearance::Destructive);
|
|
dialog.choose(parent, None::<&gio::Cancellable>, move |response| {
|
|
if response == "delete" {
|
|
on_delete();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn delete_current_per_app_config_with_guard(
|
|
parent: &libadwaita::ApplicationWindow,
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
page_ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let Some(xdg) = XdgPaths::resolve().ok() else {
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
"Could not resolve the MangoHud config directory",
|
|
);
|
|
return;
|
|
};
|
|
|
|
let Some(current_path) = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone())
|
|
else {
|
|
crate::ui::toast::show_toast(toast_overlay, "No active config target to remove");
|
|
return;
|
|
};
|
|
|
|
if current_path == xdg.global_config {
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
"The saved global config cannot be removed here",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some(file_name) = current_path.file_name().and_then(|name| name.to_str()) else {
|
|
crate::ui::toast::show_toast(toast_overlay, "That config target cannot be removed");
|
|
return;
|
|
};
|
|
if file_name == "MangoHud.conf" {
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
"The saved global config cannot be removed here",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let label = format!(
|
|
"Per-app ({})",
|
|
current_path
|
|
.file_stem()
|
|
.and_then(|stem| stem.to_str())
|
|
.unwrap_or("unknown")
|
|
);
|
|
let dirty = state.lock().map(|guard| guard.dirty).unwrap_or(false);
|
|
|
|
let perform_delete: Rc<dyn Fn()> = Rc::new({
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let global_path = xdg.global_config.clone();
|
|
let current_path = current_path.clone();
|
|
let label = label.clone();
|
|
let settings = settings.cloned();
|
|
move || match std::fs::remove_file(¤t_path) {
|
|
Ok(_) => {
|
|
let _ = load_config_into_state(
|
|
&global_path,
|
|
"Saved global config",
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
);
|
|
crate::ui::toast::show_toast(&toast_overlay, &format!("Deleted {label}"));
|
|
}
|
|
Err(err) => crate::ui::toast::show_toast(
|
|
&toast_overlay,
|
|
&format!("Could not delete {label}: {err}"),
|
|
),
|
|
}
|
|
});
|
|
|
|
if !dirty {
|
|
let parent = parent.clone();
|
|
let perform_delete = perform_delete.clone();
|
|
show_delete_per_app_dialog(&parent, &label, move || perform_delete());
|
|
return;
|
|
}
|
|
|
|
let body = format!(
|
|
"You have unsaved changes to {}. Save before deleting {}?",
|
|
active_config_label(state),
|
|
label
|
|
);
|
|
|
|
let parent_for_discard = parent.clone();
|
|
let parent_for_save = parent.clone();
|
|
let label_for_discard = label.clone();
|
|
let label_for_save = label.clone();
|
|
let state_for_save = state.clone();
|
|
let save_button_for_save = save_button.clone();
|
|
let toast_for_save = toast_overlay.clone();
|
|
let settings_for_save = settings.cloned();
|
|
let perform_delete_after_save = perform_delete.clone();
|
|
let perform_delete_after_discard = perform_delete.clone();
|
|
show_unsaved_changes_dialog(
|
|
parent,
|
|
&body,
|
|
move || {
|
|
let perform_delete = perform_delete_after_discard.clone();
|
|
show_delete_per_app_dialog(&parent_for_discard, &label_for_discard, move || {
|
|
perform_delete()
|
|
});
|
|
},
|
|
move || {
|
|
if save_current_config(
|
|
&state_for_save,
|
|
&save_button_for_save,
|
|
&toast_for_save,
|
|
settings_for_save.as_ref(),
|
|
) {
|
|
let perform_delete = perform_delete_after_save.clone();
|
|
show_delete_per_app_dialog(&parent_for_save, &label_for_save, move || {
|
|
perform_delete()
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn switch_active_config_with_guard(
|
|
parent: &libadwaita::ApplicationWindow,
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
page_ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
target: ConfigSwitchTarget,
|
|
) {
|
|
let current_path = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone());
|
|
if current_path.as_ref() == Some(&target.path) {
|
|
crate::ui::toast::show_toast(toast_overlay, &format!("Already editing {}", target.label));
|
|
return;
|
|
}
|
|
|
|
let dirty = state.lock().map(|guard| guard.dirty).unwrap_or(false);
|
|
if !dirty {
|
|
let _ = load_config_into_state(
|
|
&target.path,
|
|
&target.label,
|
|
state,
|
|
save_button,
|
|
toast_overlay,
|
|
page_ctx,
|
|
settings,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let body = format!(
|
|
"You have unsaved changes to {}. Save before switching the active config to {}?",
|
|
active_config_label(state),
|
|
target.label
|
|
);
|
|
|
|
let state_for_discard = state.clone();
|
|
let save_button_for_discard = save_button.clone();
|
|
let toast_for_discard = toast_overlay.clone();
|
|
let page_ctx_for_discard = page_ctx.clone();
|
|
let settings_for_discard = settings.cloned();
|
|
let target_for_discard = target.clone();
|
|
|
|
let state_for_save = state.clone();
|
|
let save_button_for_save = save_button.clone();
|
|
let toast_for_save = toast_overlay.clone();
|
|
let page_ctx_for_save = page_ctx.clone();
|
|
let settings_for_save = settings.cloned();
|
|
let target_for_save = target.clone();
|
|
|
|
show_unsaved_changes_dialog(
|
|
parent,
|
|
&body,
|
|
move || {
|
|
let _ = load_config_into_state(
|
|
&target_for_discard.path,
|
|
&target_for_discard.label,
|
|
&state_for_discard,
|
|
&save_button_for_discard,
|
|
&toast_for_discard,
|
|
&page_ctx_for_discard,
|
|
settings_for_discard.as_ref(),
|
|
);
|
|
},
|
|
move || {
|
|
if save_current_config(
|
|
&state_for_save,
|
|
&save_button_for_save,
|
|
&toast_for_save,
|
|
settings_for_save.as_ref(),
|
|
) {
|
|
let _ = load_config_into_state(
|
|
&target_for_save.path,
|
|
&target_for_save.label,
|
|
&state_for_save,
|
|
&save_button_for_save,
|
|
&toast_for_save,
|
|
&page_ctx_for_save,
|
|
settings_for_save.as_ref(),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
fn load_config_into_state(
|
|
path: &Path,
|
|
label: &str,
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
page_ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) -> bool {
|
|
match Parser::read(path) {
|
|
Ok(mut parsed) => {
|
|
normalize_legacy_option_values(&mut parsed);
|
|
if let Ok(mut guard) = state.lock() {
|
|
guard.config = parsed;
|
|
guard.saved_snapshot = guard.config.clone();
|
|
guard.config.dirty = false;
|
|
guard.dirty = false;
|
|
clear_workspace_session_state(&mut guard);
|
|
}
|
|
if let Some(settings) = settings {
|
|
let _ = settings.set_string("last-config-path", &path.display().to_string());
|
|
}
|
|
refresh_workspace_after_config_load(state, save_button, page_ctx);
|
|
crate::ui::toast::show_toast(
|
|
toast_overlay,
|
|
&format!("Switched active config to {label}"),
|
|
);
|
|
true
|
|
}
|
|
Err(err) => {
|
|
crate::ui::toast::show_toast(toast_overlay, &format!("Could not load {label}: {err}"));
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn install_external_config_watcher(
|
|
state: &Arc<Mutex<AppState>>,
|
|
banner: &libadwaita::Banner,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
save_button: &libadwaita::SplitButton,
|
|
page_ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) -> Option<RecommendedWatcher> {
|
|
let config_path = state.lock().ok()?.config.path.clone()?;
|
|
let watch_target = config_path.parent()?.to_path_buf();
|
|
|
|
let (sender, receiver) = mpsc::channel::<()>();
|
|
{
|
|
let banner = banner.clone();
|
|
glib::timeout_add_local(Duration::from_millis(300), move || {
|
|
let mut has_event = false;
|
|
while receiver.try_recv().is_ok() {
|
|
has_event = true;
|
|
}
|
|
if has_event {
|
|
banner.set_revealed(true);
|
|
}
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
let sender_for_watch = sender.clone();
|
|
let state_for_watch = state.clone();
|
|
let mut watcher =
|
|
notify::recommended_watcher(move |event_result: notify::Result<notify::Event>| {
|
|
let Ok(event) = event_result else {
|
|
return;
|
|
};
|
|
|
|
if !matches!(
|
|
event.kind,
|
|
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let current_path = state_for_watch
|
|
.lock()
|
|
.ok()
|
|
.and_then(|guard| guard.config.path.clone());
|
|
let Some(current_path) = current_path else {
|
|
return;
|
|
};
|
|
|
|
if event.paths.is_empty() || event.paths.iter().any(|path| path == ¤t_path) {
|
|
let _ = sender_for_watch.send(());
|
|
}
|
|
})
|
|
.ok()?;
|
|
|
|
if watcher
|
|
.configure(NotifyConfig::default())
|
|
.and_then(|_| watcher.watch(&watch_target, RecursiveMode::NonRecursive))
|
|
.is_err()
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let state = state.clone();
|
|
let save_button = save_button.clone();
|
|
let toast_overlay = toast_overlay.clone();
|
|
let page_ctx = page_ctx.clone();
|
|
let settings = settings.cloned();
|
|
let banner_clone = banner.clone();
|
|
banner.connect_button_clicked(move |_| {
|
|
let _ = reload_config_from_disk(
|
|
&state,
|
|
&save_button,
|
|
&toast_overlay,
|
|
&page_ctx,
|
|
settings.as_ref(),
|
|
);
|
|
banner_clone.set_revealed(false);
|
|
});
|
|
|
|
Some(watcher)
|
|
}
|
|
|
|
fn backup_current_config(state: &Arc<Mutex<AppState>>) -> anyhow::Result<PathBuf> {
|
|
let mut config = state
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("failed to lock app state"))?
|
|
.config
|
|
.clone();
|
|
normalize_legacy_option_values(&mut config);
|
|
write_backup_snapshot(&config, "manual")
|
|
}
|
|
|
|
fn restore_latest_backup_into_state(state: &Arc<Mutex<AppState>>) -> anyhow::Result<PathBuf> {
|
|
let source = state
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("failed to lock app state"))?
|
|
.config
|
|
.path
|
|
.clone()
|
|
.ok_or_else(|| anyhow::anyhow!("no backing file path for current config"))?;
|
|
|
|
let backup = latest_backup_for_path(&source, &["manual"])?;
|
|
let mut parsed = Parser::read(&backup)?;
|
|
normalize_legacy_option_values(&mut parsed);
|
|
parsed.path = Some(source);
|
|
parsed.dirty = true;
|
|
|
|
let mut guard = state
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("failed to lock app state"))?;
|
|
guard.config = parsed;
|
|
guard.dirty = true;
|
|
clear_workspace_session_state(&mut guard);
|
|
|
|
Ok(backup)
|
|
}
|
|
|
|
fn reset_config_to_defaults(
|
|
state: &Arc<Mutex<AppState>>,
|
|
settings: Option<&gio::Settings>,
|
|
) -> bool {
|
|
let Ok(mut guard) = state.lock() else {
|
|
return false;
|
|
};
|
|
let path = guard.config.path.clone();
|
|
guard.config = default_config_for_path(path);
|
|
guard.dirty = true;
|
|
clear_workspace_session_state(&mut guard);
|
|
drop(guard);
|
|
reset_app_preferences_to_defaults(settings);
|
|
true
|
|
}
|
|
|
|
fn default_config_for_path(path: Option<PathBuf>) -> AnnotatedConfig {
|
|
let resolved_path = path.or_else(|| XdgPaths::resolve().ok().map(|xdg| xdg.global_config));
|
|
let mut config = AnnotatedConfig {
|
|
lines: vec![
|
|
ConfigLine::Comment("### MangoHud configuration - managed by MangoTune".to_string()),
|
|
ConfigLine::Comment("### Reset to MangoTune defaults".to_string()),
|
|
ConfigLine::Blank,
|
|
],
|
|
options: indexmap::IndexMap::new(),
|
|
path: resolved_path,
|
|
dirty: true,
|
|
};
|
|
|
|
for (key, value) in mangotune_default_config_updates() {
|
|
Parser::set_value(&mut config, key, value);
|
|
}
|
|
|
|
config
|
|
}
|
|
|
|
fn mangotune_default_config_updates() -> Vec<(&'static str, mangotune::config::types::ConfigValue)>
|
|
{
|
|
use mangotune::config::types::ConfigValue;
|
|
|
|
vec![
|
|
("fps", ConfigValue::Flag),
|
|
("frametime", ConfigValue::Flag),
|
|
("frame_timing", ConfigValue::Disabled),
|
|
("gpu_stats", ConfigValue::Flag),
|
|
("gpu_temp", ConfigValue::Flag),
|
|
("cpu_stats", ConfigValue::Flag),
|
|
("cpu_temp", ConfigValue::Flag),
|
|
("ram", ConfigValue::Flag),
|
|
("vram", ConfigValue::Flag),
|
|
("hud_compact", ConfigValue::Flag),
|
|
("position", ConfigValue::Value("top-left".to_string())),
|
|
("offset_x", ConfigValue::Value("0".to_string())),
|
|
("offset_y", ConfigValue::Value("0".to_string())),
|
|
("horizontal", ConfigValue::Disabled),
|
|
("horizontal_stretch", ConfigValue::Disabled),
|
|
("hud_no_margin", ConfigValue::Disabled),
|
|
("text_outline", ConfigValue::Disabled),
|
|
("background_alpha", ConfigValue::Value("0.28".to_string())),
|
|
("alpha", ConfigValue::Value("1.00".to_string())),
|
|
("round_corners", ConfigValue::Value("12".to_string())),
|
|
("font_size", ConfigValue::Value("24".to_string())),
|
|
("font_scale", ConfigValue::Value("1.00".to_string())),
|
|
("width", ConfigValue::Value("340".to_string())),
|
|
]
|
|
}
|
|
|
|
fn reset_app_preferences_to_defaults(settings: Option<&gio::Settings>) {
|
|
let Some(settings) = settings else {
|
|
return;
|
|
};
|
|
|
|
for key in [
|
|
"show-raw-editor",
|
|
"auto-backup-on-save",
|
|
"test-window-width",
|
|
"test-window-height",
|
|
"dock-test-windows",
|
|
"preview-scene",
|
|
"preview-studio-scene",
|
|
"preview-load",
|
|
"preview-fps-cap",
|
|
"preview-vsync",
|
|
"preview-vram-pressure",
|
|
"preview-particle-count",
|
|
"preview-particle-size",
|
|
"preview-gpu-passes",
|
|
"preview-interaction-steps",
|
|
] {
|
|
settings.reset(key);
|
|
}
|
|
}
|
|
|
|
fn apply_preview_current_config(page_ctx: &PageBuildContext) {
|
|
pages::apply_live_preview_now(page_ctx);
|
|
}
|
|
|
|
fn refresh_workspace_after_config_change(
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
page_ctx: &PageBuildContext,
|
|
) {
|
|
recompute_validation(state);
|
|
refresh_save_button(state, save_button);
|
|
apply_preview_current_config(page_ctx);
|
|
let _ = gtk4::prelude::WidgetExt::activate_action(
|
|
&page_ctx.parent_window,
|
|
"win.refresh-current-page",
|
|
None,
|
|
);
|
|
}
|
|
|
|
fn refresh_workspace_after_config_load(
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
page_ctx: &PageBuildContext,
|
|
) {
|
|
refresh_workspace_after_config_change(state, save_button, page_ctx);
|
|
let _ = gtk4::prelude::WidgetExt::activate_action(
|
|
&page_ctx.parent_window,
|
|
"win.refresh-layer-stack",
|
|
None,
|
|
);
|
|
}
|
|
|
|
fn clear_workspace_session_state(state: &mut AppState) {
|
|
state.validation.clear();
|
|
state.redo_snapshot = None;
|
|
state.auto_disabled_dependents.clear();
|
|
}
|
|
|
|
fn run_workspace_bool_action(
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
page_ctx: &PageBuildContext,
|
|
operation: impl FnOnce() -> bool,
|
|
success_message: &str,
|
|
failure_message: &str,
|
|
) {
|
|
if operation() {
|
|
refresh_workspace_after_config_change(state, save_button, page_ctx);
|
|
crate::ui::toast::show_toast(toast_overlay, success_message);
|
|
} else {
|
|
crate::ui::toast::show_toast(toast_overlay, failure_message);
|
|
}
|
|
}
|
|
|
|
fn reload_config_from_disk(
|
|
state: &Arc<Mutex<AppState>>,
|
|
save_button: &libadwaita::SplitButton,
|
|
toast_overlay: &libadwaita::ToastOverlay,
|
|
page_ctx: &PageBuildContext,
|
|
settings: Option<&gio::Settings>,
|
|
) -> bool {
|
|
let path = {
|
|
let Ok(state) = state.lock() else {
|
|
crate::ui::toast::show_toast(toast_overlay, "Could not acquire config state");
|
|
return false;
|
|
};
|
|
state.config.path.clone()
|
|
};
|
|
|
|
let Some(path) = path else {
|
|
crate::ui::toast::show_toast(toast_overlay, "No config path to reload");
|
|
return false;
|
|
};
|
|
|
|
match Parser::read(&path) {
|
|
Ok(mut parsed) => {
|
|
normalize_legacy_option_values(&mut parsed);
|
|
if let Ok(mut state) = state.lock() {
|
|
state.config = parsed;
|
|
state.saved_snapshot = state.config.clone();
|
|
state.config.dirty = false;
|
|
state.dirty = false;
|
|
clear_workspace_session_state(&mut state);
|
|
}
|
|
if let Some(settings) = settings {
|
|
let _ = settings.set_string("last-config-path", &path.display().to_string());
|
|
}
|
|
refresh_workspace_after_config_load(state, save_button, page_ctx);
|
|
crate::ui::toast::show_toast(toast_overlay, "Reloaded config from disk");
|
|
true
|
|
}
|
|
Err(err) => {
|
|
crate::ui::toast::show_toast(toast_overlay, &format!("Reload failed: {err}"));
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn persist_window_geometry(
|
|
window: &libadwaita::ApplicationWindow,
|
|
settings: Option<&gio::Settings>,
|
|
) {
|
|
let Some(settings) = settings else {
|
|
return;
|
|
};
|
|
|
|
let width = window.width();
|
|
let height = window.height();
|
|
if width > 0 {
|
|
let _ = settings.set_int("window-width", width);
|
|
}
|
|
if height > 0 {
|
|
let _ = settings.set_int("window-height", height);
|
|
}
|
|
}
|
|
|
|
fn active_config_label(state: &Arc<Mutex<AppState>>) -> String {
|
|
let Ok(state) = state.lock() else {
|
|
return "current config".to_string();
|
|
};
|
|
|
|
state
|
|
.config
|
|
.path
|
|
.as_ref()
|
|
.and_then(|path| path.file_name())
|
|
.and_then(|name| name.to_str())
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| "current config".to_string())
|
|
}
|
|
|
|
fn restore_saved_snapshot(state: &Arc<Mutex<AppState>>) -> bool {
|
|
let Ok(mut state) = state.lock() else {
|
|
return false;
|
|
};
|
|
if !state.dirty {
|
|
return false;
|
|
}
|
|
|
|
state.redo_snapshot = Some(state.config.clone());
|
|
state.config = state.saved_snapshot.clone();
|
|
state.config.dirty = false;
|
|
state.dirty = false;
|
|
state.validation.clear();
|
|
state.auto_disabled_dependents.clear();
|
|
true
|
|
}
|
|
|
|
fn show_shortcuts_window(parent: &libadwaita::ApplicationWindow) {
|
|
let window = gtk4::Window::builder()
|
|
.title("Keyboard Shortcuts")
|
|
.default_width(520)
|
|
.default_height(420)
|
|
.transient_for(parent)
|
|
.modal(true)
|
|
.build();
|
|
window.add_css_class("preferences-shell");
|
|
let (root, body, footer) = tool_page::build_utility_window_shell(
|
|
"Shortcuts",
|
|
"Keyboard Shortcuts",
|
|
"Global shortcuts available while MangoTune is focused.",
|
|
);
|
|
|
|
let group = tool_page::append_custom_section(
|
|
&body,
|
|
"Global shortcuts",
|
|
"These apply to the main MangoTune window while it is focused.",
|
|
None,
|
|
);
|
|
|
|
for (title, accelerator) in [
|
|
("Save", "Ctrl+S"),
|
|
("Discard unsaved changes", "Ctrl+Z"),
|
|
("Redo", "Ctrl+Shift+Z"),
|
|
("Reload config", "Ctrl+R"),
|
|
("Close window", "Ctrl+W"),
|
|
("Refresh detection", "F5"),
|
|
] {
|
|
let row = libadwaita::ActionRow::builder()
|
|
.title(title)
|
|
.subtitle(accelerator)
|
|
.build();
|
|
row.add_css_class("preferences-row");
|
|
group.add(&row);
|
|
}
|
|
|
|
let close = gtk4::Button::with_label("Close");
|
|
close.add_css_class("shell-strip-button");
|
|
{
|
|
let window = window.clone();
|
|
close.connect_clicked(move |_| {
|
|
window.close();
|
|
});
|
|
}
|
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
|
spacer.set_hexpand(true);
|
|
footer.append(&spacer);
|
|
footer.append(&close);
|
|
|
|
window.set_child(Some(&root));
|
|
window.present();
|
|
}
|
|
|
|
fn show_about_dialog(window: &libadwaita::ApplicationWindow) {
|
|
let about = libadwaita::AboutDialog::builder()
|
|
.application_name("MangoTune")
|
|
.application_icon("com.mangotune.MangoTune")
|
|
.version(env!("CARGO_PKG_VERSION"))
|
|
.comments("A modern, accurate MangoHud configurator for Linux")
|
|
.license_type(gtk4::License::MitX11)
|
|
.website("https://github.com/your-org/mangotune")
|
|
.issue_url("https://github.com/your-org/mangotune/issues")
|
|
.developer_name("MangoTune Contributors")
|
|
.build();
|
|
about.add_legal_section(
|
|
"MangoTune",
|
|
Some("Copyright (c) MangoTune Contributors"),
|
|
gtk4::License::MitX11,
|
|
None,
|
|
);
|
|
about.add_legal_section(
|
|
"Third-Party Dependencies",
|
|
Some("Dependencies are licensed by their respective authors"),
|
|
gtk4::License::Unknown,
|
|
Some("See THIRD_PARTY_LICENSES.md in the project root for dependency license details."),
|
|
);
|
|
about.present(Some(window));
|
|
}
|
|
|
|
pub(crate) fn app_settings() -> Option<gio::Settings> {
|
|
let schema_id = "com.mangotune.MangoTune";
|
|
|
|
if let Some(default_source) = gio::SettingsSchemaSource::default() {
|
|
if let Some(schema) = default_source.lookup(schema_id, true) {
|
|
return Some(gio::Settings::new_full(
|
|
&schema,
|
|
None::<&gio::SettingsBackend>,
|
|
None::<&str>,
|
|
));
|
|
}
|
|
}
|
|
|
|
let parent_source = gio::SettingsSchemaSource::default();
|
|
let local_schema_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data");
|
|
if let Ok(source) =
|
|
gio::SettingsSchemaSource::from_directory(&local_schema_dir, parent_source.as_ref(), false)
|
|
{
|
|
if let Some(schema) = source.lookup(schema_id, true) {
|
|
return Some(gio::Settings::new_full(
|
|
&schema,
|
|
None::<&gio::SettingsBackend>,
|
|
None::<&str>,
|
|
));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn validation_errors(validation: &HashMap<String, ValidationResult>) -> Vec<(String, String)> {
|
|
validation
|
|
.iter()
|
|
.filter_map(|(key, result)| match result {
|
|
ValidationResult::Error(message) => Some((key.clone(), message.clone())),
|
|
ValidationResult::Warning(_) | ValidationResult::Ok => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn setting_bool(settings: Option<&gio::Settings>, key: &str, default: bool) -> bool {
|
|
settings.map(|s| s.boolean(key)).unwrap_or(default)
|
|
}
|
|
|
|
fn create_backup_copy_for_path(source: &std::path::Path, label: &str) -> anyhow::Result<PathBuf> {
|
|
let backup = backup_path_for(source, label);
|
|
std::fs::copy(source, &backup)?;
|
|
Ok(backup)
|
|
}
|
|
|
|
fn write_backup_snapshot(config: &AnnotatedConfig, label: &str) -> anyhow::Result<PathBuf> {
|
|
let source = config
|
|
.path
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("no backing file path for current config"))?;
|
|
let backup = backup_path_for(source, label);
|
|
let mut backup_config = config.clone();
|
|
backup_config.path = Some(backup.clone());
|
|
backup_config.dirty = false;
|
|
Parser::write(&backup_config)?;
|
|
Ok(backup)
|
|
}
|
|
|
|
fn backup_path_for(source: &std::path::Path, label: &str) -> PathBuf {
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis())
|
|
.unwrap_or(0);
|
|
PathBuf::from(format!("{}.{}-backup-{ts}.bak", source.display(), label))
|
|
}
|
|
|
|
fn latest_backup_for_path(source: &std::path::Path, labels: &[&str]) -> anyhow::Result<PathBuf> {
|
|
let parent = source
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("backup search requires a parent directory"))?;
|
|
let file_name = source
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.ok_or_else(|| anyhow::anyhow!("source config has no valid file name"))?;
|
|
let prefix = format!("{file_name}.");
|
|
|
|
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
|
|
for entry in std::fs::read_dir(parent)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
if !name.starts_with(&prefix) || !name.ends_with(".bak") {
|
|
continue;
|
|
}
|
|
if !labels.is_empty()
|
|
&& !labels
|
|
.iter()
|
|
.any(|label| name.contains(&format!(".{label}-backup-")))
|
|
{
|
|
continue;
|
|
}
|
|
let modified = entry
|
|
.metadata()
|
|
.and_then(|meta| meta.modified())
|
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
match &newest {
|
|
Some((current, _)) if modified <= *current => {}
|
|
_ => newest = Some((modified, path)),
|
|
}
|
|
}
|
|
|
|
newest
|
|
.map(|(_, path)| path)
|
|
.ok_or_else(|| anyhow::anyhow!("no backup files found for {}", source.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
default_collapsed_sections, default_config_for_path, latest_backup_for_path,
|
|
restore_saved_snapshot, AppState,
|
|
};
|
|
use mangotune::config::normalize::normalize_legacy_option_values;
|
|
use mangotune::config::parser::Parser;
|
|
use mangotune::config::types::ConfigValue;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn normalizes_flag_zero_to_disabled() {
|
|
let mut config = Parser::parse_str("horizontal_stretch=0\n", None);
|
|
let changed = normalize_legacy_option_values(&mut config);
|
|
assert_eq!(changed, 1);
|
|
assert!(matches!(
|
|
config.options.get("horizontal_stretch").map(|item| &item.1),
|
|
Some(ConfigValue::Disabled)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_flag_one_to_flag() {
|
|
let mut config = Parser::parse_str("horizontal_stretch=1\n", None);
|
|
let changed = normalize_legacy_option_values(&mut config);
|
|
assert_eq!(changed, 1);
|
|
assert!(matches!(
|
|
config.options.get("horizontal_stretch").map(|item| &item.1),
|
|
Some(ConfigValue::Flag)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn default_sidebar_collapses_deep_sections() {
|
|
let collapsed = default_collapsed_sections();
|
|
assert!(collapsed.contains("Display"));
|
|
assert!(collapsed.contains("Appearance"));
|
|
assert!(collapsed.contains("Behavior"));
|
|
assert!(collapsed.contains("Advanced"));
|
|
assert!(collapsed.contains("Tools"));
|
|
assert!(!collapsed.contains("Start"));
|
|
}
|
|
|
|
#[test]
|
|
fn restore_saved_snapshot_restores_and_tracks_redo() {
|
|
let saved = Parser::parse_str("fps\n", None);
|
|
let mut current = Parser::parse_str("frametime\n", None);
|
|
current.dirty = true;
|
|
let auto_disabled_dependents = HashMap::from([(
|
|
"fps".to_string(),
|
|
HashSet::from(["fps_color_change".to_string()]),
|
|
)]);
|
|
|
|
let state = Arc::new(Mutex::new(AppState {
|
|
config: current.clone(),
|
|
validation: HashMap::new(),
|
|
dirty: true,
|
|
saved_snapshot: saved.clone(),
|
|
redo_snapshot: None,
|
|
auto_disabled_dependents,
|
|
}));
|
|
|
|
assert!(restore_saved_snapshot(&state));
|
|
|
|
let guard = state.lock().expect("state");
|
|
assert_eq!(guard.config.options, saved.options);
|
|
assert!(!guard.dirty);
|
|
assert!(guard.redo_snapshot.is_some());
|
|
assert_eq!(
|
|
guard.redo_snapshot.as_ref().map(|cfg| &cfg.options),
|
|
Some(¤t.options)
|
|
);
|
|
assert!(guard.auto_disabled_dependents.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_uses_sane_dashboard_baseline() {
|
|
let config = default_config_for_path(Some(PathBuf::from("/tmp/MangoHud.conf")));
|
|
assert!(matches!(
|
|
config.options.get("fps").map(|entry| &entry.1),
|
|
Some(ConfigValue::Flag)
|
|
));
|
|
assert!(matches!(
|
|
config.options.get("gpu_stats").map(|entry| &entry.1),
|
|
Some(ConfigValue::Flag)
|
|
));
|
|
assert!(matches!(
|
|
config.options.get("position").map(|entry| &entry.1),
|
|
Some(ConfigValue::Value(value)) if value == "top-left"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn latest_backup_prefers_manual_safety_backups() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let source = dir.path().join("MangoHud.conf");
|
|
fs::write(&source, "fps\n").expect("source");
|
|
|
|
let manual = dir.path().join("MangoHud.conf.manual-backup-111.bak");
|
|
let autosave = dir.path().join("MangoHud.conf.autosave-backup-999.bak");
|
|
fs::write(&manual, "manual\n").expect("manual");
|
|
fs::write(&autosave, "autosave\n").expect("autosave");
|
|
|
|
let picked = latest_backup_for_path(&source, &["manual"]).expect("manual backup");
|
|
assert_eq!(picked, manual);
|
|
}
|
|
}
|