Implement theme management and settings dialog in PLib video library manager. Add theme service for light/dark/system themes, integrate settings dialog for user preferences, and update app configuration to include appearance options. Enhance thumbnail caching with new methods for cache size and clearing. Update README.md to reflect new features and settings.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using PLib.Desktop.Settings;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the settings the user can change at runtime. The file it writes is also a
|
||||
/// configuration source, so <c>IOptionsMonitor</c> picks changes up without a restart.
|
||||
/// </summary>
|
||||
public interface IAppSettingsStore
|
||||
{
|
||||
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the parts of <see cref="Application.Library.LibraryOptions"/> the user can change
|
||||
/// at runtime. Writes land in a JSON file that is also a configuration source, so
|
||||
/// <c>IOptionsMonitor</c> picks the change up without a restart.
|
||||
/// </summary>
|
||||
public interface ILibrarySettingsStore
|
||||
{
|
||||
Task SaveFoldersAsync(IReadOnlyList<string> folders, CancellationToken cancellationToken = default);
|
||||
}
|
||||
+25
-11
@@ -1,12 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Settings;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="ILibrarySettingsStore"/>
|
||||
public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
|
||||
/// <inheritdoc cref="IAppSettingsStore"/>
|
||||
public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
@@ -14,23 +15,24 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public async Task SaveFoldersAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Merge into whatever is already there: the file is hand-editable and may hold
|
||||
// keys this version of the settings screen knows nothing about.
|
||||
var root = await ReadRootAsync(cancellationToken);
|
||||
|
||||
if (root[LibraryOptions.SectionName] is not JsonObject section)
|
||||
{
|
||||
section = [];
|
||||
root[LibraryOptions.SectionName] = section;
|
||||
}
|
||||
var library = Section(root, LibraryOptions.SectionName);
|
||||
library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]);
|
||||
library["ThumbnailWidth"] = settings.ThumbnailWidth;
|
||||
library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio;
|
||||
library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency;
|
||||
library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes;
|
||||
|
||||
section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
|
||||
Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
@@ -43,6 +45,18 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject Section(JsonObject root, string name)
|
||||
{
|
||||
if (root[name] is JsonObject existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new JsonObject();
|
||||
root[name] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(SettingsFile))
|
||||
@@ -1,28 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using PLib.Desktop.Settings;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Switches the application between the light and dark variants.</summary>
|
||||
/// <summary>Applies the light/dark/system choice to the running application.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
void Toggle();
|
||||
/// <summary>The mode currently in effect.</summary>
|
||||
ThemeMode Current { get; }
|
||||
|
||||
void Apply(ThemeMode mode);
|
||||
|
||||
/// <summary>Flips between light and dark, resolving "system" to whatever is on screen.</summary>
|
||||
ThemeMode Toggle();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService"/>
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public void Toggle()
|
||||
{
|
||||
if (Avalonia.Application.Current is not { } application)
|
||||
{
|
||||
return;
|
||||
}
|
||||
public ThemeMode Current { get; private set; } = ThemeMode.System;
|
||||
|
||||
// ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
|
||||
// which is what the user is actually toggling away from.
|
||||
application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
|
||||
? ThemeVariant.Light
|
||||
: ThemeVariant.Dark;
|
||||
public void Apply(ThemeMode mode)
|
||||
{
|
||||
Current = mode;
|
||||
|
||||
if (Avalonia.Application.Current is { } application)
|
||||
{
|
||||
application.RequestedThemeVariant = mode switch
|
||||
{
|
||||
ThemeMode.Light => ThemeVariant.Light,
|
||||
ThemeMode.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ThemeMode Toggle()
|
||||
{
|
||||
// ActualThemeVariant resolves "follow the system" to what the user is actually
|
||||
// looking at, which is what they mean by "the other one".
|
||||
var isDark = Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
|
||||
var next = isDark ? ThemeMode.Light : ThemeMode.Dark;
|
||||
|
||||
Apply(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user