[New Module] Light Switch (#41987)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This pull request introduces a new module called "Light Switch" which
allows users to automatically switch between light and dark mode on a
timer.

![Light
Switch](https://github.com/user-attachments/assets/d24d7364-445f-4f23-ab5e-4b8c6a4147ab)

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #1331
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places
- [x] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [x] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [x] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here:
[#5867](https://github.com/MicrosoftDocs/windows-dev-docs-pr/pull/5867)

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

### Known bugs:
- Default settings not saving correctly when switching modes
- Issue: Sometimes when you switch from one mode to another, they are
supposed to update with new defaults but sometimes this fails for the
second variable. Potentially has to do with accessing the settings file
while another chunk of code is still updating.
- Sometimes the system looks "glitched" when switching themes

### To do:
- [x] OOBE page and assets
- [x] Logic to disable the chart when no location has been selected
- [x] Localization

### How to and what to test
Grab the latest installer from the pipeline below for your architecture
and install PowerToys from there.
- Toggle theme shortcutSystem only, Apps only, Both system and apps
selected
- Does changing the values on the settings page update the settings
file? %LOCALAPPDATA%/Microsoft/PowerToys/LightSwitch/settings.json
- Manual mode: System only, Apps only, Both system and apps selected
- Sunrise modes:  Are the times accurate?
- If you manage to let this run through sunset/rise does the theme
change?
- Set your theme to change within the next minute using manual mode and
set your device to sleepOpen your device and login once the time you set
has passed. --> Do your settings resync once the next minute ticks after
logging back into your device?
- Disable the service and ensure the tasks actually ends.
- While the module is disabled:
     - Make sure the shortcut no longer works
     - Make sure the last time you set doesn't trigger a theme change
- Bonus: Toggle GPO Configuration and make sure you are unable to enable
the module

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com>
This commit is contained in:
Jaylyn Barbee
2025-10-06 16:44:07 -04:00
committed by GitHub
parent ccc31c13ae
commit 0d5220561d
111 changed files with 5798 additions and 12 deletions

View File

@@ -513,6 +513,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
private bool lightSwitch;
[JsonPropertyName("LightSwitch")]
public bool LightSwitch
{
get => lightSwitch;
set
{
if (lightSwitch != value)
{
LogTelemetryEvent(value);
lightSwitch = value;
NotifyChange();
}
}
}
private void NotifyChange()
{
notifyEnabledChangedAction?.Invoke();

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Settings.UI.Library.Helpers
{
public class SearchLocation
{
public string City { get; set; }
public string Country { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public SearchLocation(string city, string country, double latitude, double longitude)
{
City = city;
Country = country;
Latitude = latitude;
Longitude = longitude;
}
}
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.Helpers
{
public static class SearchLocationLoader
{
private static readonly List<SearchLocation> LocationDataList = new List<SearchLocation>();
public static IEnumerable<SearchLocation> GetAll()
{
return LocationDataList
.GroupBy(l => $"{l.Country}|{l.City}|{l.Latitude.ToString(CultureInfo.InvariantCulture)}|{l.Longitude.ToString(CultureInfo.InvariantCulture)}")
.Select(g => g.First())
.OrderBy(l => l.Country, StringComparer.OrdinalIgnoreCase)
.ThenBy(l => l.City, StringComparer.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
{
public static class SunCalc
{
public static SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day)
{
double zenith = 90.833; // official sunrise/sunset
int n1 = (int)Math.Floor(275.0 * month / 9.0);
int n2 = (int)Math.Floor((month + 9.0) / 12.0);
int n3 = (int)Math.Floor(1.0 + Math.Floor((year - (4.0 * Math.Floor(year / 4.0)) + 2.0) / 3.0));
int n = n1 - (n2 * n3) + day - 30;
double? riseUT = CalcTime(isSunrise: true);
double? setUT = CalcTime(isSunrise: false);
var riseLocal = ToLocal(riseUT, year, month, day);
var setLocal = ToLocal(setUT, year, month, day);
var result = new SunTimes
{
HasSunrise = riseLocal.HasValue,
HasSunset = setLocal.HasValue,
SunriseHour = riseLocal?.Hour ?? -1,
SunriseMinute = riseLocal?.Minute ?? -1,
SunsetHour = setLocal?.Hour ?? -1,
SunsetMinute = setLocal?.Minute ?? -1,
};
return result;
// Local functions
double? CalcTime(bool isSunrise)
{
double lngHour = longitude / 15.0;
double t = isSunrise ? n + ((6 - lngHour) / 24.0) : n + ((18 - lngHour) / 24.0);
double m1 = (0.9856 * t) - 3.289;
double l = m1 + (1.916 * Math.Sin(Deg2Rad(m1))) + (0.020 * Math.Sin(2 * Deg2Rad(m1))) + 282.634;
l = NormalizeDegrees(l);
double rA = Rad2Deg(Math.Atan(0.91764 * Math.Tan(Deg2Rad(l))));
rA = NormalizeDegrees(rA);
double lquadrant = Math.Floor(l / 90.0) * 90.0;
double rAquadrant = Math.Floor(rA / 90.0) * 90.0;
rA = rA + (lquadrant - rAquadrant);
rA /= 15.0;
double sinDec = 0.39782 * Math.Sin(Deg2Rad(l));
double cosDec = Math.Cos(Math.Asin(sinDec));
double cosH = (Math.Cos(Deg2Rad(zenith)) - (sinDec * Math.Sin(Deg2Rad(latitude))))
/ (cosDec * Math.Cos(Deg2Rad(latitude)));
if (cosH > 1.0 || cosH < -1.0)
{
// Sun never rises or never sets on this date at this location
return null;
}
double h = isSunrise ? 360.0 - Rad2Deg(Math.Acos(cosH)) : Rad2Deg(Math.Acos(cosH));
h /= 15.0;
double t1 = h + rA - (0.06571 * t) - 6.622;
double uT = t1 - lngHour;
uT = NormalizeHours(uT);
return uT;
}
static (int Hour, int Minute)? ToLocal(double? ut, int y, int m, int d)
{
if (!ut.HasValue)
{
return null;
}
// Convert fractional hours to hh:mm with proper rounding
int hours = (int)Math.Floor(ut.Value);
int minutes = (int)((ut.Value - hours) * 60.0);
// Normalize minute overflow
if (minutes == 60)
{
minutes = 0;
hours = (hours + 1) % 24;
}
// Build a UTC DateTime on the given date
var utc = new DateTime(y, m, d, hours, minutes, 0, DateTimeKind.Utc);
// Convert to local time using system time zone rules for that date
var local = TimeZoneInfo.ConvertTimeFromUtc(utc, TimeZoneInfo.Local);
return (local.Hour, local.Minute);
}
static double Deg2Rad(double deg) => deg * Math.PI / 180.0;
static double Rad2Deg(double rad) => rad * 180.0 / Math.PI;
static double NormalizeDegrees(double angle)
{
angle %= 360.0;
if (angle < 0)
{
angle += 360.0;
}
return angle;
}
static double NormalizeHours(double hours)
{
hours %= 24.0;
if (hours < 0)
{
hours += 24.0;
}
return hours;
}
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
{
public struct SunTimes
{
public int SunriseHour;
public int SunriseMinute;
public int SunsetHour;
public int SunsetMinute;
public string Text;
public bool HasSunrise;
public bool HasSunset;
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class LightSwitchProperties
{
public const bool DefaultChangeSystem = true;
public const bool DefaultChangeApps = true;
public const int DefaultLightTime = 480;
public const int DefaultDarkTime = 1200;
public const int DefaultSunriseOffset = 0;
public const int DefaultSunsetOffset = 0;
public const string DefaultLatitude = "0.0";
public const string DefaultLongitude = "0.0";
public const string DefaultScheduleMode = "FixedHours";
public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D
public LightSwitchProperties()
{
ChangeSystem = new BoolProperty(DefaultChangeSystem);
ChangeApps = new BoolProperty(DefaultChangeApps);
LightTime = new IntProperty(DefaultLightTime);
DarkTime = new IntProperty(DefaultDarkTime);
Latitude = new StringProperty(DefaultLatitude);
Longitude = new StringProperty(DefaultLongitude);
SunriseOffset = new IntProperty(DefaultSunriseOffset);
SunsetOffset = new IntProperty(DefaultSunsetOffset);
ScheduleMode = new StringProperty(DefaultScheduleMode);
ToggleThemeHotkey = new KeyboardKeysProperty(DefaultToggleThemeHotkey);
}
[JsonPropertyName("changeSystem")]
public BoolProperty ChangeSystem { get; set; }
[JsonPropertyName("changeApps")]
public BoolProperty ChangeApps { get; set; }
[JsonPropertyName("lightTime")]
public IntProperty LightTime { get; set; }
[JsonPropertyName("darkTime")]
public IntProperty DarkTime { get; set; }
[JsonPropertyName("sunrise_offset")]
public IntProperty SunriseOffset { get; set; }
[JsonPropertyName("sunset_offset")]
public IntProperty SunsetOffset { get; set; }
[JsonPropertyName("latitude")]
public StringProperty Latitude { get; set; }
[JsonPropertyName("longitude")]
public StringProperty Longitude { get; set; }
[JsonPropertyName("scheduleMode")]
public StringProperty ScheduleMode { get; set; }
[JsonPropertyName("toggle-theme-hotkey")]
public KeyboardKeysProperty ToggleThemeHotkey { get; set; }
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Settings.UI.Library
{
public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable
{
public const string ModuleName = "LightSwitch";
public LightSwitchSettings()
{
Name = ModuleName;
Version = Assembly.GetExecutingAssembly().GetName().Version.ToString();
Properties = new LightSwitchProperties();
}
[JsonPropertyName("properties")]
public LightSwitchProperties Properties { get; set; }
public object Clone()
{
return new LightSwitchSettings()
{
Name = Name,
Version = Version,
Properties = new LightSwitchProperties()
{
ChangeSystem = new BoolProperty(Properties.ChangeSystem.Value),
ChangeApps = new BoolProperty(Properties.ChangeApps.Value),
ScheduleMode = new StringProperty(Properties.ScheduleMode.Value),
LightTime = new IntProperty((int)Properties.LightTime.Value),
DarkTime = new IntProperty((int)Properties.DarkTime.Value),
SunriseOffset = new IntProperty((int)Properties.SunriseOffset.Value),
SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value),
Latitude = new StringProperty(Properties.Latitude.Value),
Longitude = new StringProperty(Properties.Longitude.Value),
},
};
}
public string GetModuleName()
{
return Name;
}
public bool UpgradeSettingsConfiguration()
{
return false;
}
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Serialization;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class SndLightSwitchSettings
{
[JsonPropertyName("LightSwitch")]
public LightSwitchSettings Settings { get; set; }
public SndLightSwitchSettings()
{
}
public SndLightSwitchSettings(LightSwitchSettings settings)
{
Settings = settings;
}
public string ToJsonString()
{
return JsonSerializer.Serialize(this);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace Microsoft.PowerToys.Settings.UI.Converters
{
public partial class EnumToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value == null || parameter == null)
{
return Visibility.Collapsed;
}
string enumString = value.ToString();
string targetString = parameter.ToString();
return enumString.Equals(targetString, StringComparison.OrdinalIgnoreCase)
? Visibility.Visible
: Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Xaml.Data;
namespace Microsoft.PowerToys.Settings.UI.Converters;
public sealed partial class TimeSpanToFriendlyTimeConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is TimeSpan time)
{
return TimeSpanHelper.Convert(time);
}
return string.Empty;
}
public object ConvertBack(object value, Type targetType, object parameter, string language) => new NotImplementedException();
}

View File

@@ -52,6 +52,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
case ModuleType.CmdPal: return generalSettingsConfig.Enabled.CmdPal;
case ModuleType.ColorPicker: return generalSettingsConfig.Enabled.ColorPicker;
case ModuleType.CropAndLock: return generalSettingsConfig.Enabled.CropAndLock;
case ModuleType.LightSwitch: return generalSettingsConfig.Enabled.LightSwitch;
case ModuleType.EnvironmentVariables: return generalSettingsConfig.Enabled.EnvironmentVariables;
case ModuleType.FancyZones: return generalSettingsConfig.Enabled.FancyZones;
case ModuleType.FileLocksmith: return generalSettingsConfig.Enabled.FileLocksmith;
@@ -88,6 +89,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
case ModuleType.CmdPal: generalSettingsConfig.Enabled.CmdPal = isEnabled; break;
case ModuleType.ColorPicker: generalSettingsConfig.Enabled.ColorPicker = isEnabled; break;
case ModuleType.CropAndLock: generalSettingsConfig.Enabled.CropAndLock = isEnabled; break;
case ModuleType.LightSwitch: generalSettingsConfig.Enabled.LightSwitch = isEnabled; break;
case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break;
case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break;
case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break;
@@ -159,6 +161,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
ModuleType.CmdPal => typeof(CmdPalPage),
ModuleType.ColorPicker => typeof(ColorPickerPage),
ModuleType.CropAndLock => typeof(CropAndLockPage),
ModuleType.LightSwitch => typeof(LightSwitchPage),
ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage),
ModuleType.FancyZones => typeof(FancyZonesPage),
ModuleType.FileLocksmith => typeof(FileLocksmithPage),

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Microsoft.PowerToys.Settings.UI.Helpers;
public static class TimeSpanHelper
{
public static string Convert(TimeSpan? time)
{
if (time is not TimeSpan ts)
{
return string.Empty;
}
// If user passed in a negative TimeSpan, normalize
if (ts < TimeSpan.Zero)
{
ts = ts.Duration();
}
// Map the TimeSpan to a DateTime on today's date
var dt = DateTime.Today.Add(ts);
// This pattern automatically respects system 12/24-hour setting
string pattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern;
return dt.ToString(pattern, CultureInfo.CurrentCulture);
}
}

View File

@@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums
FileExplorer,
ImageResizer,
KBM,
LightSwitch,
MouseUtils,
MouseWithoutBorders,
Peek,

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<None Remove="Assets\Settings\Modules\APDialog.dark.png" />
<None Remove="Assets\Settings\Modules\APDialog.light.png" />
<None Remove="Assets\Settings\Modules\LightSwitch.png" />
<None Remove="SettingsXAML\Controls\Dashboard\CheckUpdateControl.xaml" />
<None Remove="SettingsXAML\Controls\Dashboard\ShortcutConflictControl.xaml" />
<None Remove="SettingsXAML\Controls\KeyVisual\KeyCharPresenter.xaml" />
@@ -156,7 +157,9 @@
</None>
<None Update="Assets\Settings\Scripts\DisableModule.ps1">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> <Page Update="SettingsXAML\Controls\TitleBar\TitleBar.xaml">
</None>
<Page Update="SettingsXAML\Controls\TitleBar\TitleBar.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="SettingsXAML\Controls\KeyVisual\KeyCharPresenter.xaml">
<Generator>MSBuild:Compile</Generator>
@@ -170,6 +173,12 @@
<Page Update="SettingsXAML\Controls\Dashboard\CheckUpdateControl.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="SettingsXAML\Controls\Timeline\TimelineStyles.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="SettingsXAML\Controls\Timeline\Timeline.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>

View File

@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
@@ -22,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
[JsonSerializable(typeof(FileLocksmithSettings))]
[JsonSerializable(typeof(FindMyMouseSettings))]
[JsonSerializable(typeof(IList<PowerToysReleaseInfo>))]
[JsonSerializable(typeof(LightSwitchSettings))]
[JsonSerializable(typeof(MeasureToolSettings))]
[JsonSerializable(typeof(MouseHighlighterSettings))]
[JsonSerializable(typeof(MouseJumpSettings))]

View File

@@ -17,6 +17,7 @@
<ResourceDictionary Source="/SettingsXAML/Styles/InfoBadge.xaml" />
<ResourceDictionary Source="/SettingsXAML/Themes/Colors.xaml" />
<ResourceDictionary Source="/SettingsXAML/Themes/Generic.xaml" />
<ResourceDictionary Source="/SettingsXAML/Controls/Timeline/TimelineStyles.xaml" />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>

View File

@@ -417,6 +417,7 @@ namespace Microsoft.PowerToys.Settings.UI
case "Awake": return typeof(AwakePage);
case "CmdNotFound": return typeof(CmdNotFoundPage);
case "ColorPicker": return typeof(ColorPickerPage);
case "LightSwitch": return typeof(LightSwitchPage);
case "FancyZones": return typeof(FancyZonesPage);
case "FileLocksmith": return typeof(FileLocksmithPage);
case "Run": return typeof(PowerLauncherPage);

View File

@@ -28,7 +28,7 @@
Style="{StaticResource TitleTextBlockStyle}"
Text="{x:Bind ModuleTitle}" />
<ScrollViewer Grid.Row="1">
<ScrollViewer Grid.Row="1" AutomationProperties.AutomationId="PageScrollViewer">
<Grid
Padding="0,0,20,48"
ChildrenTransitions="{StaticResource SettingsCardsAnimations}"

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.PowerToys.Settings.UI.Controls.Timeline"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Canvas
x:Name="HeaderCanvas"
Grid.Row="0"
Height="24" />
<!-- Timeline (bands + ticks + labels) -->
<Border
Grid.Row="1"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{ThemeResource OverlayCornerRadius}">
<Canvas
x:Name="TimelineCanvas"
Height="36"
Loaded="TimelineCanvas_Loaded" />
</Border>
<!-- Below-chart annotations (sunrise/sunset panels + major labels) -->
<Canvas
x:Name="AnnotationCanvas"
Grid.Row="2"
Height="32" />
</Grid>
</UserControl>

View File

@@ -0,0 +1,658 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Shapes;
using Windows.Foundation;
namespace Microsoft.PowerToys.Settings.UI.Controls
{
public sealed partial class Timeline : UserControl
{
public TimeSpan StartTime
{
get => (TimeSpan)GetValue(StartTimeProperty);
set => SetValue(StartTimeProperty, value);
}
public static readonly DependencyProperty StartTimeProperty = DependencyProperty.Register(nameof(StartTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(22, 0, 0), OnTimeChanged));
public TimeSpan EndTime
{
get => (TimeSpan)GetValue(EndTimeProperty);
set => SetValue(EndTimeProperty, value);
}
public static readonly DependencyProperty EndTimeProperty = DependencyProperty.Register(nameof(EndTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(7, 0, 0), OnTimeChanged));
public TimeSpan? Sunrise
{
get => (TimeSpan?)GetValue(SunriseProperty);
set => SetValue(SunriseProperty, value);
}
public static readonly DependencyProperty SunriseProperty = DependencyProperty.Register(nameof(Sunrise), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged));
public TimeSpan? Sunset
{
get => (TimeSpan?)GetValue(SunsetProperty);
set => SetValue(SunsetProperty, value);
}
public static readonly DependencyProperty SunsetProperty = DependencyProperty.Register(nameof(Sunset), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged));
private readonly List<int> _tickHours = new();
// Locale 24h/12h
private readonly bool _is24h = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains('H');
// Visuals
private readonly List<Line> _ticks = new();
private readonly List<TextBlock> _majorTickBottomLabels = new(); // 00,06,12,18,24 (below)
private readonly List<Border> _darkRects = new(); // up to 2 (wrap)
private readonly List<Border> _lightRects = new(); // up to 2 (complement)
private TextBlock _startEdgeLabel; // top-of-chart
private TextBlock _endEdgeLabel;
private Line _sunriseTick;
private Line _sunsetTick;
// Add/replace these constants (top of your class)
private const int TickHourStep = 2; // <-- every 2 hours
private StackPanel _sunrisePanel; // icon + time (below chart)
private StackPanel _sunsetPanel;
public Timeline()
{
this.InitializeComponent();
this.Loaded += Timeline_Loaded;
this.IsEnabledChanged += Timeline_IsEnabledChanged;
}
private void Timeline_Loaded(object sender, RoutedEventArgs e)
{
CheckEnabledState();
}
private void Timeline_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
{
CheckEnabledState();
}
private void CheckEnabledState()
{
if (IsEnabled)
{
this.Opacity = 1.0;
}
else
{
this.Opacity = 0.4;
}
}
private static void OnTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((Timeline)d).Setup();
}
private void Setup()
{
EnsureBands();
EnsureTicks();
EnsureStartEndEdgeLabels();
EnsureSunriseSunsetTicks();
EnsureSunPanels();
EnsureMajorTickLabels();
UpdateAll();
}
private void TimelineCanvas_Loaded(object sender, RoutedEventArgs e)
{
// SizeChanged wiring here (as requested)
HeaderCanvas.SizeChanged += (_, __) => UpdateAll();
TimelineCanvas.SizeChanged += (_, __) => UpdateAll();
AnnotationCanvas.SizeChanged += (_, __) => UpdateAll();
Setup();
}
private void UpdateAll()
{
UpdateBandsLayout();
UpdateTicksLayout();
UpdateStartEndEdgeLabelsLayout();
UpdateSunriseSunsetTicksLayout();
UpdateSunPanelsLayout();
UpdateMajorTickLabelsLayout();
AutomationProperties.SetHelpText(
this,
$"Start={StartTime};End={EndTime};Sunrise={Sunrise};Sunset={Sunset}");
}
// ===== Ticks =====
private void EnsureTicks()
{
if (_ticks.Count > 0)
{
return;
}
_tickHours.Clear();
// Build ticks at 0,2,4,...,24 but skip the first/last MAJOR ticks (0 and 24)
for (int hour = 0; hour <= 24; hour += TickHourStep)
{
bool isMajor = hour % 6 == 0;
if (isMajor && (hour == 0 || hour == 24))
{
continue; // skip first/last major ticks
}
var line = new Line
{
Style = (Style)Application.Current.Resources[isMajor ? "MajorHourTickStyle" : "HourTickStyle"],
};
Canvas.SetZIndex(line, 0); // above bands (adjust if needed)
_ticks.Add(line);
_tickHours.Add(hour);
// If you actually want these IN the chart, use TimelineCanvas instead:
AnnotationCanvas.Children.Add(line); // or TimelineCanvas.Children.Add(line);
}
}
private void UpdateTicksLayout()
{
double w = TimelineCanvas.ActualWidth;
double h = TimelineCanvas.ActualHeight; // keeping your offset
if (w <= 0 || h <= 0)
{
return;
}
double minorLen = h * 0.1;
double majorLen = h * 0.2;
for (int i = 0; i < _ticks.Count; i++)
{
int hour = _tickHours[i];
double x = Math.Round((hour / 24.0) * w);
var line = _ticks[i];
double len = (hour % 6 == 0) ? majorLen : minorLen;
line.X1 = x;
line.Y1 = 0;
line.X2 = x;
line.Y2 = len;
}
}
// ===== Bands (Dark + Light) =====
private void EnsureBands()
{
if (_darkRects.Count == 0)
{
_darkRects.Add(MakeBandRect(isDark: false));
_darkRects.Add(MakeBandRect(isDark: false));
}
if (_lightRects.Count == 0)
{
_lightRects.Add(MakeBandRect(isDark: true));
_lightRects.Add(MakeBandRect(isDark: true));
}
}
private Border MakeBandRect(bool isDark)
{
var r = new Border();
if (isDark)
{
r.Style = (Style)Application.Current.Resources["DarkBandStyle"];
FontIcon icon = new FontIcon();
icon.Style = (Style)Application.Current.Resources["DarkBandIconStyle"];
r.Child = icon;
}
else
{
r.Style = (Style)Application.Current.Resources["LightBandStyle"];
}
Canvas.SetZIndex(r, 5); // below ticks/labels
TimelineCanvas.Children.Add(r);
return r;
}
private void UpdateBandsLayout()
{
double w = TimelineCanvas.ActualWidth;
double h = TimelineCanvas.ActualHeight;
if (w <= 0 || h <= 0)
{
return;
}
foreach (var r in _darkRects)
{
r.Height = h;
Canvas.SetTop(r, 0);
}
foreach (var r in _lightRects)
{
r.Height = h;
Canvas.SetTop(r, 0);
}
var darkRanges = ToRanges(StartTime, EndTime); // 1 or 2 segments
var lightRanges = ComplementRanges(darkRanges); // 0..2
LayoutRangeRects(_darkRects, darkRanges, w);
LayoutRangeRects(_lightRects, lightRanges, w);
}
private static void LayoutRangeRects(List<Border> rects, List<(TimeSpan Start, TimeSpan End)> ranges, double width)
{
for (int i = 0; i < rects.Count; i++)
{
if (i < ranges.Count)
{
var (start, end) = ranges[i];
double x = Math.Round((start.TotalHours / 24.0) * width);
double x2 = Math.Round((end.TotalHours / 24.0) * width);
var r = rects[i];
Canvas.SetLeft(r, x);
r.Width = Math.Max(0, x2 - x);
r.Visibility = Visibility.Visible;
}
else
{
rects[i].Visibility = Visibility.Collapsed;
}
}
}
private static List<(TimeSpan Start, TimeSpan End)> ToRanges(TimeSpan start, TimeSpan end)
{
// Full day
if (start == end)
{
return new() { (TimeSpan.Zero, TimeSpan.FromHours(24)) };
}
if (start < end)
{
return new() { (start, end) };
}
// Wraps midnight
return new()
{
(start, TimeSpan.FromHours(24)),
(TimeSpan.Zero, end),
};
}
private static List<(TimeSpan Start, TimeSpan End)> ComplementRanges(List<(TimeSpan Start, TimeSpan End)> dark)
{
var res = new List<(TimeSpan, TimeSpan)>();
// If dark covers the full day, there is no light
if (dark.Count == 1 && dark[0].Start == TimeSpan.Zero && dark[0].End == TimeSpan.FromHours(24))
{
return res;
}
if (dark.Count == 1)
{
var (ds, de) = dark[0];
if (ds > TimeSpan.Zero)
{
res.Add((TimeSpan.Zero, ds));
}
if (de < TimeSpan.FromHours(24))
{
res.Add((de, TimeSpan.FromHours(24)));
}
}
else
{
// dark[0] = [a,24), dark[1] = [0,b) => single light [b,a)
var a = dark[0].Start;
var b = dark[1].End;
res.Add((b, a));
}
return res;
}
// ===== Start & End labels (TOP of chart, ABOVE rectangles) =====
private void EnsureStartEndEdgeLabels()
{
if (_startEdgeLabel == null)
{
_startEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] };
HeaderCanvas.Children.Add(_startEdgeLabel);
Canvas.SetZIndex(_startEdgeLabel, 25);
}
if (_endEdgeLabel == null)
{
_endEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] };
HeaderCanvas.Children.Add(_endEdgeLabel);
Canvas.SetZIndex(_endEdgeLabel, 25);
}
}
private void UpdateStartEndEdgeLabelsLayout()
{
double w = TimelineCanvas.ActualWidth;
if (w <= 0)
{
return;
}
_startEdgeLabel.Text = TimeSpanHelper.Convert(StartTime);
_endEdgeLabel.Text = TimeSpanHelper.Convert(EndTime);
PlaceTopLabelAtTime(_startEdgeLabel, StartTime, w);
PlaceTopLabelAtTime(_endEdgeLabel, EndTime, w);
}
private void PlaceTopLabelAtTime(TextBlock tb, TimeSpan t, double timelineWidth)
{
double x = Math.Round((t.TotalHours / 24.0) * timelineWidth);
double textW = MeasureTextWidth(tb);
double desiredLeft = x - (textW / 2.0);
Canvas.SetLeft(tb, Clamp(desiredLeft, 0, timelineWidth - textW));
Canvas.SetTop(tb, 0);
tb.Visibility = Visibility.Visible;
}
// ===== Sunrise/Sunset ticks on chart =====
private void EnsureSunriseSunsetTicks()
{
if (_sunriseTick == null)
{
_sunriseTick = new Line { Style = (Style)Application.Current.Resources["SunRiseMarkerTickStyle"] };
TimelineCanvas.Children.Add(_sunriseTick);
Canvas.SetZIndex(_sunriseTick, 12);
}
if (_sunsetTick == null)
{
_sunsetTick = new Line { Style = (Style)Application.Current.Resources["SunSetMarkerTickStyle"] };
TimelineCanvas.Children.Add(_sunsetTick);
Canvas.SetZIndex(_sunsetTick, 12);
}
}
private void UpdateSunriseSunsetTicksLayout()
{
double w = TimelineCanvas.ActualWidth;
double h = TimelineCanvas.ActualHeight + 24;
if (w <= 0 || h <= 0)
{
return;
}
void Place(Line tick, TimeSpan t)
{
double x = Math.Round((t.TotalHours / 24.0) * w);
tick.X1 = x;
tick.X2 = x;
tick.Y1 = 0;
tick.Y2 = h;
}
if (_sunriseTick != null)
{
if (Sunrise.HasValue)
{
Place(_sunriseTick, Sunrise.Value);
_sunriseTick.Visibility = Visibility.Visible;
}
else
{
_sunriseTick.Visibility = Visibility.Collapsed;
}
}
if (_sunsetTick != null)
{
if (Sunset.HasValue)
{
Place(_sunsetTick, Sunset.Value);
_sunsetTick.Visibility = Visibility.Visible;
}
else
{
_sunsetTick.Visibility = Visibility.Collapsed;
}
}
}
// ===== Sunrise/Sunset panels (below chart) =====
private void EnsureSunPanels()
{
if (_sunrisePanel == null)
{
_sunrisePanel = MakeSunPanel("\uEC8A");
AnnotationCanvas.Children.Add(_sunrisePanel);
}
if (_sunsetPanel == null)
{
_sunsetPanel = MakeSunPanel("\uED3A");
AnnotationCanvas.Children.Add(_sunsetPanel);
}
}
private StackPanel MakeSunPanel(string iconEmoji)
{
var icon = new FontIcon { Glyph = iconEmoji, Style = (Style)Application.Current.Resources["SunIconStyle"] };
var sp = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 };
sp.Children.Add(icon);
return sp;
}
private void UpdateSunPanelsLayout()
{
double timelineW = TimelineCanvas.ActualWidth;
double annotationW = AnnotationCanvas.ActualWidth;
if (annotationW <= 0)
{
annotationW = timelineW;
}
if (timelineW <= 0 || annotationW <= 0)
{
return;
}
void Place(StackPanel sp, TimeSpan t)
{
double panelW = MeasureElementWidth(sp);
double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW);
double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW);
Canvas.SetLeft(sp, left);
Canvas.SetTop(sp, 8);
}
if (_sunrisePanel != null)
{
if (Sunrise.HasValue)
{
ToolTipService.SetToolTip(_sunrisePanel, $"Sunrise: {TimeSpanHelper.Convert(Sunrise.Value)}");
_sunrisePanel.Visibility = Visibility.Visible;
Place(_sunrisePanel, Sunrise.Value);
}
else
{
ToolTipService.SetToolTip(_sunrisePanel, null);
_sunrisePanel.Visibility = Visibility.Collapsed;
}
}
if (_sunsetPanel != null)
{
if (Sunset.HasValue)
{
ToolTipService.SetToolTip(_sunsetPanel, $"Sunset: {TimeSpanHelper.Convert(Sunset.Value)}");
_sunsetPanel.Visibility = Visibility.Visible;
Place(_sunsetPanel, Sunset.Value);
}
else
{
ToolTipService.SetToolTip(_sunsetPanel, null);
_sunsetPanel.Visibility = Visibility.Collapsed;
}
}
}
// ===== Major labels BELOW chart (00,06,12,18,24) =====
private void EnsureMajorTickLabels()
{
if (_majorTickBottomLabels.Count > 0)
{
return;
}
// Includes 24:00 at end
for (int i = 0; i < 5; i++)
{
var tb = new TextBlock { Style = (Style)Application.Current.Resources["MajorTickLabelStyle"] };
Canvas.SetZIndex(tb, 5); // on annotation canvas
_majorTickBottomLabels.Add(tb);
AnnotationCanvas.Children.Add(tb);
}
}
private void UpdateMajorTickLabelsLayout()
{
double timelineW = TimelineCanvas.ActualWidth;
double annotationW = AnnotationCanvas.ActualWidth;
if (annotationW <= 0)
{
annotationW = timelineW;
}
if (timelineW <= 0 || annotationW <= 0)
{
return;
}
int[] hours = { 0, 6, 12, 18, 24 };
// 1) Place labels first
for (int i = 0; i < hours.Length; i++)
{
var tb = _majorTickBottomLabels[i];
var t = TimeSpan.FromHours(hours[i]);
tb.Text = TimeSpanHelper.Convert(t);
double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW);
double textW = MeasureTextWidth(tb);
double left = xTimeline - (textW / 2.0);
// Middle ones (06, 12) exact center; edges clamp inside canvas
if (i == 1 || i == 2)
{
Canvas.SetLeft(tb, left);
}
else
{
Canvas.SetLeft(tb, Clamp(left, 0, annotationW - textW));
}
Canvas.SetTop(tb, 8); // your existing baseline below chart
tb.Visibility = Visibility.Visible;
}
// 2) Compute sunrise/sunset occupied horizontal ranges (if present)
(double Left, double Right)? sunriseBounds = null;
(double Left, double Right)? sunsetBounds = null;
if (Sunrise.HasValue && _sunrisePanel != null)
{
sunriseBounds = GetAnnotationBoundsForTime(Sunrise.Value, timelineW, annotationW, _sunrisePanel);
}
if (Sunset.HasValue && _sunsetPanel != null)
{
sunsetBounds = GetAnnotationBoundsForTime(Sunset.Value, timelineW, annotationW, _sunsetPanel);
}
// 3) Hide any label that intersects the sunrise/sunset panel bounds
for (int i = 0; i < hours.Length; i++)
{
var tb = _majorTickBottomLabels[i];
if (tb.Visibility != Visibility.Visible)
{
continue;
}
var lbl = GetLabelBounds(tb);
bool hide =
(sunriseBounds.HasValue && Intersects(lbl, sunriseBounds.Value)) ||
(sunsetBounds.HasValue && Intersects(lbl, sunsetBounds.Value)); // include sunset too; remove if you only want sunrise
tb.Visibility = hide ? Visibility.Collapsed : Visibility.Visible;
}
}
// ===== Utilities =====
private static double Clamp(double v, double min, double max) => Math.Max(min, Math.Min(max, v));
private static double MeasureElementWidth(FrameworkElement el)
{
el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return el.DesiredSize.Width;
}
private static double MeasureTextWidth(TextBlock tb)
{
tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return tb.DesiredSize.Width;
}
private static bool Intersects((double Left, double Right) a, (double Left, double Right) b, double pad = 4)
{
// Horizontal overlap with padding
return !(a.Right + pad <= b.Left || b.Right + pad <= a.Left);
}
private (double Left, double Right) GetAnnotationBoundsForTime(TimeSpan t, double timelineW, double annotationW, FrameworkElement element)
{
// Compute the *actual* left/right the panel will occupy in AnnotationCanvas
double panelW = MeasureElementWidth(element);
double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW);
double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW);
return (left, left + panelW);
}
private (double Left, double Right) GetLabelBounds(TextBlock tb)
{
double w = MeasureTextWidth(tb);
double left = Canvas.GetLeft(tb);
return (left, left + w);
}
}
}

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="HourTickStyle" TargetType="Line">
<Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" />
<Setter Property="StrokeThickness" Value="1" />
</Style>
<Style
x:Key="MajorHourTickStyle"
BasedOn="{StaticResource HourTickStyle}"
TargetType="Line">
<Setter Property="StrokeThickness" Value="2" />
<Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" />
</Style>
<Style x:Key="SunRiseMarkerTickStyle" TargetType="Line">
<Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeDashArray" Value="2,2" />
</Style>
<Style x:Key="SunSetMarkerTickStyle" TargetType="Line">
<Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeDashArray" Value="2,2" />
</Style>
<!-- ===== Text / Labels ===== -->
<Style x:Key="EdgeLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{ThemeResource AccentTextFillColorPrimaryBrush}" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<!-- Below-chart labels for 00/06/12/18/24 -->
<Style x:Key="MajorTickLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorTertiaryBrush}" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<!-- Sunrise/Sunset panel styles -->
<Style x:Key="SunIconStyle" TargetType="FontIcon">
<Setter Property="FontSize" Value="18" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorTertiaryBrush}" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<!-- ===== Bands ===== -->
<Style x:Key="DarkBandStyle" TargetType="Border">
<!--<Setter Property="Background">
<Setter.Value>
<SolidColorBrush Opacity="0.6" Color="{ThemeResource SystemAccentColorDark1}"/>
</Setter.Value>
</Setter>-->
<Setter Property="Background" Value="{ThemeResource AccentFillColorTertiaryBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="4" />
<Setter Property="ToolTipService.ToolTip" Value="Dark mode" />
</Style>
<Style x:Key="LightBandStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Property="ToolTipService.ToolTip" Value="Light mode" />
</Style>
<Style x:Key="DarkBandIconStyle" TargetType="FontIcon">
<Setter Property="FontSize" Value="14" />
<Setter Property="Glyph" Value="&#xE793;" />
<Setter Property="Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style
x:Key="LightBandIconStyle"
BasedOn="{StaticResource DarkBandIconStyle}"
TargetType="FontIcon">
<Setter Property="Glyph" Value="&#xE706;" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeLightSwitch"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
mc:Ignorable="d">
<controls:OOBEPageControl x:Uid="Oobe_LightSwitch" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/LightSwitch.png">
<controls:OOBEPageControl.PageContent>
<StackPanel Orientation="Vertical" Spacing="12">
<TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" />
<tkcontrols:MarkdownTextBlock x:Uid="Oobe_LightSwitch_HowToUse" />
<TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" />
<tkcontrols:MarkdownTextBlock x:Uid="Oobe_LightSwitch_TipsAndTricks" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" />
<HyperlinkButton NavigateUri="https://aka.ms/PowerToysOverview_LightSwitch" Style="{StaticResource TextButtonStyle}">
<TextBlock x:Uid="LearnMore_LightSwitch" TextWrapping="Wrap" />
</HyperlinkButton>
</StackPanel>
</StackPanel>
</controls:OOBEPageControl.PageContent>
</controls:OOBEPageControl>
</Page>

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
{
public sealed partial class OobeLightSwitch : Page
{
public OobePowerToysModule ViewModel { get; set; }
public OobeLightSwitch()
{
this.InitializeComponent();
ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.LightSwitch]);
}
private void SettingsLaunchButton_Click(object sender, RoutedEventArgs e)
{
if (OobeShellPage.OpenMainWindowCallback != null)
{
OobeShellPage.OpenMainWindowCallback(typeof(LightSwitchPage));
}
ViewModel.LogOpeningSettingsEvent();
}
}
}

