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> to editable pages. pub struct AppState { pub config: AnnotatedConfig, pub validation: HashMap, pub dirty: bool, pub saved_snapshot: AnnotatedConfig, pub redo_snapshot: Option, pub auto_disabled_dependents: HashMap>, } pub struct MainWindow { pub window: libadwaita::ApplicationWindow, _config_watcher: Option, _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::() 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>, 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>) { 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>, 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>, 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::>() .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::().ok()) { if let Some(row) = container .first_child() .and_then(|child| child.next_sibling()) .and_then(|child| child.downcast::().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::().ok()) { if let Some(mark) = shell .last_child() .and_then(|child| child.downcast::().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>, 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::().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::() { scroll.vadjustment().set_value(value); } } } pages::focus_pending_search_target(&ctx_clone); }); } fn filter_sidebar_rows(sidebar: >k4::ListBox, query: &str, collapsed_sections: &HashSet) { let query = query.trim(); let mut section_has_visible_items = false; let mut current_section_row: Option = None; let mut current_section_name: Option = None; let mut child = sidebar.first_child(); while let Some(widget) = child { let next = widget.next_sibling(); let Some(row) = widget.downcast_ref::() 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 { let mut collapsed = all_sidebar_sections(); collapsed.remove("Start"); collapsed } fn all_sidebar_sections() -> HashSet { pages::SIDEBAR_ITEMS .iter() .map(|item| item.section.to_string()) .collect() } fn refresh_sidebar_section_icons(sidebar: >k4::ListBox, collapsed_sections: &HashSet) { let mut child = sidebar.first_child(); while let Some(widget) = child { let next = widget.next_sibling(); let Some(row) = widget.downcast_ref::() 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::().ok()) else { child = next; continue; }; let Some(header) = button .child() .and_then(|child| child.downcast::().ok()) else { child = next; continue; }; let Some(icon) = header .first_child() .and_then(|child| child.downcast::().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, conflict_count: usize, } async fn discover_layer_summary(active_path: Option) -> 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>, ) { 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>, 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::() 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::()) 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>, 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( 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, ) -> Vec { 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::().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::().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::().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::>(); let unique_candidates = { let mut seen = std::collections::HashSet::new(); candidates .into_iter() .filter(|candidate| seen.insert(candidate.to_lowercase())) .collect::>() }; 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::>(); 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>, 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 = 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>, 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>, 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>, banner: &libadwaita::Banner, toast_overlay: &libadwaita::ToastOverlay, save_button: &libadwaita::SplitButton, page_ctx: &PageBuildContext, settings: Option<&gio::Settings>, ) -> Option { 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| { 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>) -> anyhow::Result { 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>) -> anyhow::Result { 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>, 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) -> 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>, 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>, 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>, 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>, 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>) -> 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>) -> 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 { 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) -> 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 { let backup = backup_path_for(source, label); std::fs::copy(source, &backup)?; Ok(backup) } fn write_backup_snapshot(config: &AnnotatedConfig, label: &str) -> anyhow::Result { 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 { 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); } }