From 86c4a1132126ec74ce7a5eb07b75dfaf80034bca Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Tue, 31 Mar 2026 20:01:35 -0400 Subject: [PATCH] fix: guard preview updates behind blocking validation 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. --- data/style.css | 10 ++ src/config/validator.rs | 26 +++- src/ui/pages/mod.rs | 226 +++++++++++++++++++++++++++++- src/ui/pages/overview/cards.rs | 29 +++- src/ui/pages/overview/mod.rs | 2 +- src/ui/pages/overview/presets.rs | 7 +- src/ui/pages/overview/preview.rs | 47 ++++++- src/ui/pages/overview/profiles.rs | 5 +- src/ui/pages/raw_editor.rs | 16 ++- src/ui/widgets/toggle_row.rs | 77 +++++----- src/window.rs | 30 ++-- 11 files changed, 392 insertions(+), 83 deletions(-) diff --git a/data/style.css b/data/style.css index d09b62c..4eb6ab9 100644 --- a/data/style.css +++ b/data/style.css @@ -718,6 +718,16 @@ preferencesgroup list row.search-target-flash box { border: 1px solid @mt_border; } +.preview-status-warning { + padding: 7px 10px; + border-radius: 2px; + background: alpha(@mt_danger, 0.10); + color: @mt_text; + border: 1px solid alpha(@mt_danger, 0.28); + font-size: 0.8em; + line-height: 1.3; +} + .position-grid { padding: 8px; border-radius: 2px; diff --git a/src/config/validator.rs b/src/config/validator.rs index 286d429..78cdf8f 100644 --- a/src/config/validator.rs +++ b/src/config/validator.rs @@ -493,7 +493,20 @@ fn apply_threshold_shape_checks( let value_count = csv_value_count(config, value_key); let color_count = csv_value_count(config, color_key); - if value_count == 0 || color_count == 0 || value_count == color_count { + if value_count == 0 || color_count == 0 { + let message = format!( + "'{}' and '{}' are required while '{}' is enabled", + value_key, color_key, toggle_key + ); + issues.insert( + value_key.to_string(), + ValidationResult::Error(message.clone()), + ); + issues.insert(color_key.to_string(), ValidationResult::Error(message)); + continue; + } + + if value_count == color_count { continue; } @@ -702,6 +715,17 @@ mod tests { assert!(deps.iter().any( |(dependent, required)| dependent == "fps_color_change" && required == "fps_color" )); + + let issues = validate_all(&cfg); + assert!(matches!( + issues.get("fps_value"), + Some(ValidationResult::Error(_)) + )); + assert!(matches!( + issues.get("fps_color"), + Some(ValidationResult::Error(_)) + )); + assert!(!is_saveable(&cfg)); } #[test] diff --git a/src/ui/pages/mod.rs b/src/ui/pages/mod.rs index 81df3cb..315db33 100644 --- a/src/ui/pages/mod.rs +++ b/src/ui/pages/mod.rs @@ -82,6 +82,7 @@ pub struct SearchResultGroup { pub enum PreviewConfigUpdateMode { DebouncedApply, ImmediateApply, + Restart, } #[derive(Debug, Clone, Copy)] @@ -494,6 +495,17 @@ pub fn sync_config_ui_with_validation_rows(ctx: &PageBuildContext) { refresh_save_button(&ctx.state, &ctx.save_button); } +pub fn replace_workspace_config(ctx: &PageBuildContext, mut config: AnnotatedConfig, dirty: bool) { + if let Ok(mut state) = ctx.state.lock() { + config.dirty = dirty; + state.config = config; + state.dirty = dirty; + state.validation.clear(); + state.redo_snapshot = None; + state.auto_disabled_dependents.clear(); + } +} + fn apply_preview_config_snapshot( ctx: &PageBuildContext, config: &AnnotatedConfig, @@ -506,6 +518,9 @@ fn apply_preview_config_snapshot( .apply_live_config(config) .or_else(|_| ctx.preview.restart(config)); } + PreviewConfigUpdateMode::Restart => { + let _ = ctx.preview.restart(config); + } } } @@ -514,6 +529,13 @@ pub fn update_live_preview(ctx: &PageBuildContext, mode: PreviewConfigUpdateMode return; } + if !preview_updates_allowed(ctx) { + if let Some(source) = ctx.preview_reload_source.borrow_mut().take() { + source.remove(); + } + return; + } + if let Some(source) = ctx.preview_reload_source.borrow_mut().take() { source.remove(); } @@ -527,6 +549,11 @@ pub fn update_live_preview(ctx: &PageBuildContext, mode: PreviewConfigUpdateMode return glib::ControlFlow::Break; } + if !preview_updates_allowed(&ctx_clone) { + *ctx_clone.preview_reload_source.borrow_mut() = None; + return glib::ControlFlow::Break; + } + let config = current_config_snapshot(&ctx_clone); apply_preview_config_snapshot( &ctx_clone, @@ -543,18 +570,93 @@ pub fn update_live_preview(ctx: &PageBuildContext, mode: PreviewConfigUpdateMode let config = current_config_snapshot(ctx); apply_preview_config_snapshot(ctx, &config, mode); } + PreviewConfigUpdateMode::Restart => { + let config = current_config_snapshot(ctx); + apply_preview_config_snapshot(ctx, &config, mode); + } } } +fn preview_updates_allowed(ctx: &PageBuildContext) -> bool { + let Ok(state) = ctx.state.lock() else { + return false; + }; + validator::is_saveable(&state.config) +} + pub fn refresh_live_preview_for_key(ctx: &PageBuildContext, key: Option<&str>) { - let _ = key; - update_live_preview(ctx, PreviewConfigUpdateMode::DebouncedApply); + if key.is_some_and(|key| key_has_blocking_validation_error(ctx, key)) { + return; + } + let mode = key + .map(|key| preview_update_mode_for_key(key, PreviewConfigUpdateMode::DebouncedApply)) + .unwrap_or(PreviewConfigUpdateMode::DebouncedApply); + update_live_preview(ctx, mode); } pub fn apply_live_preview_now(ctx: &PageBuildContext) { update_live_preview(ctx, PreviewConfigUpdateMode::ImmediateApply); } +pub fn apply_live_preview_for_key_now(ctx: &PageBuildContext, key: &str) { + if key_has_blocking_validation_error(ctx, key) { + return; + } + let mode = preview_update_mode_for_key(key, PreviewConfigUpdateMode::ImmediateApply); + update_live_preview(ctx, mode); +} + +fn key_has_blocking_validation_error(ctx: &PageBuildContext, key: &str) -> bool { + let Ok(state) = ctx.state.lock() else { + return false; + }; + preview_validation_keys(key).into_iter().any(|candidate| { + matches!( + state.validation.get(candidate.as_str()), + Some(ValidationResult::Error(_)) + ) + }) +} + +fn preview_validation_keys(key: &str) -> Vec { + let mut keys = vec![key.to_string()]; + if let Some(schema) = get_schema_entry(key) { + keys.extend( + schema + .dependencies + .iter() + .map(|dependency| dependency.to_string()), + ); + } + keys +} + +fn preview_update_mode_for_key( + key: &str, + default_mode: PreviewConfigUpdateMode, +) -> PreviewConfigUpdateMode { + if key_requires_preview_restart(key) { + PreviewConfigUpdateMode::Restart + } else { + default_mode + } +} + +fn key_requires_preview_restart(key: &str) -> bool { + matches!( + key, + "fps_color_change" + | "fps_value" + | "fps_color" + | "gpu_load_change" + | "gpu_load_value" + | "gpu_load_color" + | "cpu_load_change" + | "cpu_load_value" + | "cpu_load_color" + ) +} + pub fn refresh_current_page_later(window: &libadwaita::ApplicationWindow) { let window = window.clone(); glib::idle_add_local_once(move || { @@ -1012,3 +1114,123 @@ where let runtime = Builder::new_current_thread().enable_all().build().ok()?; Some(runtime.block_on(future)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_threshold_toggle( + parent_key: &str, + threshold_toggle_key: &str, + threshold_value_key: &str, + threshold_color_key: &str, + ) -> AnnotatedConfig { + let mut config = AnnotatedConfig { + lines: Vec::new(), + options: indexmap::IndexMap::new(), + path: None, + dirty: false, + }; + Parser::set_value(&mut config, parent_key, ConfigValue::Flag); + Parser::set_value(&mut config, threshold_toggle_key, ConfigValue::Flag); + Parser::set_value( + &mut config, + threshold_value_key, + ConfigValue::Value("60,90".to_string()), + ); + Parser::set_value( + &mut config, + threshold_color_key, + ConfigValue::Value("00ff00,ff0000".to_string()), + ); + config + } + + #[test] + fn disabling_parent_toggle_only_remembers_threshold_toggle() { + let mut config = + config_with_threshold_toggle("fps", "fps_color_change", "fps_value", "fps_color"); + let mut seen = HashSet::new(); + let mut disabled_dependents = false; + let mut remembered_dependents = HashSet::new(); + + disable_toggle_with_dependents_in_config( + &mut config, + "fps", + &OptionType::Flag, + &mut seen, + &mut disabled_dependents, + &mut remembered_dependents, + ); + + assert!(disabled_dependents); + assert!(remembered_dependents.contains("fps_color_change")); + assert!(!remembered_dependents.contains("fps_value")); + assert!(!remembered_dependents.contains("fps_color")); + assert!(matches!( + config.options.get("fps_color_change").map(|entry| &entry.1), + Some(ConfigValue::Disabled) + )); + assert!(matches!( + config.options.get("fps_value").map(|entry| &entry.1), + Some(ConfigValue::Value(text)) if text == "60,90" + )); + assert!(matches!( + config.options.get("fps_color").map(|entry| &entry.1), + Some(ConfigValue::Value(text)) if text == "00ff00,ff0000" + )); + } + + #[test] + fn restoring_remembered_threshold_toggle_keeps_threshold_values() { + let mut config = config_with_threshold_toggle( + "gpu_stats", + "gpu_load_change", + "gpu_load_value", + "gpu_load_color", + ); + let mut seen = HashSet::new(); + let mut disabled_dependents = false; + let mut remembered_dependents = HashSet::new(); + + disable_toggle_with_dependents_in_config( + &mut config, + "gpu_stats", + &OptionType::Flag, + &mut seen, + &mut disabled_dependents, + &mut remembered_dependents, + ); + + enable_toggle_with_dependencies_in_config(&mut config, "gpu_stats", &OptionType::Flag); + for dependent in remembered_dependents { + let schema = get_schema_entry(&dependent).expect("schema entry"); + enable_toggle_with_dependencies_in_config(&mut config, schema.key, &schema.option_type); + } + + assert!(matches!( + config.options.get("gpu_stats").map(|entry| &entry.1), + Some(ConfigValue::Flag) + )); + assert!(matches!( + config.options.get("gpu_load_change").map(|entry| &entry.1), + Some(ConfigValue::Flag) + )); + assert!(matches!( + config.options.get("gpu_load_value").map(|entry| &entry.1), + Some(ConfigValue::Value(text)) if text == "60,90" + )); + assert!(matches!( + config.options.get("gpu_load_color").map(|entry| &entry.1), + Some(ConfigValue::Value(text)) if text == "00ff00,ff0000" + )); + } + + #[test] + fn preview_validation_gate_includes_threshold_toggle_dependencies() { + let keys = preview_validation_keys("fps_color_change"); + assert!(keys.iter().any(|key| key == "fps_color_change")); + assert!(keys.iter().any(|key| key == "fps_value")); + assert!(keys.iter().any(|key| key == "fps_color")); + } +} diff --git a/src/ui/pages/overview/cards.rs b/src/ui/pages/overview/cards.rs index 34efc25..0b6109e 100644 --- a/src/ui/pages/overview/cards.rs +++ b/src/ui/pages/overview/cards.rs @@ -1,4 +1,5 @@ use super::*; +use mangotune::config::validator; pub(super) fn build_position_card(ctx: &PageBuildContext) -> gtk4::Box { let card = dashboard_card(); @@ -602,11 +603,18 @@ pub(super) fn refresh_preview_widgets( ctx: &PageBuildContext, start_button: >k4::Button, status_label: >k4::Label, + validation_notice: >k4::Label, reload_button: >k4::Button, restart_button: >k4::Button, stop_button: >k4::Button, ) { let snapshot = ctx.preview.snapshot(); + let preview_config_valid = validator::is_saveable(¤t_config_snapshot(ctx)); + let (errors, _) = ctx + .state + .lock() + .map(|state| validation_counts(&state.validation)) + .unwrap_or((0, 0)); status_label.set_text(&snapshot.status); status_label.remove_css_class("preview-status-live"); status_label.remove_css_class("preview-status-idle"); @@ -616,9 +624,24 @@ pub(super) fn refresh_preview_widgets( status_label.add_css_class("preview-status-idle"); } - start_button.set_sensitive(!snapshot.running); - reload_button.set_sensitive(snapshot.running); - restart_button.set_sensitive(snapshot.running && snapshot.can_restart); + if preview_config_valid { + validation_notice.set_visible(false); + validation_notice.set_text(""); + } else { + let message = if errors > 0 { + format!( + "Preview updates are paused while this config has {errors} validation error(s). Fix the highlighted issues to re-enable Start, Apply, and Restart." + ) + } else { + "Preview updates are paused until validation errors are fixed. Fix the highlighted issues to re-enable Start, Apply, and Restart.".to_string() + }; + validation_notice.set_text(&message); + validation_notice.set_visible(true); + } + + start_button.set_sensitive(!snapshot.running && preview_config_valid); + reload_button.set_sensitive(snapshot.running && preview_config_valid); + restart_button.set_sensitive(snapshot.running && snapshot.can_restart && preview_config_valid); stop_button.set_sensitive(snapshot.running); } diff --git a/src/ui/pages/overview/mod.rs b/src/ui/pages/overview/mod.rs index e9cfee1..05ccd96 100644 --- a/src/ui/pages/overview/mod.rs +++ b/src/ui/pages/overview/mod.rs @@ -1,7 +1,7 @@ use crate::ui::pages::{ apply_live_preview_now, current_config_snapshot, disable_toggle_with_dependents, enable_toggle_with_dependencies, refresh_current_page_later, refresh_live_preview_for_key, - sync_config_ui, PageBuildContext, + replace_workspace_config, sync_config_ui, PageBuildContext, }; use crate::ui::toast::show_toast; use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex}; diff --git a/src/ui/pages/overview/presets.rs b/src/ui/pages/overview/presets.rs index 6900aed..139aded 100644 --- a/src/ui/pages/overview/presets.rs +++ b/src/ui/pages/overview/presets.rs @@ -167,12 +167,7 @@ fn apply_dashboard_preset_profile(ctx: &PageBuildContext, preset: DashboardPrese return false; }; - let Ok(mut state) = ctx.state.lock() else { - return false; - }; - state.config = loaded; - state.dirty = true; - drop(state); + replace_workspace_config(ctx, loaded, true); sync_config_ui(ctx); true } diff --git a/src/ui/pages/overview/preview.rs b/src/ui/pages/overview/preview.rs index 36bd8e5..b559159 100644 --- a/src/ui/pages/overview/preview.rs +++ b/src/ui/pages/overview/preview.rs @@ -1,5 +1,6 @@ use super::cards::{current_config_position, install_scroll_passthrough, refresh_preview_widgets}; use super::*; +use mangotune::config::validator; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PreviewProfile { @@ -206,6 +207,7 @@ fn persist_studio_options(studio: &PreviewStudioOptions) { struct PreviewSessionWidgets { start_button: gtk4::Button, status_label: gtk4::Label, + validation_notice: gtk4::Label, reload_button: gtk4::Button, restart_button: gtk4::Button, stop_button: gtk4::Button, @@ -217,6 +219,7 @@ impl PreviewSessionWidgets { ctx, &self.start_button, &self.status_label, + &self.validation_notice, &self.reload_button, &self.restart_button, &self.stop_button, @@ -291,8 +294,14 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { status_hint.add_css_class("dim-label"); status_hint.set_wrap(true); status_hint.set_xalign(0.0); + let validation_notice = gtk4::Label::new(None); + validation_notice.add_css_class("preview-status-warning"); + validation_notice.set_wrap(true); + validation_notice.set_xalign(0.0); + validation_notice.set_visible(false); session_body.append(&status_label); session_body.append(&status_hint); + session_body.append(&validation_notice); let buttons = gtk4::Grid::new(); buttons.add_css_class("dashboard-preview-actions"); @@ -326,6 +335,7 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let preview_controls = PreviewSessionWidgets { start_button: start_button.clone(), status_label: status_label.clone(), + validation_notice: validation_notice.clone(), reload_button: reload_button.clone(), restart_button: restart_button.clone(), stop_button: stop_button.clone(), @@ -977,7 +987,10 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let preview_controls = preview_controls.clone(); let studio_defaults = studio_defaults.clone(); start_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); + let Some(config) = preview_config_if_saveable(&ctx) else { + preview_controls.refresh(&ctx); + return; + }; let (width, height) = preview_window_settings(PreviewScene::Studio, &config); let studio = studio_defaults.borrow().clone(); @@ -996,7 +1009,10 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let ctx = ctx.clone(); let preview_controls = preview_controls.clone(); reload_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); + let Some(config) = preview_config_if_saveable(&ctx) else { + preview_controls.refresh(&ctx); + return; + }; finish_preview_pid_action( &ctx, &preview_controls, @@ -1011,7 +1027,10 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let ctx = ctx.clone(); let preview_controls = preview_controls.clone(); restart_button.clone().connect_clicked(move |_| { - let config = current_config_snapshot(&ctx); + let Some(config) = preview_config_if_saveable(&ctx) else { + preview_controls.refresh(&ctx); + return; + }; finish_preview_pid_action( &ctx, &preview_controls, @@ -1034,6 +1053,7 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let ctx = ctx.clone(); let start_button = start_button.downgrade(); let status_label = status_label.downgrade(); + let validation_notice = validation_notice.downgrade(); let reload_button = reload_button.downgrade(); let restart_button = restart_button.downgrade(); let stop_button = stop_button.downgrade(); @@ -1041,12 +1061,14 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { let ( Some(start_button), Some(status_label), + Some(validation_notice), Some(reload_button), Some(restart_button), Some(stop_button), ) = ( start_button.upgrade(), status_label.upgrade(), + validation_notice.upgrade(), reload_button.upgrade(), restart_button.upgrade(), stop_button.upgrade(), @@ -1058,6 +1080,7 @@ pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { &ctx, &start_button, &status_label, + &validation_notice, &reload_button, &restart_button, &stop_button, @@ -1307,7 +1330,10 @@ fn maybe_restart_active_preview( return; }; - let config = current_config_snapshot(ctx); + let Some(config) = preview_config_if_saveable(ctx) else { + preview_controls.refresh(ctx); + return; + }; let (width, height) = preview_window_settings(scene, &config); if let Ok(pid) = ctx .preview @@ -1349,3 +1375,16 @@ fn finish_preview_stop_action( } preview_controls.refresh(ctx); } + +fn preview_config_if_saveable(ctx: &PageBuildContext) -> Option { + let config = current_config_snapshot(ctx); + if validator::is_saveable(&config) { + Some(config) + } else { + show_toast( + &ctx.toast_overlay, + "Preview updates are paused until validation errors are fixed", + ); + None + } +} diff --git a/src/ui/pages/overview/profiles.rs b/src/ui/pages/overview/profiles.rs index d6b35da..f91edcb 100644 --- a/src/ui/pages/overview/profiles.rs +++ b/src/ui/pages/overview/profiles.rs @@ -159,10 +159,7 @@ pub(super) fn build_profiles_panel(ctx: &PageBuildContext) -> gtk4::Box { let target_path = current_config_snapshot(&ctx).path; match stored_profiles::load_profile_from_path(&path, target_path) { Ok(loaded) => { - if let Ok(mut state) = ctx.state.lock() { - state.config = loaded; - state.dirty = true; - } + replace_workspace_config(&ctx, loaded, true); sync_config_ui(&ctx); apply_live_preview_now(&ctx); refresh_current_page_later(&ctx.parent_window); diff --git a/src/ui/pages/raw_editor.rs b/src/ui/pages/raw_editor.rs index 9a9e529..f8a9154 100644 --- a/src/ui/pages/raw_editor.rs +++ b/src/ui/pages/raw_editor.rs @@ -1,5 +1,6 @@ use crate::ui::pages::{ - apply_live_preview_now, refresh_current_page_later, sync_config_ui, PageBuildContext, + apply_live_preview_now, refresh_current_page_later, replace_workspace_config, sync_config_ui, + PageBuildContext, }; use crate::ui::toast::show_toast; use crate::ui::widgets::tool_page; @@ -143,12 +144,13 @@ fn open_editor_window(ctx: &PageBuildContext) { let end = buffer_apply.end_iter(); let text = buffer_apply.text(&start, &end, false).to_string(); - if let Ok(mut state) = ctx_apply.state.lock() { - let parsed = Parser::parse_str(&text, state.config.path.clone()); - state.config = parsed; - state.config.dirty = true; - state.dirty = true; - } + let current_path = ctx_apply + .state + .lock() + .ok() + .and_then(|state| state.config.path.clone()); + let parsed = Parser::parse_str(&text, current_path); + replace_workspace_config(&ctx_apply, parsed, true); update_footer(&footer_apply, &text); sync_config_ui(&ctx_apply); diff --git a/src/ui/widgets/toggle_row.rs b/src/ui/widgets/toggle_row.rs index 8c295c4..2f868fc 100644 --- a/src/ui/widgets/toggle_row.rs +++ b/src/ui/widgets/toggle_row.rs @@ -1,7 +1,8 @@ use crate::ui::pages::{ - apply_live_preview_now, disable_toggle_with_dependents, enable_toggle_with_dependencies, - refresh_current_page_later, refresh_live_preview_for_key, register_option_row, - register_validation_row, sync_config_ui, sync_config_ui_with_validation_rows, PageBuildContext, + apply_live_preview_for_key_now, disable_toggle_with_dependents, + enable_toggle_with_dependencies, refresh_current_page_later, refresh_live_preview_for_key, + register_option_row, register_validation_row, sync_config_ui, + sync_config_ui_with_validation_rows, PageBuildContext, }; use crate::ui::toast::show_toast; use crate::ui::widgets::color_row; @@ -62,7 +63,7 @@ pub fn build_switch_row( } maybe_show_conflict_toast(&ctx_clone, &key_owned); - apply_live_preview_now(&ctx_clone); + apply_live_preview_for_key_now(&ctx_clone, &key_owned); if dependency_state_changed { refresh_current_page_later(&ctx_clone.parent_window); @@ -235,6 +236,10 @@ pub fn build_entry_row( entry.add_css_class("control-field"); entry.set_hexpand(false); configure_entry_for_option_type(&entry, &schema.option_type); + install_text_insert_filter(&entry, { + let option_type = schema.option_type.clone(); + move |text| normalize_entry_text(&option_type, text) + }); entry.set_placeholder_text(Some("Enter value")); if let Some(value) = current_string_value(ctx, key) { entry.set_text(&value); @@ -248,21 +253,9 @@ pub fn build_entry_row( let row_clone = row.clone(); let pending_preview_refresh = Rc::new(Cell::new(false)); let pending_refresh_for_change = pending_preview_refresh.clone(); - let syncing = Rc::new(Cell::new(false)); - let syncing_for_change = syncing.clone(); entry.connect_changed(move |entry| { - if syncing_for_change.get() { - return; - } let raw = entry.text().to_string(); let normalized = normalize_entry_text(&schema_clone.option_type, &raw); - if normalized != raw { - let pos = entry.position(); - syncing_for_change.set(true); - entry.set_text(&normalized); - entry.set_position(pos.min(normalized.chars().count() as i32)); - syncing_for_change.set(false); - } apply_value( &ctx_clone, @@ -323,6 +316,9 @@ pub fn build_int_triplet_row( }) .collect(), ); + for entry in entries.iter() { + install_text_insert_filter(entry, normalize_threshold_triplet_text); + } if let Some(value) = current_string_value(ctx, key) { for (index, part) in value @@ -347,7 +343,6 @@ pub fn build_int_triplet_row( let subtitle_owned = subtitle.to_string(); let row_clone = row.clone(); let pending_preview_refresh = Rc::new(Cell::new(false)); - let syncing = Rc::new(Cell::new(false)); for entry in entries.iter() { let key_owned = key_owned.clone(); @@ -357,22 +352,7 @@ pub fn build_int_triplet_row( let row_clone = row_clone.clone(); let entries_clone = entries.clone(); let pending_refresh_for_change = pending_preview_refresh.clone(); - let syncing_for_change = syncing.clone(); - entry.connect_changed(move |entry| { - if syncing_for_change.get() { - return; - } - - let raw = entry.text().to_string(); - let normalized = normalize_threshold_triplet_text(&raw); - if normalized != raw { - let pos = entry.position(); - syncing_for_change.set(true); - entry.set_text(&normalized); - entry.set_position(pos.min(normalized.chars().count() as i32)); - syncing_for_change.set(false); - } - + entry.connect_changed(move |_| { let joined = entries_clone .iter() .map(|item| item.text().to_string()) @@ -677,25 +657,32 @@ pub fn configure_spin_button_for_option_type(row: >k4::SpinButton, option_type editable.insert_text(&filtered, position); syncing_for_insert.set(false); }); +} - let syncing_for_change = syncing.clone(); - let option_type_for_change = option_type.clone(); - row.connect_changed(move |editable| { - if syncing_for_change.get() { +fn install_text_insert_filter(entry: >k4::Entry, normalize: F) +where + F: Fn(&str) -> String + 'static, +{ + let syncing = Rc::new(Cell::new(false)); + let syncing_for_insert = syncing.clone(); + entry.connect_insert_text(move |editable, new_text, position| { + if syncing_for_insert.get() { return; } - let current = editable.text().to_string(); - let normalized = normalize_spin_text(&option_type_for_change, ¤t); - if normalized == current { + let filtered = normalize(new_text); + if filtered == new_text { return; } - let pos = editable.position(); - syncing_for_change.set(true); - editable.set_text(&normalized); - editable.set_position(pos.min(normalized.chars().count() as i32)); - syncing_for_change.set(false); + editable.stop_signal_emission_by_name("insert-text"); + if filtered.is_empty() { + return; + } + + syncing_for_insert.set(true); + editable.insert_text(&filtered, position); + syncing_for_insert.set(false); }); } diff --git a/src/window.rs b/src/window.rs index 585736a..9e4e460 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1412,6 +1412,8 @@ fn install_window_actions( state.config = snapshot; state.config.dirty = true; state.dirty = true; + state.validation.clear(); + state.auto_disabled_dependents.clear(); changed = true; } } @@ -2516,10 +2518,9 @@ fn load_config_into_state( if let Ok(mut guard) = state.lock() { guard.config = parsed; guard.saved_snapshot = guard.config.clone(); - guard.redo_snapshot = None; guard.config.dirty = false; guard.dirty = false; - guard.validation.clear(); + clear_workspace_session_state(&mut guard); } if let Some(settings) = settings { let _ = settings.set_string("last-config-path", &path.display().to_string()); @@ -2651,8 +2652,7 @@ fn restore_latest_backup_into_state(state: &Arc>) -> anyhow::Res .map_err(|_| anyhow::anyhow!("failed to lock app state"))?; guard.config = parsed; guard.dirty = true; - guard.validation.clear(); - guard.redo_snapshot = None; + clear_workspace_session_state(&mut guard); Ok(backup) } @@ -2667,8 +2667,7 @@ fn reset_config_to_defaults( let path = guard.config.path.clone(); guard.config = default_config_for_path(path); guard.dirty = true; - guard.validation.clear(); - guard.redo_snapshot = None; + clear_workspace_session_state(&mut guard); drop(guard); reset_app_preferences_to_defaults(settings); true @@ -2783,6 +2782,12 @@ fn refresh_workspace_after_config_load( ); } +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, @@ -2826,10 +2831,9 @@ fn reload_config_from_disk( if let Ok(mut state) = state.lock() { state.config = parsed; state.saved_snapshot = state.config.clone(); - state.redo_snapshot = None; state.config.dirty = false; state.dirty = false; - state.validation.clear(); + clear_workspace_session_state(&mut state); } if let Some(settings) = settings { let _ = settings.set_string("last-config-path", &path.display().to_string()); @@ -2891,6 +2895,7 @@ fn restore_saved_snapshot(state: &Arc>) -> bool { state.config.dirty = false; state.dirty = false; state.validation.clear(); + state.auto_disabled_dependents.clear(); true } @@ -3097,7 +3102,7 @@ mod tests { use mangotune::config::normalize::normalize_legacy_option_values; use mangotune::config::parser::Parser; use mangotune::config::types::ConfigValue; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -3141,6 +3146,10 @@ mod tests { 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(), @@ -3148,7 +3157,7 @@ mod tests { dirty: true, saved_snapshot: saved.clone(), redo_snapshot: None, - auto_disabled_dependents: HashMap::new(), + auto_disabled_dependents, })); assert!(restore_saved_snapshot(&state)); @@ -3161,6 +3170,7 @@ mod tests { guard.redo_snapshot.as_ref().map(|cfg| &cfg.options), Some(¤t.options) ); + assert!(guard.auto_disabled_dependents.is_empty()); } #[test]