Skip to content

[Light Switch] Enable theme switching with schedule#44200

Closed
Jaylyn-Barbee wants to merge 13 commits intomicrosoft:mainfrom
Jaylyn-Barbee:jay/theme-switching-new
Closed

[Light Switch] Enable theme switching with schedule#44200
Jaylyn-Barbee wants to merge 13 commits intomicrosoft:mainfrom
Jaylyn-Barbee:jay/theme-switching-new

Conversation

@Jaylyn-Barbee
Copy link
Copy Markdown
Contributor

@Jaylyn-Barbee Jaylyn-Barbee commented Dec 10, 2025

Summary of the Pull Request

This PR introduces behavior to allow users to attach a theme file to both light and dark mode which will switch when the color mode changes.

image

PR Checklist

  • Closes: Select specific light/dark themes #42413
  • Communication: I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected
  • Localization: All end-user-facing strings can be localized
  • Documentation updated: If checked, please file a pull request on our docs repo and link it here: #xxx

Notes

  1. How do we tell people that their theme files are saved at C:\Users\username\AppData\Local\Microsoft\Windows\Themes, the file picker just opens to a default location.
  2. Double check that the theme is applying both on schedule and with the use of the shortcut.
  3. On back burner for now until we can figure out a better solution for the Settings app issue.

@Jaylyn-Barbee Jaylyn-Barbee marked this pull request as draft December 10, 2025 16:41
@karlingen
Copy link
Copy Markdown

Thank you for your work on this! What's left to get this PR published?

@Jaylyn-Barbee
Copy link
Copy Markdown
Contributor Author

Jaylyn-Barbee commented Jan 5, 2026

@karlingen

As it stands, the code in this PR will invoke the theme file just with the side effect of opening the settings app.
I am struggling to find a solution in C++ to invoke this file without the settings app opening. This is the only thing blocking this PR besides polishing things up and testing.

This comment was marked as outdated.

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 12 comments.

Comment on lines +142 to +166
// Aggressively suppress Settings window - hide, move off-screen, close, and terminate
static void SuppressSettingsWindow(HWND hwnd)
{
// First, immediately hide it to prevent flash
ShowWindow(hwnd, SW_HIDE);

// Move it off-screen as backup
SetWindowPos(hwnd, nullptr, -32000, -32000, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);

// Send close message
PostMessageW(hwnd, WM_CLOSE, 0, 0);

// Also terminate the process for faster closure
DWORD processId = 0;
GetWindowThreadProcessId(hwnd, &processId);
if (processId != 0)
{
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, processId);
if (hProcess)
{
TerminateProcess(hProcess, 0);
CloseHandle(hProcess);
}
}
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aggressive process termination: Using TerminateProcess forcefully kills SystemSettings.exe without graceful shutdown. This could potentially lead to data loss or corruption in Settings app state. Consider if this aggressive approach is necessary, or if PostMessage(WM_CLOSE) alone would suffice. Document why forceful termination is required if this behavior is intentional.

Suggested change
// Aggressively suppress Settings window - hide, move off-screen, close, and terminate
static void SuppressSettingsWindow(HWND hwnd)
{
// First, immediately hide it to prevent flash
ShowWindow(hwnd, SW_HIDE);
// Move it off-screen as backup
SetWindowPos(hwnd, nullptr, -32000, -32000, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
// Send close message
PostMessageW(hwnd, WM_CLOSE, 0, 0);
// Also terminate the process for faster closure
DWORD processId = 0;
GetWindowThreadProcessId(hwnd, &processId);
if (processId != 0)
{
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, processId);
if (hProcess)
{
TerminateProcess(hProcess, 0);
CloseHandle(hProcess);
}
}
// Suppress Settings window - hide, move off-screen, and request graceful close
static void SuppressSettingsWindow(HWND hwnd)
{
// First, immediately hide it to prevent flash
ShowWindow(hwnd, SW_HIDE);
// Move it off-screen as backup so it's not visible even if shown briefly
SetWindowPos(hwnd, nullptr, -32000, -32000, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
// Request a graceful shutdown instead of forcefully terminating the process
PostMessageW(hwnd, WM_CLOSE, 0, 0);

Copilot uses AI. Check for mistakes.

var picker = new FileOpenPicker();

var window = new Window();
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new Window instance may cause issues or resource leaks. The picker should typically be initialized with the actual application window handle. Consider obtaining the window handle from the current UI window (e.g., using App.GetMainWindow() or similar pattern) instead of creating a new Window instance.

Suggested change
var window = new Window();
var window = App.GetMainWindow();

Copilot uses AI. Check for mistakes.
{ L"SunsetToSunrise", L"Use sunrise/sunset times" },
{ L"FollowNightLight", L"Follow Windows Night Light state" }
});
{ L"FollowNightLight", L"Follow Windows Night Light state" } });
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent whitespace formatting. There's an unnecessary space before the closing braces in the initializer list. This should follow consistent formatting with the rest of the file.

