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]