|
|
@@ -1,230 +1,121 @@
|
|
|
-using System.Collections.ObjectModel;
|
|
|
-using System.Diagnostics;
|
|
|
-using System.Reactive;
|
|
|
-using System.Reactive.Linq;
|
|
|
-using DynamicData;
|
|
|
-using PicView.Core.FileAssociations;
|
|
|
-using ReactiveUI;
|
|
|
+using PicView.Core.FileAssociations;
|
|
|
+using R3;
|
|
|
+
|
|
|
+// For ToArray, FirstOrDefault, etc
|
|
|
|
|
|
namespace PicView.Core.ViewModels;
|
|
|
|
|
|
/// <summary>
|
|
|
-/// View model for managing file associations in PicView.
|
|
|
+/// View model for managing file associations in PicView, using CySharp/R3.
|
|
|
/// Handles the binding between UI checkboxes and file type data.
|
|
|
/// </summary>
|
|
|
-public class FileAssociationsViewModel : ReactiveObject
|
|
|
+public class FileAssociationsViewModel : IDisposable
|
|
|
{
|
|
|
- private readonly ReadOnlyObservableCollection<FileTypeGroup> _fileTypeGroups;
|
|
|
- private readonly SourceList<FileTypeGroup> _fileTypeGroupsList = new();
|
|
|
+ private readonly CompositeDisposable _disposables = new();
|
|
|
+
|
|
|
+ public FileAssociationsViewModel()
|
|
|
+ {
|
|
|
+ // Create file type groups and populate with data
|
|
|
+ FileTypeGroups.AddRange(FileTypeGroupHelper.GetFileTypes());
|
|
|
+
|
|
|
+ // CanExecute as observable
|
|
|
+ var canExecute = IsProcessing
|
|
|
+ .AsObservable()
|
|
|
+ .Select(processing => !processing);
|
|
|
+
|
|
|
+ // Commands
|
|
|
+ ApplyCommand = canExecute
|
|
|
+ .ToReactiveCommand(async _ => await ApplyFileAssociations())
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ UnassociateCommand = canExecute
|
|
|
+ .ToReactiveCommand(async _ => { await UnassociateFileAssociations(); })
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ ClearFilterCommand = canExecute
|
|
|
+ .ToReactiveCommand(_ => { FilterText.Value = string.Empty; })
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ ResetCommand = canExecute
|
|
|
+ .ToReactiveCommand(_ => { ResetFileTypesToDefault(); })
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ SelectAllCommand = canExecute
|
|
|
+ .ToReactiveCommand(_ => { SelectAllFileTypes(); })
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ UnselectAllCommand = canExecute
|
|
|
+ .ToReactiveCommand(_ => { UnselectAllFileTypes(); })
|
|
|
+ .AddTo(_disposables);
|
|
|
+
|
|
|
+ // Opacity reacts to IsProcessing
|
|
|
+ IsProcessing
|
|
|
+ .AsObservable()
|
|
|
+ .Subscribe(isProcessing => { Opacity.Value = isProcessing ? 0.3 : 1.0; })
|
|
|
+ .AddTo(_disposables);
|
|
|
+ }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Gets the read-only collection of file type groups that are available for association.
|
|
|
/// </summary>
|
|
|
- public ReadOnlyObservableCollection<FileTypeGroup> FileTypeGroups => _fileTypeGroups;
|
|
|
+ public List<FileTypeGroup> FileTypeGroups { get; } = [];
|
|
|
|
|
|
/// <summary>
|
|
|
/// Gets or sets the filter text used to search and filter file type groups and items.
|
|
|
/// </summary>
|
|
|
- public string? FilterText
|
|
|
- {
|
|
|
- get;
|
|
|
- set => this.RaiseAndSetIfChanged(ref field, value);
|
|
|
- } = string.Empty;
|
|
|
+ public BindableReactiveProperty<string?> FilterText { get; } = new(string.Empty);
|
|
|
|
|
|
/// <summary>
|
|
|
/// Gets or sets a value indicating whether the view model is currently processing an operation.
|
|
|
/// Used to disable UI interaction during long-running tasks.
|
|
|
/// </summary>
|
|
|
- public bool IsProcessing
|
|
|
- {
|
|
|
- get;
|
|
|
- set => this.RaiseAndSetIfChanged(ref field, value);
|
|
|
- }
|
|
|
+ public BindableReactiveProperty<bool> IsProcessing { get; } = new(false);
|
|
|
|
|
|
/// <summary>
|
|
|
/// Gets or sets the opacity value for the UI, used to visually indicate processing state.
|
|
|
/// </summary>
|
|
|
- public double Opacity
|
|
|
- {
|
|
|
- get;
|
|
|
- set => this.RaiseAndSetIfChanged(ref field, value);
|
|
|
- } = 1.0;
|
|
|
+ public BindableReactiveProperty<double> Opacity { get; } = new(1.0);
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to apply the selected file associations.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, bool> ApplyCommand { get; }
|
|
|
+ public ReactiveCommand? ApplyCommand { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to clear the current filter text.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, string> ClearFilterCommand { get; }
|
|
|
+ public ReactiveCommand? ClearFilterCommand { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to unassociate all file types from the application.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, Unit> UnassociateCommand { get; }
|
|
|
+ public ReactiveCommand? UnassociateCommand { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to reset file type selections to their default state.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, Unit> ResetCommand { get; }
|
|
|
+ public ReactiveCommand? ResetCommand { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to select all visible file types.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, Unit> SelectAllCommand { get; }
|
|
|
+ public ReactiveCommand? SelectAllCommand { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// Command to unselect all visible file types.
|
|
|
/// </summary>
|
|
|
- public ReactiveCommand<Unit, Unit> UnselectAllCommand { get; }
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Initializes a new instance of the <see cref="FileAssociationsViewModel"/> class.
|
|
|
- /// Sets up file type groups, commands, and filtering behavior.
|
|
|
- /// </summary>
|
|
|
- public FileAssociationsViewModel()
|
|
|
- {
|
|
|
- // Create file type groups and populate with data
|
|
|
- InitializeFileTypes();
|
|
|
-
|
|
|
- // Setup the filtering
|
|
|
- var filter = this.WhenAnyValue(x => x.FilterText)
|
|
|
- .Throttle(TimeSpan.FromMilliseconds(200))
|
|
|
- .Select(BuildFilter);
|
|
|
-
|
|
|
- _fileTypeGroupsList.Connect()
|
|
|
- .AutoRefresh()
|
|
|
- .Filter(filter)
|
|
|
- .Bind(out _fileTypeGroups)
|
|
|
- .Subscribe();
|
|
|
-
|
|
|
- // CanExecute for commands
|
|
|
- var canExecute = this.WhenAnyValue(x => x.IsProcessing)
|
|
|
- .Select(processing => !processing);
|
|
|
-
|
|
|
- // Initialize commands with error handling
|
|
|
- ApplyCommand = ReactiveCommand.CreateFromTask(
|
|
|
- ApplyFileAssociations,
|
|
|
- canExecute);
|
|
|
-
|
|
|
- // Handle errors from the Apply command
|
|
|
- ApplyCommand.ThrownExceptions
|
|
|
- .Subscribe(ex =>
|
|
|
- {
|
|
|
- IsProcessing = false;
|
|
|
-#if DEBUG
|
|
|
- Debug.WriteLine($"Error in ApplyCommand: {ex}");
|
|
|
-#endif
|
|
|
- });
|
|
|
-
|
|
|
- UnassociateCommand = ReactiveCommand.CreateFromTask(
|
|
|
- UnassociateFileAssociations,
|
|
|
- canExecute);
|
|
|
-
|
|
|
- UnassociateCommand.ThrownExceptions
|
|
|
- .Subscribe(ex =>
|
|
|
- {
|
|
|
- IsProcessing = false;
|
|
|
- Debug.WriteLine($"Error in UnassociateCommand: {ex}");
|
|
|
- });
|
|
|
-
|
|
|
- ClearFilterCommand = ReactiveCommand.Create(() => FilterText = string.Empty);
|
|
|
-
|
|
|
- ResetCommand = ReactiveCommand.Create(ResetFileTypesToDefault, canExecute);
|
|
|
-
|
|
|
- ResetCommand.ThrownExceptions
|
|
|
- .Subscribe(ex =>
|
|
|
- {
|
|
|
- Debug.WriteLine($"Error in ResetCommand: {ex}");
|
|
|
- });
|
|
|
-
|
|
|
- SelectAllCommand = ReactiveCommand.Create(SelectAllFileTypes, canExecute);
|
|
|
-
|
|
|
- SelectAllCommand.ThrownExceptions
|
|
|
- .Subscribe(ex =>
|
|
|
- {
|
|
|
- Debug.WriteLine($"Error in SelectAllCommand: {ex}");
|
|
|
- });
|
|
|
-
|
|
|
- UnselectAllCommand = ReactiveCommand.Create(UnselectAllFileTypes, canExecute);
|
|
|
-
|
|
|
- UnselectAllCommand.ThrownExceptions
|
|
|
- .Subscribe(ex =>
|
|
|
- {
|
|
|
- Debug.WriteLine($"Error in UnselectAllCommand: {ex}");
|
|
|
- });
|
|
|
-
|
|
|
- this.WhenAnyValue(x => x.IsProcessing).Subscribe(isProcessing =>
|
|
|
- {
|
|
|
- Opacity = isProcessing ? 0.3 : 1.0;
|
|
|
- });
|
|
|
- }
|
|
|
+ public ReactiveCommand? UnselectAllCommand { get; }
|
|
|
|
|
|
- #region Initialize and filtering
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Initializes file type groups by loading default file types from <see cref="FileTypeGroupHelper"/>.
|
|
|
- /// </summary>
|
|
|
- private void InitializeFileTypes()
|
|
|
+ public void Dispose()
|
|
|
{
|
|
|
- var groups = FileTypeGroupHelper.GetFileTypes();
|
|
|
-
|
|
|
- _fileTypeGroupsList.Edit(list =>
|
|
|
- {
|
|
|
- list.Clear();
|
|
|
- list.AddRange(groups);
|
|
|
- });
|
|
|
+ Disposable.Dispose(IsProcessing, Opacity, ApplyCommand, ClearFilterCommand, UnassociateCommand, ResetCommand,
|
|
|
+ SelectAllCommand, UnselectAllCommand);
|
|
|
}
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Builds a filter function for file type groups based on the provided filter text.
|
|
|
- /// </summary>
|
|
|
- /// <param name="filter">The filter text to search for in file type descriptions and extensions.</param>
|
|
|
- /// <returns>A function that determines if a file type group should be visible based on the filter.</returns>
|
|
|
- private Func<FileTypeGroup, bool> BuildFilter(string? filter)
|
|
|
- {
|
|
|
- if (string.IsNullOrWhiteSpace(filter))
|
|
|
- {
|
|
|
- // Reset all items to visible when filter is empty
|
|
|
- foreach (var group in _fileTypeGroupsList.Items)
|
|
|
- {
|
|
|
- foreach (var item in group.FileTypes)
|
|
|
- {
|
|
|
- item.IsVisible = true;
|
|
|
- }
|
|
|
- }
|
|
|
- return _ => true;
|
|
|
- }
|
|
|
-
|
|
|
- return group => {
|
|
|
- // Update visibility of items based on filter
|
|
|
- var anyVisible = false;
|
|
|
- foreach (var item in group.FileTypes)
|
|
|
- {
|
|
|
- item.IsVisible = item.Description.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
|
|
- item.Extension.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
|
|
- if (item.IsVisible)
|
|
|
- anyVisible = true;
|
|
|
- }
|
|
|
-
|
|
|
- // Only show groups that have at least one visible item
|
|
|
- return anyVisible;
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- #endregion
|
|
|
-
|
|
|
+
|
|
|
#region Selection
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Updates all selection states to ensure changes are properly reflected in the UI.
|
|
|
- /// This method forces property notifications to be sent for all selection states.
|
|
|
- /// </summary>
|
|
|
+
|
|
|
private void UpdateSelection()
|
|
|
{
|
|
|
- // Force property notifications to ensure all changes are processed
|
|
|
foreach (var group in FileTypeGroups)
|
|
|
{
|
|
|
group.IsSelected = group.IsSelected;
|
|
|
@@ -234,37 +125,28 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Resets all file type selections to their default state as defined by <see cref="FileTypeGroupHelper.GetFileTypes"/>.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// This method uses snapshots of collections to avoid enumeration modification exceptions.
|
|
|
- /// </remarks>
|
|
|
+
|
|
|
private void ResetFileTypesToDefault()
|
|
|
{
|
|
|
- // Get fresh default file types
|
|
|
var defaultGroups = FileTypeGroupHelper.GetFileTypes();
|
|
|
-
|
|
|
- // Use snapshot to get current groups to avoid enumeration issues
|
|
|
var currentGroups = FileTypeGroups.ToArray();
|
|
|
-
|
|
|
+
|
|
|
foreach (var group in currentGroups)
|
|
|
{
|
|
|
var defaultGroup = defaultGroups.FirstOrDefault(g => g.Name == group.Name);
|
|
|
if (defaultGroup == null)
|
|
|
+ {
|
|
|
continue;
|
|
|
-
|
|
|
- // Update the group selection state
|
|
|
+ }
|
|
|
+
|
|
|
group.IsSelected = defaultGroup.IsSelected;
|
|
|
-
|
|
|
- // Create snapshot of file types to avoid enumeration issues
|
|
|
+
|
|
|
var fileTypes = group.FileTypes.ToArray();
|
|
|
foreach (var fileType in fileTypes)
|
|
|
{
|
|
|
- var defaultType = defaultGroup.FileTypes.FirstOrDefault(dt =>
|
|
|
+ var defaultType = defaultGroup.FileTypes.FirstOrDefault(dt =>
|
|
|
dt.Description == fileType.Description);
|
|
|
-
|
|
|
+
|
|
|
if (defaultType != null)
|
|
|
{
|
|
|
fileType.IsSelected = defaultType.IsSelected;
|
|
|
@@ -273,24 +155,13 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Unselects all file types by setting their selection state to false.
|
|
|
- /// Used before unassociating all file types from the application.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// This method uses snapshots of collections to avoid enumeration modification exceptions.
|
|
|
- /// </remarks>
|
|
|
private void UnselectFileTypes()
|
|
|
{
|
|
|
- // Make a copy of the current groups to avoid enumeration issues
|
|
|
var currentGroups = FileTypeGroups.ToArray();
|
|
|
-
|
|
|
- // Update selection states to false for all items
|
|
|
+
|
|
|
foreach (var group in currentGroups)
|
|
|
{
|
|
|
group.IsSelected = false;
|
|
|
-
|
|
|
- // Use snapshot of file types to avoid enumeration issues
|
|
|
var fileTypes = group.FileTypes.ToArray();
|
|
|
foreach (var fileType in fileTypes)
|
|
|
{
|
|
|
@@ -298,58 +169,37 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Selects all visible file types except for archive types which are handled specially.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// This method uses snapshots of collections to avoid enumeration modification exceptions.
|
|
|
- /// Archive types (.zip, .rar, etc.) are not automatically selected to avoid unwanted associations.
|
|
|
- /// </remarks>
|
|
|
+
|
|
|
private void SelectAllFileTypes()
|
|
|
{
|
|
|
- // Make a copy of the current groups to avoid enumeration issues
|
|
|
var currentGroups = FileTypeGroups.ToArray();
|
|
|
-
|
|
|
- // Update selection states to true for all items
|
|
|
+
|
|
|
foreach (var group in currentGroups)
|
|
|
{
|
|
|
- // Use snapshot of file types to avoid enumeration issues
|
|
|
var fileTypes = group.FileTypes.ToArray();
|
|
|
foreach (var fileType in fileTypes)
|
|
|
{
|
|
|
if (!fileType.IsVisible)
|
|
|
{
|
|
|
- // We don't want to select hidden items
|
|
|
continue;
|
|
|
}
|
|
|
- // Only set archive types explicitly
|
|
|
+
|
|
|
if (fileType.Extension.StartsWith(".zip") ||
|
|
|
fileType.Extension.StartsWith(".rar") ||
|
|
|
fileType.Extension.StartsWith(".7z") ||
|
|
|
- fileType.Extension.StartsWith(".gzip"))
|
|
|
+ fileType.Extension.StartsWith(".gzip"))
|
|
|
{
|
|
|
continue;
|
|
|
}
|
|
|
+
|
|
|
fileType.IsSelected = true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Toggles selection state of all visible file types between indeterminate and unselected.
|
|
|
- /// If the number of indeterminate checkboxes equals or is greater than the number of non-indeterminate ones,
|
|
|
- /// all visible checkboxes will be set to unchecked. Otherwise, all will be set to indeterminate.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// This method uses snapshots of collections to avoid enumeration modification exceptions.
|
|
|
- /// </remarks>
|
|
|
private void UnselectAllFileTypes()
|
|
|
{
|
|
|
- // Make a copy of the current groups to avoid enumeration issues
|
|
|
var currentGroups = FileTypeGroups.ToArray();
|
|
|
-
|
|
|
- // Count the total number of visible checkboxes and indeterminate ones
|
|
|
var totalVisible = 0;
|
|
|
var indeterminateCount = 0;
|
|
|
|
|
|
@@ -365,12 +215,8 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Determine which state to set based on the counts
|
|
|
- // If indeterminate count is equal to or greater than non-indeterminate count,
|
|
|
- // set all to unchecked, otherwise set all to indeterminate
|
|
|
var setToUnchecked = indeterminateCount >= totalVisible - indeterminateCount;
|
|
|
|
|
|
- // Apply the chosen state to all visible checkboxes
|
|
|
foreach (var group in currentGroups)
|
|
|
{
|
|
|
foreach (var fileType in group.FileTypes.Where(ft => ft.IsVisible))
|
|
|
@@ -379,35 +225,22 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
#endregion
|
|
|
|
|
|
#region Associations
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Applies the current file type associations based on selection states.
|
|
|
- /// </summary>
|
|
|
- /// <returns>A <see cref="Task"/> representing the asynchronous operation, with a boolean result indicating success.</returns>
|
|
|
- /// <remarks>
|
|
|
- /// This method sets <see cref="IsProcessing"/> to true during execution, which disables UI interaction.
|
|
|
- /// </remarks>
|
|
|
- private async Task<bool> ApplyFileAssociations() => await SetFileAssociations(false);
|
|
|
-
|
|
|
- /// <summary>
|
|
|
- /// Unassociates all file types from the application by setting all selection states to false
|
|
|
- /// and then applying the associations.
|
|
|
- /// </summary>
|
|
|
- /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
|
- /// <remarks>
|
|
|
- /// This method sets <see cref="IsProcessing"/> to true during execution, which disables UI interaction.
|
|
|
- /// </remarks>
|
|
|
- private async Task UnassociateFileAssociations() => await SetFileAssociations(true);
|
|
|
-
|
|
|
+
|
|
|
+ private async Task<bool> ApplyFileAssociations()
|
|
|
+ => await SetFileAssociations(false);
|
|
|
+
|
|
|
+ private async Task UnassociateFileAssociations()
|
|
|
+ => await SetFileAssociations(true);
|
|
|
+
|
|
|
private async Task<bool> SetFileAssociations(bool unassociate)
|
|
|
{
|
|
|
try
|
|
|
{
|
|
|
- IsProcessing = true;
|
|
|
+ IsProcessing.Value = true;
|
|
|
|
|
|
return await Task.Run(async () =>
|
|
|
{
|
|
|
@@ -419,15 +252,15 @@ public class FileAssociationsViewModel : ReactiveObject
|
|
|
{
|
|
|
UpdateSelection();
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
return await FileAssociationProcessor.SetFileAssociations(FileTypeGroups);
|
|
|
});
|
|
|
}
|
|
|
finally
|
|
|
{
|
|
|
- IsProcessing = false;
|
|
|
+ IsProcessing.Value = false;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
#endregion
|
|
|
}
|