View File

@@ -117,6 +117,10 @@
x:Uid="Shell_KeyboardManager"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}"
Tag="KBM" />
<NavigationViewItem
x:Uid="Shell_LightSwitch"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}"
Tag="LightSwitch" />
<NavigationViewItem
x:Uid="Shell_MouseUtilities"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}"

View File

@@ -138,6 +138,11 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
ModuleName = "KBM",
IsNew = false,
});
Modules.Insert((int)PowerToysModules.LightSwitch, new OobePowerToysModule()
{
ModuleName = "LightSwitch",
IsNew = true,
});
Modules.Insert((int)PowerToysModules.MouseUtils, new OobePowerToysModule()
{
ModuleName = "MouseUtils",
@@ -287,6 +292,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
case "Run": NavigationFrame.Navigate(typeof(OobeRun)); break;
case "ImageResizer": NavigationFrame.Navigate(typeof(OobeImageResizer)); break;
case "KBM": NavigationFrame.Navigate(typeof(OobeKBM)); break;
case "LightSwitch": NavigationFrame.Navigate(typeof(OobeLightSwitch)); break;
case "PowerRename": NavigationFrame.Navigate(typeof(OobePowerRename)); break;
case "QuickAccent": NavigationFrame.Navigate(typeof(OobePowerAccent)); break;
case "FileExplorer": NavigationFrame.Navigate(typeof(OobeFileExplorer)); break;

View File

@@ -0,0 +1,320 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.PowerToys.Settings.UI.Views.LightSwitchPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ViewModel="using:Microsoft.PowerToys.Settings.UI.ViewModels"
xmlns:animations="using:CommunityToolkit.WinUI.Animations"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Settings.UI.Library.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
xmlns:ui="using:CommunityToolkit.WinUI"
d:DataContext="{d:DesignInstance Type=ViewModel:LightSwitchViewModel}"
AutomationProperties.LandmarkType="Main"
mc:Ignorable="d">
<Page.Resources>
<converters:EnumToVisibilityConverter x:Key="EnumToVisibilityConverter" />
<converters:TimeSpanToFriendlyTimeConverter x:Key="TimeSpanToFriendlyTimeConverter" />
</Page.Resources>
<controls:SettingsPageControl
x:Uid="LightSwitch"
IsTabStop="False"
ModuleImageSource="ms-appx:///Assets/Settings/Modules/LightSwitch.png">
<controls:SettingsPageControl.ModuleContent>
<StackPanel Orientation="Vertical">
<controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}">
<tkcontrols:SettingsCard
x:Uid="LightSwitch_EnableSettingsCard"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}"
IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<ToggleSwitch AutomationProperties.AutomationId="Toggle_LightSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:GPOInfoControl>
<controls:SettingsGroup x:Uid="LightSwitch_ShortcutsSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="LightSwitch_ThemeToggle_Shortcut" HeaderIcon="{ui:FontIcon Glyph=&#xE708;}">
<controls:ShortcutControl
MinWidth="{StaticResource SettingActionControlMinWidth}"
AllowDisable="True"
AutomationProperties.AutomationId="Shortcut_LightSwitch"
HotkeySettings="{x:Bind Path=ViewModel.ToggleThemeActivationShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="LightSwitch_ScheduleSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsExpander
x:Uid="LightSwitch_ModeSettingsExpander"
HeaderIcon="{ui:FontIcon Glyph=&#xE823;}"
IsExpanded="True">
<ComboBox
x:Name="ModeSelector"
AutomationProperties.AutomationId="ModeSelection_LightSwitch"
SelectedValue="{x:Bind ViewModel.ScheduleMode, Mode=TwoWay}"
SelectedValuePath="Tag"
SelectionChanged="ModeSelector_SelectionChanged">
<ComboBoxItem
x:Uid="LightSwitch_ModeManual"
AutomationProperties.AutomationId="ManualCBItem_LightSwitch"
Tag="FixedHours" />
<ComboBoxItem
x:Uid="LightSwitch_ModeSunsetToSunrise"
AutomationProperties.AutomationId="SunCBItem_LightSwitch"
Tag="SunsetToSunrise" />
</ComboBox>
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard x:Uid="LightSwitch_TurnOnDarkMode" Visibility="{x:Bind ViewModel.ScheduleMode, Mode=OneWay, Converter={StaticResource EnumToVisibilityConverter}, ConverterParameter=FixedHours}">
<TimePicker AutomationProperties.AutomationId="DarkTimePicker" Time="{x:Bind ViewModel.DarkTimePickerValue, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="LightSwitch_TurnOffDarkMode" Visibility="{x:Bind ViewModel.ScheduleMode, Mode=OneWay, Converter={StaticResource EnumToVisibilityConverter}, ConverterParameter=FixedHours}">
<TimePicker AutomationProperties.AutomationId="LightTimePicker" Time="{x:Bind ViewModel.LightTimePickerValue, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="LightSwitch_LocationSettingsCard" Visibility="{x:Bind ViewModel.ScheduleMode, Mode=OneWay, Converter={StaticResource EnumToVisibilityConverter}, ConverterParameter=SunsetToSunrise}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.SyncButtonInformation, Mode=OneWay}" />
<Button
Padding="8"
AutomationProperties.AutomationId="SetLocationButton_LightSwitch"
Click="SyncLocationButton_Click"
Content="{ui:FontIcon Glyph=&#xECAF;,
FontSize=16}" />
</StackPanel>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="LightSwitch_OffsetSettingsCard" Visibility="{x:Bind ViewModel.ScheduleMode, Mode=OneWay, Converter={StaticResource EnumToVisibilityConverter}, ConverterParameter=SunsetToSunrise}">
<StackPanel Orientation="Horizontal" Spacing="20">
<StackPanel Orientation="Horizontal" Spacing="8">
<!--<FontIcon Glyph="&#xED39;" FontSize="16" />-->
<controls:IsEnabledTextBlock x:Uid="LightSwitch_SunriseText" VerticalAlignment="Center" />
<NumberBox
AutomationProperties.AutomationId="SunriseOffset_LightSwitch"
Maximum="60"
Minimum="-60"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.SunriseOffset, Mode=TwoWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:IsEnabledTextBlock x:Uid="LightSwitch_SunsetText" VerticalAlignment="Center" />
<NumberBox
AutomationProperties.AutomationId="SunsetOffset_LightSwitch"
Maximum="60"
Minimum="-60"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.SunsetOffset, Mode=TwoWay}" />
</StackPanel>
</StackPanel>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
x:Name="TimelineCard"
HorizontalContentAlignment="Stretch"
ContentAlignment="Vertical">
<controls:Timeline
Margin="0,24,0,24"
AutomationProperties.AutomationId="Timeline_LightSwitch"
EndTime="{x:Bind ViewModel.DarkTimeTimeSpan, Mode=OneWay}"
StartTime="{x:Bind ViewModel.LightTimeTimeSpan, Mode=OneWay}"
Sunrise="{x:Bind ViewModel.SunriseTimeSpan, Mode=OneWay}"
Sunset="{x:Bind ViewModel.SunsetTimeSpan, Mode=OneWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
<InfoBar
x:Name="LocationWarningBar"
x:Uid="LightSwitch_LocationWarningBar"
IsOpen="True"
Severity="Informational"
Visibility="Collapsed" />
<controls:SettingsGroup x:Uid="LightSwitch_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsExpander
x:Uid="LightSwitch_ApplyDarkModeExpander"
HeaderIcon="{ui:FontIcon Glyph=&#xE790;}"
IsExpanded="True">
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Left">
<controls:CheckBoxWithDescriptionControl
x:Uid="LightSwitch_SystemCheckbox"
AutomationProperties.AutomationId="ChangeSystemCheckbox_LightSwitch"
IsChecked="{x:Bind ViewModel.ChangeSystem, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Left">
<controls:CheckBoxWithDescriptionControl
x:Uid="LightSwitch_AppsCheckbox"
AutomationProperties.AutomationId="ChangeAppsCheckbox_LightSwitch"
IsChecked="{x:Bind ViewModel.ChangeApps, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
<!-- Force mode buttons -->
<!--<tkcontrols:SettingsCard
Header="Force mode now"
HeaderIcon="{ui:FontIcon Glyph=&#xE706;}"
Description="Apply light or dark mode immediately">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
Content="Force Light"
Command="{x:Bind ViewModel.ForceLightCommand}" />
<Button
Content="Force Dark"
Command="{x:Bind ViewModel.ForceDarkCommand}" />
</StackPanel>
</tkcontrols:SettingsCard>-->
<ContentDialog
x:Name="LocationDialog"
x:Uid="LightSwitch_LocationDialog"
IsPrimaryButtonEnabled="True"
IsSecondaryButtonEnabled="True"
Opened="LocationDialog_Opened"
PrimaryButtonClick="LocationDialog_PrimaryButtonClick"
PrimaryButtonStyle="{StaticResource AccentButtonStyle}">
<Grid RowSpacing="48">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock x:Uid="LightSwitch_LocationDialog_Description" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<!--<AutoSuggestBox
x:Name="CityAutoSuggestBox"
Grid.Row="1"
Margin="0,16,0,8"
AutomationProperties.AutomationId="CitySearchBox_LightSwitch"
ItemsSource="{x:Bind ViewModel.SearchLocations, Mode=OneWay}"
PlaceholderText="Search for a city near you.."
QueryIcon="Find"
SuggestionChosen="CityAutoSuggestBox_SuggestionChosen"
TextChanged="CityAutoSuggestBox_TextChanged">
<AutoSuggestBox.ItemTemplate>
<DataTemplate x:DataType="helpers:SearchLocation">
<Grid Padding="12,8,0,8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="{x:Bind City}" />
<TextBlock
Grid.Row="1"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Country}" />
</Grid>
</DataTemplate>
</AutoSuggestBox.ItemTemplate>
</AutoSuggestBox>-->
<StackPanel
Grid.Row="2"
Margin="0,24,0,0"
HorizontalAlignment="Center"
Orientation="Vertical"
Spacing="32">
<Button
x:Name="SyncButton"
HorizontalAlignment="Stretch"
AutomationProperties.AutomationId="SyncLocationButton_LightSwitch"
Style="{StaticResource AccentButtonStyle}"
Visibility="Collapsed">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xECAF;" />
<TextBlock x:Uid="LightSwitch_GetCurrentLocation" />
</StackPanel>
</Button>
</StackPanel>
<ProgressRing
x:Name="SyncLoader"
Grid.Row="1"
Width="40"
Height="40"
VerticalAlignment="Center"
IsActive="False"
Visibility="Collapsed" />
<Grid
x:Name="LocationResultPanel"
Grid.Row="1"
VerticalAlignment="Bottom"
ColumnSpacing="16"
RowSpacing="12"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<FontIcon FontSize="16" Glyph="&#xECAF;">
<ToolTipService.ToolTip>
<TextBlock x:Uid="LightSwitch_LocationTooltip" />
</ToolTipService.ToolTip>
</FontIcon>
<TextBlock
Grid.Row="1"
AutomationProperties.AutomationId="LocationResultText_LightSwitch"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextAlignment="Center">
<Run Text="{x:Bind ViewModel.Latitude, Mode=OneWay}" /><Run Text="°, " />
<Run Text="{x:Bind ViewModel.Longitude, Mode=OneWay}" /><Run Text="°" />
</TextBlock>
<FontIcon
Grid.Column="1"
FontSize="20"
Glyph="&#xED39;">
<ToolTipService.ToolTip>
<TextBlock x:Uid="LightSwitch_SunriseTooltip" />
</ToolTipService.ToolTip>
</FontIcon>
<TextBlock
Grid.Row="1"
Grid.Column="1"
AutomationProperties.AutomationId="SunriseText_LightSwitch"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.LightTimeTimeSpan, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}"
TextAlignment="Center" />
<FontIcon
Grid.Column="2"
FontSize="20"
Glyph="&#xED3A;">
<ToolTipService.ToolTip>
<TextBlock x:Uid="LightSwitch_SunsetTooltip" />
</ToolTipService.ToolTip>
</FontIcon>
<TextBlock
Grid.Row="2"
Grid.Column="2"
AutomationProperties.AutomationId="SunsetText_LightSwitch"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.DarkTimeTimeSpan, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}"
TextAlignment="Center" />
</Grid>
</Grid>
</ContentDialog>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>
<controls:PageLink x:Uid="LearnMore_LightSwitch" Link="https://aka.ms/PowerToysOverview_LightSwitch" />
</controls:SettingsPageControl.PrimaryLinks>
</controls:SettingsPageControl>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="LocationEnabledStates">
<VisualState x:Name="LocationSet" />
<VisualState x:Name="LocationNotSet">
<VisualState.Setters>
<Setter Target="TimelineCard.Visibility" Value="Collapsed" />
<Setter Target="LocationWarningBar.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Page>

View File

@@ -0,0 +1,324 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using PowerToys.GPOWrapper;
using Settings.UI.Library;
using Settings.UI.Library.Helpers;
using Windows.Devices.Geolocation;
using Windows.Services.Maps;
namespace Microsoft.PowerToys.Settings.UI.Views
{
public sealed partial class LightSwitchPage : Page
{
private readonly string _appName = "LightSwitch";
private readonly SettingsUtils _settingsUtils;
private readonly Func<string, int> _sendConfigMsg = ShellPage.SendDefaultIPCMessage;
private readonly ISettingsRepository<GeneralSettings> _generalSettingsRepository;
private readonly ISettingsRepository<LightSwitchSettings> _moduleSettingsRepository;
private readonly IFileSystem _fileSystem;
private readonly IFileSystemWatcher _fileSystemWatcher;
private readonly DispatcherQueue _dispatcherQueue;
private LightSwitchViewModel ViewModel { get; set; }
public LightSwitchPage()
{
_settingsUtils = new SettingsUtils();
_sendConfigMsg = ShellPage.SendDefaultIPCMessage;
_generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils);
_moduleSettingsRepository = SettingsRepository<LightSwitchSettings>.GetInstance(_settingsUtils);
// Get settings from JSON (or defaults if JSON missing)
var darkSettings = _moduleSettingsRepository.SettingsConfig;
// Pass them into the ViewModel
ViewModel = new LightSwitchViewModel(darkSettings, ShellPage.SendDefaultIPCMessage);
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
LoadSettings(_generalSettingsRepository, _moduleSettingsRepository);
DataContext = ViewModel;
var settingsPath = _settingsUtils.GetSettingsFilePath(_appName);
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_fileSystem = new FileSystem();
_fileSystemWatcher = _fileSystem.FileSystemWatcher.New();
_fileSystemWatcher.Path = _fileSystem.Path.GetDirectoryName(settingsPath);
_fileSystemWatcher.Filter = _fileSystem.Path.GetFileName(settingsPath);
_fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime;
_fileSystemWatcher.Changed += Settings_Changed;
_fileSystemWatcher.EnableRaisingEvents = true;
this.InitializeComponent();
this.Loaded += LightSwitchPage_Loaded;
}
private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e)
{
if (ViewModel.SearchLocations.Count == 0)
{
foreach (var city in SearchLocationLoader.GetAll())
{
ViewModel.SearchLocations.Add(city);
}
}
ViewModel.InitializeScheduleMode();
}
private async Task GetGeoLocation()
{
SyncButton.IsEnabled = false;
SyncLoader.IsActive = true;
SyncLoader.Visibility = Visibility.Visible;
try
{
// Request access
var accessStatus = await Geolocator.RequestAccessAsync();
if (accessStatus != GeolocationAccessStatus.Allowed)
{
// User denied location or it's not available
return;
}
var geolocator = new Geolocator { DesiredAccuracy = PositionAccuracy.Default };
Geoposition pos = await geolocator.GetGeopositionAsync();
double latitude = Math.Round(pos.Coordinate.Point.Position.Latitude);
double longitude = Math.Round(pos.Coordinate.Point.Position.Longitude);
SunTimes result = SunCalc.CalculateSunriseSunset(
latitude,
longitude,
DateTime.Now.Year,
DateTime.Now.Month,
DateTime.Now.Day);
ViewModel.LightTime = (result.SunriseHour * 60) + result.SunriseMinute;
ViewModel.DarkTime = (result.SunsetHour * 60) + result.SunsetMinute;
ViewModel.Latitude = latitude.ToString(CultureInfo.InvariantCulture);
ViewModel.Longitude = longitude.ToString(CultureInfo.InvariantCulture);
// Since we use this mode, we can remove the selected city data.
ViewModel.SelectedCity = null;
// CityAutoSuggestBox.Text = string.Empty;
ViewModel.SyncButtonInformation = $"{ViewModel.Latitude}<7D>, {ViewModel.Longitude}<7D>";
// ViewModel.CityTimesText = $"Sunrise: {result.SunriseHour}:{result.SunriseMinute:D2}\n" + $"Sunset: {result.SunsetHour}:{result.SunsetMinute:D2}";
SyncButton.IsEnabled = true;
SyncLoader.IsActive = false;
SyncLoader.Visibility = Visibility.Collapsed;
LocationDialog.IsPrimaryButtonEnabled = true;
LocationResultPanel.Visibility = Visibility.Visible;
}
catch (Exception ex)
{
SyncButton.IsEnabled = true;
SyncLoader.IsActive = false;
System.Diagnostics.Debug.WriteLine("Location error: " + ex.Message);
}
}
private void LocationDialog_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs args)
{
if (ViewModel.ScheduleMode == "SunriseToSunsetUser")
{
ViewModel.SyncButtonInformation = ViewModel.SelectedCity.City;
}
else if (ViewModel.ScheduleMode == "SunriseToSunsetGeo")
{
ViewModel.SyncButtonInformation = $"{ViewModel.Latitude}<7D>, {ViewModel.Longitude}<7D>";
}
SunriseModeChartState();
}
private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == "IsEnabled")
{
if (ViewModel.IsEnabled != _generalSettingsRepository.SettingsConfig.Enabled.LightSwitch)
{
_generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = ViewModel.IsEnabled;
var generalSettingsMessage = new OutGoingGeneralSettings(_generalSettingsRepository.SettingsConfig).ToString();
Logger.LogInfo($"Saved general settings from Light Switch page.");
_sendConfigMsg?.Invoke(generalSettingsMessage);
}
}
else
{
if (ViewModel.ModuleSettings != null)
{
SndLightSwitchSettings currentSettings = new(_moduleSettingsRepository.SettingsConfig);
SndModuleSettings<SndLightSwitchSettings> csIpcMessage = new(currentSettings);
SndLightSwitchSettings outSettings = new(ViewModel.ModuleSettings);
SndModuleSettings<SndLightSwitchSettings> outIpcMessage = new(outSettings);
string csMessage = csIpcMessage.ToJsonString();
string outMessage = outIpcMessage.ToJsonString();
if (!csMessage.Equals(outMessage, StringComparison.Ordinal))
{
Logger.LogInfo($"Saved Light Switch settings from Light Switch page.");
_sendConfigMsg?.Invoke(outMessage);
}
}
}
}
private void LoadSettings(ISettingsRepository<GeneralSettings> generalSettingsRepository, ISettingsRepository<LightSwitchSettings> moduleSettingsRepository)
{
if (generalSettingsRepository != null)
{
if (moduleSettingsRepository != null)
{
UpdateViewModelSettings(moduleSettingsRepository.SettingsConfig, generalSettingsRepository.SettingsConfig);
}
else
{
throw new ArgumentNullException(nameof(moduleSettingsRepository));
}
}
else
{
throw new ArgumentNullException(nameof(generalSettingsRepository));
}
}
private void UpdateViewModelSettings(LightSwitchSettings lightSwitchSettings, GeneralSettings generalSettings)
{
if (lightSwitchSettings != null)
{
if (generalSettings != null)
{
ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch;
ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone();
UpdateEnabledState(generalSettings.Enabled.LightSwitch);
}
else
{
throw new ArgumentNullException(nameof(generalSettings));
}
}
else
{
throw new ArgumentNullException(nameof(lightSwitchSettings));
}
}
private void Settings_Changed(object sender, FileSystemEventArgs e)
{
_dispatcherQueue.TryEnqueue(() =>
{
_moduleSettingsRepository.ReloadSettings();
LoadSettings(_generalSettingsRepository, _moduleSettingsRepository);
});
}
private void UpdateEnabledState(bool recommendedState)
{
var enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredLightSwitchEnabledValue();
if (enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
{
// Get the enabled state from GPO.
ViewModel.IsEnabledGpoConfigured = true;
ViewModel.EnabledGPOConfiguration = enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
}
else
{
ViewModel.IsEnabled = recommendedState;
}
}
private async void SyncLocationButton_Click(object sender, RoutedEventArgs e)
{
LocationDialog.IsPrimaryButtonEnabled = false;
LocationResultPanel.Visibility = Visibility.Collapsed;
await LocationDialog.ShowAsync();
}
private void CityAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput && !string.IsNullOrWhiteSpace(sender.Text))
{
string query = sender.Text.ToLower(CultureInfo.CurrentCulture);
// Filter your cities (assuming ViewModel.Cities is a List<City>)
var filtered = ViewModel.SearchLocations
.Where(c =>
(c.City?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false) ||
(c.Country?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false))
.ToList();
sender.ItemsSource = filtered;
}
}
private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
{
if (args.SelectedItem is SearchLocation location)
{
ViewModel.SelectedCity = location;
// CityAutoSuggestBox.Text = $"{location.City}, {location.Country}";
LocationDialog.IsPrimaryButtonEnabled = true;
LocationResultPanel.Visibility = Visibility.Visible;
}
}
private void ModeSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
SunriseModeChartState();
}
private void SunriseModeChartState()
{
if (ViewModel.Latitude == "0.0" && ViewModel.Longitude == "0.0" && ViewModel.ScheduleMode == "SunsetToSunrise")
{
TimelineCard.Visibility = Visibility.Collapsed;
LocationWarningBar.Visibility = Visibility.Visible;
}
else
{
TimelineCard.Visibility = Visibility.Visible;
LocationWarningBar.Visibility = Visibility.Collapsed;
}
}
private async void LocationDialog_Opened(ContentDialog sender, ContentDialogOpenedEventArgs args)
{
await GetGeoLocation();
}
}
}

View File

@@ -30,7 +30,6 @@
Command="{x:Bind ViewModel.LaunchEventHandler}"
HeaderIcon="{ui:FontIcon Glyph=&#xEA37;}"
IsClickEnabled="True" />
<tkcontrols:SettingsCard
Name="RegistryPreviewDefaultRegApp"
x:Uid="RegistryPreview_DefaultRegApp"

View File

@@ -202,6 +202,12 @@
helpers:NavHelper.NavigateTo="views:ColorPickerPage"
AutomationProperties.AutomationId="ColorPickerNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" />
<NavigationViewItem
x:Name="LightSwitchNavigationItem"
x:Uid="Shell_LightSwitch"
helpers:NavHelper.NavigateTo="views:LightSwitchPage"
AutomationProperties.AutomationId="LightSwitchNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}" />
<NavigationViewItem
x:Name="PowerLauncherNavigationItem"
x:Uid="Shell_PowerLauncher"

View File

@@ -3161,19 +3161,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Peek_ConfirmFileDelete.Description" xml:space="preserve">
<value>You'll be asked to confirm before files are moved to the Recycle Bin</value>
</data>
<data name="Peek_ActivationMethod.Header" xml:space="preserve">
<data name="Peek_ActivationMethod.Header" xml:space="preserve">
<value>Activation method</value>
</data>
<data name="Peek_ActivationMethod.Description" xml:space="preserve">
<data name="Peek_ActivationMethod.Description" xml:space="preserve">
<value>Use a shortcut or press the Spacebar when a file is selected</value>
<comment>Spacebar is a physical keyboard key</comment>
<comment>Spacebar is a physical keyboard key</comment>
</data>
<data name="Peek_ActivationMethod_CustomizedShortcut.Content" xml:space="preserve">
<data name="Peek_ActivationMethod_CustomizedShortcut.Content" xml:space="preserve">
<value>Custom shortcut</value>
</data>
<data name="Peek_ActivationMethod_SpaceBar.Content" xml:space="preserve">
<data name="Peek_ActivationMethod_SpaceBar.Content" xml:space="preserve">
<value>Spacebar</value>
</data>
</data>
<data name="FancyZones_DisableRoundCornersOnWindowSnap.Content" xml:space="preserve">
<value>Disable rounded corners when a window is snapped</value>
</data>
@@ -3261,6 +3261,15 @@ Activate by holding the key for the character you want to add an accent to, then
<data name="AlwaysOnTop_ShortDescription" xml:space="preserve">
<value>Pin a window</value>
</data>
<data name="LightSwitch_ThemeToggle_Shortcut.Header" xml:space="preserve">
<value>Theme toggle shortcut</value>
</data>
<data name="LightSwitch_ThemeToggle_Shortcut.Description" xml:space="preserve">
<value>Switch between light and dark mode</value>
</data>
<data name="LightSwitch_ForceDarkMode" xml:space="preserve">
<value>Toggle theme</value>
</data>
<data name="ColorPicker_ShortDescription" xml:space="preserve">
<value>Pick a color</value>
</data>
@@ -5224,6 +5233,117 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="GeneralPage_EnableViewDiagnosticDataText.Text" xml:space="preserve">
<value>Stores diagnostic data locally in .xml format; folder may include .etl files as well. May use up 1GB or more of disk space.</value>
</data>
<data name="Shell_LightSwitch.Content" xml:space="preserve">
<value>Light Switch</value>
</data>
<data name="LightSwitch_EnableToggleControl_HeaderText.Header" xml:space="preserve">
<value>Enable Light Switch</value>
</data>
<data name="LightSwitch.ModuleDescription" xml:space="preserve">
<value>Easily switch between light and dark mode - on a schedule, automatically, or with a shortcut.</value>
</data>
<data name="LightSwitch.ModuleTitle" xml:space="preserve">
<value>Light Switch</value>
</data>
<data name="LearnMore_LightSwitch.Text" xml:space="preserve">
<value>Learn more about Light Switch</value>
</data>
<data name="LightSwitch_BehaviorSettingsGroup.Header" xml:space="preserve">
<value>Behavior</value>
</data>
<data name="LightSwitch_EnableSettingsCard.Header" xml:space="preserve">
<value>Enable Light Switch</value>
</data>
<data name="LightSwitch_ShortcutsSettingsGroup.Header" xml:space="preserve">
<value>Shortcuts</value>
</data>
<data name="LightSwitch_ScheduleSettingsGroup.Header" xml:space="preserve">
<value>Schedule</value>
</data>
<data name="LightSwitch_ModeSettingsExpander.Header" xml:space="preserve">
<value>Mode</value>
</data>
<data name="LightSwitch_ModeSettingsExpander.Description" xml:space="preserve">
<value>Determine when dark mode should be turned on</value>
</data>
<data name="LightSwitch_ModeManual.Content" xml:space="preserve">
<value>Manual</value>
</data>
<data name="LightSwitch_ModeSunsetToSunrise.Content" xml:space="preserve">
<value>Sunset to sunrise</value>
</data>
<data name="LightSwitch_TurnOnDarkMode.Header" xml:space="preserve">
<value>Turn on dark mode</value>
</data>
<data name="LightSwitch_TurnOffDarkMode.Header" xml:space="preserve">
<value>Turn off dark mode</value>
</data>
<data name="LightSwitch_LocationSettingsCard.Header" xml:space="preserve">
<value>Location</value>
</data>
<data name="LightSwitch_LocationSettingsCard.Description" xml:space="preserve">
<value>Used to automatically calculate accurate sunrise and sunset times</value>
</data>
<data name="LightSwitch_OffsetSettingsCard.Header" xml:space="preserve">
<value>Offset (in minutes)</value>
</data>
<data name="LightSwitch_OffsetSettingsCard.Description" xml:space="preserve">
<value>Adjust the trigger time by starting earlier or later</value>
</data>
<data name="LightSwitch_LocationWarningBar.Title" xml:space="preserve">
<value>Location required</value>
</data>
<data name="LightSwitch_LocationWarningBar.Message" xml:space="preserve">
<value>Sync your location so Light Switch can calculate the correct sunrise- and sunset times</value>
</data>
<data name="LightSwitch_ApplyDarkModeExpander.Header" xml:space="preserve">
<value>Apply dark mode to</value>
</data>
<data name="LightSwitch_ApplyDarkModeExpander.Description" xml:space="preserve">
<value>Pick which parts of your PC should follow Light Switch</value>
</data>
<data name="LightSwitch_SystemCheckbox.Header" xml:space="preserve">
<value>System</value>
</data>
<data name="LightSwitch_SystemCheckbox.Description" xml:space="preserve">
<value>Taskbar, Start, and other system UI</value>
</data>
<data name="LightSwitch_AppsCheckbox.Header" xml:space="preserve">
<value>Apps</value>
</data>
<data name="LightSwitch_AppsCheckbox.Description" xml:space="preserve">
<value>Supported applications</value>
</data>
<data name="LightSwitch_LocationDialog.Title" xml:space="preserve">
<value>Select a location</value>
</data>
<data name="LightSwitch_LocationDialog.PrimaryButtonText" xml:space="preserve">
<value>Select</value>
</data>
<data name="LightSwitch_LocationDialog.SecondaryButtonText" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="LightSwitch_GetCurrentLocation.Text" xml:space="preserve">
<value>Get current location</value>
</data>
<data name="LightSwitch_LocationDialog_Description.Text" xml:space="preserve">
<value>To calculate the sunrise and sunset, Light Switch needs a location.</value>
</data>
<data name="LightSwitch_SunriseText.Text" xml:space="preserve">
<value>Sunrise</value>
</data>
<data name="LightSwitch_SunsetText.Text" xml:space="preserve">
<value>Sunset</value>
</data>
<data name="LightSwitch_LocationTooltip.Text" xml:space="preserve">
<value>Location</value>
</data>
<data name="LightSwitch_SunriseTooltip.Text" xml:space="preserve">
<value>Sunrise</value>
</data>
<data name="LightSwitch_SunsetTooltip.Text" xml:space="preserve">
<value>Sunset</value>
</data>
<data name="Close_NavViewItem.Content" xml:space="preserve">
<value>Close PowerToys</value>
<comment>Don't loc "PowerToys"</comment>
@@ -5321,6 +5441,22 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="UtilitiesHeader.Title" xml:space="preserve">
<value>Utilities</value>
</data>
<data name="Oobe_LightSwitch.Title" xml:space="preserve">
<value>Light Switch</value>
<comment>Product name. Do not localize this string</comment>
</data>
<data name="Oobe_LightSwitch.Description" xml:space="preserve">
<value>Light Switch automatically manages your Windows light and dark mode based on schedules, sunrise/sunset times, or manual control. Keep your system theme synchronized with your preferences and daily rhythm.</value>
<comment>Light Switch is a product name, do not localize</comment>
</data>
<data name="Oobe_LightSwitch_HowToUse.Text" xml:space="preserve">
<value>Open **PowerToys Settings** and enable Light Switch to set up automatic theme switching</value>
<comment>Light Switch is a product name, do not localize</comment>
</data>
<data name="Oobe_LightSwitch_TipsAndTricks.Text" xml:space="preserve">
<value>Use the **keyboard shortcut** to instantly toggle between light and dark modes, or set up **sunrise/sunset automation** for natural theme transitions.</value>
<comment>Light Switch is a product name, do not localize</comment>
</data>
<data name="DismissConflictBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Dismiss</value>
</data>

