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:
@@ -9,8 +9,9 @@
|
||||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||||
- Окно настроек: папки библиотеки (с удалением), параметры превью и сканирования, тема,
|
||||
очистка кэша превью. Всё пишется в `settings.json` и подхватывается без перезапуска.
|
||||
- Настройки — выдвижной панелью в том же окне: папки библиотеки (с удалением), параметры
|
||||
превью и сканирования, тема, очистка кэша. Всё пишется в `settings.json` и подхватывается
|
||||
без перезапуска.
|
||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
|
||||
|
||||
@@ -62,7 +63,10 @@ dotnet test
|
||||
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
|
||||
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
|
||||
`IServiceScopeFactory` и создаёт scope на каждый вызов.
|
||||
- **Настройки — рабочая копия.** Диалог правит снимок `AppSettings` и записывает его целиком
|
||||
- **Одно окно.** Настройки — панель поверх сетки, а не второе окно: библиотека остаётся
|
||||
видна, в alt-tab ничего не добавляется, и приложение остаётся переносимым на
|
||||
`ISingleViewApplicationLifetime`, где `ShowDialog` попросту не существует.
|
||||
- **Настройки — рабочая копия.** Панель правит снимок `AppSettings` и записывает его целиком
|
||||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||||
его не вызывает.
|
||||
|
||||
@@ -48,7 +48,6 @@ internal static class AppHost
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<IAppSettingsStore, JsonAppSettingsStore>();
|
||||
builder.Services.AddSingleton<IDialogService, DialogService>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
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" />
|
||||
</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 ============================ -->
|
||||
|
||||
<Style Selector="Border.badge">
|
||||
|
||||
@@ -35,7 +35,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly IAppSettingsStore _settingsStore;
|
||||
private readonly IOptionsMonitor<AppearanceOptions> _appearance;
|
||||
private readonly IDialogService _dialogs;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
@@ -60,13 +59,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isScanning;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> options,
|
||||
IOptionsMonitor<AppearanceOptions> appearance,
|
||||
IAppSettingsStore settingsStore,
|
||||
IDialogService dialogs,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
IThemeService theme,
|
||||
@@ -76,7 +75,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
_options = options;
|
||||
_appearance = appearance;
|
||||
_settingsStore = settingsStore;
|
||||
_dialogs = dialogs;
|
||||
_theme = theme;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
@@ -87,7 +85,19 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
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
|
||||
// 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> 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]
|
||||
public partial string SearchText { get; set; }
|
||||
|
||||
@@ -204,7 +225,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
CancelScanCommand.ThrownExceptions,
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
ToggleThemeCommand.ThrownExceptions,
|
||||
OpenSettingsCommand.ThrownExceptions)
|
||||
OpenSettingsCommand.ThrownExceptions,
|
||||
CloseSettingsCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A command failed");
|
||||
@@ -313,14 +335,35 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
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 })
|
||||
{
|
||||
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.
|
||||
await WaitForConfigurationAsync(
|
||||
() => _options.CurrentValue.Folders.SequenceEqual(saved.Folders, LibraryPathComparer.Instance));
|
||||
|
||||
@@ -57,9 +57,9 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
|
||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||
ThumbnailWidth = _original.ThumbnailWidth;
|
||||
ThumbnailPositionPercent = Math.Round(_original.ThumbnailPositionRatio * 100);
|
||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||
MinimumFileSizeMegabytes = Math.Round(_original.MinimumFileSizeInBytes / BytesPerMegabyte, 2);
|
||||
MinimumFileSizeMegabytes = ToMegabytes(_original.MinimumFileSizeInBytes);
|
||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
@@ -127,12 +127,28 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
{
|
||||
Folders = [.. Folders.Select(entry => entry.Path)],
|
||||
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,
|
||||
MinimumFileSizeInBytes = (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
|
||||
MinimumFileSizeInBytes = MinimumFileSizeMegabytes == ToMegabytes(_original.MinimumFileSizeInBytes)
|
||||
? _original.MinimumFileSizeInBytes
|
||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
|
||||
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()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
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"
|
||||
x:Class="PLib.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
@@ -15,6 +16,10 @@
|
||||
|
||||
<Window.Resources>
|
||||
|
||||
<DataTemplate x:Key="SettingsTemplate" DataType="vm:SettingsViewModel">
|
||||
<views:SettingsView />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ======================= Video card ======================= -->
|
||||
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
|
||||
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
|
||||
@@ -234,5 +239,17 @@
|
||||
</Grid>
|
||||
</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>
|
||||
</Window>
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
using ReactiveUI.Avalonia;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using ReactiveUI.Avalonia;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.SettingsWindow"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="Настройки"
|
||||
Width="620"
|
||||
Height="700"
|
||||
MinWidth="520"
|
||||
MinHeight="520"
|
||||
Background="{DynamicResource PageBackgroundBrush}"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.SettingsView"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<Window.Styles>
|
||||
<UserControl.Styles>
|
||||
<!-- One settings block: a titled surface holding a few related controls. -->
|
||||
<Style Selector="Border.section">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
@@ -33,7 +26,7 @@
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -43,8 +36,9 @@
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="20,14">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Width="32"
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0"
|
||||
Width="32"
|
||||
Height="32"
|
||||
CornerRadius="9"
|
||||
Background="{DynamicResource AccentSoftBrush}">
|
||||
@@ -53,8 +47,18 @@
|
||||
Height="18"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
</Border>
|
||||
<TextBlock Classes="sectionTitle" Text="Настройки" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
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>
|
||||
|
||||
<!-- ======================= Body ======================= -->
|
||||
@@ -241,4 +245,4 @@
|
||||
</Border>
|
||||
|
||||
</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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user