Copilot uses AI. Check for mistakes.
if (!_state.isManualOverride && (appsNeedsToChange || systemNeedsToChange))
{
Logger::info(L"[LightSwitchStateManager] Applying {} theme", shouldBeLight ? L"light" : L"dark");

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace after the opening brace. This should be removed to maintain code cleanliness.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +311
void StopSettingsMonitor()
{
if (!g_monitorRunning)
{
return;
}

g_stopMonitoring = true;

if (g_monitorThread.joinable())
{
g_monitorThread.join();
}

g_monitorRunning = false;
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: The g_monitorRunning flag is set to false after joining the thread, but g_stopMonitoring is set to true before the join. If another thread calls StartSettingsMonitor between setting g_stopMonitoring and resetting g_monitorRunning, it could lead to inconsistent state. Consider using a mutex to protect the state transitions or ensure atomic operations.

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +296
x:Uid="LightSwitch_ThemeButtonSelectFolder"
Grid.Column="1"
Click="ThemeFilePickerButton_Click"
Content="{ui:FontIcon Glyph=,
FontSize=12}"
Tag="Light">
<ToolTipService.ToolTip>
<ToolTip>
<TextBlock x:Uid="LightSwitch_ThemeButtonSelectLocation" />
</ToolTip>
</ToolTipService.ToolTip>
</Button>
</Grid>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="LightSwitch_DarkThemePath" IsEnabled="{x:Bind ViewModel.UseThemeSwitching, Mode=TwoWay}">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
x:Name="darkPathTextBlock"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.DarkThemePath, Mode=TwoWay}"
TextWrapping="Wrap">
<ToolTipService.ToolTip>
<ToolTip IsEnabled="{Binding IsTextTrimmed, ElementName=darkPathTextBlock, Mode=OneWay}">
<TextBlock Text="{x:Bind ViewModel.DarkThemePath, Mode=TwoWay}" TextWrapping="Wrap" />
</ToolTip>
</ToolTipService.ToolTip>
</TextBlock>
<Button
x:Uid="LightSwitch_ThemeButtonSelectFolder"
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing localization resource: The x:Uid "LightSwitch_ThemeButtonSelectFolder" is used in the XAML but does not have a corresponding entry in the Resources.resw file. This will cause localization errors. Either add the resource or remove the x:Uid attribute if not needed.

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +311
void StopSettingsMonitor()
{
if (!g_monitorRunning)
{
return;
}

g_stopMonitoring = true;

if (g_monitorThread.joinable())
{
g_monitorThread.join();
}

g_monitorRunning = false;
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential resource leak: If g_monitorThread.joinable() returns false but g_monitorRunning is true, the thread could be running but unjoinable. Consider resetting g_monitorRunning atomically with the thread state check to prevent state inconsistencies.

Copilot uses AI. Check for mistakes.
NotifyObservers(SettingId::DarkThemePath);
}
}

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace at the end of line. This should be removed to maintain code cleanliness.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +400 to +424
public string LightThemePath
{
get => ModuleSettings.Properties.LightThemePath.Value;
set
{
if (ModuleSettings.Properties.LightThemePath.Value != value)
{
ModuleSettings.Properties.LightThemePath.Value = value;
NotifyPropertyChanged();
}
}
}

public string DarkThemePath
{
get => ModuleSettings.Properties.DarkThemePath.Value;
set
{
if (ModuleSettings.Properties.DarkThemePath.Value != value)
{
ModuleSettings.Properties.DarkThemePath.Value = value;
NotifyPropertyChanged();
}
}
}
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for theme file paths. Users could potentially set empty or invalid paths through the UI. Consider adding validation in the setter or before saving to ensure paths are non-empty when UseThemeSwitching is enabled.

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +358
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}

// Start the preemptive Settings monitor - call this before any operation that might trigger Settings
void StartSettingsMonitor()
{
// If already running, do nothing
if (g_monitorRunning.exchange(true))
{
return;
}

g_stopMonitoring = false;
g_monitorThread = std::thread(PreemptiveSettingsMonitor);

// Small delay to ensure monitor is running
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}

// Stop the preemptive Settings monitor
void StopSettingsMonitor()
{
if (!g_monitorRunning)
{
return;
}

g_stopMonitoring = true;

if (g_monitorThread.joinable())
{
g_monitorThread.join();
}

g_monitorRunning = false;

// Final cleanup pass
for (int i = 0; i < 5; i++)
{
FindAndSuppressSettings();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}

void SetThemeFile(const std::wstring& themeFilePath)
{
// Check if monitor is already running from external call (e.g., ToggleTheme)
bool externalMonitor = g_monitorRunning;

if (!externalMonitor)
{
// Start our own monitor
StartSettingsMonitor();
}

// Execute the theme file
SHELLEXECUTEINFOW sei{};
sei.cbSize = sizeof(sei);
sei.fMask = SEE_MASK_FLAG_NO_UI | SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC;
sei.lpVerb = L"open";
sei.lpFile = themeFilePath.c_str();
sei.nShow = SW_HIDE; // Try to hide from the start

if (!ShellExecuteExW(&sei))
{
DWORD err = GetLastError();
Logger::error(L"[LightSwitch] ShellExecuteExW failed ({}): {}", err, themeFilePath);
if (!externalMonitor)
{
StopSettingsMonitor();
}
return;
}

if (sei.hProcess)
{
WaitForInputIdle(sei.hProcess, 3000);
CloseHandle(sei.hProcess);
}

// Give time for the theme to be applied
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic numbers without explanation. The sleep durations (10ms, 50ms, 100ms, 1500ms, 3000ms) are hardcoded without documentation explaining why these specific values were chosen. Consider extracting these to named constants with comments explaining the timing requirements.

Copilot uses AI. Check for mistakes.
@niels9001
Copy link
Copy Markdown
Collaborator

Closing this PR as we have not figured out a good way to do this without 1) using a private API or 2) have proper support for multiple desktops.

@niels9001 niels9001 closed this Apr 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Select specific light/dark themes

4 participants