This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Windows.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Settings.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// An option in a list. The caption changes together with the language, while the
|
||||
/// object itself lives for as long as the window does: recreating the list items
|
||||
/// makes the ComboBox drop the selected value.
|
||||
/// </summary>
|
||||
public sealed class EnumOption<T> : ObservableObject where T : struct, Enum
|
||||
{
|
||||
private string _display;
|
||||
|
||||
public EnumOption(T value, string display)
|
||||
{
|
||||
Value = value;
|
||||
_display = display;
|
||||
}
|
||||
|
||||
public T Value { get; }
|
||||
|
||||
public string Display
|
||||
{
|
||||
get => _display;
|
||||
set => SetProperty(ref _display, value);
|
||||
}
|
||||
|
||||
// Accessibility tools take the name of the list item from here
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The settings window. The values are edited right in <see cref="AppSettings"/>,
|
||||
/// so the popup picks them up at once, without an "Apply" button.
|
||||
/// </summary>
|
||||
public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly Color[] Palette =
|
||||
[
|
||||
Color.FromArgb(0x20, 0x20, 0x20),
|
||||
Color.FromArgb(0x00, 0x00, 0x00),
|
||||
Color.FromArgb(0x1E, 0x3A, 0x8A),
|
||||
Color.FromArgb(0x0F, 0x76, 0x6E),
|
||||
Color.FromArgb(0x7C, 0x2D, 0x12),
|
||||
Color.FromArgb(0x86, 0x19, 0x8F),
|
||||
Color.FromArgb(0xB9, 0x1C, 0x1C),
|
||||
Color.FromArgb(0xF5, 0xF5, 0xF5),
|
||||
];
|
||||
|
||||
private static readonly Color[] ForegroundPalette =
|
||||
[
|
||||
Color.FromArgb(0xFF, 0xFF, 0xFF),
|
||||
Color.FromArgb(0xD4, 0xD4, 0xD4),
|
||||
Color.FromArgb(0x00, 0x00, 0x00),
|
||||
Color.FromArgb(0xFA, 0xCC, 0x15),
|
||||
Color.FromArgb(0x4A, 0xDE, 0x80),
|
||||
Color.FromArgb(0x60, 0xA5, 0xFA),
|
||||
Color.FromArgb(0xF9, 0x73, 0x16),
|
||||
Color.FromArgb(0xF8, 0x71, 0x71),
|
||||
];
|
||||
|
||||
private readonly IStartupService _startup;
|
||||
|
||||
private StartupState _startupState = StartupState.Unavailable;
|
||||
|
||||
public SettingsViewModel(
|
||||
AppSettings settings,
|
||||
ILocalizationService localization,
|
||||
IStartupService startup,
|
||||
UpdateViewModel updates)
|
||||
{
|
||||
Settings = settings;
|
||||
Localization = localization;
|
||||
Updates = updates;
|
||||
_startup = startup;
|
||||
|
||||
// The interface language is a setting like any other and is stored in the same place
|
||||
Localization.CurrentLanguage = settings.Language;
|
||||
Settings.PropertyChanged += OnSettingsChanged;
|
||||
Localization.PropertyChanged += OnLocalizationChanged;
|
||||
|
||||
Themes = CreateOptions<AppTheme>();
|
||||
PlacementModes = CreateOptions<PopupPlacementMode>();
|
||||
AnchorSides = CreateOptions<AnchorSide>();
|
||||
ScreenPositions = CreateOptions<ScreenPosition>();
|
||||
}
|
||||
|
||||
public AppSettings Settings { get; }
|
||||
|
||||
public ILocalizationService Localization { get; }
|
||||
|
||||
/// <summary>The updates section: it has a state and commands of its own.</summary>
|
||||
public UpdateViewModel Updates { get; }
|
||||
|
||||
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
|
||||
|
||||
public IReadOnlyList<Color> TextPalette { get; } = ForegroundPalette;
|
||||
|
||||
public IReadOnlyList<EnumOption<AppTheme>> Themes { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<PopupPlacementMode>> PlacementModes { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<AnchorSide>> AnchorSides { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<ScreenPosition>> ScreenPositions { get; }
|
||||
|
||||
/// <summary>Whether to show the startup setting.</summary>
|
||||
public bool IsStartupAvailable => _startupState != StartupState.Unavailable;
|
||||
|
||||
/// <summary>Startup is up to the application rather than to Windows.</summary>
|
||||
public bool CanChangeStartup => _startupState is StartupState.Enabled or StartupState.Disabled;
|
||||
|
||||
/// <summary>It has to be explained why the setting does not give in.</summary>
|
||||
public bool IsStartupLocked => IsStartupAvailable && !CanChangeStartup;
|
||||
|
||||
/// <summary>
|
||||
/// Start the application together with Windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value is not stored in the application settings: Windows knows it, and it
|
||||
/// may be changed behind our back — in the "Startup apps" section. So the property
|
||||
/// answers from the last known state of the task every time.
|
||||
/// </remarks>
|
||||
public bool RunAtStartup
|
||||
{
|
||||
get => _startupState is StartupState.Enabled or StartupState.EnabledByPolicy;
|
||||
set
|
||||
{
|
||||
if (value == RunAtStartup)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ApplyStartupAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks Windows about the state of startup. Called after the window is shown: the
|
||||
/// answer has to be waited for, while the settings must open right away.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync() => UpdateStartupState(await _startup.GetStateAsync());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Settings.PropertyChanged -= OnSettingsChanged;
|
||||
Localization.PropertyChanged -= OnLocalizationChanged;
|
||||
}
|
||||
|
||||
private async Task ApplyStartupAsync(bool enabled) =>
|
||||
UpdateStartupState(await _startup.SetEnabledAsync(enabled));
|
||||
|
||||
private void UpdateStartupState(StartupState state)
|
||||
{
|
||||
_startupState = state;
|
||||
|
||||
// The change is reported even when the state is the same: the checkbox has
|
||||
// already been toggled, and only a re-read value can put it back
|
||||
OnPropertyChanged(nameof(RunAtStartup));
|
||||
OnPropertyChanged(nameof(IsStartupAvailable));
|
||||
OnPropertyChanged(nameof(CanChangeStartup));
|
||||
OnPropertyChanged(nameof(IsStartupLocked));
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.Language))
|
||||
{
|
||||
Localization.CurrentLanguage = Settings.Language;
|
||||
}
|
||||
}
|
||||
|
||||
// The option captions come from the resources, so on a language change we update
|
||||
// only the text — the list items themselves stay the same
|
||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != Binding.IndexerName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Translate(Themes);
|
||||
Translate(PlacementModes);
|
||||
Translate(AnchorSides);
|
||||
Translate(ScreenPositions);
|
||||
}
|
||||
|
||||
private EnumOption<T>[] CreateOptions<T>() where T : struct, Enum =>
|
||||
[.. Enum.GetValues<T>().Select(value => new EnumOption<T>(value, GetDisplayName(value)))];
|
||||
|
||||
private void Translate<T>(IReadOnlyList<EnumOption<T>> options) where T : struct, Enum
|
||||
{
|
||||
foreach (EnumOption<T> option in options)
|
||||
{
|
||||
option.Display = GetDisplayName(option.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// The resource key is built from the type name and the value: PopupPlacementMode_AtCursor
|
||||
private string GetDisplayName<T>(T value) where T : struct, Enum =>
|
||||
Localization[$"{typeof(T).Name}_{value}"];
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Settings.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A check made on opening the window passes its failures in silence: the machine does
|
||||
/// not always have a live network, and there is no point complaining about it to a user
|
||||
/// who came to change the popup colour. A check started by the button does report a
|
||||
/// failure — it is awaited and watched.
|
||||
/// </remarks>
|
||||
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IUpdateService _updates;
|
||||
private readonly ILocalizationService _localization;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly UpdateOptions _options;
|
||||
|
||||
private CancellationTokenSource? _work;
|
||||
private ReleaseInfo? _release;
|
||||
private string? _packagePath;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusText))]
|
||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||
[NotifyPropertyChangedFor(nameof(CanCheck))]
|
||||
[NotifyPropertyChangedFor(nameof(HasStatus))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
|
||||
private UpdateStatus _status = UpdateStatus.Idle;
|
||||
|
||||
/// <summary>The downloaded fraction: from 0 to 1.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
private double _progress;
|
||||
|
||||
public UpdateViewModel(
|
||||
IUpdateService updates,
|
||||
ILocalizationService localization,
|
||||
AppSettings settings,
|
||||
UpdateOptions options)
|
||||
{
|
||||
_updates = updates;
|
||||
_localization = localization;
|
||||
_settings = settings;
|
||||
_options = options;
|
||||
|
||||
_localization.PropertyChanged += OnLocalizationChanged;
|
||||
}
|
||||
|
||||
/// <summary>Whether to show the updates section at all.</summary>
|
||||
public bool IsSupported => _updates.IsSupported;
|
||||
|
||||
public string CurrentVersion => _updates.CurrentVersion.ToString();
|
||||
|
||||
/// <summary>Check for new versions at startup.</summary>
|
||||
public bool CheckAutomatically
|
||||
{
|
||||
get => _settings.CheckForUpdates;
|
||||
set
|
||||
{
|
||||
if (value == _settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.CheckForUpdates = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A request or a download is in flight — the buttons freeze for that time.</summary>
|
||||
public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading;
|
||||
|
||||
public bool CanCheck => !IsBusy;
|
||||
|
||||
public bool HasStatus => Status != UpdateStatus.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// The package size is unknown, and the bar shows only the fact of the download.
|
||||
/// Its start looks the same until the first report arrives.
|
||||
/// </summary>
|
||||
public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0;
|
||||
|
||||
public bool IsDownloadOffered => Status == UpdateStatus.Available;
|
||||
|
||||
public bool IsInstallOffered => Status == UpdateStatus.Ready;
|
||||
|
||||
public bool IsProgressShown => Status == UpdateStatus.Downloading;
|
||||
|
||||
public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking;
|
||||
|
||||
/// <summary>The release page: the release notes live there too.</summary>
|
||||
public Uri? ReleaseUrl => _release?.PageUrl;
|
||||
|
||||
public string StatusText => Status switch
|
||||
{
|
||||
UpdateStatus.Checking => _localization["UpdateChecking"],
|
||||
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
|
||||
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
|
||||
UpdateStatus.Downloading => _localization["UpdateDownloading"],
|
||||
UpdateStatus.Ready => _localization["UpdateReady"],
|
||||
UpdateStatus.Failed => _localization["UpdateFailed"],
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates when the user has not forbidden it and enough time has
|
||||
/// passed since the previous check. Called once when the window opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It used to be called when the application started, which back then meant when
|
||||
/// the machine was switched on. The background half is a separate process now and
|
||||
/// does not go to the network at all — nothing in it could show the answer — so the
|
||||
/// question is asked when there is a window to answer into.
|
||||
/// </remarks>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (!IsSupported || !_settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings.LastUpdateCheck is { } last && DateTimeOffset.UtcNow - last < _options.CheckInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunCheckAsync(quiet: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_localization.PropertyChanged -= OnLocalizationChanged;
|
||||
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task CheckAsync() => RunCheckAsync(quiet: false);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
if (_release is not { } release)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Progress = 0;
|
||||
Status = UpdateStatus.Downloading;
|
||||
|
||||
try
|
||||
{
|
||||
var progress = new Progress<double>(value => Progress = value);
|
||||
_packagePath = await _updates.DownloadAsync(release, progress, token);
|
||||
Status = UpdateStatus.Ready;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The download was interrupted by the next piece of work: it has already set its own state
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Install()
|
||||
{
|
||||
if (_packagePath is null || !File.Exists(_packagePath))
|
||||
{
|
||||
// The file was removed by the temp folder cleanup — downloading it again is what is left
|
||||
Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_updates.Install(_packagePath);
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The errors that simply leave the update undone: an unreachable network, an
|
||||
/// unexpected response, a file in use. Everything else is a reason to crash.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OperationCanceledException</c> means an expired request deadline here:
|
||||
/// cancellation by the application itself is caught by a separate handler above.
|
||||
/// </remarks>
|
||||
private static bool IsExpected(Exception e) =>
|
||||
e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException
|
||||
or NotSupportedException or InvalidOperationException or Win32Exception
|
||||
or OperationCanceledException;
|
||||
|
||||
private async Task RunCheckAsync(bool quiet)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Status = UpdateStatus.Checking;
|
||||
|
||||
try
|
||||
{
|
||||
_release = await _updates.CheckAsync(token);
|
||||
_packagePath = null;
|
||||
|
||||
_settings.LastUpdateCheck = DateTimeOffset.UtcNow;
|
||||
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The check was cancelled by the next piece of work: it has already set its own state
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = quiet ? UpdateStatus.Idle : UpdateStatus.Failed;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ReleaseUrl));
|
||||
OnPropertyChanged(nameof(IsReleaseLinkShown));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new piece of work, cancelling the previous one: the user may have
|
||||
/// pressed "Check" in the middle of a download.
|
||||
/// </summary>
|
||||
private CancellationToken StartWork()
|
||||
{
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = new CancellationTokenSource();
|
||||
return _work.Token;
|
||||
}
|
||||
|
||||
private string Format(string key, string? argument) =>
|
||||
string.Format(CultureInfo.CurrentCulture, _localization[key], argument);
|
||||
|
||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == Binding.IndexerName)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user