From 95dcbb12bc1e69754e95dc4063e926887c130826 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Mon, 3 Aug 2026 23:19:25 +0100 Subject: [PATCH] Replace timer-based fix-names preview debounce with CancellationTokenSource Co-Authored-By: Claude Fable 5 --- .../Tools/ChangeCasing/FixNamesViewModel.cs | 78 +++++++++++-------- .../Tools/ChangeCasing/FixNamesWindow.cs | 22 +++--- src/ui/Logic/TableViewExtras.cs | 6 +- 3 files changed, 61 insertions(+), 45 deletions(-) diff --git a/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs b/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs index 1faea9406e..4dd138b578 100644 --- a/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs +++ b/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs @@ -14,6 +14,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace Nikse.SubtitleEdit.Features.Tools.ChangeCasing; @@ -37,47 +38,20 @@ public partial class FixNamesViewModel : ObservableObject, IClosingCleanup private const string SuffixChars = " ,.!?:;…')]<-\"\r\n"; private static readonly string[] CommonWords = ["US", "Lane", "Bill", "Rose"]; private readonly HashSet _usedNames; - private string _oldNames; - private readonly System.Timers.Timer _previewTimer; - private bool _loading; - private readonly Lock _lock = new(); public FixNamesViewModel() { Names = new ObservableCollection(); Hits = new ObservableCollection(); - _loading = true; _nameListInclMulti = new List(); _language = "en_US"; _subtitleBefore = new Subtitle(); _subtitle = new Subtitle(); _usedNames = new HashSet(); ExtraNames = string.Empty; - _oldNames = string.Empty; Info = string.Empty; Subtitle = new Subtitle(); - - _previewTimer = new System.Timers.Timer(500); - _previewTimer.Elapsed += PreviewTimerElapsed; - } - - private void PreviewTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) - { - var namesString = string.Join(' ', Names.Where(p => p.IsChecked).Select(p => p.Name)); - if (namesString != _oldNames && !_loading) - { - lock (_lock) - { - GeneratePreview(); - _oldNames = namesString; - } - } - } - - public void OnClosingCleanup() - { - _previewTimer.StopAndDispose(PreviewTimerElapsed); } internal void Initialize(Subtitle subtitle) @@ -85,8 +59,6 @@ internal void Initialize(Subtitle subtitle) subtitle.Renumber(); _subtitle = new Subtitle(subtitle); _subtitleBefore = subtitle; - _oldNames = string.Empty; - _language = LanguageAutoDetect.AutoDetectGoogleLanguage(_subtitle); if (string.IsNullOrEmpty(_language)) { @@ -161,6 +133,44 @@ private static bool IsWordBoundary(string text, int startIndex, string name) && (afterNameIndex == text.Length || SuffixChars.Contains(text[afterNameIndex])); } + private CancellationTokenSource? _cancellationTokenSource; + + [RelayCommand] + private void FixNameItemChanged(FixNameItem item) + { + RequestPreview(); + } + + internal void RequestPreview() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + _ = DebouncedPreviewAsync(_cancellationTokenSource.Token); + } + + private async Task DebouncedPreviewAsync(CancellationToken token) + { + try + { + await Task.Delay(500, token).ConfigureAwait(false); + GeneratePreview(); + } + catch (OperationCanceledException) + { + // Superseded by a newer request, or the window closed. + } + } + + public void OnClosingCleanup() + { + // Null out so a repeated Closed callback (or a late RequestPreview) never + // touches the disposed source. + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + private void GeneratePreview() { var hits = new List(); @@ -214,6 +224,8 @@ public void NamesSelectAll() { name.IsChecked = true; } + + GeneratePreview(); } [RelayCommand] @@ -223,6 +235,8 @@ public void NamesInvertSelection() { name.IsChecked = !name.IsChecked; } + + GeneratePreview(); } [RelayCommand] @@ -266,6 +280,7 @@ private void Ok() noOfLinesChanged++; } } + Info = $"Change casing - lines changed: {noOfLinesChanged}"; OkPressed = true; @@ -281,9 +296,8 @@ public void Cancel() [RelayCommand] public void AddExtraName() { - _loading = true; FindAllNames(); - _loading = false; + GeneratePreview(); } internal void OnKeyDown(KeyEventArgs e) @@ -313,7 +327,5 @@ internal async void OnLoaded(RoutedEventArgs e) ExtraNames = Se.Settings.Tools.ChangeCasing.ExtraNames; FindAllNames(); GeneratePreview(); - _previewTimer.Start(); - _loading = false; } } diff --git a/src/ui/Features/Tools/ChangeCasing/FixNamesWindow.cs b/src/ui/Features/Tools/ChangeCasing/FixNamesWindow.cs index c4ab5a0732..48c99ecdf6 100644 --- a/src/ui/Features/Tools/ChangeCasing/FixNamesWindow.cs +++ b/src/ui/Features/Tools/ChangeCasing/FixNamesWindow.cs @@ -3,14 +3,10 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; -using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; -using Avalonia.Threading; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; -using System; -using System.Linq; namespace Nikse.SubtitleEdit.Features.Tools.ChangeCasing; @@ -132,15 +128,20 @@ private static Border MakeNamesView(FixNamesViewModel vm) Header = Se.Language.General.Enabled, CellTheme = UiUtil.TableViewNoPaddingCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - CellTemplate = new FuncDataTemplate(static (item, _) => - new Border + CellTemplate = new FuncDataTemplate((item, _) => new Border { Background = Brushes.Transparent, // Prevents highlighting Padding = new Thickness(4), Child = new CheckBox { Focusable = false, - [!ToggleButton.IsCheckedProperty] = new Binding(nameof(FixNameItem.IsChecked)), + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(FixNameItem.IsChecked)) + { + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + }, + Command = vm.FixNameItemChangedCommand, + CommandParameter = item, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, } }), @@ -157,8 +158,11 @@ private static Border MakeNamesView(FixNamesViewModel vm) HeaderTheme = UiUtil.TableViewColumnHeaderTheme, }); - TableViewExtras.AddSpaceToggle(dataGrid, - item => item.IsChecked, (item, v) => item.IsChecked = v); + TableViewExtras.AddSpaceToggle(dataGrid, item => item.IsChecked, (item, v) => + { + item.IsChecked = v; + vm.RequestPreview(); + }); var flyout = new MenuFlyout(); flyout.Items.Add(new MenuItem { Header = Se.Language.General.SelectAll, Command = vm.NamesSelectAllCommand }); diff --git a/src/ui/Logic/TableViewExtras.cs b/src/ui/Logic/TableViewExtras.cs index b146b899ee..0b43fb8ba8 100644 --- a/src/ui/Logic/TableViewExtras.cs +++ b/src/ui/Logic/TableViewExtras.cs @@ -411,10 +411,10 @@ e.KeyModifiers is not (KeyModifiers.None or KeyModifiers.Control) || /// CheckboxMultiSelect helper that TableView does not provide natively (extended /// selection itself is native ListBox behavior). /// - public static void AddSpaceToggle(TableView tableView, Func getChecked, Action setChecked) + public static void AddSpaceToggle(TableView tableView, Func checkStatus, Action setChecked) where TItem : class { - tableView.AddHandler(InputElement.KeyDownEvent, (object? _, KeyEventArgs e) => + tableView.AddHandler(InputElement.KeyDownEvent, (_, e) => { if (e.Key != Key.Space) { @@ -427,7 +427,7 @@ public static void AddSpaceToggle(TableView tableView, Func return; } - var newValue = !selected.All(getChecked); + var newValue = !selected.All(checkStatus); foreach (var item in selected) { setChecked(item, newValue);