View File

@@ -22,6 +22,7 @@ using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
@@ -226,6 +227,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleType.FancyZones => GetModuleItemsFancyZones(),
ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(),
ModuleType.Hosts => GetModuleItemsHosts(),
ModuleType.LightSwitch => GetModuleItemsLightSwitch(),
ModuleType.MouseHighlighter => GetModuleItemsMouseHighlighter(),
ModuleType.MouseJump => GetModuleItemsMouseJump(),
ModuleType.MousePointerCrosshairs => GetModuleItemsMousePointerCrosshairs(),
@@ -274,6 +276,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return new ObservableCollection<DashboardModuleItem>(list);
}
private ObservableCollection<DashboardModuleItem> GetModuleItemsLightSwitch()
{
ISettingsRepository<LightSwitchSettings> moduleSettingsRepository = SettingsRepository<LightSwitchSettings>.GetInstance(new SettingsUtils());
var settings = moduleSettingsRepository.SettingsConfig;
var list = new List<DashboardModuleItem>
{
new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("LightSwitch_ForceDarkMode"), Shortcut = settings.Properties.ToggleThemeHotkey.Value.GetKeysList() },
};
return new ObservableCollection<DashboardModuleItem>(list);
}
private ObservableCollection<DashboardModuleItem> GetModuleItemsCropAndLock()
{
ISettingsRepository<CropAndLockSettings> moduleSettingsRepository = SettingsRepository<CropAndLockSettings>.GetInstance(new SettingsUtils());

View File

@@ -0,0 +1,510 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Windows.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
using Newtonsoft.Json.Linq;
using Settings.UI.Library;
using Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public partial class LightSwitchViewModel : Observable
{
private Func<string, int> SendConfigMSG { get; }
public ObservableCollection<SearchLocation> SearchLocations { get; } = new();
public LightSwitchViewModel(LightSwitchSettings initialSettings = null, Func<string, int> ipcMSGCallBackFunc = null)
{
_moduleSettings = initialSettings ?? new LightSwitchSettings();
SendConfigMSG = ipcMSGCallBackFunc;
ForceLightCommand = new RelayCommand(ForceLightNow);
ForceDarkCommand = new RelayCommand(ForceDarkNow);
AvailableScheduleModes = new ObservableCollection<string>
{
"FixedHours",
"SunsetToSunrise",
};
_toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value;
}
private void ForceLightNow()
{
Logger.LogInfo("Sending custom action: forceLight");
SendCustomAction("forceLight");
}
private void ForceDarkNow()
{
Logger.LogInfo("Sending custom action: forceDark");
SendCustomAction("forceDark");
}
private void SendCustomAction(string actionName)
{
SendConfigMSG("{\"action\":{\"LightSwitch\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}");
}
public LightSwitchSettings ModuleSettings
{
get => _moduleSettings;
set
{
if (_moduleSettings != value)
{
_moduleSettings = value;
OnPropertyChanged(nameof(ModuleSettings));
RefreshModuleSettings();
RefreshEnabledState();
}
}
}
public bool IsEnabled
{
get
{
if (_enabledStateIsGPOConfigured)
{
return _enabledGPOConfiguration;
}
else
{
return _isEnabled;
}
}
set
{
if (_isEnabled != value)
{
if (_enabledStateIsGPOConfigured)
{
// If it's GPO configured, shouldn't be able to change this state.
return;
}
_isEnabled = value;
RefreshEnabledState();
NotifyPropertyChanged();
}
}
}
public bool IsEnabledGpoConfigured
{
get => _enabledStateIsGPOConfigured;
set
{
if (_enabledStateIsGPOConfigured != value)
{
_enabledStateIsGPOConfigured = value;
NotifyPropertyChanged();
}
}
}
public bool EnabledGPOConfiguration
{
get => _enabledGPOConfiguration;
set
{
if (_enabledGPOConfiguration != value)
{
_enabledGPOConfiguration = value;
NotifyPropertyChanged();
}
}
}
public string ScheduleMode
{
get => ModuleSettings.Properties.ScheduleMode.Value;
set
{
var oldMode = ModuleSettings.Properties.ScheduleMode.Value;
if (ModuleSettings.Properties.ScheduleMode.Value != value)
{
ModuleSettings.Properties.ScheduleMode.Value = value;
OnPropertyChanged(nameof(ScheduleMode));
}
if (ModuleSettings.Properties.ScheduleMode.Value == "FixedHours" && oldMode != "FixedHours")
{
LightTime = 360;
DarkTime = 1080;
SunsetTimeSpan = null;
SunriseTimeSpan = null;
OnPropertyChanged(nameof(LightTimePickerValue));
OnPropertyChanged(nameof(DarkTimePickerValue));
}
if (ModuleSettings.Properties.ScheduleMode.Value == "SunsetToSunrise")
{
if (ModuleSettings.Properties.Latitude != "0.0" && ModuleSettings.Properties.Longitude != "0.0")
{
double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture);
double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture);
UpdateSunTimes(lat, lon);
}
}
}
}
public ObservableCollection<string> AvailableScheduleModes { get; }
public bool ChangeSystem
{
get => ModuleSettings.Properties.ChangeSystem.Value;
set
{
if (ModuleSettings.Properties.ChangeSystem.Value != value)
{
ModuleSettings.Properties.ChangeSystem.Value = value;
NotifyPropertyChanged();
}
}
}
public bool ChangeApps
{
get => ModuleSettings.Properties.ChangeApps.Value;
set
{
if (ModuleSettings.Properties.ChangeApps.Value != value)
{
ModuleSettings.Properties.ChangeApps.Value = value;
NotifyPropertyChanged();
}
}
}
public int LightTime
{
get => ModuleSettings.Properties.LightTime.Value;
set
{
if (ModuleSettings.Properties.LightTime.Value != value)
{
ModuleSettings.Properties.LightTime.Value = value;
NotifyPropertyChanged();
OnPropertyChanged(nameof(LightTimeTimeSpan));
if (ScheduleMode == "SunsetToSunrise")
{
SunriseTimeSpan = TimeSpan.FromMinutes(value);
}
}
}
}
public int DarkTime
{
get => ModuleSettings.Properties.DarkTime.Value;
set
{
if (ModuleSettings.Properties.DarkTime.Value != value)
{
ModuleSettings.Properties.DarkTime.Value = value;
NotifyPropertyChanged();
OnPropertyChanged(nameof(DarkTimeTimeSpan));
if (ScheduleMode == "SunsetToSunrise")
{
SunsetTimeSpan = TimeSpan.FromMinutes(value);
}
}
}
}
public int SunriseOffset
{
get => ModuleSettings.Properties.SunriseOffset.Value;
set
{
if (ModuleSettings.Properties.SunriseOffset.Value != value)
{
ModuleSettings.Properties.SunriseOffset.Value = value;
OnPropertyChanged(nameof(LightTimeTimeSpan));
}
}
}
public int SunsetOffset
{
get => ModuleSettings.Properties.SunsetOffset.Value;
set
{
if (ModuleSettings.Properties.SunsetOffset.Value != value)
{
ModuleSettings.Properties.SunsetOffset.Value = value;
OnPropertyChanged(nameof(DarkTimeTimeSpan));
}
}
}
// === Computed projections (OneWay bindings only) ===
public TimeSpan LightTimeTimeSpan
{
get
{
if (ScheduleMode == "SunsetToSunrise")
{
return TimeSpan.FromMinutes(LightTime + SunriseOffset);
}
else
{
return TimeSpan.FromMinutes(LightTime);
}
}
}
public TimeSpan DarkTimeTimeSpan
{
get
{
if (ScheduleMode == "SunsetToSunrise")
{
return TimeSpan.FromMinutes(DarkTime + SunsetOffset);
}
else
{
return TimeSpan.FromMinutes(DarkTime);
}
}
}
// === Values to pass to timeline ===
public TimeSpan? SunriseTimeSpan
{
get => _sunriseTimeSpan;
set
{
if (_sunriseTimeSpan != value)
{
_sunriseTimeSpan = value;
NotifyPropertyChanged();
}
}
}
public TimeSpan? SunsetTimeSpan
{
get => _sunsetTimeSpan;
set
{
if (_sunsetTimeSpan != value)
{
_sunsetTimeSpan = value;
NotifyPropertyChanged();
}
}
}
// === Picker values (TwoWay binding targets for TimePickers) ===
public TimeSpan LightTimePickerValue
{
get => TimeSpan.FromMinutes(LightTime);
set => LightTime = (int)value.TotalMinutes;
}
public TimeSpan DarkTimePickerValue
{
get => TimeSpan.FromMinutes(DarkTime);
set => DarkTime = (int)value.TotalMinutes;
}
public string Latitude
{
get => ModuleSettings.Properties.Latitude.Value;
set
{
if (ModuleSettings.Properties.Latitude.Value != value)
{
ModuleSettings.Properties.Latitude.Value = value;
NotifyPropertyChanged();
}
}
}
public string Longitude
{
get => ModuleSettings.Properties.Longitude.Value;
set
{
if (ModuleSettings.Properties.Longitude.Value != value)
{
ModuleSettings.Properties.Longitude.Value = value;
NotifyPropertyChanged();
}
}
}
private SearchLocation _selectedSearchLocation;
public SearchLocation SelectedCity
{
get => _selectedSearchLocation;
set
{
if (_selectedSearchLocation != value)
{
_selectedSearchLocation = value;
NotifyPropertyChanged();
UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City);
}
}
}
private string _syncButtonInformation = "Please sync your location";
public string SyncButtonInformation
{
get => _syncButtonInformation;
set
{
if (_syncButtonInformation != value)
{
_syncButtonInformation = value;
NotifyPropertyChanged();
}
}
}
public HotkeySettings ToggleThemeActivationShortcut
{
get => _toggleThemeHotkey;
set
{
if (value != _toggleThemeHotkey)
{
if (value == null)
{
_toggleThemeHotkey = LightSwitchProperties.DefaultToggleThemeHotkey;
}
else
{
_toggleThemeHotkey = value;
}
_moduleSettings.Properties.ToggleThemeHotkey.Value = _toggleThemeHotkey;
NotifyPropertyChanged();
SendConfigMSG(
string.Format(
CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
LightSwitchSettings.ModuleName,
JsonSerializer.Serialize(_moduleSettings, (System.Text.Json.Serialization.Metadata.JsonTypeInfo<LightSwitchSettings>)SourceGenerationContextContext.Default.LightSwitchSettings)));
}
}
}
public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
{
Logger.LogInfo($"Changed the property {propertyName}");
OnPropertyChanged(propertyName);
}
public void RefreshEnabledState()
{
OnPropertyChanged(nameof(IsEnabled));
}
public void RefreshModuleSettings()
{
OnPropertyChanged(nameof(ChangeSystem));
OnPropertyChanged(nameof(ChangeApps));
OnPropertyChanged(nameof(LightTime));
OnPropertyChanged(nameof(DarkTime));
OnPropertyChanged(nameof(SunriseOffset));
OnPropertyChanged(nameof(SunsetOffset));
OnPropertyChanged(nameof(Latitude));
OnPropertyChanged(nameof(Longitude));
OnPropertyChanged(nameof(ScheduleMode));
}
private void UpdateSunTimes(double latitude, double longitude, string city = "n/a")
{
SunTimes result = SunCalc.CalculateSunriseSunset(
latitude,
longitude,
DateTime.Now.Year,
DateTime.Now.Month,
DateTime.Now.Day);
LightTime = (result.SunriseHour * 60) + result.SunriseMinute;
DarkTime = (result.SunsetHour * 60) + result.SunsetMinute;
Latitude = latitude.ToString(CultureInfo.InvariantCulture);
Longitude = longitude.ToString(CultureInfo.InvariantCulture);
if (city != "n/a")
{
SyncButtonInformation = city;
}
}
public void InitializeScheduleMode()
{
if (ScheduleMode == "SunsetToSunrise" &&
double.TryParse(Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLat) &&
double.TryParse(Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLng))
{
var match = SearchLocations.FirstOrDefault(c =>
Math.Abs(c.Latitude - savedLat) < 0.0001 &&
Math.Abs(c.Longitude - savedLng) < 0.0001);
if (match != null)
{
SelectedCity = match;
}
SyncButtonInformation = SelectedCity != null
? SelectedCity.City
: $"{Latitude},{Longitude}";
double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture);
double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture);
UpdateSunTimes(lat, lon);
SunriseTimeSpan = TimeSpan.FromMinutes(LightTime);
SunsetTimeSpan = TimeSpan.FromMinutes(DarkTime);
}
}
private bool _enabledStateIsGPOConfigured;
private bool _enabledGPOConfiguration;
private LightSwitchSettings _moduleSettings;
private bool _isEnabled;
private HotkeySettings _toggleThemeHotkey;
private TimeSpan? _sunriseTimeSpan;
private TimeSpan? _sunsetTimeSpan;
public ICommand ForceLightCommand { get; }
public ICommand ForceDarkCommand { get; }
}
}