From 2eeec2170288c631a499d92962d3c108e06a53b6 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 17:06:42 +0200 Subject: [PATCH 01/18] feat: clearer process and rule editors - Process details: CPU, memory and I/O priority pickers with an explicit Apply, undo of the last manual priority change (guarded against reused PIDs), adaptive side/bottom details panel. - Manual process control stays available while automation is paused, with a persistent notice instead of an overlay. - Saved-rule editor stages all edits (including the core mask) and saves them together, showing pending changes. - Localized the new strings in all seven languages. --- Locales/de-DE.xaml | 7 + Locales/en-US.xaml | 7 + Locales/es-ES.xaml | 7 + Locales/fr-FR.xaml | 7 + Locales/it-IT.xaml | 7 + Locales/ru-RU.xaml | 7 + Locales/zh-CN.xaml | 7 + Services/ProcessRuleCreationService.cs | 29 +++- .../ProcessViewModelContextMenuTests.cs | 3 + .../ProcessViewXamlBindingTests.cs | 85 +++++------ .../ProcessPowerPlanAssociationViewModel.cs | 37 +++-- .../ProcessViewModel.Behaviors.partial.cs | 140 +++++++++--------- ViewModels/ProcessViewModel.PriorityEditor.cs | 121 +++++++++++++++ ViewModels/ProcessViewModel.cs | 9 +- Views/ProcessPowerPlanAssociationView.xaml | 21 ++- Views/ProcessView.xaml | 98 +++++++----- Views/ProcessView.xaml.cs | 9 ++ docs/IMPLEMENTATION_PLAN.md | 56 +++---- 18 files changed, 444 insertions(+), 213 deletions(-) create mode 100644 ViewModels/ProcessViewModel.PriorityEditor.cs diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 0f87acd..755ccf6 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -769,4 +769,11 @@ Einstellungen und {0} Energiesparplan-Zuordnungen ersetzen. Prozessregeln: {1}. Fortfahren? vorhandene Regeln behalten durch {0} importierte Regeln ersetzen + + + Anwenden + Automatisierung pausiert. Manuelle Änderungen funktionieren weiterhin; gespeicherte Regeln werden erst nach Fortsetzen der Überwachung angewendet. + Letzte Prioritätsänderung rückgängig machen + Prozesse angezeigt + Nicht gespeicherte Änderungen diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 1517f5b..36a7e97 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -769,4 +769,11 @@ Replace settings and {0} power plan associations. Persistent process rules: {1}. Continue? keep existing rules replace with {0} imported rules + + + Apply + Automation is paused. Manual changes still work; saved rules are not applied until monitoring resumes. + Undo last priority change + processes shown + Unsaved changes diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index e96c55c..d6c5226 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -769,4 +769,11 @@ Reemplazar la configuración y {0} asociaciones de planes de energía. Reglas de procesos: {1}. ¿Continuar? mantener las reglas existentes reemplazar con {0} reglas importadas + + + Aplicar + Automatización en pausa. Los cambios manuales siguen funcionando; las reglas guardadas no se aplican hasta reanudar la supervisión. + Deshacer el último cambio de prioridad + procesos mostrados + Cambios sin guardar diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index e71fb5f..4471838 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -769,4 +769,11 @@ Remplacer les paramètres et {0} associations de modes d'alimentation. Règles de processus : {1}. Continuer ? conserver les règles existantes remplacer par {0} règles importées + + + Appliquer + Automatisation en pause. Les modifications manuelles fonctionnent toujours ; les règles enregistrées ne s'appliquent qu'à la reprise de la surveillance. + Annuler la dernière modification de priorité + processus affichés + Modifications non enregistrées diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index 84da79d..08952e8 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -769,4 +769,11 @@ Sostituire le impostazioni e {0} associazioni ai piani di alimentazione? Regole dei processi: {1}. Continuare? mantieni le regole esistenti sostituisci con {0} regole importate + + + Applica + Automazione in pausa. Le modifiche manuali funzionano; le regole salvate non vengono applicate finché il monitoraggio non riprende. + Annulla ultima modifica di priorità + processi visibili + Modifiche non salvate diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 058abe2..6f72c3b 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -769,4 +769,11 @@ Заменить настройки и {0} привязок к схемам электропитания. Правила процессов: {1}. Продолжить? сохранить существующие правила заменить {0} импортированными правилами + + + Применить + Автоматизация приостановлена. Ручные изменения работают; сохранённые правила не применяются до возобновления мониторинга. + Отменить последнее изменение приоритета + процессов показано + Несохранённые изменения diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index cc212a3..82322b3 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -769,4 +769,11 @@ 替换设置和 {0} 个电源计划关联。进程规则:{1}。是否继续? 保留现有规则 替换为 {0} 条导入的规则 + + + 应用 + 自动化已暂停。手动更改仍然有效;恢复监控之前不会应用已保存的规则。 + 撤销上次优先级更改 + 个进程已显示 + 未保存的更改 diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index cdbede8..29c499a 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -35,6 +35,11 @@ Task UpdateRuleAsync( PersistentProcessRule rule, CancellationToken cancellationToken = default); + Task UpdateRuleWithCoreSelectionAsync( + PersistentProcessRule rule, + IReadOnlyList? coreSelection, + CancellationToken cancellationToken = default); + Task UpdateRuleCoreSelectionAsync( string ruleId, IReadOnlyList coreSelection, @@ -207,8 +212,14 @@ public async Task DeleteRuleByIdAsync( // Editing a rule in place, from the saved-rules list. The affinity payload is not editable // there - that needs the core picker in the Process tab - so it is carried over untouched // rather than rebuilt, and only the fields the editor owns are replaced. - public async Task UpdateRuleAsync( + public Task UpdateRuleAsync( PersistentProcessRule rule, + CancellationToken cancellationToken = default) => + this.UpdateRuleWithCoreSelectionAsync(rule, null, cancellationToken); + + public async Task UpdateRuleWithCoreSelectionAsync( + PersistentProcessRule rule, + IReadOnlyList? coreSelection, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(rule); @@ -242,6 +253,22 @@ public async Task UpdateRuleAsync( UpdatedAt = DateTime.UtcNow, }; + if (coreSelection != null) + { + var affinity = await this.BuildAffinityPayloadFromCoreSelectionAsync(coreSelection, "Saved rule editor", cancellationToken).ConfigureAwait(false); + if (!affinity.Success || affinity.Payload == null) + { + return affinity; + } + + updated = updated with + { + CpuSelection = affinity.Payload.CpuSelection, + LegacyAffinityMask = affinity.Payload.LegacyAffinityMask, + ApplyAffinityOnStart = true, + }; + } + rules[existingIndex] = updated; cancellationToken.ThrowIfCancellationRequested(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index d7c906a..0bfb9ac 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -224,6 +224,9 @@ public async Task ContextMemoryPriorityCommand_WhenServiceFails_ShowsSafeUserMes ProcessOperationUserMessages.AccessDenied, "Access is denied.", isAccessDenied: true)); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.Normal); var process = CreateProcess(); var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index ef06947..a2373fe 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -35,7 +35,7 @@ public void LastOperationMessageBinding_IsDisplayOnly() } [Fact] - public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() + public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() { var editableControls = new HashSet(StringComparer.Ordinal) { @@ -61,22 +61,22 @@ public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) .ToList(); - Assert.Empty(editableSummaryBindings); - } - - [Fact] - public void CpuAssignmentMode_IsStagedTwoWayAndHasAccessibleHelp() - { - var serialized = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace) - .ToString(SaveOptions.DisableFormatting); - - Assert.Contains("SelectedValue=\"{Binding SelectedCpuAssignmentMode, Mode=TwoWay}\"", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.AffinityMask", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.IdealProcessor", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.CpuSets", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.Automatic", serialized, StringComparison.Ordinal); - Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource ProcessView_CpuAssignmentModeTooltip}\"", serialized, StringComparison.Ordinal); - } + Assert.Empty(editableSummaryBindings); + } + + [Fact] + public void CpuAssignmentMode_IsStagedTwoWayAndHasAccessibleHelp() + { + var serialized = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace) + .ToString(SaveOptions.DisableFormatting); + + Assert.Contains("SelectedValue=\"{Binding SelectedCpuAssignmentMode, Mode=TwoWay}\"", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.AffinityMask", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.IdealProcessor", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.CpuSets", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.Automatic", serialized, StringComparison.Ordinal); + Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource ProcessView_CpuAssignmentModeTooltip}\"", serialized, StringComparison.Ordinal); + } [Fact] public void ProcessGridRowStyle_HighlightsSelectedRowsWithNeutralTheme() @@ -197,39 +197,34 @@ public void ProcessGridContextMenu_ContainsExpectedActionsAndSubmenus() } [Fact] - public void ProcessToolbar_ExposesLockProcessListToggle() - { + public void ProcessToolbar_ExposesLockProcessListToggle() + { var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); var serialized = document.ToString(SaveOptions.DisableFormatting); Assert.Contains("ProcessView_LockList", serialized, StringComparison.Ordinal); Assert.Contains("IsChecked=\"{Binding IsProcessListLocked}\"", serialized, StringComparison.Ordinal); - Assert.Contains("ProcessView_LockListTooltip", serialized, StringComparison.Ordinal); - } - - [Fact] - public void MonitoringDisabledOverlay_CoversProcessControlsWithSinglePrimaryMessage() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var overlay = Assert.Single( - document.Descendants(), - element => element.Attributes().Any(attribute => - attribute.Name.LocalName == "Name" && - attribute.Value == "MonitoringDisabledOverlay")); - var serialized = overlay.ToString(SaveOptions.DisableFormatting); - - Assert.Equal("Border", overlay.Name.LocalName); - Assert.Contains("Grid.RowSpan=\"3\"", serialized, StringComparison.Ordinal); - Assert.Contains("Panel.ZIndex=\"10\"", serialized, StringComparison.Ordinal); - Assert.Contains("Background=\"{DynamicResource SurfaceMutedBrush}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Foreground=\"{DynamicResource TextPrimaryBrush}\"", serialized, StringComparison.Ordinal); - Assert.Contains("InverseBoolToVisibilityConverter", serialized, StringComparison.Ordinal); - Assert.Single(overlay.Descendants(), element => element.Name.LocalName == "TextBlock"); - Assert.DoesNotContain("ProcessView_MonitoringDisabledDescription", serialized, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_SelectedCpuTilesUseSubtleMaskSelectionResources() + Assert.Contains("ProcessView_LockListTooltip", serialized, StringComparison.Ordinal); + } + + [Fact] + public void PausedAutomation_ShowsNoticeWithoutBlockingManualControls() + { + // Pausing automation stops saved rules only; manual process control must stay usable. + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.DoesNotContain("MonitoringDisabledOverlay", serialized, StringComparison.Ordinal); + var notice = Assert.Single( + document.Descendants(), + element => element.Attributes().Any(attribute => attribute.Value == "{DynamicResource ProcessView_AutomationPaused}")); + var visibility = notice.Attribute("Visibility")?.Value ?? string.Empty; + Assert.Contains("IsAutomationMonitoringEnabled", visibility, StringComparison.Ordinal); + Assert.Contains("InverseBoolToVisibilityConverter", visibility, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_SelectedCpuTilesUseSubtleMaskSelectionResources() { var masksViewPath = Path.Combine( GetRepositoryRoot(), diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index b6858ba..f8c6db2 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -196,21 +196,27 @@ private async Task DeleteSavedProcessRuleAsync(SavedProcessRule? rule) private SavedProcessRule? selectedSavedProcessRule; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private bool savedRuleIsEnabled = true; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private bool savedRulePreventSleep; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRulePriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleMemoryPriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleIoPriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleCpuAssignmentMode = nameof(CpuAssignmentMode.AffinityMask); public const string NoneOption = "None"; @@ -243,6 +249,7 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) return; } + this.SavedRuleCoreMask = null; var rule = value.Source; this.SavedRuleIsEnabled = rule.IsEnabled; this.SavedRulePreventSleep = rule.PreventSystemSleepWhileRunning; @@ -257,26 +264,16 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) // was the one thing you could not change. The vocabulary here is the saved CPU masks, which // is what the Masks tab exists to define, rather than a second per-core picker. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private CoreMask? savedRuleCoreMask; - [RelayCommand] - private async Task ApplySavedRuleCoreMaskAsync() - { - var selected = this.SelectedSavedProcessRule; - var mask = this.SavedRuleCoreMask; - if (selected == null || mask == null || this.ruleCreationService == null) - { - return; - } - - var result = await this.ruleCreationService - .UpdateRuleCoreSelectionAsync(selected.Id, mask.BoolMask) - .ConfigureAwait(true); - this.SetStatus(result.UserMessage, false); - await this.RefreshSavedProcessRulesAsync().ConfigureAwait(true); - this.SelectedSavedProcessRule = this.SavedProcessRules - .FirstOrDefault(row => string.Equals(row.Id, selected.Id, StringComparison.Ordinal)); - } + public bool HasPendingRuleChanges => this.SelectedSavedProcessRule is { } selected && + (this.SavedRuleCoreMask != null || this.SavedRuleIsEnabled != selected.Source.IsEnabled || + this.SavedRulePreventSleep != selected.Source.PreventSystemSleepWhileRunning || + this.SavedRulePriority != (selected.Source.Priority?.ToString() ?? NoneOption) || + this.SavedRuleMemoryPriority != (selected.Source.MemoryPriority?.ToString() ?? NoneOption) || + this.SavedRuleIoPriority != (selected.Source.IoPriority?.ToString() ?? NoneOption) || + this.SavedRuleCpuAssignmentMode != selected.Source.CpuAssignmentMode.ToString()); [RelayCommand] private async Task SaveSavedProcessRuleAsync() @@ -299,7 +296,9 @@ private async Task SaveSavedProcessRuleAsync() : selected.Source.CpuAssignmentMode, }; - var result = await this.ruleCreationService.UpdateRuleAsync(updated).ConfigureAwait(true); + var result = this.SavedRuleCoreMask == null + ? await this.ruleCreationService.UpdateRuleAsync(updated).ConfigureAwait(true) + : await this.ruleCreationService.UpdateRuleWithCoreSelectionAsync(updated, this.SavedRuleCoreMask.BoolMask).ConfigureAwait(true); this.SetStatus(result.UserMessage, false); await this.RefreshSavedProcessRulesAsync().ConfigureAwait(true); this.SelectedSavedProcessRule = this.SavedProcessRules diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index d214b29..851855d 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -84,7 +84,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Power plans loaded"); // Load processes automatically only while automation monitoring is enabled. - if (this.IsAutomationMonitoringEnabled) + if (this.isProcessViewActive) { System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load processes automatically"); await this.LoadProcessesCommand.ExecuteAsync(null); @@ -99,7 +99,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Processes loaded automatically, count: {processCount}"); // Start refresh timer only while automation monitoring is enabled. - if (this.IsAutomationMonitoringEnabled) + if (this.isProcessViewActive) { System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting refresh timer"); this.refreshTimer?.Start(); @@ -184,22 +184,22 @@ partial void OnSelectedCpuAssignmentModeChanged(CpuAssignmentMode value) private async Task<(CpuAssignmentMode Mode, bool FromRule)> ResolveCpuAssignmentModeAsync(ProcessModel process) { var defaultMode = this.settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; - if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) - { - return (defaultMode, false); - } - - try - { - var rules = await this.persistentRuleStore.LoadAsync().ConfigureAwait(false); - var rule = rules.FirstOrDefault(candidate => candidate.IsEnabled && this.persistentRuleMatcher.IsMatch(candidate, process)); - return rule == null ? (defaultMode, false) : (rule.CpuAssignmentMode, true); - } - catch (Exception ex) - { - this.Logger.LogDebug(ex, "Could not resolve saved CPU assignment mode for {ProcessName}", process.Name); - return (defaultMode, false); - } + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return (defaultMode, false); + } + + try + { + var rules = await this.persistentRuleStore.LoadAsync().ConfigureAwait(false); + var rule = PersistentRuleResolution.MatchInApplyOrder(rules, process, this.persistentRuleMatcher).LastOrDefault(candidate => candidate.ApplyAffinityOnStart); + return rule == null ? (defaultMode, false) : (rule.CpuAssignmentMode, true); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "Could not resolve saved CPU assignment mode for {ProcessName}", process.Name); + return (defaultMode, false); + } } private void SetCpuAssignmentMode(CpuAssignmentMode mode, bool overriddenByRule) @@ -241,22 +241,22 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) // Refresh process info to get current state from OS await this.processService.RefreshProcessInfo(value); - var assignment = await this.ResolveCpuAssignmentModeAsync(value); - var cpuSetIndexes = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync( - value, - CpuAssignmentMode.CpuSets); - IReadOnlyList? softAssignmentIndexes; - if (cpuSetIndexes is { Count: > 0 }) - { - assignment = (CpuAssignmentMode.CpuSets, assignment.FromRule && assignment.Mode == CpuAssignmentMode.CpuSets); - softAssignmentIndexes = cpuSetIndexes; - } - else - { - softAssignmentIndexes = assignment.Mode == CpuAssignmentMode.IdealProcessor - ? await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(value, assignment.Mode) - : null; - } + var assignment = await this.ResolveCpuAssignmentModeAsync(value); + var cpuSetIndexes = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync( + value, + CpuAssignmentMode.CpuSets); + IReadOnlyList? softAssignmentIndexes; + if (cpuSetIndexes is { Count: > 0 }) + { + assignment = (CpuAssignmentMode.CpuSets, assignment.FromRule && assignment.Mode == CpuAssignmentMode.CpuSets); + softAssignmentIndexes = cpuSetIndexes; + } + else + { + softAssignmentIndexes = assignment.Mode == CpuAssignmentMode.IdealProcessor + ? await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(value, assignment.Mode) + : null; + } // Update UI on main thread with fresh data await InvokeOnUiAsync(() => @@ -541,19 +541,19 @@ private long CalculateAffinityMask() return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); } - private List GetPendingCoreSelectionMask() - { + private List GetPendingCoreSelectionMask() + { return this.CpuCores .OrderBy(core => core.LogicalCoreId) .Select(core => core.IsSelected) - .ToList(); - } - - private static List GetSelectedLogicalProcessorIndexes(IReadOnlyList selection) => - selection.Select((selected, index) => (selected, index)) - .Where(item => item.selected) - .Select(item => item.index) - .ToList(); + .ToList(); + } + + private static List GetSelectedLogicalProcessorIndexes(IReadOnlyList selection) => + selection.Select((selected, index) => (selected, index)) + .Where(item => item.selected) + .Select(item => item.index) + .ToList(); private void UpdateAffinityDisplayState() { @@ -912,10 +912,10 @@ await this.LogUserActionAsync( } else { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - applyResult.EffectiveMode, - true); + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + applyResult.EffectiveMode, + true); } process.ForceNotifyProcessorAffinityChanged(); @@ -1018,10 +1018,10 @@ await InvokeOnUiAsync(() => { if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - result.EffectiveMode, - true); + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + result.EffectiveMode, + true); } this.HasPendingAffinityEdits = false; @@ -1243,7 +1243,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => pendingSelection, "Manual Process tab quick apply CPU selection", this.SelectedCpuAssignmentMode); - if (!result.Success) + if (!result.Success) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { @@ -1256,18 +1256,18 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.OnPropertyChanged(nameof(this.SelectedProcess)); this.SetStatus(result.Message, false); }); - return; - } - - if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) - { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - result.EffectiveMode, - true); - } - - this.HasPendingAffinityEdits = false; + return; + } + + if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) + { + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + result.EffectiveMode, + true); + } + + this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); affinityModeChangedOsAffinity = result.EffectiveMode == CpuAssignmentMode.AffinityMask; } @@ -1740,7 +1740,9 @@ await this.LogUserActionAsync( try { + var undo = CapturePriorityUndo(process, cpu: true); await this.processService.SetProcessPriority(process, priority, ProcessPriorityWriteSource.ManualUiAction); + this.RememberPriority(undo); await this.processService.RefreshProcessInfo(process); var warning = ProcessPriorityGuardrails.GetWarning(priority); @@ -1790,6 +1792,8 @@ private async Task SetContextMemoryPriorityAsync(ProcessModel? process, ProcessM try { + var previous = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + var undo = previous.HasValue ? CapturePriorityUndo(process, memory: previous) : null; var result = await this.memoryPriorityService.SetMemoryPriorityAsync(process, priority); if (!result.Success) { @@ -1805,6 +1809,7 @@ await this.LogUserActionAsync( return; } + this.RememberPriority(undo); this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); await this.LogUserActionAsync( "ProcessMemoryPriorityChanged", @@ -1831,6 +1836,8 @@ private async Task SetContextIoPriorityAsync(ProcessModel? process, ProcessIoPri return; } + var previous = await this.memoryPriorityService.GetIoPriorityAsync(process); + var undo = previous.HasValue ? CapturePriorityUndo(process, io: previous) : null; var result = await this.memoryPriorityService.SetIoPriorityAsync(process, priority); if (!result.Success) { @@ -1843,6 +1850,7 @@ await this.LogUserActionAsync( return; } + this.RememberPriority(undo); this.SetStatus($"I/O priority applied successfully to {process.Name}: {priority}.", false); await this.LogUserActionAsync( "ProcessIoPriorityChanged", @@ -2107,7 +2115,7 @@ partial void OnIsProcessListLockedChanged(bool value) private bool ShouldRunProcessUiRefresh() { - return this.IsAutomationMonitoringEnabled && this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; + return this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; } private bool ShouldPreloadVirtualizedBatches() diff --git a/ViewModels/ProcessViewModel.PriorityEditor.cs b/ViewModels/ProcessViewModel.PriorityEditor.cs new file mode 100644 index 0000000..b4bd26e --- /dev/null +++ b/ViewModels/ProcessViewModel.PriorityEditor.cs @@ -0,0 +1,121 @@ +namespace ThreadPilot.ViewModels +{ + using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class ProcessViewModel + { + [ObservableProperty] + private ProcessPriorityClass pendingCpuPriority = ProcessPriorityClass.Normal; + + [ObservableProperty] + private ProcessMemoryPriority pendingMemoryPriority = ProcessMemoryPriority.Normal; + + [ObservableProperty] + private ProcessIoPriority pendingIoPriority = ProcessIoPriority.Normal; + + private PriorityUndoSnapshot? priorityUndo; + + [RelayCommand] + private Task ApplySelectedCpuPriority() => this.SetContextCpuPriorityAsync(this.SelectedProcess, this.PendingCpuPriority); + + [RelayCommand] + private Task ApplySelectedMemoryPriority() => this.SetContextMemoryPriorityAsync(this.SelectedProcess, this.PendingMemoryPriority); + + [RelayCommand] + private Task ApplySelectedIoPriority() => this.SetContextIoPriorityAsync(this.SelectedProcess, this.PendingIoPriority); + + private bool CanUndoPriority() => this.priorityUndo != null; + + private void RememberPriority(PriorityUndoSnapshot? snapshot) + { + this.priorityUndo = snapshot; + this.UndoPriorityCommand.NotifyCanExecuteChanged(); + } + + private static PriorityUndoSnapshot? CapturePriorityUndo( + ProcessModel process, bool cpu = false, ProcessMemoryPriority? memory = null, ProcessIoPriority? io = null) + { + try + { + using var native = Process.GetProcessById(process.ProcessId); + return new(process.ProcessId, native.StartTime.ToUniversalTime(), process.Name, + cpu ? native.PriorityClass : null, memory, io); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + [RelayCommand(CanExecute = nameof(CanUndoPriority))] + private async Task UndoPriorityAsync() + { + var undo = this.priorityUndo; + if (undo == null) + { + return; + } + + try + { + using var native = Process.GetProcessById(undo.ProcessId); + if (!undo.Matches(native.Id, native.StartTime.ToUniversalTime()) || native.HasExited) + { + this.RememberPriority(null); + this.SetContextError(ProcessOperationUserMessages.ProcessExited); + return; + } + + var process = new ProcessModel { ProcessId = undo.ProcessId, Name = undo.Name }; + if (undo.Cpu.HasValue) + { + await this.processService.SetProcessPriority(process, undo.Cpu.Value, ProcessPriorityWriteSource.ManualUiAction); + } + else + { + if (this.memoryPriorityService == null) + { + return; + } + + var result = undo.Memory.HasValue + ? await this.memoryPriorityService.SetMemoryPriorityAsync(process, undo.Memory.Value) + : await this.memoryPriorityService.SetIoPriorityAsync(process, undo.Io!.Value); + if (!result.Success) + { + this.SetContextError(result.UserMessage); + return; + } + } + + this.RememberPriority(null); + this.SetStatus(this.UiText("ProcessView_PriorityRestored", "Previous priority restored."), false); + await this.LogUserActionAsync("ProcessPriorityRestored", $"Previous priority restored for {undo.Name}", $"PID: {undo.ProcessId}"); + if (this.SelectedProcess?.ProcessId == undo.ProcessId) + { + await this.TryRefreshContextProcessSummaryAsync(this.SelectedProcess); + } + } + catch (Exception ex) + { + this.SetContextError(MapProcessOperationException(ex)); + } + } + + private string UiText(string key, string fallback) + { + var text = this.uiLocalization?.GetString(key); + return string.IsNullOrWhiteSpace(text) || text == key ? fallback : text; + } + + internal sealed record PriorityUndoSnapshot(int ProcessId, DateTime StartedUtc, string Name, + ProcessPriorityClass? Cpu, ProcessMemoryPriority? Memory, ProcessIoPriority? Io) + { + public bool Matches(int processId, DateTime startedUtc) => this.ProcessId == processId && this.StartedUtc == startedUtc; + } + } +} diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index fafeb38..5e2c877 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -37,6 +37,7 @@ public partial class ProcessViewModel : BaseViewModel private readonly ProcessPowerRequestService? powerRequestService; private readonly IProcessRuleCreationService? processRuleCreationService; private readonly IApplicationSettingsService? settingsService; + private readonly ILocalizationService? uiLocalization; private readonly IPersistentProcessRuleStore? persistentRuleStore; private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; private readonly Action clipboardSetter; @@ -224,6 +225,7 @@ public ProcessViewModel( this.memoryPriorityService = memoryPriorityService; this.powerRequestService = powerRequestService; this.settingsService = settingsService; + this.uiLocalization = localizationService; this.persistentRuleStore = persistentRuleStore; this.persistentRuleMatcher = persistentRuleMatcher; this.SelectedCpuAssignmentMode = settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; @@ -281,13 +283,6 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr { this.SetCpuAssignmentMode(e.NewSettings.DefaultCpuAssignmentMode, overriddenByRule: false); } - if (!this.IsAutomationMonitoringEnabled) - { - this.SetUiRefreshEnabled(false, refreshImmediately: false); - this.SelectedProcess = null; - return; - } - this.SetUiRefreshEnabled(this.isProcessViewActive && !this.IsProcessListLocked); } diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index 696040a..cd397a2 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -164,7 +164,7 @@ @@ -180,11 +180,11 @@ Background="{DynamicResource SurfaceAltBrush}" Padding="8" Margin="0,0,0,10" Visibility="{Binding HasSelectedExecutable, Converter={StaticResource BoolToVisibilityConverter}}"> - + + FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"/> @@ -192,7 +192,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="{DynamicResource ProcessView_CpuAssignmentModeTooltip}" + AutomationProperties.Name="{DynamicResource ProcessView_CpuAssignmentMode}" + AutomationProperties.HelpText="{DynamicResource ProcessView_CpuAssignmentModeTooltip}"> + + + + - - - - - - + + + + + + + + + + + - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/UX_REDESIGN_PLAN.md b/docs/UX_REDESIGN_PLAN.md index f3fea03..5c75e93 100644 --- a/docs/UX_REDESIGN_PLAN.md +++ b/docs/UX_REDESIGN_PLAN.md @@ -23,8 +23,10 @@ Branch `feat/ux-clarity`, impilato su #69 (EcoQoS). Nessuna modifica al formato - [x] Processi: ricerca con testo guida; filtri e colonne in due menu a tendina; dettagli in tre riquadri (Processo, Priorità, Core CPU) più la riga della regola; selettori allineati allo stato attuale; testi di aiuto nei tooltip. - [x] Regole: scheda "Regole dei processi" per prima, nomi delle schede più chiari, etichette localizzate nell'editor. - [x] Sottotitoli accorciati in tutte le lingue. -- [ ] Impostazioni e Ottimizzazioni con righe in stile Impostazioni di Windows 11 (`CardControl`): prossimo passo, richiede una riscrittura più ampia delle due viste. -- [ ] Area unica per gli avvisi della finestra principale: da valutare dopo la verifica sul campo. +- [x] Impostazioni e Ottimizzazioni in una colonna di righe in stile Impostazioni di Windows 11 (titolo e descrizione a sinistra, interruttore a destra); sezioni più usate in alto. +- [x] Avvisi della finestra principale: già mostrati uno alla volta (coda elevazione → avvio ridotto → modalità CPU in `MainWindow.Behaviors.partial.cs`); nessuna modifica necessaria. +- [x] Elenco regole: colonne "Applica" e "Modalità" localizzate. +- [x] Rimosse 76 stringhe non più usate da tutte le lingue. ## Verifica From 20abf7006c0fd2bc67859817f3ec1dc0a6cc236b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 18:42:05 +0200 Subject: [PATCH 06/18] fix: issues found testing the beta in a clean Windows 11 VM - Process counter could show more visible processes than the total ('157 / 151'): the batch total was a snapshot from the first load. - Narrow windows (or 150% scaling): toolbar buttons move under the search box instead of squeezing it to a few letters. - Rules list keeps the 'Applies' column readable when narrow. - Settings status message gets its own line instead of being cut. Tested in a Hyper-V Windows 11 VM: clean install, upgrade, uninstall, light and dark theme, 100% and 150% scaling. --- .../ProcessViewModelContextMenuTests.cs | 19 +++ .../ProcessViewModel.Behaviors.partial.cs | 7 ++ Views/ProcessPowerPlanAssociationView.xaml | 3 +- Views/ProcessView.xaml | 108 +++++++++--------- Views/ProcessView.xaml.cs | 6 + Views/SettingsView.xaml | 20 ++-- 6 files changed, 99 insertions(+), 64 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 56a4ca6..0b026a7 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -285,6 +285,25 @@ public async Task PriorityPickers_StartFromTheSelectedProcessCurrentValues() Assert.Null(viewModel.PendingIoPriority); // unreadable: the picker stays empty instead of guessing } + [Fact] + public void ProcessCounter_NeverShowsMoreVisibleThanTotal() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + viewModel.TotalProcessCount = 1; // stale snapshot from an earlier batch load + foreach (var pid in new[] { 101, 102, 103 }) + { + viewModel.Processes.Add(new ProcessModel { ProcessId = pid, Name = $"p{pid}" }); + } + + // Filtering is debounced through the dispatcher in the app; call the filter step directly. + typeof(ProcessViewModel) + .GetMethod("FilterProcesses", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(viewModel, null); + + Assert.Equal(3, viewModel.FilteredProcesses.Count); + Assert.Equal(3, viewModel.TotalProcessCount); + } + [Fact] public async Task CopyContextProcessInfo_IncludesNamePidAndPath() { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 9171fec..b1ad7ba 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2219,6 +2219,13 @@ private void FilterProcesses() var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); this.FilteredProcesses = new ObservableCollection(filteredResults); + + // The batch total is a snapshot from the first load; refreshes add processes, so the + // counter showed "157 / 151". Once everything is loaded the list itself is the total. + if (!this.HasMoreBatches || this.Processes.Count > this.TotalProcessCount) + { + this.TotalProcessCount = this.Processes.Count; + } } while (this.filterRefreshPending); } diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index e3e3912..f9305fe 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -120,7 +120,8 @@ Width="150"/> + Width="*" + MinWidth="160"/> diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 007abb1..c29baac 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -194,66 +194,68 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - -