diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1ffdc0f..0b0ccdcf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -95,6 +95,7 @@ jobs: -p:CSharpier_Bypass=true --output LightBulb/bin/publish/ --configuration Release + --use-current-runtime - name: Create installer shell: pwsh diff --git a/Installer/Compile installer.bat b/Installer/Compile installer.bat index 89339bc5..584210a8 100644 --- a/Installer/Compile installer.bat +++ b/Installer/Compile installer.bat @@ -1,2 +1,2 @@ -dotnet publish ../LightBulb/ -o Source/ --configuration Release +dotnet publish ../LightBulb/ -o Source/ --configuration Release --use-current-runtime "c:\Program Files (x86)\Inno Setup 6\ISCC.exe" Installer.iss \ No newline at end of file diff --git a/LightBulb.Core/SolarTimes.cs b/LightBulb.Core/SolarTimes.cs index a8dfd4b3..427097e5 100644 --- a/LightBulb.Core/SolarTimes.cs +++ b/LightBulb.Core/SolarTimes.cs @@ -3,6 +3,7 @@ namespace LightBulb.Core; +// Times are presented in the current timezone, which is a flimsy convention public readonly record struct SolarTimes(TimeOnly Sunrise, TimeOnly Sunset) { private static double DegreesToRadians(double degree) => degree * (Math.PI / 180); @@ -11,7 +12,7 @@ public readonly record struct SolarTimes(TimeOnly Sunrise, TimeOnly Sunset) private static TimeOnly CalculateSolarTime( GeoLocation location, - DateTimeOffset date, + DateTimeOffset instant, double zenith, bool isSunrise ) @@ -21,7 +22,7 @@ bool isSunrise // Convert longitude to hour value and calculate an approximate time var lngHours = location.Longitude / 15; var timeApproxHours = isSunrise ? 6 : 18; - var timeApproxDays = date.DayOfYear + (timeApproxHours - lngHours) / 24; + var timeApproxDays = instant.DayOfYear + (timeApproxHours - lngHours) / 24; // Calculate Sun's mean anomaly var sunMeanAnomaly = 0.9856 * timeApproxDays - 3.289; @@ -75,14 +76,14 @@ bool isSunrise // Adjust UTC time to local time // (we use the provided offset because it's impossible to calculate timezone from coordinates) - var localHours = (utcHours + date.Offset.TotalHours).Wrap(0, 24); + var localHours = (utcHours + instant.Offset.TotalHours).Wrap(0, 24); return TimeOnly.FromTimeSpan(TimeSpan.FromHours(localHours)); } - public static SolarTimes Calculate(GeoLocation location, DateTimeOffset date) => + public static SolarTimes Calculate(GeoLocation location, DateTimeOffset instant) => new( - CalculateSolarTime(location, date, 90.83, true), - CalculateSolarTime(location, date, 90.83, false) + CalculateSolarTime(location, instant, 90.83, true), + CalculateSolarTime(location, instant, 90.83, false) ); } diff --git a/LightBulb.WindowsApi/DeviceContext.cs b/LightBulb.WindowsApi/DeviceContext.cs index d7014cd8..29570dc1 100644 --- a/LightBulb.WindowsApi/DeviceContext.cs +++ b/LightBulb.WindowsApi/DeviceContext.cs @@ -12,7 +12,7 @@ public partial class DeviceContext(nint handle) : NativeResource(handle) private void SetGammaRamp(GammaRamp ramp) { if (!NativeMethods.SetDeviceGammaRamp(Handle, ref ramp)) - Debug.WriteLine($"Failed to set gamma ramp on device context #${Handle})."); + Debug.WriteLine($"Failed to set gamma ramp on device context #{Handle})."); } public void SetGamma(double redMultiplier, double greenMultiplier, double blueMultiplier) diff --git a/LightBulb.WindowsApi/LightBulb.WindowsApi.csproj b/LightBulb.WindowsApi/LightBulb.WindowsApi.csproj index 14c1d251..86464046 100644 --- a/LightBulb.WindowsApi/LightBulb.WindowsApi.csproj +++ b/LightBulb.WindowsApi/LightBulb.WindowsApi.csproj @@ -1,9 +1,10 @@  + true - + diff --git a/LightBulb/App.axaml b/LightBulb/App.axaml new file mode 100644 index 00000000..fe79d36a --- /dev/null +++ b/LightBulb/App.axaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/App.axaml.cs b/LightBulb/App.axaml.cs new file mode 100644 index 00000000..ed1caff6 --- /dev/null +++ b/LightBulb/App.axaml.cs @@ -0,0 +1,132 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using LightBulb.Framework; +using LightBulb.Services; +using LightBulb.Utils.Extensions; +using LightBulb.ViewModels; +using LightBulb.ViewModels.Components; +using LightBulb.ViewModels.Components.Settings; +using LightBulb.ViewModels.Dialogs; +using LightBulb.Views; +using Material.Styles.Themes; +using Microsoft.Extensions.DependencyInjection; + +namespace LightBulb; + +public class App : Application, IDisposable +{ + private readonly ServiceProvider _services; + private readonly MainViewModel _mainViewModel; + + public App() + { + var services = new ServiceCollection(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // View model framework + services.AddSingleton(); + services.AddSingleton(); + + // View models + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // View framework + services.AddSingleton(); + + _services = services.BuildServiceProvider(true); + _mainViewModel = _services.GetRequiredService().CreateMainViewModel(); + } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + desktopLifetime.MainWindow = new MainView { DataContext = _mainViewModel }; + + base.OnFrameworkInitializationCompleted(); + + // Set custom theme colors + this.LocateMaterialTheme().CurrentTheme = Theme.Create( + Theme.Light, + Color.Parse("#343838"), + Color.Parse("#F9A825") + ); + + // Finalize pending updates (and restart) before launching the app + _services.GetRequiredService().FinalizePendingUpdates(); + + // Load settings + _services.GetRequiredService().Load(); + } + + private void ShowMainWindow() + { + if (ApplicationLifetime?.TryGetMainWindow() is { } window) + { + window.Show(); + window.Activate(); + window.Focus(); + } + } + + private void TrayIcon_OnClicked(object? sender, EventArgs args) => ShowMainWindow(); + + private void ShowSettingsMenuItem_OnClick(object? sender, EventArgs args) + { + ShowMainWindow(); + _mainViewModel.ShowSettingsCommand.Execute(null); + } + + private void ToggleMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.IsEnabled = !_mainViewModel.Dashboard.IsEnabled; + + private void DisableUntilSunriseMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableUntilSunriseCommand.Execute(null); + + private void DisableTemporarily1DayMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromDays(1)); + + private void DisableTemporarily12HoursMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromHours(12)); + + private void DisableTemporarily6HoursMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromHours(6)); + + private void DisableTemporarily3HoursMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromHours(3)); + + private void DisableTemporarily1HourMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromHours(1)); + + private void DisableTemporarily30MinutesMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromMinutes(30)); + + private void DisableTemporarily5MinutesMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromMinutes(5)); + + private void DisableTemporarily1MinuteMenuItem_OnClick(object? sender, EventArgs args) => + _mainViewModel.Dashboard.DisableTemporarilyCommand.Execute(TimeSpan.FromMinutes(1)); + + private void ExitMenuItem_OnClick(object? sender, EventArgs args) => + ApplicationLifetime?.TryShutdown(); + + public void Dispose() => _services.Dispose(); +} diff --git a/LightBulb/App.xaml b/LightBulb/App.xaml deleted file mode 100644 index e2dfe7ac..00000000 --- a/LightBulb/App.xaml +++ /dev/null @@ -1,420 +0,0 @@ - - - - - - - - - - - - - - - - #343838 - #5E6262 - #0D1212 - #F9A825 - #C17900 - #000000 - #FFFFFF - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/App.xaml.cs b/LightBulb/App.xaml.cs deleted file mode 100644 index 22691cfc..00000000 --- a/LightBulb/App.xaml.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; - -namespace LightBulb; - -public partial class App -{ - private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly(); - - public static string Name { get; } = Assembly.GetName().Name!; - - public static Version Version { get; } = Assembly.GetName().Version!; - - public static string VersionString { get; } = Version.ToString(3); - - public static string ExecutableDirPath { get; } = AppDomain.CurrentDomain.BaseDirectory; - - public static string ExecutableFilePath { get; } = - Path.ChangeExtension(Assembly.Location, "exe"); - - public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/LightBulb"; -} - -public partial class App -{ - private static IReadOnlyList CommandLineArgs { get; } = - Environment.GetCommandLineArgs().Skip(1).ToArray(); - - public static string HiddenOnLaunchArgument { get; } = "--start-hidden"; - - public static bool IsHiddenOnLaunch { get; } = - CommandLineArgs.Contains(HiddenOnLaunchArgument, StringComparer.OrdinalIgnoreCase); -} diff --git a/LightBulb/Behaviors/BubbleScrollBehavior.cs b/LightBulb/Behaviors/BubbleScrollBehavior.cs deleted file mode 100644 index 8ed8e430..00000000 --- a/LightBulb/Behaviors/BubbleScrollBehavior.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Windows; -using System.Windows.Input; -using Microsoft.Xaml.Behaviors; - -namespace LightBulb.Behaviors; - -public class BubbleScrollBehavior : Behavior -{ - private void AssociatedObject_OnPreviewMouseWheel(object? sender, MouseWheelEventArgs args) - { - args.Handled = true; - - AssociatedObject.RaiseEvent( - new MouseWheelEventArgs(args.MouseDevice, args.Timestamp, args.Delta) - { - RoutedEvent = UIElement.MouseWheelEvent - } - ); - } - - protected override void OnAttached() - { - base.OnAttached(); - AssociatedObject.PreviewMouseWheel += AssociatedObject_OnPreviewMouseWheel; - } - - protected override void OnDetaching() - { - AssociatedObject.PreviewMouseWheel -= AssociatedObject_OnPreviewMouseWheel; - base.OnDetaching(); - } -} diff --git a/LightBulb/Behaviors/ExternalApplicationMultiSelectionListBoxBehavior.cs b/LightBulb/Behaviors/ExternalApplicationMultiSelectionListBoxBehavior.cs deleted file mode 100644 index b5e36e4f..00000000 --- a/LightBulb/Behaviors/ExternalApplicationMultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LightBulb.Models; - -namespace LightBulb.Behaviors; - -// Bless WPF -public class ExternalApplicationMultiSelectionListBoxBehavior - : MultiSelectionListBoxBehavior; diff --git a/LightBulb/Behaviors/MultiSelectionListBoxBehavior.cs b/LightBulb/Behaviors/MultiSelectionListBoxBehavior.cs deleted file mode 100644 index b2ec83c4..00000000 --- a/LightBulb/Behaviors/MultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections; -using System.Collections.Specialized; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using Microsoft.Xaml.Behaviors; - -namespace LightBulb.Behaviors; - -public class MultiSelectionListBoxBehavior : Behavior -{ - public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register( - nameof(SelectedItems), - typeof(IList), - typeof(MultiSelectionListBoxBehavior), - new FrameworkPropertyMetadata( - null, - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, - OnSelectedItemsChanged - ) - ); - - private static void OnSelectedItemsChanged( - DependencyObject sender, - DependencyPropertyChangedEventArgs args - ) - { - var behavior = (MultiSelectionListBoxBehavior)sender; - if (behavior._modelHandled) - return; - - if (behavior.AssociatedObject is null) - return; - - behavior._modelHandled = true; - behavior.SelectItems(); - behavior._modelHandled = false; - } - - private bool _viewHandled; - private bool _modelHandled; - - public IList? SelectedItems - { - get => (IList?)GetValue(SelectedItemsProperty); - set => SetValue(SelectedItemsProperty, value); - } - - // Propagate selected items from the model to the view - private void SelectItems() - { - _viewHandled = true; - - AssociatedObject.SelectedItems.Clear(); - if (SelectedItems is not null) - { - foreach (var item in SelectedItems) - AssociatedObject.SelectedItems.Add(item); - } - - _viewHandled = false; - } - - // Propagate selected items from the view to the model - private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - - SelectedItems = AssociatedObject.SelectedItems.Cast().ToArray(); - } - - private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - SelectItems(); - } - - protected override void OnAttached() - { - base.OnAttached(); - - AssociatedObject.SelectionChanged += OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged += - OnListBoxItemsChanged; - } - - protected override void OnDetaching() - { - base.OnDetaching(); - - if (AssociatedObject is not null) - { - AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -= - OnListBoxItemsChanged; - } - } -} diff --git a/LightBulb/Bootstrapper.cs b/LightBulb/Bootstrapper.cs deleted file mode 100644 index 1668b05e..00000000 --- a/LightBulb/Bootstrapper.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Threading; -using LightBulb.Services; -using LightBulb.ViewModels; -using LightBulb.ViewModels.Components; -using LightBulb.ViewModels.Components.Settings; -using LightBulb.ViewModels.Dialogs; -using LightBulb.ViewModels.Framework; -using Stylet; -using StyletIoC; -using MessageBoxViewModel = LightBulb.ViewModels.Dialogs.MessageBoxViewModel; -#if !DEBUG -using System; -using System.Windows; -using System.Windows.Threading; -#endif - -namespace LightBulb; - -public class Bootstrapper : Bootstrapper -{ - private readonly Mutex _identityMutex; - private readonly bool _isOnlyRunningInstance; - - public Bootstrapper() - { - _identityMutex = new Mutex(true, "LightBulb_Identity", out _isOnlyRunningInstance); - } - - private T GetInstance() => (T)base.GetInstance(typeof(T)); - - public override void Start(string[] args) - { - // Ensure only one instance of the app is running at a time - if (!_isOnlyRunningInstance) - { -#if !DEBUG - Environment.Exit(0); - return; -#endif - } - - base.Start(args); - } - - protected override void ConfigureIoC(IStyletIoCBuilder builder) - { - base.ConfigureIoC(builder); - - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToAbstractFactory(); - - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToAllImplementations().InSingletonScope(); - } - - protected override void Launch() - { - // Finalize pending updates (and restart) before launching the app - GetInstance() - .FinalizePendingUpdates(); - - // Load settings (this has to come before any view is loaded because bindings are not updated) - GetInstance() - .Load(); - - // Stylet/WPF is slow, so we preload all dialogs, including descendants, for smoother UX - _ = GetInstance().GetViewForDialogScreen(GetInstance()); - _ = GetInstance().GetViewForDialogScreen(GetInstance()); - - base.Launch(); - } - -#if !DEBUG - protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args) - { - base.OnUnhandledException(args); - - MessageBox.Show( - args.Exception.ToString(), - "Error occured", - MessageBoxButton.OK, - MessageBoxImage.Error - ); - } -#endif - - public override void Dispose() - { - _identityMutex.Dispose(); - base.Dispose(); - } -} diff --git a/LightBulb/Converters/CycleStateToMaterialIconKindConverter.cs b/LightBulb/Converters/CycleStateToMaterialIconKindConverter.cs new file mode 100644 index 00000000..32d4caed --- /dev/null +++ b/LightBulb/Converters/CycleStateToMaterialIconKindConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using LightBulb.Core; +using Material.Icons; + +namespace LightBulb.Converters; + +public class CycleStateToMaterialIconKindConverter : IValueConverter +{ + public static CycleStateToMaterialIconKindConverter Instance { get; } = new(); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value switch + { + CycleState.Disabled => MaterialIconKind.Cancel, + CycleState.Paused => MaterialIconKind.PauseCircleOutline, + CycleState.Day => MaterialIconKind.WhiteBalanceSunny, + CycleState.Night => MaterialIconKind.MoonAndStars, + CycleState.Transition => MaterialIconKind.Sync, + _ => MaterialIconKind.QuestionMark // shouldn't happen + }; + + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/LightBulb/Converters/CycleStateToPackIconKindConverter.cs b/LightBulb/Converters/CycleStateToPackIconKindConverter.cs deleted file mode 100644 index 77f1d3b3..00000000 --- a/LightBulb/Converters/CycleStateToPackIconKindConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using LightBulb.Core; -using MaterialDesignThemes.Wpf; - -namespace LightBulb.Converters; - -[ValueConversion(typeof(CycleState), typeof(PackIconKind))] -public class CycleStateToPackIconKindConverter : IValueConverter -{ - public static CycleStateToPackIconKindConverter Instance { get; } = new(); - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value switch - { - CycleState.Disabled => PackIconKind.Cancel, - CycleState.Paused => PackIconKind.PauseCircleOutline, - CycleState.Day => PackIconKind.WhiteBalanceSunny, - CycleState.Night => PackIconKind.MoonAndStars, - CycleState.Transition => PackIconKind.Sync, - _ => PackIconKind.QuestionMark // shouldn't happen - }; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => throw new NotSupportedException(); -} diff --git a/LightBulb/Converters/DoubleToStringConverter.cs b/LightBulb/Converters/DoubleToStringConverter.cs index 915b740f..9d9d3b83 100644 --- a/LightBulb/Converters/DoubleToStringConverter.cs +++ b/LightBulb/Converters/DoubleToStringConverter.cs @@ -1,10 +1,9 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(double), typeof(string))] public class DoubleToStringConverter : IValueConverter { public static DoubleToStringConverter Instance { get; } = new(); diff --git a/LightBulb/Converters/FractionToDegreesConverter.cs b/LightBulb/Converters/FractionToDegreesConverter.cs index df5913df..7f4fbf7e 100644 --- a/LightBulb/Converters/FractionToDegreesConverter.cs +++ b/LightBulb/Converters/FractionToDegreesConverter.cs @@ -1,10 +1,9 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(double), typeof(double))] public class FractionToDegreesConverter : IValueConverter { public static FractionToDegreesConverter Instance { get; } = new(); diff --git a/LightBulb/Converters/FractionToPercentageStringConverter.cs b/LightBulb/Converters/FractionToPercentageStringConverter.cs index f05a1e49..663b1c0c 100644 --- a/LightBulb/Converters/FractionToPercentageStringConverter.cs +++ b/LightBulb/Converters/FractionToPercentageStringConverter.cs @@ -1,10 +1,9 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(double), typeof(string))] public class FractionToPercentageStringConverter : IValueConverter { public static FractionToPercentageStringConverter Instance { get; } = new(); diff --git a/LightBulb/Converters/InverseBoolConverter.cs b/LightBulb/Converters/InverseBoolConverter.cs deleted file mode 100644 index e7f3d9bd..00000000 --- a/LightBulb/Converters/InverseBoolConverter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace LightBulb.Converters; - -[ValueConversion(typeof(bool), typeof(bool))] -public class InverseBoolConverter : IValueConverter -{ - public static InverseBoolConverter Instance { get; } = new(); - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value is false; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is false; -} diff --git a/LightBulb/Converters/SettingsTabToMaterialIconKindConverter.cs b/LightBulb/Converters/SettingsTabToMaterialIconKindConverter.cs new file mode 100644 index 00000000..02fbdb9a --- /dev/null +++ b/LightBulb/Converters/SettingsTabToMaterialIconKindConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using LightBulb.ViewModels.Components.Settings; +using Material.Icons; + +namespace LightBulb.Converters; + +public class SettingsTabToMaterialIconKindConverter : IValueConverter +{ + public static SettingsTabToMaterialIconKindConverter Instance { get; } = new(); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value switch + { + GeneralSettingsTabViewModel => MaterialIconKind.Settings, + LocationSettingsTabViewModel => MaterialIconKind.Globe, + AdvancedSettingsTabViewModel => MaterialIconKind.CheckboxesMarked, + ApplicationWhitelistSettingsTabViewModel => MaterialIconKind.Apps, + HotKeySettingsTabViewModel => MaterialIconKind.Keyboard, + _ => MaterialIconKind.QuestionMark // shouldn't happen + }; + + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/LightBulb/Converters/SettingsTabViewModelToPackIconKindConverter.cs b/LightBulb/Converters/SettingsTabViewModelToPackIconKindConverter.cs deleted file mode 100644 index 1c088cbe..00000000 --- a/LightBulb/Converters/SettingsTabViewModelToPackIconKindConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using LightBulb.ViewModels.Components.Settings; -using MaterialDesignThemes.Wpf; - -namespace LightBulb.Converters; - -[ValueConversion(typeof(ISettingsTabViewModel), typeof(PackIconKind))] -public class SettingsTabViewModelToPackIconKindConverter : IValueConverter -{ - public static SettingsTabViewModelToPackIconKindConverter Instance { get; } = new(); - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value switch - { - GeneralSettingsTabViewModel => PackIconKind.Settings, - LocationSettingsTabViewModel => PackIconKind.Globe, - AdvancedSettingsTabViewModel => PackIconKind.CheckboxesMarked, - ApplicationWhitelistSettingsTabViewModel => PackIconKind.Apps, - HotKeySettingsTabViewModel => PackIconKind.Keyboard, - _ => PackIconKind.QuestionMark // shouldn't happen - }; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => throw new NotSupportedException(); -} diff --git a/LightBulb/Converters/TimeOnlyToDegreesConverter.cs b/LightBulb/Converters/TimeOnlyToDegreesDoubleConverter.cs similarity index 72% rename from LightBulb/Converters/TimeOnlyToDegreesConverter.cs rename to LightBulb/Converters/TimeOnlyToDegreesDoubleConverter.cs index b63a3bc4..d4cf84e2 100644 --- a/LightBulb/Converters/TimeOnlyToDegreesConverter.cs +++ b/LightBulb/Converters/TimeOnlyToDegreesDoubleConverter.cs @@ -1,13 +1,12 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(TimeOnly), typeof(double))] -public class TimeOnlyToDegreesConverter : IValueConverter +public class TimeOnlyToDegreesDoubleConverter : IValueConverter { - public static TimeOnlyToDegreesConverter Instance { get; } = new(); + public static TimeOnlyToDegreesDoubleConverter Instance { get; } = new(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is TimeOnly timeOfDayValue ? timeOfDayValue.ToTimeSpan().TotalDays * 360.0 : default; diff --git a/LightBulb/Converters/TimeOnlyToHoursConverter.cs b/LightBulb/Converters/TimeOnlyToHoursDoubleConverter.cs similarity index 72% rename from LightBulb/Converters/TimeOnlyToHoursConverter.cs rename to LightBulb/Converters/TimeOnlyToHoursDoubleConverter.cs index 5f327bcd..e6cc12de 100644 --- a/LightBulb/Converters/TimeOnlyToHoursConverter.cs +++ b/LightBulb/Converters/TimeOnlyToHoursDoubleConverter.cs @@ -1,13 +1,12 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(TimeOnly), typeof(double))] -public class TimeOnlyToHoursConverter : IValueConverter +public class TimeOnlyToHoursDoubleConverter : IValueConverter { - public static TimeOnlyToHoursConverter Instance { get; } = new(); + public static TimeOnlyToHoursDoubleConverter Instance { get; } = new(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is TimeOnly timeOfDayValue ? timeOfDayValue.ToTimeSpan().TotalHours : default; diff --git a/LightBulb/Converters/TimeOnlyToStringConverter.cs b/LightBulb/Converters/TimeOnlyToStringConverter.cs index e11e7545..beb99db0 100644 --- a/LightBulb/Converters/TimeOnlyToStringConverter.cs +++ b/LightBulb/Converters/TimeOnlyToStringConverter.cs @@ -1,10 +1,9 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(TimeOnly), typeof(string))] public class TimeOnlyToStringConverter : IValueConverter { public static TimeOnlyToStringConverter Instance { get; } = new(); diff --git a/LightBulb/Converters/TimeSpanToDurationStringConverter.cs b/LightBulb/Converters/TimeSpanToDurationStringConverter.cs index 6667f4d2..eb59b53f 100644 --- a/LightBulb/Converters/TimeSpanToDurationStringConverter.cs +++ b/LightBulb/Converters/TimeSpanToDurationStringConverter.cs @@ -1,10 +1,9 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(TimeSpan), typeof(string))] public class TimeSpanToDurationStringConverter : IValueConverter { public static TimeSpanToDurationStringConverter Instance { get; } = new(); diff --git a/LightBulb/Converters/TimeSpanToHoursConverter.cs b/LightBulb/Converters/TimeSpanToHoursDoubleConverter.cs similarity index 70% rename from LightBulb/Converters/TimeSpanToHoursConverter.cs rename to LightBulb/Converters/TimeSpanToHoursDoubleConverter.cs index 46bee255..cd17a50d 100644 --- a/LightBulb/Converters/TimeSpanToHoursConverter.cs +++ b/LightBulb/Converters/TimeSpanToHoursDoubleConverter.cs @@ -1,13 +1,12 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace LightBulb.Converters; -[ValueConversion(typeof(TimeSpan), typeof(double))] -public class TimeSpanToHoursConverter : IValueConverter +public class TimeSpanToHoursDoubleConverter : IValueConverter { - public static TimeSpanToHoursConverter Instance { get; } = new(); + public static TimeSpanToHoursDoubleConverter Instance { get; } = new(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is TimeSpan timeSpanValue ? timeSpanValue.TotalHours : default; diff --git a/LightBulb/FodyWeavers.xml b/LightBulb/FodyWeavers.xml deleted file mode 100644 index 4e68ed1a..00000000 --- a/LightBulb/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/LightBulb/FodyWeavers.xsd b/LightBulb/FodyWeavers.xsd deleted file mode 100644 index 69dbe488..00000000 --- a/LightBulb/FodyWeavers.xsd +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - Used to control if the On_PropertyName_Changed feature is enabled. - - - - - Used to control if the Dependent properties feature is enabled. - - - - - Used to control if the IsChanged property feature is enabled. - - - - - Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form. - - - - - Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project. - - - - - Used to control if equality checks should use the Equals method resolved from the base class. - - - - - Used to control if equality checks should use the static Equals method resolved from the base class. - - - - - Used to turn off build warnings from this weaver. - - - - - Used to turn off build warnings about mismatched On_PropertyName_Changed methods. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/LightBulb/Framework/DialogManager.cs b/LightBulb/Framework/DialogManager.cs new file mode 100644 index 00000000..62c2aa0a --- /dev/null +++ b/LightBulb/Framework/DialogManager.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DialogHostAvalonia; + +namespace LightBulb.Framework; + +public class DialogManager : IDisposable +{ + private readonly SemaphoreSlim _dialogLock = new(1, 1); + + public async Task ShowDialogAsync(DialogViewModelBase dialog) + { + await _dialogLock.WaitAsync(); + try + { + await DialogHost.Show( + dialog, + // It's fine to await in a void method here because it's an event handler + // ReSharper disable once AsyncVoidLambda + async (object _, DialogOpenedEventArgs args) => + { + await dialog.WaitForCloseAsync(); + + try + { + args.Session.Close(); + } + catch (InvalidOperationException) + { + // Dialog host is already processing a close operation + } + } + ); + + return dialog.DialogResult; + } + finally + { + _dialogLock.Release(); + } + } + + public void Dispose() => _dialogLock.Dispose(); +} diff --git a/LightBulb/Framework/DialogViewModelBase.cs b/LightBulb/Framework/DialogViewModelBase.cs new file mode 100644 index 00000000..51a47f91 --- /dev/null +++ b/LightBulb/Framework/DialogViewModelBase.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace LightBulb.Framework; + +public abstract partial class DialogViewModelBase : ViewModelBase +{ + private readonly TaskCompletionSource _closeTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + [ObservableProperty] + private T? _dialogResult; + + [RelayCommand] + protected void Close(T dialogResult) + { + DialogResult = dialogResult; + _closeTcs.TrySetResult(dialogResult); + } + + public async Task WaitForCloseAsync() => await _closeTcs.Task; +} + +public abstract class DialogViewModelBase : DialogViewModelBase; diff --git a/LightBulb/Framework/UserControl.cs b/LightBulb/Framework/UserControl.cs new file mode 100644 index 00000000..c5b255c5 --- /dev/null +++ b/LightBulb/Framework/UserControl.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace LightBulb.Framework; + +public class UserControl : UserControl +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/LightBulb/Framework/ViewManager.cs b/LightBulb/Framework/ViewManager.cs new file mode 100644 index 00000000..c2f1f996 --- /dev/null +++ b/LightBulb/Framework/ViewManager.cs @@ -0,0 +1,38 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +namespace LightBulb.Framework; + +public partial class ViewManager +{ + public Control? TryBindView(ViewModelBase viewModel) + { + var name = viewModel + .GetType() + .FullName + ?.Replace("ViewModel", "View", StringComparison.Ordinal); + + if (string.IsNullOrWhiteSpace(name)) + return null; + + var type = Type.GetType(name); + if (type is null) + return null; + + if (Activator.CreateInstance(type) is not Control view) + return null; + + view.DataContext ??= viewModel; + + return view; + } +} + +public partial class ViewManager : IDataTemplate +{ + bool IDataTemplate.Match(object? data) => data is ViewModelBase; + + Control? ITemplate.Build(object? data) => + data is ViewModelBase viewModel ? TryBindView(viewModel) : null; +} diff --git a/LightBulb/Framework/ViewModelBase.cs b/LightBulb/Framework/ViewModelBase.cs new file mode 100644 index 00000000..484d5f35 --- /dev/null +++ b/LightBulb/Framework/ViewModelBase.cs @@ -0,0 +1,15 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LightBulb.Framework; + +public abstract class ViewModelBase : ObservableObject, IDisposable +{ + ~ViewModelBase() => Dispose(false); + + protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty); + + protected virtual void Dispose(bool disposing) { } + + public void Dispose() => Dispose(true); +} diff --git a/LightBulb/Framework/ViewModelManager.cs b/LightBulb/Framework/ViewModelManager.cs new file mode 100644 index 00000000..83b8366d --- /dev/null +++ b/LightBulb/Framework/ViewModelManager.cs @@ -0,0 +1,35 @@ +using System; +using LightBulb.ViewModels; +using LightBulb.ViewModels.Components; +using LightBulb.ViewModels.Dialogs; +using Microsoft.Extensions.DependencyInjection; + +namespace LightBulb.Framework; + +public class ViewModelManager(IServiceProvider services) +{ + public MainViewModel CreateMainViewModel() => services.GetRequiredService(); + + public DashboardViewModel CreateDashboardViewModel() => + services.GetRequiredService(); + + public MessageBoxViewModel CreateMessageBoxViewModel( + string title, + string message, + string? okButtonText, + string? cancelButtonText + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Title = title; + viewModel.Message = message; + viewModel.DefaultButtonText = okButtonText; + viewModel.CancelButtonText = cancelButtonText; + + return viewModel; + } + + public SettingsViewModel CreateSettingsViewModel() => + services.GetRequiredService(); +} diff --git a/LightBulb/Framework/Window.cs b/LightBulb/Framework/Window.cs new file mode 100644 index 00000000..e5efbd76 --- /dev/null +++ b/LightBulb/Framework/Window.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace LightBulb.Framework; + +public class Window : Window +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/LightBulb/LightBulb.csproj b/LightBulb/LightBulb.csproj index e5951689..27f56e27 100644 --- a/LightBulb/LightBulb.csproj +++ b/LightBulb/LightBulb.csproj @@ -2,12 +2,13 @@ WinExe - true - ../favicon.ico + true + app.manifest + ..\favicon.ico - + @@ -16,18 +17,19 @@ + + + + - - - - - + + + + + - - - \ No newline at end of file diff --git a/LightBulb/Models/ExternalApplication.cs b/LightBulb/Models/ExternalApplication.cs index cba81ffa..204c942f 100644 --- a/LightBulb/Models/ExternalApplication.cs +++ b/LightBulb/Models/ExternalApplication.cs @@ -5,7 +5,7 @@ namespace LightBulb.Models; public partial class ExternalApplication(string executableFilePath) { - public string ExecutableFilePath { get; } = executableFilePath; + public string ExecutableFilePath { get; } = NormalizeFilePath(executableFilePath); public string Name => Path.GetFileNameWithoutExtension(ExecutableFilePath); @@ -14,7 +14,7 @@ public partial class ExternalApplication(string executableFilePath) public partial class ExternalApplication { - private static string? NormalizeFilePath(string? filePath) => + private static string NormalizeFilePath(string filePath) => !string.IsNullOrWhiteSpace(filePath) ? Path.GetFullPath(filePath) : filePath; } @@ -28,8 +28,8 @@ public bool Equals(ExternalApplication? other) return true; return string.Equals( - NormalizeFilePath(ExecutableFilePath), - NormalizeFilePath(other.ExecutableFilePath), + ExecutableFilePath, + other.ExecutableFilePath, StringComparison.OrdinalIgnoreCase ); } @@ -44,7 +44,8 @@ public override bool Equals(object? obj) return obj.GetType() == GetType() && Equals((ExternalApplication)obj); } - public override int GetHashCode() => HashCode.Combine(NormalizeFilePath(ExecutableFilePath)); + public override int GetHashCode() => + StringComparer.OrdinalIgnoreCase.GetHashCode(ExecutableFilePath); public static bool operator ==(ExternalApplication? a, ExternalApplication? b) => a?.Equals(b) ?? false; diff --git a/LightBulb/Models/HotKey.cs b/LightBulb/Models/HotKey.cs index b018f71b..015de669 100644 --- a/LightBulb/Models/HotKey.cs +++ b/LightBulb/Models/HotKey.cs @@ -1,24 +1,26 @@ using System.Text; -using System.Windows.Input; +using Avalonia.Input; namespace LightBulb.Models; -public readonly partial record struct HotKey(Key Key, ModifierKeys Modifiers = ModifierKeys.None) +public readonly record struct HotKey(PhysicalKey Key, KeyModifiers Modifiers = KeyModifiers.None) { + public static HotKey None { get; } = new(); + public override string ToString() { - if (Key == Key.None && Modifiers == ModifierKeys.None) + if (Key == PhysicalKey.None && Modifiers == KeyModifiers.None) return "< None >"; var buffer = new StringBuilder(); - if (Modifiers.HasFlag(ModifierKeys.Control)) + if (Modifiers.HasFlag(KeyModifiers.Control)) buffer.Append("Ctrl + "); - if (Modifiers.HasFlag(ModifierKeys.Shift)) + if (Modifiers.HasFlag(KeyModifiers.Shift)) buffer.Append("Shift + "); - if (Modifiers.HasFlag(ModifierKeys.Alt)) + if (Modifiers.HasFlag(KeyModifiers.Alt)) buffer.Append("Alt + "); - if (Modifiers.HasFlag(ModifierKeys.Windows)) + if (Modifiers.HasFlag(KeyModifiers.Meta)) buffer.Append("Win + "); buffer.Append(Key); @@ -26,8 +28,3 @@ public override string ToString() return buffer.ToString(); } } - -public partial record struct HotKey -{ - public static HotKey None { get; } = new(); -} diff --git a/LightBulb/Program.cs b/LightBulb/Program.cs new file mode 100644 index 00000000..8257fd63 --- /dev/null +++ b/LightBulb/Program.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using Avalonia; +using LightBulb.Utils; + +namespace LightBulb; + +public static class Program +{ + private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly(); + + public static string Name { get; } = Assembly.GetName().Name ?? "LightBulb"; + + public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0); + + public static string VersionString { get; } = Version.ToString(3); + + public static string ExecutableDirPath { get; } = AppDomain.CurrentDomain.BaseDirectory; + + public static string ExecutableFilePath { get; } = + Path.ChangeExtension(Assembly.Location, "exe"); + + public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/LightBulb"; + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UsePlatformDetect().LogToTrace(); + + [STAThread] + public static int Main(string[] args) + { + // Ensure only one instance of the app is running at a time + using var identityMutex = new Mutex( + true, + $"{Name}_Identity", + out var isOnlyRunningInstance + ); + + if (!isOnlyRunningInstance) + return 1; + + // Build and run the app + var builder = BuildAvaloniaApp(); + + try + { + return builder.StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + if (OperatingSystem.IsWindows()) + _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10); + + throw; + } + finally + { + // Clean up after application shutdown + if (builder.Instance is IDisposable disposableApp) + disposableApp.Dispose(); + } + } +} diff --git a/LightBulb/Services/GammaService.cs b/LightBulb/Services/GammaService.cs index 911d075d..520ee8d0 100644 --- a/LightBulb/Services/GammaService.cs +++ b/LightBulb/Services/GammaService.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reactive.Disposables; using LightBulb.Core; +using LightBulb.Utils; using LightBulb.Utils.Extensions; using LightBulb.WindowsApi; @@ -11,11 +12,11 @@ namespace LightBulb.Services; public partial class GammaService : IDisposable { private readonly SettingsService _settingsService; - private readonly IDisposable _eventRegistration; + private readonly DisposableCollector _eventRoot = new(); private bool _isUpdatingGamma; - private IReadOnlyList _deviceContexts = Array.Empty(); + private IReadOnlyList _deviceContexts = []; private bool _areDeviceContextsValid; private DateTimeOffset _lastGammaInvalidationTimestamp = DateTimeOffset.MinValue; @@ -26,37 +27,63 @@ public GammaService(SettingsService settingsService) { _settingsService = settingsService; - // Register for all system events that may indicate that the device context or gamma was changed from outside - _eventRegistration = new[] - { + // Listen to all system events that may indicate that the device context or gamma was changed from the outside + _eventRoot.Add( // https://github.com/Tyrrrz/LightBulb/issues/223 SystemHook.TryRegister(SystemHook.ForegroundWindowChanged, InvalidateGamma) - ?? Disposable.Empty, + ?? Disposable.Empty + ); + + _eventRoot.Add( PowerSettingNotification.TryRegister( PowerSettingNotification.Ids.ConsoleDisplayStateChanged, InvalidateGamma - ) ?? Disposable.Empty, + ) ?? Disposable.Empty + ); + + _eventRoot.Add( PowerSettingNotification.TryRegister( PowerSettingNotification.Ids.PowerSavingStatusChanged, InvalidateGamma - ) ?? Disposable.Empty, + ) ?? Disposable.Empty + ); + + _eventRoot.Add( PowerSettingNotification.TryRegister( PowerSettingNotification.Ids.SessionDisplayStatusChanged, InvalidateGamma - ) ?? Disposable.Empty, + ) ?? Disposable.Empty + ); + + _eventRoot.Add( PowerSettingNotification.TryRegister( PowerSettingNotification.Ids.MonitorPowerStateChanged, InvalidateGamma - ) ?? Disposable.Empty, + ) ?? Disposable.Empty + ); + + _eventRoot.Add( PowerSettingNotification.TryRegister( PowerSettingNotification.Ids.AwayModeChanged, InvalidateGamma - ) ?? Disposable.Empty, - SystemEvent.Register(SystemEvent.Ids.DisplayChanged, InvalidateDeviceContexts), - SystemEvent.Register(SystemEvent.Ids.PaletteChanged, InvalidateDeviceContexts), - SystemEvent.Register(SystemEvent.Ids.SettingsChanged, InvalidateDeviceContexts), + ) ?? Disposable.Empty + ); + + _eventRoot.Add( + SystemEvent.Register(SystemEvent.Ids.DisplayChanged, InvalidateDeviceContexts) + ); + + _eventRoot.Add( + SystemEvent.Register(SystemEvent.Ids.PaletteChanged, InvalidateDeviceContexts) + ); + + _eventRoot.Add( + SystemEvent.Register(SystemEvent.Ids.SettingsChanged, InvalidateDeviceContexts) + ); + + _eventRoot.Add( SystemEvent.Register(SystemEvent.Ids.SystemColorsChanged, InvalidateDeviceContexts) - }.Aggregate(); + ); } private void InvalidateGamma() @@ -97,14 +124,18 @@ private bool IsGammaStale() // Assume gamma continues to be stale for some time after it has been invalidated if ((instant - _lastGammaInvalidationTimestamp).Duration() <= TimeSpan.FromSeconds(0.3)) + { return true; + } // If polling is enabled, assume gamma is stale after some time has passed since the last update if ( _settingsService.IsGammaPollingEnabled && (instant - _lastUpdateTimestamp).Duration() > TimeSpan.FromSeconds(1) ) + { return true; + } return false; } @@ -121,7 +152,7 @@ private bool IsSignificantChange(ColorConfiguration configuration) public void SetGamma(ColorConfiguration configuration) { - // Avoid unnecessary changes as updating too often will cause stutters + // Avoid unnecessary changes as updating too often will cause stuttering if (!IsGammaStale() && !IsSignificantChange(configuration)) return; @@ -151,7 +182,7 @@ public void Dispose() foreach (var deviceContext in _deviceContexts) deviceContext.ResetGamma(); - _eventRegistration.Dispose(); + _eventRoot.Dispose(); _deviceContexts.DisposeAll(); } } diff --git a/LightBulb/Services/HotKeyService.cs b/LightBulb/Services/HotKeyService.cs index 449b66df..d59edfc1 100644 --- a/LightBulb/Services/HotKeyService.cs +++ b/LightBulb/Services/HotKeyService.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Windows.Input; +using Avalonia.Input; +using Avalonia.Win32.Input; using LightBulb.Models; using LightBulb.Utils.Extensions; using LightBulb.WindowsApi; @@ -10,18 +11,18 @@ namespace LightBulb.Services; public class HotKeyService : IDisposable { - private readonly List _hotKeyRegistrations = new(); + private readonly List _hotKeyRegistrations = []; public void RegisterHotKey(HotKey hotKey, Action callback) { // Convert WPF key/modifiers to Windows API virtual key/modifiers - var virtualKey = KeyInterop.VirtualKeyFromKey(hotKey.Key); + var virtualKey = KeyInterop.VirtualKeyFromKey(hotKey.Key.ToQwertyKey()); var modifiers = (int)hotKey.Modifiers; - var hotKeyRegistration = GlobalHotKey.TryRegister(virtualKey, modifiers, callback); + var registration = GlobalHotKey.TryRegister(virtualKey, modifiers, callback); - if (hotKeyRegistration is not null) - _hotKeyRegistrations.Add(hotKeyRegistration); + if (registration is not null) + _hotKeyRegistrations.Add(registration); else Debug.WriteLine("Failed to register hotkey."); } diff --git a/LightBulb/Services/SettingsService.cs b/LightBulb/Services/SettingsService.cs index c902989f..637e022e 100644 --- a/LightBulb/Services/SettingsService.cs +++ b/LightBulb/Services/SettingsService.cs @@ -1,20 +1,19 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.IO; using System.Text.Json.Serialization; using Cogwheel; +using CommunityToolkit.Mvvm.ComponentModel; using LightBulb.Core; using LightBulb.Models; using LightBulb.Utils; using LightBulb.WindowsApi; using Microsoft.Win32; -using PropertyChanged; namespace LightBulb.Services; -[AddINotifyPropertyChangedInterface] -public partial class SettingsService() : SettingsBase(GetFilePath()), INotifyPropertyChanged +[INotifyPropertyChanged] +public partial class SettingsService() : SettingsBase(GetFilePath()) { private readonly RegistrySwitch _extendedGammaRangeSwitch = new( @@ -28,16 +27,19 @@ public partial class SettingsService() : SettingsBase(GetFilePath()), INotifyPro new( RegistryHive.CurrentUser, @"Software\Microsoft\Windows\CurrentVersion\Run", - App.Name, - $"\"{App.ExecutableFilePath}\" {App.HiddenOnLaunchArgument}" + "LightBulb", + $"\"{Program.ExecutableFilePath}\" {StartupOptions.IsInitiallyHiddenArgument}" ); - public bool IsFirstTimeExperienceEnabled { get; set; } = true; + [ObservableProperty] + private bool _isFirstTimeExperienceEnabled = true; - public bool IsUkraineSupportMessageEnabled { get; set; } = true; + [ObservableProperty] + private bool _isUkraineSupportMessageEnabled = true; - [JsonIgnore] // comes from registry - public bool IsExtendedGammaRangeUnlocked { get; set; } + [ObservableProperty] + [property: JsonIgnore] // comes from registry + private bool _isExtendedGammaRangeUnlocked; // General @@ -49,68 +51,82 @@ public partial class SettingsService() : SettingsBase(GetFilePath()), INotifyPro public double MaximumBrightness => 1; - public ColorConfiguration NightConfiguration { get; set; } = new(3900, 0.85); + [ObservableProperty] + private ColorConfiguration _nightConfiguration = new(3900, 0.85); - public ColorConfiguration DayConfiguration { get; set; } = new(6600, 1); + [ObservableProperty] + private ColorConfiguration _dayConfiguration = new(6600, 1); - public TimeSpan ConfigurationTransitionDuration { get; set; } = TimeSpan.FromMinutes(40); + [ObservableProperty] + private TimeSpan _configurationTransitionDuration = TimeSpan.FromMinutes(40); - public double ConfigurationTransitionOffset { get; set; } + [ObservableProperty] + private double _configurationTransitionOffset; // Location - public bool IsManualSunriseSunsetEnabled { get; set; } = true; + [ObservableProperty] + private bool _isManualSunriseSunsetEnabled = true; - [JsonPropertyName("ManualSunriseTime")] - public TimeOnly ManualSunrise { get; set; } = new(07, 20); + [ObservableProperty] + [property: JsonPropertyName("ManualSunriseTime")] + private TimeOnly _manualSunrise = new(07, 20); - [JsonPropertyName("ManualSunsetTime")] - public TimeOnly ManualSunset { get; set; } = new(16, 30); + [ObservableProperty] + [property: JsonPropertyName("ManualSunsetTime")] + private TimeOnly _manualSunset = new(16, 30); - public GeoLocation? Location { get; set; } + [ObservableProperty] + private GeoLocation? _location; // Advanced - [JsonIgnore] // comes from registry - public bool IsAutoStartEnabled { get; set; } + [ObservableProperty] + [property: JsonIgnore] // comes from registry + private bool _isAutoStartEnabled; - public bool IsAutoUpdateEnabled { get; set; } = true; + [ObservableProperty] + private bool _isAutoUpdateEnabled = true; - public bool IsDefaultToDayConfigurationEnabled { get; set; } + [ObservableProperty] + private bool _isDefaultToDayConfigurationEnabled; - public bool IsConfigurationSmoothingEnabled { get; set; } = true; + [ObservableProperty] + private bool _isConfigurationSmoothingEnabled = true; - public bool IsPauseWhenFullScreenEnabled { get; set; } + [ObservableProperty] + private bool _isPauseWhenFullScreenEnabled; - public bool IsGammaPollingEnabled { get; set; } + [ObservableProperty] + private bool _isGammaPollingEnabled; // Application whitelist - public bool IsApplicationWhitelistEnabled { get; set; } + [ObservableProperty] + private bool _isApplicationWhitelistEnabled; - public IReadOnlyList? WhitelistedApplications { get; set; } + [ObservableProperty] + private IReadOnlyList? _whitelistedApplications; // HotKeys - public HotKey ToggleHotKey { get; set; } + [ObservableProperty] + private HotKey _toggleHotKey; - public HotKey IncreaseTemperatureOffsetHotKey { get; set; } + [ObservableProperty] + private HotKey _increaseTemperatureOffsetHotKey; - public HotKey DecreaseTemperatureOffsetHotKey { get; set; } + [ObservableProperty] + private HotKey _decreaseTemperatureOffsetHotKey; - public HotKey IncreaseBrightnessOffsetHotKey { get; set; } + [ObservableProperty] + private HotKey _increaseBrightnessOffsetHotKey; - public HotKey DecreaseBrightnessOffsetHotKey { get; set; } + [ObservableProperty] + private HotKey _decreaseBrightnessOffsetHotKey; - public HotKey ResetConfigurationOffsetHotKey { get; set; } - - // Events - - public event EventHandler? SettingsReset; - - public event EventHandler? SettingsLoaded; - - public event EventHandler? SettingsSaved; + [ObservableProperty] + private HotKey _resetConfigurationOffsetHotKey; public override void Reset() { @@ -120,7 +136,8 @@ public override void Reset() IsFirstTimeExperienceEnabled = false; IsUkraineSupportMessageEnabled = false; - SettingsReset?.Invoke(this, EventArgs.Empty); + // Trigger UI updates + OnPropertyChanged(string.Empty); } public override void Save() @@ -136,7 +153,8 @@ public override void Save() _extendedGammaRangeSwitch.IsSet = IsExtendedGammaRangeUnlocked; _autoStartSwitch.IsSet = IsAutoStartEnabled; - SettingsSaved?.Invoke(this, EventArgs.Empty); + // Trigger UI updates + OnPropertyChanged(string.Empty); } public override bool Load() @@ -147,7 +165,8 @@ public override bool Load() IsExtendedGammaRangeUnlocked = _extendedGammaRangeSwitch.IsSet; IsAutoStartEnabled = _autoStartSwitch.IsSet; - SettingsLoaded?.Invoke(this, EventArgs.Empty); + // Trigger UI updates + OnPropertyChanged(string.Empty); return wasLoaded; } @@ -157,10 +176,10 @@ public partial class SettingsService { private static string GetFilePath() { - var isInstalled = File.Exists(Path.Combine(App.ExecutableDirPath, ".installed")); + var isInstalled = File.Exists(Path.Combine(Program.ExecutableDirPath, ".installed")); // Prefer storing settings in appdata when installed or when the current directory is write-protected - if (isInstalled || !DirectoryEx.CheckWriteAccess(App.ExecutableDirPath)) + if (isInstalled || !DirectoryEx.CheckWriteAccess(Program.ExecutableDirPath)) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @@ -171,7 +190,7 @@ private static string GetFilePath() // Otherwise, store them in the current directory else { - return Path.Combine(App.ExecutableDirPath, "Settings.json"); + return Path.Combine(Program.ExecutableDirPath, "Settings.json"); } } } diff --git a/LightBulb/Services/UpdateService.cs b/LightBulb/Services/UpdateService.cs index d375a584..caa43677 100644 --- a/LightBulb/Services/UpdateService.cs +++ b/LightBulb/Services/UpdateService.cs @@ -1,6 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; +using Avalonia; +using LightBulb.Utils.Extensions; using Onova; using Onova.Exceptions; using Onova.Services; @@ -41,6 +43,10 @@ public void FinalizePendingUpdates() if (!settingsService.IsAutoUpdateEnabled) return; + // Onova only works on Windows currently + if (!OperatingSystem.IsWindows()) + return; + try { var lastPreparedUpdate = TryGetLastPreparedUpdate(); @@ -51,7 +57,9 @@ public void FinalizePendingUpdates() return; _updateManager.LaunchUpdater(lastPreparedUpdate); - Environment.Exit(0); + + if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true) + Environment.Exit(2); } catch (UpdaterAlreadyLaunchedException) { diff --git a/LightBulb/StartupOptions.cs b/LightBulb/StartupOptions.cs new file mode 100644 index 00000000..1ed51fa3 --- /dev/null +++ b/LightBulb/StartupOptions.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LightBulb; + +public partial class StartupOptions +{ + public bool IsInitiallyHidden { get; init; } +} + +public partial class StartupOptions +{ + public static string IsInitiallyHiddenArgument { get; } = "--start-hidden"; + + public static StartupOptions Parse(IReadOnlyList commandLineArgs) => + new() + { + IsInitiallyHidden = commandLineArgs.Contains( + IsInitiallyHiddenArgument, + StringComparer.OrdinalIgnoreCase + ) + }; +} + +public partial class StartupOptions +{ + public static StartupOptions Current { get; } = + Parse(Environment.GetCommandLineArgs().Skip(1).ToArray()); +} diff --git a/LightBulb/Utils/DisposableCollector.cs b/LightBulb/Utils/DisposableCollector.cs new file mode 100644 index 00000000..71a2607f --- /dev/null +++ b/LightBulb/Utils/DisposableCollector.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using LightBulb.Utils.Extensions; + +namespace LightBulb.Utils; + +internal class DisposableCollector : IDisposable +{ + private readonly object _lock = new(); + private readonly List _items = []; + + public void Add(IDisposable item) + { + lock (_lock) + { + _items.Add(item); + } + } + + public void Dispose() + { + lock (_lock) + { + _items.DisposeAll(); + _items.Clear(); + } + } +} diff --git a/LightBulb/Utils/Extensions/AvaloniaExtensions.cs b/LightBulb/Utils/Extensions/AvaloniaExtensions.cs new file mode 100644 index 00000000..81ae4438 --- /dev/null +++ b/LightBulb/Utils/Extensions/AvaloniaExtensions.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; + +namespace LightBulb.Utils.Extensions; + +internal static class AvaloniaExtensions +{ + public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) + { + if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + return desktopLifetime.MainWindow; + + return null; + } + + public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0) + { + if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + return desktopLifetime.TryShutdown(exitCode); + } + + if (lifetime is IControlledApplicationLifetime controlledLifetime) + { + controlledLifetime.Shutdown(exitCode); + return true; + } + + return false; + } +} diff --git a/LightBulb/Utils/Extensions/DisposableExtensions.cs b/LightBulb/Utils/Extensions/DisposableExtensions.cs index 964f66e4..d9609ab4 100644 --- a/LightBulb/Utils/Extensions/DisposableExtensions.cs +++ b/LightBulb/Utils/Extensions/DisposableExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reactive.Disposables; namespace LightBulb.Utils.Extensions; @@ -11,23 +10,19 @@ public static void DisposeAll(this IEnumerable disposables) { var exceptions = default(List); - foreach (var i in disposables) + foreach (var disposable in disposables) { try { - i.Dispose(); + disposable.Dispose(); } catch (Exception ex) { - exceptions ??= new List(); - exceptions.Add(ex); + (exceptions ??= []).Add(ex); } } if (exceptions?.Any() == true) throw new AggregateException(exceptions); } - - public static IDisposable Aggregate(this IEnumerable disposables) => - Disposable.Create(disposables.DisposeAll); } diff --git a/LightBulb/Utils/Extensions/GenericExtensions.cs b/LightBulb/Utils/Extensions/GenericExtensions.cs index 37a0f20e..de69b0ac 100644 --- a/LightBulb/Utils/Extensions/GenericExtensions.cs +++ b/LightBulb/Utils/Extensions/GenericExtensions.cs @@ -5,25 +5,25 @@ namespace LightBulb.Utils.Extensions; internal static class GenericExtensions { - public static double StepTo(this double value, double target, double absStep) => + public static double StepTo(this double value, double target, double step) => target >= value - ? Math.Min(value + Math.Abs(absStep), target) - : Math.Max(value - Math.Abs(absStep), target); + ? Math.Min(value + Math.Abs(step), target) + : Math.Max(value - Math.Abs(step), target); public static DateTimeOffset StepTo( this DateTimeOffset value, DateTimeOffset target, - TimeSpan absStep + TimeSpan step ) { if (target >= value) { - var result = value + absStep.Duration(); + var result = value + step.Duration(); return result <= target ? result : target; } else { - var result = value - absStep.Duration(); + var result = value - step.Duration(); return result >= target ? result : target; } } @@ -31,25 +31,25 @@ TimeSpan absStep public static ColorConfiguration StepTo( this ColorConfiguration value, ColorConfiguration target, - double temperatureMaxAbsStep, - double brightnessMaxAbsStep + double temperatureMaxStep, + double brightnessMaxStep ) { - var temperatureAbsDelta = Math.Abs(target.Temperature - value.Temperature); - var brightnessAbsDelta = Math.Abs(target.Brightness - value.Brightness); + var temperatureDelta = Math.Abs(target.Temperature - value.Temperature); + var brightnessDelta = Math.Abs(target.Brightness - value.Brightness); - var temperatureSteps = temperatureAbsDelta / temperatureMaxAbsStep; - var brightnessSteps = brightnessAbsDelta / brightnessMaxAbsStep; + var temperatureSteps = temperatureDelta / temperatureMaxStep; + var brightnessSteps = brightnessDelta / brightnessMaxStep; var temperatureAdjustedStep = temperatureSteps >= brightnessSteps - ? temperatureMaxAbsStep - : temperatureAbsDelta / brightnessSteps; + ? temperatureMaxStep + : temperatureDelta / brightnessSteps; var brightnessAdjustedStep = brightnessSteps >= temperatureSteps - ? brightnessMaxAbsStep - : brightnessAbsDelta / temperatureSteps; + ? brightnessMaxStep + : brightnessDelta / temperatureSteps; return new ColorConfiguration( value.Temperature.StepTo(target.Temperature, temperatureAdjustedStep), diff --git a/LightBulb/Utils/Extensions/NotifyPropertyChangedExtensions.cs b/LightBulb/Utils/Extensions/NotifyPropertyChangedExtensions.cs new file mode 100644 index 00000000..a57926b2 --- /dev/null +++ b/LightBulb/Utils/Extensions/NotifyPropertyChangedExtensions.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reactive.Disposables; +using System.Reflection; + +namespace LightBulb.Utils.Extensions; + +internal static class NotifyPropertyChangedExtensions +{ + public static IDisposable WatchProperty( + this TOwner owner, + Expression> propertyExpression, + Action handle, + bool watchInitialValue = true + ) + where TOwner : INotifyPropertyChanged + { + if (propertyExpression.Body is not MemberExpression { Member: PropertyInfo property }) + throw new ArgumentException("Provided expression must reference a property."); + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if ( + string.IsNullOrWhiteSpace(args.PropertyName) + || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) + ) + { + handle(); + } + } + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + handle(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + public static IDisposable WatchAllProperties( + this TOwner owner, + Action handle, + bool watchInitialValues = true + ) + where TOwner : INotifyPropertyChanged + { + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => handle(); + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValues) + handle(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + public static IDisposable WatchCollection( + this ObservableCollection collection, + Action handle, + bool watchInitialValues = true + ) + { + void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) => handle(); + + collection.CollectionChanged += OnCollectionChanged; + + if (watchInitialValues) + handle(); + + return Disposable.Create(() => collection.CollectionChanged -= OnCollectionChanged); + } +} diff --git a/LightBulb/Utils/NativeMethods.cs b/LightBulb/Utils/NativeMethods.cs new file mode 100644 index 00000000..109f4a6e --- /dev/null +++ b/LightBulb/Utils/NativeMethods.cs @@ -0,0 +1,12 @@ +using System.Runtime.InteropServices; + +namespace LightBulb.Utils; + +internal static class NativeMethods +{ + public static class Windows + { + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int MessageBox(nint hWnd, string text, string caption, uint type); + } +} diff --git a/LightBulb/Utils/ProcessEx.cs b/LightBulb/Utils/ProcessEx.cs index e726fc2e..c20e93ea 100644 --- a/LightBulb/Utils/ProcessEx.cs +++ b/LightBulb/Utils/ProcessEx.cs @@ -6,11 +6,8 @@ internal static class ProcessEx { public static void StartShellExecute(string path) { - using var process = new Process - { - StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true } - }; - + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }; process.Start(); } } diff --git a/LightBulb/ViewModels/Components/DashboardViewModel.cs b/LightBulb/ViewModels/Components/DashboardViewModel.cs index 7f32c0c6..e28e3255 100644 --- a/LightBulb/ViewModels/Components/DashboardViewModel.cs +++ b/LightBulb/ViewModels/Components/DashboardViewModel.cs @@ -1,40 +1,107 @@ using System; using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using LightBulb.Core; using LightBulb.Core.Utils.Extensions; +using LightBulb.Framework; using LightBulb.Models; using LightBulb.Services; +using LightBulb.Utils; using LightBulb.Utils.Extensions; using LightBulb.WindowsApi; -using Stylet; namespace LightBulb.ViewModels.Components; -public class DashboardViewModel : PropertyChangedBase, IDisposable +public partial class DashboardViewModel : ViewModelBase { private readonly SettingsService _settingsService; private readonly GammaService _gammaService; private readonly HotKeyService _hotKeyService; private readonly ExternalApplicationService _externalApplicationService; + private readonly DisposableCollector _eventRoot = new(); + private readonly Timer _updateInstantTimer; private readonly Timer _updateConfigurationTimer; private readonly Timer _updateIsPausedTimer; private IDisposable? _enableAfterDelayRegistration; - public bool IsEnabled { get; set; } = true; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsActive))] + private bool _isEnabled = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsActive))] + private bool _isPaused; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsActive))] + private bool _isCyclePreviewEnabled; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SolarTimes))] + [NotifyPropertyChangedFor(nameof(SunriseStart))] + [NotifyPropertyChangedFor(nameof(SunriseEnd))] + [NotifyPropertyChangedFor(nameof(SunsetStart))] + [NotifyPropertyChangedFor(nameof(SunsetEnd))] + [NotifyPropertyChangedFor(nameof(TargetConfiguration))] + [NotifyPropertyChangedFor(nameof(CycleState))] + private DateTimeOffset _instant = DateTimeOffset.Now; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOffsetEnabled))] + [NotifyPropertyChangedFor(nameof(TargetConfiguration))] + [NotifyPropertyChangedFor(nameof(AdjustedDayConfiguration))] + [NotifyPropertyChangedFor(nameof(AdjustedNightConfiguration))] + [NotifyPropertyChangedFor(nameof(CycleState))] + private double _temperatureOffset; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOffsetEnabled))] + [NotifyPropertyChangedFor(nameof(TargetConfiguration))] + [NotifyPropertyChangedFor(nameof(AdjustedDayConfiguration))] + [NotifyPropertyChangedFor(nameof(AdjustedNightConfiguration))] + [NotifyPropertyChangedFor(nameof(CycleState))] + private double _brightnessOffset; + + [ObservableProperty] + private ColorConfiguration _currentConfiguration = ColorConfiguration.Default; - public bool IsPaused { get; private set; } + public DashboardViewModel( + SettingsService settingsService, + GammaService gammaService, + HotKeyService hotKeyService, + ExternalApplicationService externalApplicationService + ) + { + _settingsService = settingsService; + _gammaService = gammaService; + _hotKeyService = hotKeyService; + _externalApplicationService = externalApplicationService; - public bool IsCyclePreviewEnabled { get; set; } + _eventRoot.Add( + // Cancel 'disable temporarily' when switching to enabled + this.WatchProperty( + o => o.IsEnabled, + () => + { + if (IsEnabled) + _enableAfterDelayRegistration?.Dispose(); + } + ) + ); - public bool IsActive => IsEnabled && !IsPaused || IsCyclePreviewEnabled; + _updateConfigurationTimer = new Timer(TimeSpan.FromMilliseconds(50), UpdateConfiguration); + _updateInstantTimer = new Timer(TimeSpan.FromMilliseconds(50), UpdateInstant); + _updateIsPausedTimer = new Timer(TimeSpan.FromSeconds(1), UpdateIsPaused); + } - public DateTimeOffset Instant { get; private set; } = DateTimeOffset.Now; + public bool IsActive => IsEnabled && !IsPaused || IsCyclePreviewEnabled; public SolarTimes SolarTimes => - !_settingsService.IsManualSunriseSunsetEnabled && _settingsService.Location is { } location + _settingsService is { IsManualSunriseSunsetEnabled: false, Location: { } location } ? SolarTimes.Calculate(location, Instant) : new SolarTimes(_settingsService.ManualSunrise, _settingsService.ManualSunset); @@ -66,9 +133,7 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable _settingsService.ConfigurationTransitionOffset ); - public double TemperatureOffset { get; set; } - - public double BrightnessOffset { get; set; } + public bool IsOffsetEnabled => Math.Abs(TemperatureOffset) + Math.Abs(BrightnessOffset) >= 0.01; public ColorConfiguration TargetConfiguration => IsActive @@ -92,8 +157,6 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable ? _settingsService.DayConfiguration : ColorConfiguration.Default; - public ColorConfiguration CurrentConfiguration { get; set; } = ColorConfiguration.Default; - public ColorConfiguration AdjustedDayConfiguration => _settingsService.DayConfiguration.WithOffset(TemperatureOffset, BrightnessOffset); @@ -111,58 +174,16 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable _ => CycleState.Transition }; - public DashboardViewModel( - SettingsService settingsService, - GammaService gammaService, - HotKeyService hotKeyService, - ExternalApplicationService externalApplicationService - ) - { - _settingsService = settingsService; - _gammaService = gammaService; - _hotKeyService = hotKeyService; - _externalApplicationService = externalApplicationService; - - _updateConfigurationTimer = new Timer(TimeSpan.FromMilliseconds(50), UpdateConfiguration); - - _updateInstantTimer = new Timer(TimeSpan.FromMilliseconds(50), UpdateInstant); - - _updateIsPausedTimer = new Timer(TimeSpan.FromSeconds(1), UpdateIsPaused); - - // Cancel 'disable temporarily' when switching to enabled - this.Bind( - o => o.IsEnabled, - (_, _) => - { - if (IsEnabled) - _enableAfterDelayRegistration?.Dispose(); - } - ); - - // Handle settings changes - _settingsService.SettingsSaved += (_, _) => - { - Refresh(); - RegisterHotKeys(); - }; - } - - public void OnViewLoaded() - { - _updateInstantTimer.Start(); - _updateConfigurationTimer.Start(); - _updateIsPausedTimer.Start(); - - RegisterHotKeys(); - } - private void RegisterHotKeys() { _hotKeyService.UnregisterAllHotKeys(); if (_settingsService.ToggleHotKey != HotKey.None) { - _hotKeyService.RegisterHotKey(_settingsService.ToggleHotKey, Toggle); + _hotKeyService.RegisterHotKey( + _settingsService.ToggleHotKey, + () => IsEnabled = !IsEnabled + ); } if (_settingsService.IncreaseTemperatureOffsetHotKey != HotKey.None) @@ -232,17 +253,17 @@ private void RegisterHotKeys() private void UpdateInstant() { - // If in cycle preview mode - advance quickly until full cycle + // If in cycle preview mode, advance quickly until the full cycle has been reached if (IsCyclePreviewEnabled) { - // Cycle is supposed to end 1 full day past current real time + // Cycle is supposed to end 1 full day past the current real time var targetInstant = DateTimeOffset.Now + TimeSpan.FromDays(1); Instant = Instant.StepTo(targetInstant, TimeSpan.FromMinutes(5)); if (Instant >= targetInstant) IsCyclePreviewEnabled = false; } - // Otherwise - synchronize instant with system clock + // Otherwise, synchronize the instant with the system clock else { Instant = DateTimeOffset.Now; @@ -276,46 +297,55 @@ bool IsPausedByWhitelistedApplication() => IsPaused = IsPausedByFullScreen() || IsPausedByWhitelistedApplication(); } - public void Enable() => IsEnabled = true; + [RelayCommand] + private void Initialize() + { + _updateInstantTimer.Start(); + _updateConfigurationTimer.Start(); + _updateIsPausedTimer.Start(); - public void Disable() => IsEnabled = false; + RegisterHotKeys(); + } - public void DisableTemporarily(TimeSpan duration) + [RelayCommand] + private void DisableTemporarily(TimeSpan duration) { _enableAfterDelayRegistration?.Dispose(); - _enableAfterDelayRegistration = Timer.QueueDelayedAction(duration, Enable); + _enableAfterDelayRegistration = Timer.QueueDelayedAction(duration, () => IsEnabled = true); IsEnabled = false; } - public void DisableTemporarilyUntilSunrise() + [RelayCommand] + private void DisableUntilSunrise() { - // Use real time here instead of Instant, because that's what the user likely wants var now = DateTimeOffset.Now; var timeUntilSunrise = SolarTimes.Sunrise.NextAfter(now) - now; DisableTemporarily(timeUntilSunrise); } - public void Toggle() => IsEnabled = !IsEnabled; - - public void EnableCyclePreview() => IsCyclePreviewEnabled = true; - - public void DisableCyclePreview() => IsCyclePreviewEnabled = false; + [RelayCommand] + private void ToggleCyclePreview() => IsCyclePreviewEnabled = !IsCyclePreviewEnabled; - public bool CanResetConfigurationOffset => - Math.Abs(TemperatureOffset) + Math.Abs(BrightnessOffset) >= 0.01; - - public void ResetConfigurationOffset() + [RelayCommand] + private void ResetConfigurationOffset() { TemperatureOffset = 0; BrightnessOffset = 0; } - public void Dispose() + protected override void Dispose(bool disposing) { - _updateInstantTimer.Dispose(); - _updateConfigurationTimer.Dispose(); - _updateIsPausedTimer.Dispose(); + if (disposing) + { + _updateInstantTimer.Dispose(); + _updateConfigurationTimer.Dispose(); + _updateIsPausedTimer.Dispose(); - _enableAfterDelayRegistration?.Dispose(); + _eventRoot.Dispose(); + + _enableAfterDelayRegistration?.Dispose(); + } + + base.Dispose(disposing); } } diff --git a/LightBulb/ViewModels/Components/Settings/ApplicationWhitelistSettingsTabViewModel.cs b/LightBulb/ViewModels/Components/Settings/ApplicationWhitelistSettingsTabViewModel.cs index d811dc8d..f343fb82 100644 --- a/LightBulb/ViewModels/Components/Settings/ApplicationWhitelistSettingsTabViewModel.cs +++ b/LightBulb/ViewModels/Components/Settings/ApplicationWhitelistSettingsTabViewModel.cs @@ -1,45 +1,77 @@ using System; using System.Collections.Generic; using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using LightBulb.Models; using LightBulb.Services; +using LightBulb.Utils; +using LightBulb.Utils.Extensions; namespace LightBulb.ViewModels.Components.Settings; -public class ApplicationWhitelistSettingsTabViewModel( - SettingsService settingsService, - ExternalApplicationService externalApplicationService -) : SettingsTabViewModelBase(settingsService, 3, "Application whitelist") +public partial class ApplicationWhitelistSettingsTabViewModel : SettingsTabViewModelBase { + private readonly ExternalApplicationService _externalApplicationService; + private readonly DisposableCollector _eventRoot = new(); + + [ObservableProperty] + private IReadOnlyList? _applications; + + public ApplicationWhitelistSettingsTabViewModel( + SettingsService settingsService, + ExternalApplicationService externalApplicationService + ) + : base(settingsService, 3, "Application whitelist") + { + _externalApplicationService = externalApplicationService; + + _eventRoot.Add( + this.WatchProperty( + o => o.IsApplicationWhitelistEnabled, + () => RefreshApplicationsCommand.NotifyCanExecuteChanged() + ) + ); + } + public bool IsApplicationWhitelistEnabled { get => SettingsService.IsApplicationWhitelistEnabled; set => SettingsService.IsApplicationWhitelistEnabled = value; } - public IReadOnlyList? AvailableApplications { get; private set; } - public IReadOnlyList? WhitelistedApplications { get => SettingsService.WhitelistedApplications; set => SettingsService.WhitelistedApplications = value; } - public void OnViewLoaded() => PullAvailableApplications(); + private bool CanRefreshApplications() => IsApplicationWhitelistEnabled; - public void PullAvailableApplications() + [RelayCommand(CanExecute = nameof(CanRefreshApplications))] + private void RefreshApplications() { var applications = new HashSet(); // Add previously whitelisted applications - // (this has to be first to preserve references in selected applications) - foreach (var application in WhitelistedApplications ?? Array.Empty()) + // (this has to be done first to preserve references in selected applications) + foreach (var application in WhitelistedApplications ?? []) applications.Add(application); // Add all running applications - foreach (var application in externalApplicationService.GetAllRunningApplications()) + foreach (var application in _externalApplicationService.GetAllRunningApplications()) applications.Add(application); - AvailableApplications = applications.ToArray(); + Applications = applications.ToArray(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); } } diff --git a/LightBulb/ViewModels/Components/Settings/ISettingsTabViewModel.cs b/LightBulb/ViewModels/Components/Settings/ISettingsTabViewModel.cs deleted file mode 100644 index 0e9dbd06..00000000 --- a/LightBulb/ViewModels/Components/Settings/ISettingsTabViewModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LightBulb.ViewModels.Components.Settings; - -public interface ISettingsTabViewModel -{ - int Order { get; } - - string DisplayName { get; } - - bool IsActive { get; set; } -} diff --git a/LightBulb/ViewModels/Components/Settings/LocationSettingsTabViewModel.cs b/LightBulb/ViewModels/Components/Settings/LocationSettingsTabViewModel.cs index ce59e62b..cdf97038 100644 --- a/LightBulb/ViewModels/Components/Settings/LocationSettingsTabViewModel.cs +++ b/LightBulb/ViewModels/Components/Settings/LocationSettingsTabViewModel.cs @@ -1,13 +1,37 @@ using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using LightBulb.Core; using LightBulb.Services; -using Stylet; +using LightBulb.Utils; +using LightBulb.Utils.Extensions; namespace LightBulb.ViewModels.Components.Settings; -public class LocationSettingsTabViewModel : SettingsTabViewModelBase +public partial class LocationSettingsTabViewModel : SettingsTabViewModelBase { - public bool IsBusy { get; private set; } + private readonly DisposableCollector _eventRoot = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(AutoResolveLocationCommand))] + [NotifyCanExecuteChangedFor(nameof(ResolveLocationCommand))] + private bool _isBusy; + + [ObservableProperty] + private bool _isLocationError; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ResolveLocationCommand))] + private string? _locationQuery; + + public LocationSettingsTabViewModel(SettingsService settingsService) + : base(settingsService, 1, "Location") + { + _eventRoot.Add( + this.WatchProperty(o => o.Location, () => LocationQuery = Location?.ToString()) + ); + } public bool IsManualSunriseSunsetEnabled { @@ -33,24 +57,14 @@ public GeoLocation? Location set => SettingsService.Location = value; } - public bool IsLocationError { get; private set; } + private bool CanAutoResolveLocation() => !IsBusy; - public string? LocationQuery { get; set; } - - public LocationSettingsTabViewModel(SettingsService settingsService) - : base(settingsService, 1, "Location") + [RelayCommand(CanExecute = nameof(CanAutoResolveLocation))] + private async Task AutoResolveLocationAsync() { - // Update the location query when the actual location changes - settingsService.BindAndInvoke( - o => o.Location, - (_, _) => LocationQuery = Location?.ToString() - ); - } - - public bool CanAutoDetectLocation => !IsBusy; + if (IsBusy) + return; - public async void AutoDetectLocation() - { IsBusy = true; IsLocationError = false; @@ -68,14 +82,19 @@ public async void AutoDetectLocation() } } - public bool CanSetLocation => + private bool CanResolveLocation() => !IsBusy && !string.IsNullOrWhiteSpace(LocationQuery) && LocationQuery != Location?.ToString(); - public async void SetLocation() + [RelayCommand(CanExecute = nameof(CanResolveLocation))] + private async Task ResolveLocationAsync() { - if (string.IsNullOrWhiteSpace(LocationQuery)) + if ( + IsBusy + || string.IsNullOrWhiteSpace(LocationQuery) + || LocationQuery == Location?.ToString() + ) return; IsBusy = true; @@ -95,4 +114,14 @@ public async void SetLocation() IsBusy = false; } } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); + } } diff --git a/LightBulb/ViewModels/Components/Settings/SettingsTabViewModelBase.cs b/LightBulb/ViewModels/Components/Settings/SettingsTabViewModelBase.cs index 609b626f..d399f500 100644 --- a/LightBulb/ViewModels/Components/Settings/SettingsTabViewModelBase.cs +++ b/LightBulb/ViewModels/Components/Settings/SettingsTabViewModelBase.cs @@ -1,17 +1,17 @@ -using LightBulb.Services; -using Stylet; +using CommunityToolkit.Mvvm.ComponentModel; +using LightBulb.Framework; +using LightBulb.Services; +using LightBulb.Utils; +using LightBulb.Utils.Extensions; namespace LightBulb.ViewModels.Components.Settings; -public abstract class SettingsTabViewModelBase : PropertyChangedBase, ISettingsTabViewModel +public abstract partial class SettingsTabViewModelBase : ViewModelBase { - protected SettingsService SettingsService { get; } - - public int Order { get; } - - public string DisplayName { get; } + private readonly DisposableCollector _eventRoot = new(); - public bool IsActive { get; set; } + [ObservableProperty] + private bool _isActive; protected SettingsTabViewModelBase( SettingsService settingsService, @@ -23,8 +23,28 @@ string displayName Order = order; DisplayName = displayName; - SettingsService.SettingsReset += (_, _) => Refresh(); - SettingsService.SettingsLoaded += (_, _) => Refresh(); - SettingsService.SettingsSaved += (_, _) => Refresh(); + _eventRoot.Add( + // Implementing classes will bind to settings properties through + // their own properties, so make sure they stay in sync. + // This is a bit overkill as it triggers a lot of unnecessary events, + // but it's a simple and reliable solution. + SettingsService.WatchAllProperties(OnAllPropertiesChanged) + ); + } + + protected SettingsService SettingsService { get; } + + public int Order { get; } + + public string DisplayName { get; } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); } } diff --git a/LightBulb/ViewModels/Dialogs/MessageBoxViewModel.cs b/LightBulb/ViewModels/Dialogs/MessageBoxViewModel.cs index 2bf805d8..7563038f 100644 --- a/LightBulb/ViewModels/Dialogs/MessageBoxViewModel.cs +++ b/LightBulb/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -1,43 +1,29 @@ -using LightBulb.ViewModels.Framework; +using CommunityToolkit.Mvvm.ComponentModel; +using LightBulb.Framework; namespace LightBulb.ViewModels.Dialogs; -public class MessageBoxViewModel : DialogScreen +public partial class MessageBoxViewModel : DialogViewModelBase { - public string? Title { get; set; } + [ObservableProperty] + private string? _title = "Title"; - public string? Message { get; set; } + [ObservableProperty] + private string? _message = "Message"; - public bool IsOkButtonVisible { get; set; } = true; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _defaultButtonText = "OK"; - public string? OkButtonText { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _cancelButtonText = "Cancel"; - public bool IsCancelButtonVisible { get; set; } + public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText); - public string? CancelButtonText { get; set; } + public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText); - public int ButtonsCount => (IsOkButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); -} - -public static class MessageBoxViewModelExtensions -{ - public static MessageBoxViewModel CreateMessageBoxViewModel( - this IViewModelFactory factory, - string title, - string message, - string? okButtonText, - string? cancelButtonText - ) - { - var viewModel = factory.CreateMessageBoxViewModel(); - - viewModel.Title = title; - viewModel.Message = message; - viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText); - viewModel.OkButtonText = okButtonText; - viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText); - viewModel.CancelButtonText = cancelButtonText; - - return viewModel; - } + public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); } diff --git a/LightBulb/ViewModels/Dialogs/SettingsViewModel.cs b/LightBulb/ViewModels/Dialogs/SettingsViewModel.cs index 08db73d8..8992cc97 100644 --- a/LightBulb/ViewModels/Dialogs/SettingsViewModel.cs +++ b/LightBulb/ViewModels/Dialogs/SettingsViewModel.cs @@ -1,22 +1,23 @@ using System.Collections.Generic; using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LightBulb.Framework; using LightBulb.Services; using LightBulb.ViewModels.Components.Settings; -using LightBulb.ViewModels.Framework; namespace LightBulb.ViewModels.Dialogs; -public class SettingsViewModel : DialogScreen +public partial class SettingsViewModel : DialogViewModelBase { private readonly SettingsService _settingsService; - public IReadOnlyList Tabs { get; } - - public ISettingsTabViewModel? ActiveTab { get; private set; } + [ObservableProperty] + private SettingsTabViewModelBase? _activeTab; public SettingsViewModel( SettingsService settingsService, - IEnumerable tabs + IEnumerable tabs ) { _settingsService = settingsService; @@ -28,34 +29,39 @@ IEnumerable tabs ActivateTab(firstTab); } - public void ActivateTab(ISettingsTabViewModel settingsTab) + public IReadOnlyList Tabs { get; } + + [RelayCommand] + private void ActivateTab(SettingsTabViewModelBase tab) { - // Deactivate previously selected tab + // Deactivate the previously selected tab if (ActiveTab is not null) ActiveTab.IsActive = false; - ActiveTab = settingsTab; - settingsTab.IsActive = true; + ActiveTab = tab; + tab.IsActive = true; } - // This should just be an overload, but Stylet gets confused when there are two methods with the same name - public void ActivateTabByType() - where T : ISettingsTabViewModel + public void ActivateTab() + where T : SettingsTabViewModelBase { var tab = Tabs.OfType().FirstOrDefault(); if (tab is not null) ActivateTab(tab); } - public void Reset() => _settingsService.Reset(); + [RelayCommand] + private void Reset() => _settingsService.Reset(); - public void Save() + [RelayCommand] + private void Save() { _settingsService.Save(); Close(true); } - public void Cancel() + [RelayCommand] + private void Cancel() { _settingsService.Load(); Close(false); diff --git a/LightBulb/ViewModels/Framework/DialogManager.cs b/LightBulb/ViewModels/Framework/DialogManager.cs deleted file mode 100644 index 15a05bed..00000000 --- a/LightBulb/ViewModels/Framework/DialogManager.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; -using MaterialDesignThemes.Wpf; -using Stylet; - -namespace LightBulb.ViewModels.Framework; - -public class DialogManager(IViewManager viewManager) : IDisposable -{ - // Cache and reuse dialog screen views, as creating them is incredibly slow - private readonly Dictionary _dialogScreenViewCache = new(); - private readonly SemaphoreSlim _dialogLock = new(1, 1); - - public UIElement GetViewForDialogScreen(DialogScreen dialogScreen) - { - var dialogScreenType = dialogScreen.GetType(); - - if (_dialogScreenViewCache.TryGetValue(dialogScreenType, out var cachedView)) - { - viewManager.BindViewToModel(cachedView, dialogScreen); - return cachedView; - } - else - { - var view = viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); - - // This warms up the view and triggers all bindings. - // We need to do this, as the view may have nested model-bound ContentControls - // which take a very long time to load. - // By pre-loading them as early as possible, we avoid doing it when the dialog - // actually pops up, which improves user experience. - // Ideally, the whole view cache should be populated at application startup. - view.Arrange(new Rect(0, 0, 500, 500)); - - return _dialogScreenViewCache[dialogScreenType] = view; - } - } - - public async Task ShowDialogAsync(DialogScreen dialogScreen) - { - var view = GetViewForDialogScreen(dialogScreen); - - void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs) - { - void OnScreenClosed(object? closeSender, EventArgs args) - { - try - { - openArgs.Session.Close(); - } - catch (InvalidOperationException) - { - // Race condition: dialog is already being closed - } - - dialogScreen.Closed -= OnScreenClosed; - } - - dialogScreen.Closed += OnScreenClosed; - } - - await _dialogLock.WaitAsync(); - try - { - await DialogHost.Show(view, OnDialogOpened); - return dialogScreen.DialogResult; - } - finally - { - _dialogLock.Release(); - } - } - - public void Dispose() - { - _dialogLock.Dispose(); - } -} diff --git a/LightBulb/ViewModels/Framework/DialogScreen.cs b/LightBulb/ViewModels/Framework/DialogScreen.cs deleted file mode 100644 index d182bd27..00000000 --- a/LightBulb/ViewModels/Framework/DialogScreen.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Stylet; - -namespace LightBulb.ViewModels.Framework; - -public abstract class DialogScreen : PropertyChangedBase -{ - public T? DialogResult { get; private set; } - - public event EventHandler? Closed; - - public void Close(T dialogResult) - { - DialogResult = dialogResult; - Closed?.Invoke(this, EventArgs.Empty); - } -} - -public abstract class DialogScreen : DialogScreen; diff --git a/LightBulb/ViewModels/Framework/IViewModelFactory.cs b/LightBulb/ViewModels/Framework/IViewModelFactory.cs deleted file mode 100644 index 6c0560e0..00000000 --- a/LightBulb/ViewModels/Framework/IViewModelFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using LightBulb.ViewModels.Components; -using LightBulb.ViewModels.Dialogs; - -namespace LightBulb.ViewModels.Framework; - -// Used to instantiate new view models while making use of dependency injection -public interface IViewModelFactory -{ - DashboardViewModel CreateDashboardViewModel(); - - MessageBoxViewModel CreateMessageBoxViewModel(); - - SettingsViewModel CreateSettingsViewModel(); -} diff --git a/LightBulb/ViewModels/MainViewModel.cs b/LightBulb/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..62b9efcc --- /dev/null +++ b/LightBulb/ViewModels/MainViewModel.cs @@ -0,0 +1,135 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using LightBulb.Framework; +using LightBulb.Services; +using LightBulb.Utils; +using LightBulb.ViewModels.Components; +using LightBulb.ViewModels.Components.Settings; +using LightBulb.WindowsApi; + +namespace LightBulb.ViewModels; + +public partial class MainViewModel( + ViewModelManager viewModelManager, + DialogManager dialogManager, + SettingsService settingsService, + UpdateService updateService +) : ViewModelBase +{ + private readonly Timer _checkForUpdatesTimer = + new(TimeSpan.FromHours(3), async () => await updateService.CheckPrepareUpdateAsync()); + + public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel(); + + private async Task ShowGammaRangePromptAsync() + { + if (settingsService.IsExtendedGammaRangeUnlocked) + return; + + var dialog = viewModelManager.CreateMessageBoxViewModel( + "Limited gamma range", + $""" + {Program.Name} has detected that extended gamma range controls are not enabled on this system. + This may cause some color configurations to not work correctly. + + Press FIX to unlock the gamma range. Administrator privileges may be required. + """, + "FIX", + "CLOSE" + ); + + if (await dialogManager.ShowDialogAsync(dialog) == true) + { + settingsService.IsExtendedGammaRangeUnlocked = true; + settingsService.Save(); + } + } + + private async Task ShowFirstTimeExperienceMessageAsync() + { + if (!settingsService.IsFirstTimeExperienceEnabled) + return; + + var dialog = viewModelManager.CreateMessageBoxViewModel( + "Welcome!", + $""" + Thank you for installing {Program.Name}! + To get the most personalized experience, please set your preferred solar configuration. + + Press OK to open settings. + """, + "OK", + "CLOSE" + ); + + // Disable this message in the future + settingsService.IsFirstTimeExperienceEnabled = false; + settingsService.IsAutoStartEnabled = true; + settingsService.Save(); + + if (await dialogManager.ShowDialogAsync(dialog) == true) + { + var settingsDialog = viewModelManager.CreateSettingsViewModel(); + settingsDialog.ActivateTab(); + + await dialogManager.ShowDialogAsync(settingsDialog); + } + } + + private async Task ShowUkraineSupportMessageAsync() + { + if (!settingsService.IsUkraineSupportMessageEnabled) + return; + + var dialog = viewModelManager.CreateMessageBoxViewModel( + "Thank you for supporting Ukraine!", + """ + As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. + + Click LEARN MORE to find ways that you can help. + """, + "LEARN MORE", + "CLOSE" + ); + + // Disable this message in the future + settingsService.IsUkraineSupportMessageEnabled = false; + settingsService.Save(); + + if (await dialogManager.ShowDialogAsync(dialog) == true) + ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=lightbulb"); + } + + [RelayCommand] + private async Task InitializeAsync() + { + await ShowGammaRangePromptAsync(); + await ShowFirstTimeExperienceMessageAsync(); + await ShowUkraineSupportMessageAsync(); + + _checkForUpdatesTimer.Start(); + } + + [RelayCommand] + private async Task ShowSettingsAsync() + { + await dialogManager.ShowDialogAsync(viewModelManager.CreateSettingsViewModel()); + + // Re-initialize timers, hotkeys, and other stateful components + Dashboard.InitializeCommand.Execute(null); + } + + [RelayCommand] + private void ShowAbout() => ProcessEx.StartShellExecute(Program.ProjectUrl); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _checkForUpdatesTimer.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/LightBulb/ViewModels/RootViewModel.cs b/LightBulb/ViewModels/RootViewModel.cs deleted file mode 100644 index 2d972ac8..00000000 --- a/LightBulb/ViewModels/RootViewModel.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Windows; -using LightBulb.Services; -using LightBulb.Utils; -using LightBulb.ViewModels.Components; -using LightBulb.ViewModels.Components.Settings; -using LightBulb.ViewModels.Dialogs; -using LightBulb.ViewModels.Framework; -using LightBulb.WindowsApi; -using Stylet; - -namespace LightBulb.ViewModels; - -public class RootViewModel : Screen, IDisposable -{ - private readonly IViewModelFactory _viewModelFactory; - private readonly DialogManager _dialogManager; - private readonly SettingsService _settingsService; - - private readonly Timer _checkForUpdatesTimer; - - public DashboardViewModel Dashboard { get; } - - public RootViewModel( - IViewModelFactory viewModelFactory, - DialogManager dialogManager, - SettingsService settingsService, - UpdateService updateService - ) - { - _viewModelFactory = viewModelFactory; - _dialogManager = dialogManager; - _settingsService = settingsService; - - _checkForUpdatesTimer = new Timer( - TimeSpan.FromHours(3), - async () => await updateService.CheckPrepareUpdateAsync() - ); - - Dashboard = viewModelFactory.CreateDashboardViewModel(); - - DisplayName = $"{App.Name} v{App.VersionString}"; - } - - private async Task ShowGammaRangePromptAsync() - { - if (_settingsService.IsExtendedGammaRangeUnlocked) - return; - - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Limited gamma range", - $""" - {App.Name} has detected that extended gamma range controls are not enabled on this system. - This may cause some color configurations to not work correctly. - - Press FIX to unlock the gamma range. Administrator privileges may be required. - """, - "FIX", - "CLOSE" - ); - - if (await _dialogManager.ShowDialogAsync(dialog) == true) - { - _settingsService.IsExtendedGammaRangeUnlocked = true; - _settingsService.Save(); - } - } - - private async Task ShowFirstTimeExperienceMessageAsync() - { - if (!_settingsService.IsFirstTimeExperienceEnabled) - return; - - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Welcome!", - $""" - Thank you for installing {App.Name}! - To get the most personalized experience, please set your preferred solar configuration. - - Press OK to open settings. - """, - "OK", - "CLOSE" - ); - - // Disable this message in the future - _settingsService.IsFirstTimeExperienceEnabled = false; - _settingsService.IsAutoStartEnabled = true; - _settingsService.Save(); - - if (await _dialogManager.ShowDialogAsync(dialog) == true) - { - var settingsDialog = _viewModelFactory.CreateSettingsViewModel(); - settingsDialog.ActivateTabByType(); - - await _dialogManager.ShowDialogAsync(settingsDialog); - } - } - - private async Task ShowUkraineSupportMessageAsync() - { - if (!_settingsService.IsUkraineSupportMessageEnabled) - return; - - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Thank you for supporting Ukraine!", - """ - As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. - - Click LEARN MORE to find ways that you can help. - """, - "LEARN MORE", - "CLOSE" - ); - - // Disable this message in the future - _settingsService.IsUkraineSupportMessageEnabled = false; - _settingsService.Save(); - - if (await _dialogManager.ShowDialogAsync(dialog) == true) - ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=lightbulb"); - } - - protected override void OnViewLoaded() - { - base.OnViewLoaded(); - - _checkForUpdatesTimer.Start(); - } - - // This is a custom event that fires when the dialog host is loaded - public async void OnViewFullyLoaded() - { - await ShowGammaRangePromptAsync(); - await ShowFirstTimeExperienceMessageAsync(); - await ShowUkraineSupportMessageAsync(); - } - - public async void ShowSettings() => - await _dialogManager.ShowDialogAsync(_viewModelFactory.CreateSettingsViewModel()); - - public void ShowAbout() => ProcessEx.StartShellExecute(App.ProjectUrl); - - public void Exit() => Application.Current.Shutdown(); - - public void Dispose() => _checkForUpdatesTimer.Dispose(); -} diff --git a/LightBulb/Views/Components/DashboardView.xaml b/LightBulb/Views/Components/DashboardView.axaml similarity index 58% rename from LightBulb/Views/Components/DashboardView.xaml rename to LightBulb/Views/Components/DashboardView.axaml index e2fbe567..7527c6d8 100644 --- a/LightBulb/Views/Components/DashboardView.xaml +++ b/LightBulb/Views/Components/DashboardView.axaml @@ -1,133 +1,145 @@  - - - - - - + xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + x:Name="UserControl" + Loaded="UserControl_OnLoaded"> + + + + - + - + - + - - - + + - + - + + StrokeThickness="28" /> + StrokeThickness="28" /> + StrokeThickness="28" /> + StrokeThickness="28" /> + + Size="26" /> - + PointerPressed="ConfigurationOffsetStackPanel_OnPointerPressed"> + + - + + - + - - + @@ -141,29 +153,25 @@ - - - + Kind="{Binding CycleState, Converter={x:Static converters:CycleStateToMaterialIconKindConverter.Instance}}" /> + + - + - @@ -171,31 +179,29 @@ Margin="4,0,0,0" FontSize="14" FontWeight="Light" - Text="{Binding Instant, StringFormat=\{0:t\}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" /> + Text="{Binding Instant, StringFormat={}{0:t}}" /> - + - + - + - + - + - + - + \ No newline at end of file diff --git a/LightBulb/Views/Components/DashboardView.axaml.cs b/LightBulb/Views/Components/DashboardView.axaml.cs new file mode 100644 index 00000000..db1126e4 --- /dev/null +++ b/LightBulb/Views/Components/DashboardView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Input; +using Avalonia.Interactivity; +using LightBulb.Framework; +using LightBulb.ViewModels.Components; + +namespace LightBulb.Views.Components; + +public partial class DashboardView : UserControl +{ + public DashboardView() => InitializeComponent(); + + private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) => + DataContext.InitializeCommand.Execute(null); + + private void ConfigurationOffsetStackPanel_OnPointerPressed( + object? sender, + PointerPressedEventArgs args + ) => DataContext.ResetConfigurationOffsetCommand.Execute(null); +} diff --git a/LightBulb/Views/Components/DashboardView.xaml.cs b/LightBulb/Views/Components/DashboardView.xaml.cs deleted file mode 100644 index d7c8bfec..00000000 --- a/LightBulb/Views/Components/DashboardView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components; - -public partial class DashboardView -{ - public DashboardView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml new file mode 100644 index 00000000..b897a8cb --- /dev/null +++ b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml.cs b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml.cs new file mode 100644 index 00000000..7bb35ee3 --- /dev/null +++ b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.axaml.cs @@ -0,0 +1,9 @@ +using LightBulb.Framework; +using LightBulb.ViewModels.Components.Settings; + +namespace LightBulb.Views.Components.Settings; + +public partial class AdvancedSettingsTabView : UserControl +{ + public AdvancedSettingsTabView() => InitializeComponent(); +} diff --git a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml deleted file mode 100644 index fc029659..00000000 --- a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml.cs b/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml.cs deleted file mode 100644 index a777ff1b..00000000 --- a/LightBulb/Views/Components/Settings/AdvancedSettingsTabView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components.Settings; - -public partial class AdvancedSettingsTabView -{ - public AdvancedSettingsTabView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml new file mode 100644 index 00000000..d31ee01e --- /dev/null +++ b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml.cs b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml.cs new file mode 100644 index 00000000..c6104c45 --- /dev/null +++ b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.axaml.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Interactivity; +using LightBulb.Framework; +using LightBulb.Models; +using LightBulb.Utils; +using LightBulb.Utils.Extensions; +using LightBulb.ViewModels.Components.Settings; + +namespace LightBulb.Views.Components.Settings; + +public partial class ApplicationWhitelistSettingsTabView + : UserControl, + IDisposable +{ + private readonly DisposableCollector _eventRoot = new(); + + public ApplicationWhitelistSettingsTabView() => InitializeComponent(); + + private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) + { + DataContext.RefreshApplicationsCommand.Execute(null); + + _eventRoot.Add( + // This hack is required to avoid having to use an ObservableCollection on the view model + DataContext.WatchProperty( + o => o.WhitelistedApplications, + () => + WhitelistedApplicationsListBox.SelectedItems = new AvaloniaList( + DataContext.WhitelistedApplications ?? [] + ) + ) + ); + } + + private void UserControl_OnUnloaded(object? sender, RoutedEventArgs args) => + _eventRoot.Dispose(); + + // This hack is required to avoid having to use an ObservableCollection on the view model + private void WhitelistedApplicationsListBox_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs args + ) => + DataContext.WhitelistedApplications = WhitelistedApplicationsListBox + .SelectedItems + ?.Cast() + .ToArray(); + + public void Dispose() => _eventRoot.Dispose(); +} diff --git a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml deleted file mode 100644 index b40520d5..00000000 --- a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - Refresh - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml.cs b/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml.cs deleted file mode 100644 index 3bc503e0..00000000 --- a/LightBulb/Views/Components/Settings/ApplicationWhitelistSettingsTabView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components.Settings; - -public partial class ApplicationWhitelistSettingsTabView -{ - public ApplicationWhitelistSettingsTabView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml new file mode 100644 index 00000000..8d2f64af --- /dev/null +++ b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml.cs b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml.cs new file mode 100644 index 00000000..849a37b3 --- /dev/null +++ b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.axaml.cs @@ -0,0 +1,9 @@ +using LightBulb.Framework; +using LightBulb.ViewModels.Components.Settings; + +namespace LightBulb.Views.Components.Settings; + +public partial class GeneralSettingsTabView : UserControl +{ + public GeneralSettingsTabView() => InitializeComponent(); +} diff --git a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml deleted file mode 100644 index e498cce8..00000000 --- a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml.cs b/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml.cs deleted file mode 100644 index 915309d4..00000000 --- a/LightBulb/Views/Components/Settings/GeneralSettingsTabView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components.Settings; - -public partial class GeneralSettingsTabView -{ - public GeneralSettingsTabView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml new file mode 100644 index 00000000..334b9a52 --- /dev/null +++ b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml.cs b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml.cs new file mode 100644 index 00000000..3744c137 --- /dev/null +++ b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.axaml.cs @@ -0,0 +1,9 @@ +using LightBulb.Framework; +using LightBulb.ViewModels.Components.Settings; + +namespace LightBulb.Views.Components.Settings; + +public partial class HotKeySettingsTabView : UserControl +{ + public HotKeySettingsTabView() => InitializeComponent(); +} diff --git a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml deleted file mode 100644 index 05f04564..00000000 --- a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml.cs b/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml.cs deleted file mode 100644 index c5796c61..00000000 --- a/LightBulb/Views/Components/Settings/HotKeySettingsTabView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components.Settings; - -public partial class HotKeySettingsTabView -{ - public HotKeySettingsTabView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml b/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml new file mode 100644 index 00000000..e4bd9f5c --- /dev/null +++ b/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml.cs b/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml.cs new file mode 100644 index 00000000..056b4c49 --- /dev/null +++ b/LightBulb/Views/Components/Settings/LocationSettingsTabView.axaml.cs @@ -0,0 +1,9 @@ +using LightBulb.Framework; +using LightBulb.ViewModels.Components.Settings; + +namespace LightBulb.Views.Components.Settings; + +public partial class LocationSettingsTabView : UserControl +{ + public LocationSettingsTabView() => InitializeComponent(); +} diff --git a/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml b/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml deleted file mode 100644 index 097cc973..00000000 --- a/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml.cs b/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml.cs deleted file mode 100644 index c876ad5e..00000000 --- a/LightBulb/Views/Components/Settings/LocationSettingsTabView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LightBulb.Views.Components.Settings; - -public partial class LocationSettingsTabView -{ - public LocationSettingsTabView() - { - InitializeComponent(); - } -} diff --git a/LightBulb/Views/Controls/Arc.cs b/LightBulb/Views/Controls/Arc.cs index 760d35e6..96c3c5df 100644 --- a/LightBulb/Views/Controls/Arc.cs +++ b/LightBulb/Views/Controls/Arc.cs @@ -1,7 +1,7 @@ using System; -using System.Windows; -using System.Windows.Media; -using System.Windows.Shapes; +using Avalonia; +using Avalonia.Controls.Shapes; +using Avalonia.Media; namespace LightBulb.Views.Controls; @@ -9,79 +9,60 @@ namespace LightBulb.Views.Controls; // https://wpf.2000things.com/2014/09/11/1156-changing-circular-progress-control-to-be-only-an-arc public class Arc : Shape { - private static object CoerceAngle(DependencyObject d, object baseValue) => - baseValue is double angle ? angle % 360.0 : baseValue; + public static readonly StyledProperty StartAngleProperty = AvaloniaProperty.Register< + Arc, + double + >(nameof(StartAngle), coerce: (_, a) => a % 360.0); - public static readonly DependencyProperty StartAngleProperty = DependencyProperty.Register( - nameof(StartAngle), - typeof(double), - typeof(Arc), - new FrameworkPropertyMetadata( - 0.0, - FrameworkPropertyMetadataOptions.AffectsRender, - null, - CoerceAngle - ) - ); - - public static readonly DependencyProperty EndAngleProperty = DependencyProperty.Register( - nameof(EndAngle), - typeof(double), - typeof(Arc), - new FrameworkPropertyMetadata( - 90.0, - FrameworkPropertyMetadataOptions.AffectsRender, - null, - CoerceAngle - ) - ); + public static readonly StyledProperty EndAngleProperty = AvaloniaProperty.Register< + Arc, + double + >(nameof(EndAngle), coerce: (_, a) => a % 360.0); public double StartAngle { - get => (double)GetValue(StartAngleProperty); + get => GetValue(StartAngleProperty); set => SetValue(StartAngleProperty, value); } public double EndAngle { - get => (double)GetValue(EndAngleProperty); + get => GetValue(EndAngleProperty); set => SetValue(EndAngleProperty, value); } - protected override Geometry DefiningGeometry + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs args) { - get - { - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); + base.OnPropertyChanged(args); + + if (args.Property == StartAngleProperty || args.Property == EndAngleProperty) + InvalidateGeometry(); + } - var radiusX = ActualWidth / 2.0; - var radiusY = ActualHeight / 2.0; + protected override Geometry CreateDefiningGeometry() + { + var geometry = new StreamGeometry(); + using var context = geometry.Open(); - var startX = radiusX + radiusX * Math.Sin(StartAngle * Math.PI / 180.0); - var startY = radiusY - radiusY * Math.Cos(StartAngle * Math.PI / 180.0); + var radius = new Size(Width / 2.0, Height / 2.0); - var endX = radiusX + radiusX * Math.Sin(EndAngle * Math.PI / 180.0); - var endY = radiusY - radiusY * Math.Cos(EndAngle * Math.PI / 180.0); + var start = new Point( + radius.Width + radius.Width * Math.Sin(StartAngle * Math.PI / 180.0), + radius.Height - radius.Height * Math.Cos(StartAngle * Math.PI / 180.0) + ); - // This single line took me 2 hours to write - var isLargeArc = - StartAngle <= EndAngle && Math.Abs(EndAngle - StartAngle) > 180.0 - || StartAngle > EndAngle && Math.Abs(EndAngle - StartAngle) < 180.0; + var end = new Point( + radius.Width + radius.Width * Math.Sin(EndAngle * Math.PI / 180.0), + radius.Height - radius.Height * Math.Cos(EndAngle * Math.PI / 180.0) + ); - ctx.BeginFigure(new Point(startX, startY), true, false); + var isLargeArc = + StartAngle <= EndAngle && Math.Abs(EndAngle - StartAngle) > 180.0 + || StartAngle > EndAngle && Math.Abs(EndAngle - StartAngle) < 180.0; - ctx.ArcTo( - new Point(endX, endY), - new Size(radiusX, radiusY), - 0.0, - isLargeArc, - SweepDirection.Clockwise, - true, - false - ); + context.BeginFigure(start, true); + context.ArcTo(end, radius, 0.0, isLargeArc, SweepDirection.Clockwise); - return geometry; - } + return geometry; } } diff --git a/LightBulb/Views/Controls/ArcPoint.cs b/LightBulb/Views/Controls/ArcPoint.cs index 87d2f35a..e2b83564 100644 --- a/LightBulb/Views/Controls/ArcPoint.cs +++ b/LightBulb/Views/Controls/ArcPoint.cs @@ -1,62 +1,51 @@ using System; -using System.Windows; -using System.Windows.Media; -using System.Windows.Shapes; +using Avalonia; +using Avalonia.Controls.Shapes; +using Avalonia.Media; namespace LightBulb.Views.Controls; public class ArcPoint : Shape { - private static object CoerceAngle(DependencyObject d, object baseValue) => - baseValue is double angle ? angle % 360.0 : baseValue; - - public static readonly DependencyProperty AngleProperty = DependencyProperty.Register( - nameof(Angle), - typeof(double), - typeof(ArcPoint), - new FrameworkPropertyMetadata( - 0.0, - FrameworkPropertyMetadataOptions.AffectsRender, - null, - CoerceAngle - ) - ); - - public static readonly DependencyProperty SizeProperty = DependencyProperty.Register( - nameof(Size), - typeof(double), - typeof(ArcPoint), - new FrameworkPropertyMetadata( - 2.0, - FrameworkPropertyMetadataOptions.AffectsRender, - null, - CoerceAngle - ) - ); + public static readonly StyledProperty AngleProperty = AvaloniaProperty.Register< + ArcPoint, + double + >(nameof(Angle), coerce: (_, a) => a % 360.0); + + public static readonly StyledProperty SizeProperty = AvaloniaProperty.Register< + ArcPoint, + double + >(nameof(Size)); public double Angle { - get => (double)GetValue(AngleProperty); + get => GetValue(AngleProperty); set => SetValue(AngleProperty, value); } public double Size { - get => (double)GetValue(SizeProperty); + get => GetValue(SizeProperty); set => SetValue(SizeProperty, value); } - protected override Geometry DefiningGeometry + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + base.OnPropertyChanged(args); + + if (args.Property == AngleProperty || args.Property == SizeProperty) + InvalidateGeometry(); + } + + protected override Geometry CreateDefiningGeometry() { - get - { - var radiusX = ActualWidth / 2.0; - var radiusY = ActualHeight / 2.0; + var radius = new Size(Width / 2.0, Height / 2.0); - var x = radiusX + radiusX * Math.Sin(Angle * Math.PI / 180.0); - var y = radiusY - radiusY * Math.Cos(Angle * Math.PI / 180.0); + var center = new Point( + radius.Width + radius.Width * Math.Sin(Angle * Math.PI / 180.0) - Size / 2.0, + radius.Height - radius.Height * Math.Cos(Angle * Math.PI / 180.0) - Size / 2.0 + ); - return new EllipseGeometry(new Point(x, y), Size, Size); - } + return new EllipseGeometry(new Rect(center, new Size(Size, Size))); } } diff --git a/LightBulb/Views/Controls/HotKeyTextBox.axaml b/LightBulb/Views/Controls/HotKeyTextBox.axaml new file mode 100644 index 00000000..18a465f0 --- /dev/null +++ b/LightBulb/Views/Controls/HotKeyTextBox.axaml @@ -0,0 +1,12 @@ + + + diff --git a/LightBulb/Views/Controls/HotKeyTextBox.axaml.cs b/LightBulb/Views/Controls/HotKeyTextBox.axaml.cs new file mode 100644 index 00000000..77ac1378 --- /dev/null +++ b/LightBulb/Views/Controls/HotKeyTextBox.axaml.cs @@ -0,0 +1,82 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using LightBulb.Models; + +namespace LightBulb.Views.Controls; + +public partial class HotKeyTextBox : UserControl +{ + public static readonly StyledProperty HotKeyProperty = AvaloniaProperty.Register< + HotKeyTextBox, + HotKey + >(nameof(HotKey), defaultBindingMode: BindingMode.TwoWay); + + public HotKeyTextBox() => InitializeComponent(); + + public HotKey HotKey + { + get => GetValue(HotKeyProperty); + set => SetValue(HotKeyProperty, value); + } + + private void TextBox_OnKeyDown(object? sender, KeyEventArgs args) + { + args.Handled = true; + + var modifiers = args.KeyModifiers; + var key = args.PhysicalKey; + + if (key == PhysicalKey.None) + return; + + // Clear the current value if Delete/Back/Escape is pressed without modifiers + if ( + key is PhysicalKey.Delete or PhysicalKey.Backspace or PhysicalKey.Escape + && modifiers == KeyModifiers.None + ) + { + HotKey = HotKey.None; + return; + } + + // Require at least one non-modifier key to be pressed + if ( + key + is PhysicalKey.ControlLeft + or PhysicalKey.ControlRight + or PhysicalKey.AltLeft + or PhysicalKey.AltRight + or PhysicalKey.ShiftLeft + or PhysicalKey.ShiftRight + or PhysicalKey.MetaLeft + or PhysicalKey.MetaRight + or PhysicalKey.NumPadClear + ) + { + return; + } + + // Don't allow Enter/Space/Tab to be used as hotkeys without modifiers + if ( + key is PhysicalKey.Enter or PhysicalKey.Space or PhysicalKey.Tab + && modifiers == KeyModifiers.None + ) + { + return; + } + + // Don't allow character keys to be used as hotkeys without modifiers or with Shift + if ( + key.ToQwertyKeySymbol() is not null + && modifiers is KeyModifiers.None or KeyModifiers.Shift + ) + { + return; + } + + // Set value + HotKey = new HotKey(key, modifiers); + } +} diff --git a/LightBulb/Views/Controls/HotKeyTextBox.cs b/LightBulb/Views/Controls/HotKeyTextBox.cs deleted file mode 100644 index 3709a42a..00000000 --- a/LightBulb/Views/Controls/HotKeyTextBox.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using LightBulb.Models; - -namespace LightBulb.Views.Controls; - -public class HotKeyTextBox : TextBox -{ - public static readonly DependencyProperty HotKeyProperty = DependencyProperty.Register( - nameof(HotKey), - typeof(HotKey), - typeof(HotKeyTextBox), - new FrameworkPropertyMetadata( - default(HotKey), - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, - (sender, _) => - { - var control = (HotKeyTextBox)sender; - control.Text = control.HotKey.ToString(); - } - ) - ); - - public HotKey HotKey - { - get => (HotKey)GetValue(HotKeyProperty); - set => SetValue(HotKeyProperty, value); - } - - public HotKeyTextBox() - { - IsReadOnly = true; - IsReadOnlyCaretVisible = false; - IsUndoEnabled = false; - - if (ContextMenu is not null) - ContextMenu.Visibility = Visibility.Collapsed; - - Text = HotKey.ToString(); - } - - private static bool HasKeyChar(Key key) => - key - is - // A - Z - >= Key.A - and <= Key.Z - or - // 0 - 9 - >= Key.D0 - and <= Key.D9 - or - // Numpad 0 - 9 - >= Key.NumPad0 - and <= Key.NumPad9 - or - // The rest - Key.OemQuestion - or Key.OemQuotes - or Key.OemPlus - or Key.OemOpenBrackets - or Key.OemCloseBrackets - or Key.OemMinus - or Key.DeadCharProcessed - or Key.Oem1 - or Key.Oem5 - or Key.Oem7 - or Key.OemPeriod - or Key.OemComma - or Key.Add - or Key.Divide - or Key.Multiply - or Key.Subtract - or Key.Oem102 - or Key.Decimal; - - protected override void OnPreviewKeyDown(KeyEventArgs args) - { - args.Handled = true; - - // Get modifiers and key data - var modifiers = Keyboard.Modifiers; - var key = args.Key; - - // If nothing was pressed - return - if (key == Key.None) - return; - - // If Alt is used as modifier - the key needs to be extracted from SystemKey - if (key == Key.System) - key = args.SystemKey; - - // If Delete/Backspace/Escape is pressed without modifiers - clear current value and return - if (key is Key.Delete or Key.Back or Key.Escape && modifiers == ModifierKeys.None) - { - HotKey = HotKey.None; - return; - } - - // If the only key pressed is one of the modifier keys - return - if ( - key - is Key.LeftCtrl - or Key.RightCtrl - or Key.LeftAlt - or Key.RightAlt - or Key.LeftShift - or Key.RightShift - or Key.LWin - or Key.RWin - or Key.Clear - or Key.OemClear - or Key.Apps - ) - return; - - // If Enter/Space/Tab is pressed without modifiers - return - if (key is Key.Enter or Key.Space or Key.Tab && modifiers == ModifierKeys.None) - return; - - // If key has a character and pressed without modifiers or only with Shift - return - if (HasKeyChar(key) && modifiers is ModifierKeys.None or ModifierKeys.Shift) - return; - - // Set value - HotKey = new HotKey(key, modifiers); - } -} diff --git a/LightBulb/Views/Dialogs/MessageBoxView.xaml b/LightBulb/Views/Dialogs/MessageBoxView.axaml similarity index 51% rename from LightBulb/Views/Dialogs/MessageBoxView.xaml rename to LightBulb/Views/Dialogs/MessageBoxView.axaml index 542ac3e4..0de19fff 100644 --- a/LightBulb/Views/Dialogs/MessageBoxView.xaml +++ b/LightBulb/Views/Dialogs/MessageBoxView.axaml @@ -1,23 +1,15 @@  - - - - - - + xmlns:system="clr-namespace:System;assembly=System.Runtime" + Width="350"> + + + + + ToolTip.Tip="{Binding Title}" /> @@ -63,11 +55,11 @@ + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LightBulb/Views/MainView.axaml.cs b/LightBulb/Views/MainView.axaml.cs new file mode 100644 index 00000000..f44d0e20 --- /dev/null +++ b/LightBulb/Views/MainView.axaml.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using LightBulb.Framework; +using LightBulb.ViewModels; + +namespace LightBulb.Views; + +public partial class MainView : Window +{ + public MainView() => InitializeComponent(); + + private void Window_OnLoaded(object sender, RoutedEventArgs args) + { + if (StartupOptions.Current.IsInitiallyHidden) + Hide(); + } + + private void Window_OnClosing(object sender, WindowClosingEventArgs args) + { + args.Cancel = true; + Hide(); + } + + private void DialogHost_OnLoaded(object? sender, RoutedEventArgs args) => + DataContext.InitializeCommand.Execute(null); + + private void HeaderBorder_OnPointerPressed(object? sender, PointerPressedEventArgs args) => + BeginMoveDrag(args); + + private void HideButton_OnClick(object sender, RoutedEventArgs args) => Hide(); +} diff --git a/LightBulb/Views/RootView.xaml b/LightBulb/Views/RootView.xaml deleted file mode 100644 index d50cd90c..00000000 --- a/LightBulb/Views/RootView.xaml +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 24:0:0 - - - - - 12:0:0 - - - - - 6:0:0 - - - - - 3:0:0 - - - - - 1:0:0 - - - - - 0:30:0 - - - - - 0:5:0 - - - - - 0:1:0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/LightBulb/Views/RootView.xaml.cs b/LightBulb/Views/RootView.xaml.cs deleted file mode 100644 index eff3b9b1..00000000 --- a/LightBulb/Views/RootView.xaml.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.ComponentModel; -using System.Windows; -using System.Windows.Input; - -namespace LightBulb.Views; - -public partial class RootView -{ - public RootView() - { - InitializeComponent(); - } - - private void HideToTray() - { - Hide(); - } - - private void RestoreFromTray() - { - Show(); - Activate(); - Focus(); - } - - private void RootView_OnLoaded(object sender, RoutedEventArgs args) - { - // Hide to tray as soon as the window is loaded, if necessary - if (App.IsHiddenOnLaunch) - HideToTray(); - } - - private void RootView_OnClosing(object sender, CancelEventArgs args) - { - args.Cancel = true; - HideToTray(); - } - - private void TaskbarIcon_OnTrayLeftMouseUp(object sender, RoutedEventArgs args) - { - RestoreFromTray(); - } - - private void ShowWindowMenuItem_OnClick(object sender, RoutedEventArgs args) - { - RestoreFromTray(); - } - - private void Header_OnMouseDown(object sender, MouseButtonEventArgs args) - { - if (args.ChangedButton == MouseButton.Left) - DragMove(); - } - - private void CloseButton_OnClick(object sender, RoutedEventArgs args) - { - HideToTray(); - } -} diff --git a/LightBulb/app.manifest b/LightBulb/app.manifest new file mode 100644 index 00000000..42a047e7 --- /dev/null +++ b/LightBulb/app.manifest @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file