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
\ 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