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.
|
- Метаданные (длительность, разрешение, кодек) через 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` и записывает его целиком
|
||||||
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
|
||||||
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
|
||||||
его не вызывает.
|
его не вызывает.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +1,173 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
<!-- ============================ Card ============================ -->
|
<!-- ============================ Card ============================ -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
||||||
for free. Its template is reduced to a single surface Border because the Semi button
|
for free. Its template is reduced to a single surface Border because the Semi button
|
||||||
chrome would fight the artwork.
|
chrome would fight the artwork.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.card">
|
<Style Selector="Button.card">
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
||||||
hands it, which leaves the cards small and the grid full of gaps. -->
|
hands it, which leaves the cards small and the grid full of gaps. -->
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
<Setter Property="RenderTransform" Value="none" />
|
<Setter Property="RenderTransform" Value="none" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<ControlTemplate>
|
<ControlTemplate>
|
||||||
<Border Name="PART_Surface"
|
<Border Name="PART_Surface"
|
||||||
Background="{DynamicResource CardBackgroundBrush}"
|
Background="{DynamicResource CardBackgroundBrush}"
|
||||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="14"
|
CornerRadius="14"
|
||||||
Padding="8,8,8,10">
|
Padding="8,8,8,10">
|
||||||
<Border.Transitions>
|
<Border.Transitions>
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Border.Transitions>
|
</Border.Transitions>
|
||||||
<ContentPresenter Content="{TemplateBinding Content}"
|
<ContentPresenter Content="{TemplateBinding Content}"
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
VerticalContentAlignment="Stretch" />
|
VerticalContentAlignment="Stretch" />
|
||||||
</Border>
|
</Border>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.card:pointerover">
|
<Style Selector="Button.card:pointerover">
|
||||||
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.card:pressed">
|
<Style Selector="Button.card:pressed">
|
||||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Play affordance: invisible until the pointer is over the card. -->
|
<!-- Play affordance: invisible until the pointer is over the card. -->
|
||||||
<Style Selector="Border.playOverlay">
|
<Style Selector="Border.playOverlay">
|
||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.card:pointerover Border.playOverlay">
|
<Style Selector="Button.card:pointerover Border.playOverlay">
|
||||||
<Setter Property="Opacity" Value="1" />
|
<Setter Property="Opacity" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- ============================ Text ============================ -->
|
<!-- ============================ Text ============================ -->
|
||||||
|
|
||||||
<Style Selector="TextBlock.cardTitle">
|
<Style Selector="TextBlock.cardTitle">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
<Setter Property="LineHeight" Value="18" />
|
<Setter Property="LineHeight" Value="18" />
|
||||||
<Setter Property="MaxLines" Value="2" />
|
<Setter Property="MaxLines" Value="2" />
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.cardMeta">
|
<Style Selector="TextBlock.cardMeta">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="11.5" />
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.sectionTitle">
|
<Style Selector="TextBlock.sectionTitle">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="17" />
|
<Setter Property="FontSize" Value="17" />
|
||||||
<Setter Property="FontWeight" Value="Bold" />
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.subtle">
|
<Style Selector="TextBlock.subtle">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="12.5" />
|
<Setter Property="FontSize" Value="12.5" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- ============================ Badge ============================ -->
|
<!-- ======================== Settings overlay ======================== -->
|
||||||
|
|
||||||
<Style Selector="Border.badge">
|
<!--
|
||||||
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
The overlay stays in the tree and hides behind opacity rather than IsVisible: a collapsed
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
element has nothing to animate from, and the slide-in is the whole point. Nothing inside
|
||||||
<Setter Property="Padding" Value="6,2" />
|
is focusable while it is closed, because the panel's content is null until it opens.
|
||||||
</Style>
|
-->
|
||||||
|
<Style Selector="Panel.settingsOverlay">
|
||||||
<Style Selector="Border.badge > TextBlock">
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
<Setter Property="IsHitTestVisible" Value="False" />
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="Transitions">
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Transitions>
|
||||||
</Style>
|
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
</Styles>
|
</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">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Padding" Value="6,2" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
|
|||||||
@@ -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("Выберите папку с видео");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
using ReactiveUI.Avalonia;
|
using Avalonia.Input;
|
||||||
using PLib.Desktop.ViewModels;
|
using Avalonia.Interactivity;
|
||||||
|
using PLib.Desktop.ViewModels;
|
||||||
namespace PLib.Desktop.Views;
|
using ReactiveUI.Avalonia;
|
||||||
|
|
||||||
public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
|
namespace PLib.Desktop.Views;
|
||||||
{
|
|
||||||
public MainWindow() => InitializeComponent();
|
public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
|
||||||
}
|
{
|
||||||
|
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"
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user