Refactor settings management in PLib video library manager. Replace dialog-based settings with a single-panel overlay for improved user experience. Update MainWindow and SettingsView to support new settings panel, and adjust ViewModels accordingly. Enhance README.md to reflect these changes.

This commit is contained in:
Leonid Pershin
2026-08-08 12:40:53 +03:00
parent 186dd900e1
commit 835937357e
12 changed files with 441 additions and 216 deletions
+7 -3
View File
@@ -9,8 +9,9 @@
- Метаданные (длительность, разрешение, кодек) через ffprobe. - Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске. - Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Окно настроек: папки библиотеки (с удалением), параметры превью и сканирования, тема, - Настройки — выдвижной панелью в том же окне: папки библиотеки (с удалением), параметры
очистка кэша превью. Всё пишется в `settings.json` и подхватывается без перезапуска. превью и сканирования, тема, очистка кэша. Всё пишется в `settings.json` и подхватывается
без перезапуска.
- Светлая, тёмная и системная темы; выбор запоминается. - Светлая, тёмная и системная темы; выбор запоминается.
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. - Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
@@ -62,7 +63,10 @@ dotnet test
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт - **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
`IServiceScopeFactory` и создаёт scope на каждый вызов. `IServiceScopeFactory` и создаёт scope на каждый вызов.
- **Настройки — рабочая копия.** Диалог правит снимок `AppSettings` и записывает его целиком - **Одно окно.** Настройки — панель поверх сетки, а не второе окно: библиотека остаётся
видна, в alt-tab ничего не добавляется, и приложение остаётся переносимым на
`ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует.
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
его не вызывает. его не вызывает.
-1
View File
@@ -48,7 +48,6 @@ internal static class AppHost
builder.Services.AddSingleton<ThumbnailCache>(); builder.Services.AddSingleton<ThumbnailCache>();
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>()); builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>(); builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
builder.Services.AddSingleton<IDialogService, DialogService>();
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>(); builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
builder.Services.AddSingleton<ISystemShell, SystemShell>(); builder.Services.AddSingleton<ISystemShell, SystemShell>();
builder.Services.AddSingleton<IThemeService, ThemeService>(); builder.Services.AddSingleton<IThemeService, ThemeService>();
@@ -1,42 +0,0 @@
using System.Reactive.Linq;
using Avalonia.Controls.ApplicationLifetimes;
using Microsoft.Extensions.DependencyInjection;
using PLib.Desktop.ViewModels;
using PLib.Desktop.Views;
namespace PLib.Desktop.Services;
/// <summary>Opens the application's dialogs, so view models never touch window types.</summary>
public interface IDialogService
{
Task<SettingsDialogOutcome> ShowSettingsAsync();
}
/// <inheritdoc cref="IDialogService"/>
public sealed class DialogService(IServiceScopeFactory scopeFactory) : IDialogService
{
public async Task<SettingsDialogOutcome> ShowSettingsAsync()
{
if (Avalonia.Application.Current?.ApplicationLifetime
is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
{
return SettingsDialogOutcome.Cancelled;
}
// A scope per dialog: the container would otherwise keep every settings view model
// it ever built alive until shutdown, and disposing the scope disposes the model.
await using var scope = scopeFactory.CreateAsyncScope();
var viewModel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
var window = new SettingsWindow { DataContext = viewModel };
// The view model decides when it is done; the window only carries the answer out.
using var subscription = viewModel.Closed.Subscribe(outcome => window.Close(outcome));
// Show a real number in the cache section rather than an empty label.
await viewModel.RefreshCacheSizeCommand.Execute().FirstAsync();
// Closing through the title bar yields null, which counts as cancelling.
return await window.ShowDialog<SettingsDialogOutcome?>(owner) ?? SettingsDialogOutcome.Cancelled;
}
}
@@ -104,6 +104,58 @@
<Setter Property="FontSize" Value="12.5" /> <Setter Property="FontSize" Value="12.5" />
</Style> </Style>
<!-- ======================== Settings overlay ======================== -->
<!--
The overlay stays in the tree and hides behind opacity rather than IsVisible: a collapsed
element has nothing to animate from, and the slide-in is the whole point. Nothing inside
is focusable while it is closed, because the panel's content is null until it opens.
-->
<Style Selector="Panel.settingsOverlay">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Panel.settingsOverlay.open">
<Setter Property="Opacity" Value="1" />
<Setter Property="IsHitTestVisible" Value="True" />
</Style>
<!-- Click-anywhere-to-dismiss backdrop. A Button gets the click handling for free. -->
<Style Selector="Button.scrim">
<Setter Property="Background" Value="{DynamicResource OverlayBrush}" />
<Setter Property="Focusable" Value="False" />
<Setter Property="Cursor" Value="Arrow" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" />
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Border.drawer">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Width" Value="470" />
<Setter Property="Background" Value="{DynamicResource PageBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
<Setter Property="BorderThickness" Value="1,0,0,0" />
<Setter Property="RenderTransform" Value="translateX(30px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.2" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Panel.settingsOverlay.open Border.drawer">
<Setter Property="RenderTransform" Value="none" />
</Style>
<!-- ============================ Badge ============================ --> <!-- ============================ Badge ============================ -->
<Style Selector="Border.badge"> <Style Selector="Border.badge">
@@ -35,7 +35,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IOptionsMonitor<LibraryOptions> _options; private readonly IOptionsMonitor<LibraryOptions> _options;
private readonly IAppSettingsStore _settingsStore; private readonly IAppSettingsStore _settingsStore;
private readonly IOptionsMonitor<AppearanceOptions> _appearance; private readonly IOptionsMonitor<AppearanceOptions> _appearance;
private readonly IDialogService _dialogs;
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker; private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell; private readonly ISystemShell _shell;
@@ -60,13 +59,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos; private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos;
private readonly ObservableAsPropertyHelper<bool> _isScanning; private readonly ObservableAsPropertyHelper<bool> _isScanning;
private readonly ObservableAsPropertyHelper<bool> _isEmpty; private readonly ObservableAsPropertyHelper<bool> _isEmpty;
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
public MainWindowViewModel( public MainWindowViewModel(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptionsMonitor<LibraryOptions> options, IOptionsMonitor<LibraryOptions> options,
IOptionsMonitor<AppearanceOptions> appearance, IOptionsMonitor<AppearanceOptions> appearance,
IAppSettingsStore settingsStore, IAppSettingsStore settingsStore,
IDialogService dialogs,
IFolderPicker folderPicker, IFolderPicker folderPicker,
ISystemShell shell, ISystemShell shell,
IThemeService theme, IThemeService theme,
@@ -76,7 +75,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_options = options; _options = options;
_appearance = appearance; _appearance = appearance;
_settingsStore = settingsStore; _settingsStore = settingsStore;
_dialogs = dialogs;
_theme = theme; _theme = theme;
_folderPicker = folderPicker; _folderPicker = folderPicker;
_shell = shell; _shell = shell;
@@ -87,7 +85,19 @@ public sealed partial class MainWindowViewModel : ViewModelBase
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync); InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync); ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync);
OpenSettingsCommand = ReactiveCommand.CreateFromTask(OpenSettingsAsync);
_isSettingsOpen = this
.WhenAnyValue(x => x.SettingsPanel)
.Select(panel => panel is not null)
.ToProperty(this, x => x.IsSettingsOpen);
OpenSettingsCommand = ReactiveCommand.CreateFromTask(
OpenSettingsAsync,
this.WhenAnyValue(x => x.IsSettingsOpen).Select(open => !open));
CloseSettingsCommand = ReactiveCommand.Create(
() => { SettingsPanel?.CancelCommand.Execute().Subscribe(); },
this.WhenAnyValue(x => x.IsSettingsOpen));
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling // Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out. // simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
@@ -128,6 +138,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; } public ReactiveCommand<RxVoid, RxVoid> OpenSettingsCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> CloseSettingsCommand { get; }
/// <summary>
/// The settings panel while it is on screen, or <c>null</c>. Its presence is what the
/// overlay binds to — settings live in this window rather than a second one.
/// </summary>
[Reactive]
public partial SettingsViewModel? SettingsPanel { get; set; }
public bool IsSettingsOpen => _isSettingsOpen.Value;
[Reactive] [Reactive]
public partial string SearchText { get; set; } public partial string SearchText { get; set; }
@@ -204,7 +225,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
CancelScanCommand.ThrownExceptions, CancelScanCommand.ThrownExceptions,
AddFolderCommand.ThrownExceptions, AddFolderCommand.ThrownExceptions,
ToggleThemeCommand.ThrownExceptions, ToggleThemeCommand.ThrownExceptions,
OpenSettingsCommand.ThrownExceptions) OpenSettingsCommand.ThrownExceptions,
CloseSettingsCommand.ThrownExceptions)
.Subscribe(ex => .Subscribe(ex =>
{ {
_logger.LogError(ex, "A command failed"); _logger.LogError(ex, "A command failed");
@@ -313,14 +335,35 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private async Task OpenSettingsAsync() private async Task OpenSettingsAsync()
{ {
var outcome = await _dialogs.ShowSettingsAsync(); // A scope per opening: the panel edits a working copy, so a cancelled edit must not
// survive into the next time it is opened, and disposing the scope disposes it.
await using var scope = _scopeFactory.CreateAsyncScope();
var panel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
// Fill in the cache size before the panel appears, so the number never pops in late.
await panel.RefreshCacheSizeCommand.Execute().FirstAsync();
SettingsPanel = panel;
SettingsDialogOutcome outcome;
try
{
outcome = await panel.Closed.FirstAsync();
}
finally
{
// Close first: a rescan started below would otherwise run behind a panel the
// user has already dismissed.
SettingsPanel = null;
}
if (outcome is not { Saved: true, Settings: { } saved }) if (outcome is not { Saved: true, Settings: { } saved })
{ {
return; return;
} }
// The dialog wrote the file; wait for the configuration to catch up before anything // The panel wrote the file; wait for the configuration to catch up before anything
// reads it back, otherwise the rescan below would use the previous folder list. // reads it back, otherwise the rescan below would use the previous folder list.
await WaitForConfigurationAsync( await WaitForConfigurationAsync(
() => _options.CurrentValue.Folders.SequenceEqual(saved.Folders, LibraryPathComparer.Instance)); () => _options.CurrentValue.Folders.SequenceEqual(saved.Folders, LibraryPathComparer.Instance));
@@ -57,9 +57,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
Folders = [.. _original.Folders.Select(CreateEntry)]; Folders = [.. _original.Folders.Select(CreateEntry)];
ThumbnailWidth = _original.ThumbnailWidth; ThumbnailWidth = _original.ThumbnailWidth;
ThumbnailPositionPercent = Math.Round(_original.ThumbnailPositionRatio * 100); ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
MaxIndexingConcurrency = _original.MaxIndexingConcurrency; MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
MinimumFileSizeMegabytes = Math.Round(_original.MinimumFileSizeInBytes / BytesPerMegabyte, 2); MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme); SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync); AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
@@ -127,12 +127,28 @@ public sealed partial class SettingsViewModel : ViewModelBase
{ {
Folders = [.. Folders.Select(entry => entry.Path)], Folders = [.. Folders.Select(entry => entry.Path)],
ThumbnailWidth = ThumbnailWidth, ThumbnailWidth = ThumbnailWidth,
ThumbnailPositionRatio = Math.Round(ThumbnailPositionPercent / 100, 4),
// Both of these are shown in a friendlier unit than they are stored in, and that
// conversion is lossy: 65 536 bytes displays as 0,06 MB and converts back to 62 915.
// An untouched field therefore keeps the original value verbatim — otherwise merely
// opening the panel and pressing Save would rewrite settings and force a rescan.
ThumbnailPositionRatio = ThumbnailPositionPercent == ToPercent(_original.ThumbnailPositionRatio)
? _original.ThumbnailPositionRatio
: Math.Round(ThumbnailPositionPercent / 100, 4),
MaxIndexingConcurrency = MaxIndexingConcurrency, MaxIndexingConcurrency = MaxIndexingConcurrency,
MinimumFileSizeInBytes = (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
? _original.MinimumFileSizeInBytes
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
Theme = SelectedTheme.Mode, Theme = SelectedTheme.Mode,
}; };
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
private static double ToMegabytes(long bytes) => Math.Round(bytes / BytesPerMegabyte, 2);
private async Task AddFolderAsync() private async Task AddFolderAsync()
{ {
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео"); var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
+17
View File
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:PLib.Desktop.Controls" xmlns:controls="clr-namespace:PLib.Desktop.Controls"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:views="clr-namespace:PLib.Desktop.Views"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels" xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.MainWindow" x:Class="PLib.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
@@ -15,6 +16,10 @@
<Window.Resources> <Window.Resources>
<DataTemplate x:Key="SettingsTemplate" DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
<!-- ======================= Video card ======================= --> <!-- ======================= Video card ======================= -->
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel"> <DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}"> <Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
@@ -234,5 +239,17 @@
</Grid> </Grid>
</Border> </Border>
<!-- ======================= Settings panel ======================= -->
<Panel Grid.Row="0"
Grid.RowSpan="3"
Classes="settingsOverlay"
Classes.open="{Binding IsSettingsOpen}">
<Button Classes="scrim" Command="{Binding CloseSettingsCommand}" />
<Border Classes="drawer">
<ContentControl Content="{Binding SettingsPanel}"
ContentTemplate="{StaticResource SettingsTemplate}" />
</Border>
</Panel>
</Grid> </Grid>
</Window> </Window>
+28 -2
View File
@@ -1,9 +1,35 @@
using ReactiveUI.Avalonia; using Avalonia.Input;
using Avalonia.Interactivity;
using PLib.Desktop.ViewModels; using PLib.Desktop.ViewModels;
using ReactiveUI.Avalonia;
namespace PLib.Desktop.Views; namespace PLib.Desktop.Views;
public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel> public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{ {
public MainWindow() => InitializeComponent(); public MainWindow()
{
InitializeComponent();
// Handled here rather than as a KeyBinding: a KeyBinding only fires if the event
// bubbles all the way up untouched, and whichever control inside the settings panel
// happens to hold focus may well swallow Escape first. Both strategies are
// registered because a focused control can mark the event handled on the way down.
AddHandler(
KeyDownEvent,
OnPreviewKeyDown,
RoutingStrategies.Tunnel | RoutingStrategies.Bubble,
handledEventsToo: true);
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is not Key.Escape || DataContext is not MainWindowViewModel { IsSettingsOpen: true } viewModel)
{
return;
}
viewModel.CloseSettingsCommand.Execute().Subscribe();
e.Handled = true;
}
} }
+21 -17
View File
@@ -1,18 +1,11 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels" xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.SettingsWindow" x:Class="PLib.Desktop.Views.SettingsView"
x:DataType="vm:SettingsViewModel" x:DataType="vm:SettingsViewModel">
Title="Настройки"
Width="620"
Height="700"
MinWidth="520"
MinHeight="520"
Background="{DynamicResource PageBackgroundBrush}"
WindowStartupLocation="CenterOwner">
<Window.Styles> <UserControl.Styles>
<!-- One settings block: a titled surface holding a few related controls. --> <!-- One settings block: a titled surface holding a few related controls. -->
<Style Selector="Border.section"> <Style Selector="Border.section">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" /> <Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
@@ -33,7 +26,7 @@
<Setter Property="FontSize" Value="11.5" /> <Setter Property="FontSize" Value="11.5" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
</Style> </Style>
</Window.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
@@ -43,8 +36,9 @@
BorderBrush="{DynamicResource SurfaceBorderBrush}" BorderBrush="{DynamicResource SurfaceBorderBrush}"
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
Padding="20,14"> Padding="20,14">
<StackPanel Orientation="Horizontal" Spacing="10"> <Grid ColumnDefinitions="Auto,*,Auto">
<Border Width="32" <Border Grid.Column="0"
Width="32"
Height="32" Height="32"
CornerRadius="9" CornerRadius="9"
Background="{DynamicResource AccentSoftBrush}"> Background="{DynamicResource AccentSoftBrush}">
@@ -53,8 +47,18 @@
Height="18" Height="18"
Foreground="{DynamicResource AccentBrush}" /> Foreground="{DynamicResource AccentBrush}" />
</Border> </Border>
<TextBlock Classes="sectionTitle" Text="Настройки" VerticalAlignment="Center" /> <TextBlock Grid.Column="1"
</StackPanel> Classes="sectionTitle"
Margin="10,0,0,0"
Text="Настройки"
VerticalAlignment="Center" />
<Button Grid.Column="2"
Command="{Binding CancelCommand}"
Padding="7"
ToolTip.Tip="Закрыть без сохранения">
<icons:MaterialIcon Kind="Close" Width="15" Height="15" />
</Button>
</Grid>
</Border> </Border>
<!-- ======================= Body ======================= --> <!-- ======================= Body ======================= -->
@@ -241,4 +245,4 @@
</Border> </Border>
</Grid> </Grid>
</Window> </UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace PLib.Desktop.Views;
public sealed partial class SettingsView : UserControl
{
public SettingsView() => InitializeComponent();
}
@@ -1,9 +0,0 @@
using PLib.Desktop.ViewModels;
using ReactiveUI.Avalonia;
namespace PLib.Desktop.Views;
public sealed partial class SettingsWindow : ReactiveWindow<SettingsViewModel>
{
public SettingsWindow() => InitializeComponent();
}
@@ -0,0 +1,107 @@
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Library;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using PLib.Desktop.ViewModels;
using Shouldly;
namespace PLib.Tests.Settings;
public sealed class SettingsViewModelTests
{
private readonly IAppSettingsStore _store = Substitute.For<IAppSettingsStore>();
[Fact]
public async Task Opening_and_saving_without_touching_anything_changes_nothing()
{
// Both of these are displayed in a lossier unit than they are stored in: 65 536 bytes
// shows as 0,06 MB. Converting the untouched field back used to yield 62 915, which
// silently rewrote the setting and forced a full rescan on every visit to the panel.
var options = new LibraryOptions
{
Folders = [@"C:\videos"],
MinimumFileSizeInBytes = 65_536,
ThumbnailPositionRatio = 0.15,
};
var (viewModel, outcome) = await SaveAsync(options);
outcome.RescanRequired.ShouldBeFalse();
Written.MinimumFileSizeInBytes.ShouldBe(65_536);
Written.ThumbnailPositionRatio.ShouldBe(0.15);
viewModel.HasFolders.ShouldBeTrue();
}
[Fact]
public async Task Editing_the_minimum_size_does_write_the_new_value()
{
var (_, outcome) = await SaveAsync(
new LibraryOptions { Folders = [@"C:\videos"], MinimumFileSizeInBytes = 65_536 },
viewModel => viewModel.MinimumFileSizeMegabytes = 4);
Written.MinimumFileSizeInBytes.ShouldBe(4 * 1024 * 1024);
outcome.RescanRequired.ShouldBeTrue();
}
[Fact]
public async Task Removing_a_folder_is_carried_into_the_saved_settings()
{
var (_, outcome) = await SaveAsync(
new LibraryOptions { Folders = [@"C:\a", @"C:\b"] },
viewModel => viewModel.Folders[0].RemoveCommand.Execute().Subscribe());
Written.Folders.ShouldBe([@"C:\b"]);
outcome.RescanRequired.ShouldBeTrue();
}
[Fact]
public async Task Cancelling_writes_nothing()
{
var viewModel = Create(new LibraryOptions { Folders = [@"C:\videos"] });
var closed = viewModel.Closed.FirstAsync().ToTask();
viewModel.MinimumFileSizeMegabytes = 999;
await viewModel.CancelCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken);
(await closed).Saved.ShouldBeFalse();
_store.ReceivedCalls().ShouldBeEmpty();
}
private AppSettings Written =>
(AppSettings)_store.ReceivedCalls().Single(call => call.GetMethodInfo().Name == nameof(IAppSettingsStore.SaveAsync))
.GetArguments()[0]!;
private async Task<(SettingsViewModel ViewModel, SettingsDialogOutcome Outcome)> SaveAsync(
LibraryOptions options,
Action<SettingsViewModel>? edit = null)
{
var viewModel = Create(options);
var closed = viewModel.Closed.FirstAsync().ToTask();
edit?.Invoke(viewModel);
await viewModel.SaveCommand.Execute().FirstAsync().ToTask(TestContext.Current.CancellationToken);
return (viewModel, await closed);
}
private SettingsViewModel Create(LibraryOptions options) => new(
Substitute.For<IServiceScopeFactory>(),
Monitor(options),
Monitor(new AppearanceOptions()),
_store,
Substitute.For<IFolderPicker>(),
Substitute.For<IThemeService>(),
NullLogger<SettingsViewModel>.Instance);
private static IOptionsMonitor<T> Monitor<T>(T value)
{
var monitor = Substitute.For<IOptionsMonitor<T>>();
monitor.CurrentValue.Returns(value);
return monitor;
}
}