mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-01 01:46:50 +01:00
Compare commits
10 Commits
user/yeela
...
tools/Rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0371b4134c | ||
|
|
e963125210 | ||
|
|
235c3ef3e6 | ||
|
|
bb67ae4068 | ||
|
|
39646748a0 | ||
|
|
a8596fed3d | ||
|
|
faf7c7f1a1 | ||
|
|
3b4007d299 | ||
|
|
296d8f87b6 | ||
|
|
8d4ed04f1a |
3
.github/actions/spell-check/expect.txt
vendored
3
.github/actions/spell-check/expect.txt
vendored
@@ -580,7 +580,6 @@ GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTEXTLENGTH
|
||||
gitmodules
|
||||
GHND
|
||||
GMEM
|
||||
GNumber
|
||||
@@ -1344,7 +1343,6 @@ PRTL
|
||||
prvpane
|
||||
psapi
|
||||
pscid
|
||||
pscustomobject
|
||||
PSECURITY
|
||||
psfgao
|
||||
psfi
|
||||
@@ -1999,7 +1997,6 @@ WORKSPACESEDITOR
|
||||
WORKSPACESLAUNCHER
|
||||
WORKSPACESSNAPSHOTTOOL
|
||||
WORKSPACESWINDOWARRANGER
|
||||
Worktree
|
||||
wox
|
||||
wparam
|
||||
wpf
|
||||
|
||||
@@ -94,6 +94,21 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
static void SetCrosshairsOrientation(CrosshairsOrientation orientation)
|
||||
{
|
||||
if (instance != nullptr)
|
||||
{
|
||||
auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
|
||||
dispatcherQueue.TryEnqueue([orientation]() {
|
||||
if (instance != nullptr)
|
||||
{
|
||||
instance->m_crosshairs_orientation = orientation;
|
||||
instance->UpdateCrosshairsPosition();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
enum class MouseButton
|
||||
{
|
||||
@@ -147,6 +162,7 @@ private:
|
||||
int m_crosshairs_border_size = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE;
|
||||
bool m_crosshairs_is_fixed_length_enabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED;
|
||||
int m_crosshairs_fixed_length = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH;
|
||||
CrosshairsOrientation m_crosshairs_orientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION);
|
||||
float m_crosshairs_opacity = max(0.f, min(1.f, (float)INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_OPACITY / 100.0f));
|
||||
bool m_crosshairs_auto_hide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE;
|
||||
};
|
||||
@@ -286,6 +302,8 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
|
||||
float halfPixelAdjustment = m_crosshairs_thickness % 2 == 1 ? 0.5f : 0.0f;
|
||||
float borderSizePadding = m_crosshairs_border_size * 2.f;
|
||||
|
||||
// Left and Right crosshairs (horizontal line)
|
||||
if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::HorizontalOnly)
|
||||
{
|
||||
float leftCrosshairsFullScreenLength = ptCursor.x - ptMonitorUpperLeft.x - m_crosshairs_radius + halfPixelAdjustment * 2.f;
|
||||
float leftCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : leftCrosshairsFullScreenLength;
|
||||
@@ -294,9 +312,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
|
||||
m_left_crosshairs_border.Size({ leftCrosshairsBorderLength, m_crosshairs_thickness + borderSizePadding });
|
||||
m_left_crosshairs.Offset({ ptCursor.x - m_crosshairs_radius + halfPixelAdjustment * 2.f, ptCursor.y + halfPixelAdjustment, .0f });
|
||||
m_left_crosshairs.Size({ leftCrosshairsLength, static_cast<float>(m_crosshairs_thickness) });
|
||||
}
|
||||
|
||||
{
|
||||
float rightCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.x) - ptCursor.x - m_crosshairs_radius;
|
||||
float rightCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : rightCrosshairsFullScreenLength;
|
||||
float rightCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : rightCrosshairsFullScreenLength + m_crosshairs_border_size;
|
||||
@@ -305,7 +321,17 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
|
||||
m_right_crosshairs.Offset({ static_cast<float>(ptCursor.x) + m_crosshairs_radius, ptCursor.y + halfPixelAdjustment, .0f });
|
||||
m_right_crosshairs.Size({ rightCrosshairsLength, static_cast<float>(m_crosshairs_thickness) });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hide horizontal crosshairs by setting size to 0
|
||||
m_left_crosshairs_border.Size({ 0.0f, 0.0f });
|
||||
m_left_crosshairs.Size({ 0.0f, 0.0f });
|
||||
m_right_crosshairs_border.Size({ 0.0f, 0.0f });
|
||||
m_right_crosshairs.Size({ 0.0f, 0.0f });
|
||||
}
|
||||
|
||||
// Top and Bottom crosshairs (vertical line)
|
||||
if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::VerticalOnly)
|
||||
{
|
||||
float topCrosshairsFullScreenLength = ptCursor.y - ptMonitorUpperLeft.y - m_crosshairs_radius + halfPixelAdjustment * 2.f;
|
||||
float topCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : topCrosshairsFullScreenLength;
|
||||
@@ -314,9 +340,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
|
||||
m_top_crosshairs_border.Size({ m_crosshairs_thickness + borderSizePadding, topCrosshairsBorderLength });
|
||||
m_top_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, ptCursor.y - m_crosshairs_radius + halfPixelAdjustment * 2.f, .0f });
|
||||
m_top_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), topCrosshairsLength });
|
||||
}
|
||||
|
||||
{
|
||||
float bottomCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.y) - ptCursor.y - m_crosshairs_radius;
|
||||
float bottomCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : bottomCrosshairsFullScreenLength;
|
||||
float bottomCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : bottomCrosshairsFullScreenLength + m_crosshairs_border_size;
|
||||
@@ -325,6 +349,14 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
|
||||
m_bottom_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, static_cast<float>(ptCursor.y) + m_crosshairs_radius, .0f });
|
||||
m_bottom_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), bottomCrosshairsLength });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hide vertical crosshairs by setting size to 0
|
||||
m_top_crosshairs_border.Size({ 0.0f, 0.0f });
|
||||
m_top_crosshairs.Size({ 0.0f, 0.0f });
|
||||
m_bottom_crosshairs_border.Size({ 0.0f, 0.0f });
|
||||
m_bottom_crosshairs.Size({ 0.0f, 0.0f });
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept
|
||||
@@ -398,6 +430,7 @@ void InclusiveCrosshairs::ApplySettings(InclusiveCrosshairsSettings& settings, b
|
||||
m_crosshairs_auto_hide = settings.crosshairsAutoHide;
|
||||
m_crosshairs_is_fixed_length_enabled = settings.crosshairsIsFixedLengthEnabled;
|
||||
m_crosshairs_fixed_length = settings.crosshairsFixedLength;
|
||||
m_crosshairs_orientation = settings.crosshairsOrientation;
|
||||
|
||||
if (applyToRunTimeObjects)
|
||||
{
|
||||
@@ -618,6 +651,11 @@ void InclusiveCrosshairsSetExternalControl(bool enabled)
|
||||
InclusiveCrosshairs::SetExternalControl(enabled);
|
||||
}
|
||||
|
||||
void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation)
|
||||
{
|
||||
InclusiveCrosshairs::SetCrosshairsOrientation(orientation);
|
||||
}
|
||||
|
||||
int InclusiveCrosshairsMain(HINSTANCE hInstance, InclusiveCrosshairsSettings& settings)
|
||||
{
|
||||
Logger::info("Starting a crosshairs instance.");
|
||||
|
||||
@@ -10,8 +10,16 @@ constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE = 1;
|
||||
constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE = false;
|
||||
constexpr bool INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED = false;
|
||||
constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH = 1;
|
||||
constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION = 0; // 0=Both, 1=Vertical, 2=Horizontal
|
||||
constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE = false;
|
||||
|
||||
enum struct CrosshairsOrientation : int
|
||||
{
|
||||
Both = 0,
|
||||
VerticalOnly = 1,
|
||||
HorizontalOnly = 2,
|
||||
};
|
||||
|
||||
struct InclusiveCrosshairsSettings
|
||||
{
|
||||
winrt::Windows::UI::Color crosshairsColor = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_COLOR;
|
||||
@@ -23,6 +31,7 @@ struct InclusiveCrosshairsSettings
|
||||
bool crosshairsAutoHide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE;
|
||||
bool crosshairsIsFixedLengthEnabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED;
|
||||
int crosshairsFixedLength = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH;
|
||||
CrosshairsOrientation crosshairsOrientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION);
|
||||
bool autoActivate = INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE;
|
||||
};
|
||||
|
||||
@@ -35,3 +44,4 @@ void InclusiveCrosshairsRequestUpdatePosition();
|
||||
void InclusiveCrosshairsEnsureOn();
|
||||
void InclusiveCrosshairsEnsureOff();
|
||||
void InclusiveCrosshairsSetExternalControl(bool enabled);
|
||||
void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation);
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
<AdditionalIncludeDirectories>..\..\..\;..\..\..\modules;..\..\..\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <algorithm>
|
||||
|
||||
extern void InclusiveCrosshairsRequestUpdatePosition();
|
||||
extern void InclusiveCrosshairsEnsureOn();
|
||||
@@ -30,6 +31,7 @@ namespace
|
||||
const wchar_t JSON_KEY_CROSSHAIRS_AUTO_HIDE[] = L"crosshairs_auto_hide";
|
||||
const wchar_t JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED[] = L"crosshairs_is_fixed_length_enabled";
|
||||
const wchar_t JSON_KEY_CROSSHAIRS_FIXED_LENGTH[] = L"crosshairs_fixed_length";
|
||||
const wchar_t JSON_KEY_CROSSHAIRS_ORIENTATION[] = L"crosshairs_orientation";
|
||||
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
|
||||
const wchar_t JSON_KEY_GLIDE_TRAVEL_SPEED[] = L"gliding_travel_speed";
|
||||
const wchar_t JSON_KEY_GLIDE_DELAY_SPEED[] = L"gliding_delay_speed";
|
||||
@@ -62,6 +64,9 @@ const static wchar_t* MODULE_NAME = L"MousePointerCrosshairs";
|
||||
// Add a description that will we shown in the module settings page.
|
||||
const static wchar_t* MODULE_DESC = L"<no description>";
|
||||
|
||||
class MousePointerCrosshairs; // fwd
|
||||
static std::atomic<MousePointerCrosshairs*> g_instance{ nullptr }; // for hook callback
|
||||
|
||||
// Implement the PowerToy Module Interface and all the required methods.
|
||||
class MousePointerCrosshairs : public PowertoyModuleIface
|
||||
{
|
||||
@@ -70,8 +75,11 @@ private:
|
||||
bool m_enabled = false;
|
||||
|
||||
// Additional hotkeys (legacy API) to support multiple shortcuts
|
||||
Hotkey m_activationHotkey{}; // Crosshairs toggle
|
||||
Hotkey m_glidingHotkey{}; // Gliding cursor state machine
|
||||
Hotkey m_activationHotkey{}; // Crosshairs toggle
|
||||
Hotkey m_glidingHotkey{}; // Gliding cursor state machine
|
||||
|
||||
// Low-level keyboard hook (Escape to cancel gliding)
|
||||
HHOOK m_keyboardHook = nullptr;
|
||||
|
||||
// Shared state for worker threads (decoupled from this lifetime)
|
||||
struct State
|
||||
@@ -84,7 +92,7 @@ private:
|
||||
int currentYPos{ 0 };
|
||||
int currentXSpeed{ 0 }; // pixels per base window
|
||||
int currentYSpeed{ 0 }; // pixels per base window
|
||||
int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan
|
||||
int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan
|
||||
|
||||
// Fractional accumulators to spread movement across 10ms ticks
|
||||
double xFraction{ 0.0 };
|
||||
@@ -92,9 +100,9 @@ private:
|
||||
|
||||
// Speeds represent pixels per 200ms (min 5, max 60 enforced by UI/settings)
|
||||
int fastHSpeed{ 30 }; // pixels per base window
|
||||
int slowHSpeed{ 5 }; // pixels per base window
|
||||
int slowHSpeed{ 5 }; // pixels per base window
|
||||
int fastVSpeed{ 30 }; // pixels per base window
|
||||
int slowVSpeed{ 5 }; // pixels per base window
|
||||
int slowVSpeed{ 5 }; // pixels per base window
|
||||
};
|
||||
|
||||
std::shared_ptr<State> m_state;
|
||||
@@ -120,13 +128,16 @@ public:
|
||||
LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::mousePointerCrosshairsLoggerName);
|
||||
m_state = std::make_shared<State>();
|
||||
init_settings();
|
||||
g_instance.store(this, std::memory_order_release);
|
||||
};
|
||||
|
||||
// Destroy the powertoy and free memory
|
||||
virtual void destroy() override
|
||||
{
|
||||
UninstallKeyboardHook();
|
||||
StopXTimer();
|
||||
StopYTimer();
|
||||
g_instance.store(nullptr, std::memory_order_release);
|
||||
// Release shared state so worker threads (if any) exit when weak_ptr lock fails
|
||||
m_state.reset();
|
||||
delete this;
|
||||
@@ -196,6 +207,7 @@ public:
|
||||
{
|
||||
m_enabled = false;
|
||||
Trace::EnableMousePointerCrosshairs(false);
|
||||
UninstallKeyboardHook();
|
||||
StopXTimer();
|
||||
StopYTimer();
|
||||
m_glideState = 0;
|
||||
@@ -220,7 +232,7 @@ public:
|
||||
if (buffer && buffer_size >= 2)
|
||||
{
|
||||
buffer[0] = m_activationHotkey; // Crosshairs toggle
|
||||
buffer[1] = m_glidingHotkey; // Gliding cursor toggle
|
||||
buffer[1] = m_glidingHotkey; // Gliding cursor toggle
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -256,6 +268,27 @@ private:
|
||||
SendInput(2, inputs, sizeof(INPUT));
|
||||
}
|
||||
|
||||
// Cancel gliding without performing the final click (Escape handling)
|
||||
void CancelGliding()
|
||||
{
|
||||
int state = m_glideState.load();
|
||||
if (state == 0)
|
||||
{
|
||||
return; // nothing to cancel
|
||||
}
|
||||
StopXTimer();
|
||||
StopYTimer();
|
||||
m_glideState = 0;
|
||||
InclusiveCrosshairsEnsureOff();
|
||||
InclusiveCrosshairsSetExternalControl(false);
|
||||
if (auto s = m_state)
|
||||
{
|
||||
s->xFraction = 0.0;
|
||||
s->yFraction = 0.0;
|
||||
}
|
||||
Logger::debug("Gliding cursor cancelled via Escape key");
|
||||
}
|
||||
|
||||
// Stateless helpers operating on shared State
|
||||
static void PositionCursorX(const std::shared_ptr<State>& s)
|
||||
{
|
||||
@@ -398,10 +431,14 @@ private:
|
||||
{
|
||||
case 0:
|
||||
{
|
||||
// For detect for cancel key
|
||||
InstallKeyboardHook();
|
||||
// Ensure crosshairs on (do not toggle off if already on)
|
||||
InclusiveCrosshairsEnsureOn();
|
||||
// Disable internal mouse hook so we control position updates explicitly
|
||||
InclusiveCrosshairsSetExternalControl(true);
|
||||
// Override crosshairs to show both for Gliding Cursor
|
||||
InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both);
|
||||
|
||||
s->currentXPos = 0;
|
||||
s->currentXSpeed = s->fastHSpeed;
|
||||
@@ -444,12 +481,15 @@ private:
|
||||
case 4:
|
||||
default:
|
||||
{
|
||||
UninstallKeyboardHook();
|
||||
// Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state
|
||||
StopYTimer();
|
||||
m_glideState = 0;
|
||||
LeftClick();
|
||||
InclusiveCrosshairsEnsureOff();
|
||||
InclusiveCrosshairsSetExternalControl(false);
|
||||
// Restore original crosshairs orientation setting
|
||||
InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation);
|
||||
s->xFraction = 0.0;
|
||||
s->yFraction = 0.0;
|
||||
break;
|
||||
@@ -457,6 +497,51 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level keyboard hook procedures
|
||||
static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
if (nCode == HC_ACTION)
|
||||
{
|
||||
const KBDLLHOOKSTRUCT* kb = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||||
if (kb && kb->vkCode == VK_ESCAPE && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN))
|
||||
{
|
||||
if (auto inst = g_instance.load(std::memory_order_acquire))
|
||||
{
|
||||
if (inst->m_enabled && inst->m_glideState.load() != 0)
|
||||
{
|
||||
inst->UninstallKeyboardHook();
|
||||
inst->CancelGliding();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do not swallow Escape; pass it through
|
||||
return CallNextHookEx(nullptr, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
void InstallKeyboardHook()
|
||||
{
|
||||
if (m_keyboardHook)
|
||||
{
|
||||
return; // already installed
|
||||
}
|
||||
m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, m_hModule, 0);
|
||||
if (!m_keyboardHook)
|
||||
{
|
||||
Logger::error("Failed to install low-level keyboard hook for MousePointerCrosshairs (Escape cancel). GetLastError={}.", GetLastError());
|
||||
}
|
||||
}
|
||||
|
||||
void UninstallKeyboardHook()
|
||||
{
|
||||
if (m_keyboardHook)
|
||||
{
|
||||
UnhookWindowsHookEx(m_keyboardHook);
|
||||
m_keyboardHook = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Load the settings file.
|
||||
void init_settings()
|
||||
{
|
||||
@@ -475,264 +560,287 @@ private:
|
||||
|
||||
void parse_settings(PowerToysSettings::PowerToyValues& settings)
|
||||
{
|
||||
// TODO: refactor to use common/utils/json.h instead
|
||||
// Refactored JSON parsing: uses inline try-catch blocks for each property for clarity and error handling
|
||||
auto settingsObject = settings.get_raw_json();
|
||||
InclusiveCrosshairsSettings inclusiveCrosshairsSettings;
|
||||
|
||||
if (settingsObject.GetView().Size())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse primary activation HotKey (for centralized hook)
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
|
||||
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
|
||||
auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
|
||||
|
||||
// Parse activation hotkey
|
||||
try
|
||||
{
|
||||
auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
|
||||
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject);
|
||||
m_activationHotkey.win = hotkey.win_pressed();
|
||||
m_activationHotkey.ctrl = hotkey.ctrl_pressed();
|
||||
m_activationHotkey.shift = hotkey.shift_pressed();
|
||||
m_activationHotkey.alt = hotkey.alt_pressed();
|
||||
m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut");
|
||||
}
|
||||
|
||||
// Map to legacy Hotkey for multi-hotkey API
|
||||
m_activationHotkey.win = hotkey.win_pressed();
|
||||
m_activationHotkey.ctrl = hotkey.ctrl_pressed();
|
||||
m_activationHotkey.shift = hotkey.shift_pressed();
|
||||
m_activationHotkey.alt = hotkey.alt_pressed();
|
||||
m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Gliding Cursor HotKey
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT);
|
||||
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
|
||||
m_glidingHotkey.win = hotkey.win_pressed();
|
||||
m_glidingHotkey.ctrl = hotkey.ctrl_pressed();
|
||||
m_glidingHotkey.shift = hotkey.shift_pressed();
|
||||
m_glidingHotkey.alt = hotkey.alt_pressed();
|
||||
m_glidingHotkey.key = static_cast<unsigned char>(hotkey.get_code());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// note that this is also defined in src\settings-ui\Settings.UI.Library\MousePointerCrosshairsProperties.cs, DefaultGlidingCursorActivationShortcut
|
||||
// both need to be kept in sync!
|
||||
Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+.");
|
||||
m_glidingHotkey.win = true;
|
||||
m_glidingHotkey.alt = true;
|
||||
m_glidingHotkey.ctrl = false;
|
||||
m_glidingHotkey.shift = false;
|
||||
m_glidingHotkey.key = VK_OEM_PERIOD;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Opacity
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_OPACITY);
|
||||
int value = static_cast<uint8_t>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 0)
|
||||
// Parse gliding cursor hotkey
|
||||
try
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsOpacity = value;
|
||||
auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT);
|
||||
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject);
|
||||
m_glidingHotkey.win = hotkey.win_pressed();
|
||||
m_glidingHotkey.ctrl = hotkey.ctrl_pressed();
|
||||
m_glidingHotkey.shift = hotkey.shift_pressed();
|
||||
m_glidingHotkey.alt = hotkey.alt_pressed();
|
||||
m_glidingHotkey.key = static_cast<unsigned char>(hotkey.get_code());
|
||||
}
|
||||
else
|
||||
catch (...)
|
||||
{
|
||||
throw std::runtime_error("Invalid Opacity value");
|
||||
Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+.");
|
||||
m_glidingHotkey.win = true;
|
||||
m_glidingHotkey.alt = true;
|
||||
m_glidingHotkey.ctrl = false;
|
||||
m_glidingHotkey.shift = false;
|
||||
m_glidingHotkey.key = VK_OEM_PERIOD;
|
||||
}
|
||||
|
||||
// Parse individual properties with error handling and defaults
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_opacity"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_opacity");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsOpacity = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_radius"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_radius");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsRadius = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_thickness"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_thickness");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsThickness = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_border_size"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_size");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsBorderSize = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_fixed_length"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_fixed_length");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsFixedLength = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_auto_hide"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_auto_hide");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsAutoHide = propertyObj.GetNamedBoolean(L"value");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_is_fixed_length_enabled"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_is_fixed_length_enabled");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = propertyObj.GetNamedBoolean(L"value");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"auto_activate"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"auto_activate");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
inclusiveCrosshairsSettings.autoActivate = propertyObj.GetNamedBoolean(L"value");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value */ }
|
||||
|
||||
// Parse orientation with validation - this fixes the original issue!
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_orientation"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_orientation");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
int orientationValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
if (orientationValue >= 0 && orientationValue <= 2)
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsOrientation = static_cast<CrosshairsOrientation>(orientationValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default value (Both = 0) */ }
|
||||
|
||||
// Parse colors with validation
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_color"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_color");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
std::wstring crosshairsColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str());
|
||||
uint8_t r, g, b;
|
||||
if (checkValidRGB(crosshairsColorValue, &r, &g, &b))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default color */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"crosshairs_border_color"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_color");
|
||||
if (propertyObj.HasKey(L"value"))
|
||||
{
|
||||
std::wstring borderColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str());
|
||||
uint8_t r, g, b;
|
||||
if (checkValidRGB(borderColorValue, &r, &g, &b))
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) { /* Use default border color */ }
|
||||
|
||||
// Parse speed settings with validation
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"gliding_travel_speed"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"gliding_travel_speed");
|
||||
if (propertyObj.HasKey(L"value") && m_state)
|
||||
{
|
||||
int travelSpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
if (travelSpeedValue >= 5 && travelSpeedValue <= 60)
|
||||
{
|
||||
m_state->fastHSpeed = travelSpeedValue;
|
||||
m_state->fastVSpeed = travelSpeedValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clamp to valid range
|
||||
int clampedValue = travelSpeedValue;
|
||||
if (clampedValue < 5) clampedValue = 5;
|
||||
if (clampedValue > 60) clampedValue = 60;
|
||||
m_state->fastHSpeed = clampedValue;
|
||||
m_state->fastVSpeed = clampedValue;
|
||||
Logger::warn("Travel speed value out of range, clamped to valid range");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
if (m_state)
|
||||
{
|
||||
m_state->fastHSpeed = 25;
|
||||
m_state->fastVSpeed = 25;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (propertiesObject.HasKey(L"gliding_delay_speed"))
|
||||
{
|
||||
auto propertyObj = propertiesObject.GetNamedObject(L"gliding_delay_speed");
|
||||
if (propertyObj.HasKey(L"value") && m_state)
|
||||
{
|
||||
int delaySpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
|
||||
if (delaySpeedValue >= 5 && delaySpeedValue <= 60)
|
||||
{
|
||||
m_state->slowHSpeed = delaySpeedValue;
|
||||
m_state->slowVSpeed = delaySpeedValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clamp to valid range
|
||||
int clampedValue = delaySpeedValue;
|
||||
if (clampedValue < 5) clampedValue = 5;
|
||||
if (clampedValue > 60) clampedValue = 60;
|
||||
m_state->slowHSpeed = clampedValue;
|
||||
m_state->slowVSpeed = clampedValue;
|
||||
Logger::warn("Delay speed value out of range, clamped to valid range");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
if (m_state)
|
||||
{
|
||||
m_state->slowHSpeed = 5;
|
||||
m_state->slowVSpeed = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize Opacity from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse crosshairs color
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_COLOR);
|
||||
auto crosshairsColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
|
||||
uint8_t r, g, b;
|
||||
if (!checkValidRGB(crosshairsColor, &r, &g, &b))
|
||||
{
|
||||
Logger::error("Crosshairs color RGB value is invalid. Will use default value");
|
||||
}
|
||||
else
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize crosshairs color from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Radius
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_RADIUS);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 0)
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsRadius = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid Radius value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize Radius from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Thickness
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_THICKNESS);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 0)
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsThickness = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid Thickness value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize Thickness from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse crosshairs border color
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_COLOR);
|
||||
auto crosshairsBorderColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
|
||||
uint8_t r, g, b;
|
||||
if (!checkValidRGB(crosshairsBorderColor, &r, &g, &b))
|
||||
{
|
||||
Logger::error("Crosshairs border color RGB value is invalid. Will use default value");
|
||||
}
|
||||
else
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize crosshairs border color from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse border size
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_SIZE);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 0)
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsBorderSize = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid Border Color value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize border color from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse auto hide
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_AUTO_HIDE);
|
||||
inclusiveCrosshairsSettings.crosshairsAutoHide = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize auto hide from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse whether the fixed length is enabled
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED);
|
||||
bool value = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = value;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize fixed length enabled from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse fixed length
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_FIXED_LENGTH);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 0)
|
||||
{
|
||||
inclusiveCrosshairsSettings.crosshairsFixedLength = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid Fixed Length value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize fixed length from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse auto activate
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE);
|
||||
inclusiveCrosshairsSettings.autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize auto activate from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Travel speed (fast speed mapping)
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_TRAVEL_SPEED);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 5 && value <= 60)
|
||||
{
|
||||
m_state->fastHSpeed = value;
|
||||
m_state->fastVSpeed = value;
|
||||
}
|
||||
else if (value < 5)
|
||||
{
|
||||
m_state->fastHSpeed = 5; m_state->fastVSpeed = 5;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_state->fastHSpeed = 60; m_state->fastVSpeed = 60;
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize gliding travel speed from settings. Using default 25.");
|
||||
if (m_state)
|
||||
{
|
||||
m_state->fastHSpeed = 25;
|
||||
m_state->fastVSpeed = 25;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse Delay speed (slow speed mapping)
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_DELAY_SPEED);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value >= 5 && value <= 60)
|
||||
{
|
||||
m_state->slowHSpeed = value;
|
||||
m_state->slowVSpeed = value;
|
||||
}
|
||||
else if (value < 5)
|
||||
{
|
||||
m_state->slowHSpeed = 5; m_state->slowVSpeed = 5;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_state->slowHSpeed = 60; m_state->slowVSpeed = 60;
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize gliding delay speed from settings. Using default 5.");
|
||||
if (m_state)
|
||||
{
|
||||
m_state->slowHSpeed = 5;
|
||||
m_state->slowVSpeed = 5;
|
||||
}
|
||||
Logger::warn("Error parsing some MousePointerCrosshairs properties. Using defaults for failed properties.");
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -740,6 +848,7 @@ private:
|
||||
Logger::info("Mouse Pointer Crosshairs settings are empty");
|
||||
}
|
||||
|
||||
// Set default hotkeys if not configured
|
||||
if (m_activationHotkey.key == 0)
|
||||
{
|
||||
m_activationHotkey.win = true;
|
||||
@@ -756,6 +865,7 @@ private:
|
||||
m_glidingHotkey.shift = false;
|
||||
m_glidingHotkey.key = VK_OEM_PERIOD;
|
||||
}
|
||||
|
||||
m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,16 +258,6 @@ private:
|
||||
{
|
||||
Logger::info("AlwaysOnTop settings are empty");
|
||||
}
|
||||
|
||||
if (!m_hotkey.key)
|
||||
{
|
||||
Logger::info("AlwaysOnTop is going to use default shortcut");
|
||||
m_hotkey.win = true;
|
||||
m_hotkey.alt = false;
|
||||
m_hotkey.shift = false;
|
||||
m_hotkey.ctrl = true;
|
||||
m_hotkey.key = 'T';
|
||||
}
|
||||
}
|
||||
|
||||
bool is_process_running()
|
||||
|
||||
@@ -154,8 +154,7 @@ HRESULT CPowerRenameRegEx::_OnEnumerateOrRandomizeItemsChanged()
|
||||
std::find_if(
|
||||
m_randomizer.begin(),
|
||||
m_randomizer.end(),
|
||||
[option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; }
|
||||
))
|
||||
[option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; }))
|
||||
{
|
||||
// Only add as enumerator if we didn't find a randomizer already at this offset.
|
||||
// Every randomizer will also be a valid enumerator according to the definition of enumerators, which allows any string to mean the default enumerator, so it should be interpreted that the user wanted a randomizer if both were found at the same offset of the replace string.
|
||||
@@ -395,11 +394,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
|
||||
}
|
||||
|
||||
std::wstring sourceToUse;
|
||||
std::wstring originalSource;
|
||||
sourceToUse.reserve(MAX_PATH);
|
||||
originalSource.reserve(MAX_PATH);
|
||||
sourceToUse = source;
|
||||
originalSource = sourceToUse;
|
||||
|
||||
std::wstring searchTerm(m_searchTerm);
|
||||
std::wstring replaceTerm;
|
||||
@@ -487,27 +483,46 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
|
||||
}
|
||||
}
|
||||
|
||||
bool replacedSomething = false;
|
||||
bool shouldIncrementCounter = false;
|
||||
const bool isCaseInsensitive = !(m_flags & CaseSensitive);
|
||||
|
||||
if (m_flags & UseRegularExpressions)
|
||||
{
|
||||
replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0");
|
||||
replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4");
|
||||
|
||||
res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive));
|
||||
replacedSomething = originalSource != res;
|
||||
res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, isCaseInsensitive);
|
||||
|
||||
// Use regex search to determine if a match exists. This is the basis for incrementing
|
||||
// the counter.
|
||||
if (_useBoostLib)
|
||||
{
|
||||
boost::wregex pattern(m_searchTerm, boost::wregex::ECMAScript | (isCaseInsensitive ? boost::wregex::icase : boost::wregex::normal));
|
||||
shouldIncrementCounter = boost::regex_search(sourceToUse, pattern);
|
||||
}
|
||||
else
|
||||
{
|
||||
auto regexFlags = std::wregex::ECMAScript;
|
||||
if (isCaseInsensitive)
|
||||
{
|
||||
regexFlags |= std::wregex::icase;
|
||||
}
|
||||
std::wregex pattern(m_searchTerm, regexFlags);
|
||||
shouldIncrementCounter = std::regex_search(sourceToUse, pattern);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Simple search and replace
|
||||
// Simple search and replace.
|
||||
size_t pos = 0;
|
||||
do
|
||||
{
|
||||
pos = _Find(sourceToUse, searchTerm, (!(m_flags & CaseSensitive)), pos);
|
||||
pos = _Find(sourceToUse, searchTerm, isCaseInsensitive, pos);
|
||||
if (pos != std::string::npos)
|
||||
{
|
||||
res = sourceToUse.replace(pos, searchTerm.length(), replaceTerm);
|
||||
pos += replaceTerm.length();
|
||||
replacedSomething = true;
|
||||
shouldIncrementCounter = true;
|
||||
}
|
||||
if (!(m_flags & MatchAllOccurrences))
|
||||
{
|
||||
@@ -516,7 +531,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
|
||||
} while (pos != std::string::npos);
|
||||
}
|
||||
hr = SHStrDup(res.c_str(), result);
|
||||
if (replacedSomething)
|
||||
|
||||
if (shouldIncrementCounter)
|
||||
enumIndex++;
|
||||
}
|
||||
catch (regex_error e)
|
||||
|
||||
@@ -611,6 +611,42 @@ TEST_METHOD (VerifyRandomizerRegExAllBackToBack)
|
||||
CoTaskMemFree(result);
|
||||
}
|
||||
|
||||
TEST_METHOD(VerifyCounterIncrementsWhenResultIsUnchanged)
|
||||
{
|
||||
CComPtr<IPowerRenameRegEx> renameRegEx;
|
||||
Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK);
|
||||
DWORD flags = EnumerateItems | UseRegularExpressions;
|
||||
Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK);
|
||||
|
||||
renameRegEx->PutSearchTerm(L"(.*)");
|
||||
renameRegEx->PutReplaceTerm(L"NewFile-${start=1}");
|
||||
|
||||
PWSTR result = nullptr;
|
||||
unsigned long index = 0;
|
||||
|
||||
renameRegEx->Replace(L"DocA", &result, index);
|
||||
Assert::AreEqual(1ul, index, L"Counter should advance to 1 on first match.");
|
||||
Assert::AreEqual(L"NewFile-1", result, L"First file should be renamed correctly.");
|
||||
CoTaskMemFree(result);
|
||||
|
||||
renameRegEx->Replace(L"DocB", &result, index);
|
||||
Assert::AreEqual(2ul, index, L"Counter should advance to 2 on second match.");
|
||||
Assert::AreEqual(L"NewFile-2", result, L"Second file should be renamed correctly.");
|
||||
CoTaskMemFree(result);
|
||||
|
||||
// The original term and the replacement are identical.
|
||||
renameRegEx->Replace(L"NewFile-3", &result, index);
|
||||
Assert::AreEqual(3ul, index, L"Counter must advance on a match, even if the new name is identical to the old one.");
|
||||
Assert::AreEqual(L"NewFile-3", result, L"Filename should be unchanged on a coincidental match.");
|
||||
CoTaskMemFree(result);
|
||||
|
||||
// Test that there wasn't a "stall" in the numbering.
|
||||
renameRegEx->Replace(L"DocC", &result, index);
|
||||
Assert::AreEqual(4ul, index, L"Counter should continue sequentially after the coincidental match.");
|
||||
Assert::AreEqual(L"NewFile-4", result, L"The subsequent file should receive the correct next number.");
|
||||
CoTaskMemFree(result);
|
||||
}
|
||||
|
||||
#ifndef TESTS_PARTIAL
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,29 @@
|
||||
#include <common/version/version.h>
|
||||
#include <common/utils/resources.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
json::JsonValue create_empty_shortcut_array_value()
|
||||
{
|
||||
return json::JsonValue::Parse(L"[]");
|
||||
}
|
||||
|
||||
void ensure_ignored_conflict_properties_shape(json::JsonObject& obj)
|
||||
{
|
||||
if (!json::has(obj, L"ignored_shortcuts", json::JsonValueType::Array))
|
||||
{
|
||||
obj.SetNamedValue(L"ignored_shortcuts", create_empty_shortcut_array_value());
|
||||
}
|
||||
}
|
||||
|
||||
json::JsonObject create_default_ignored_conflict_properties()
|
||||
{
|
||||
json::JsonObject obj;
|
||||
ensure_ignored_conflict_properties_shape(obj);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: would be nice to get rid of these globals, since they're basically cached json settings
|
||||
static std::wstring settings_theme = L"system";
|
||||
static bool show_tray_icon = true;
|
||||
@@ -23,11 +46,15 @@ static bool download_updates_automatically = true;
|
||||
static bool show_whats_new_after_updates = true;
|
||||
static bool enable_experimentation = true;
|
||||
static bool enable_warnings_elevated_apps = true;
|
||||
static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties();
|
||||
|
||||
json::JsonObject GeneralSettings::to_json()
|
||||
{
|
||||
json::JsonObject result;
|
||||
|
||||
auto ignoredProps = ignoredConflictProperties;
|
||||
ensure_ignored_conflict_properties_shape(ignoredProps);
|
||||
|
||||
result.SetNamedValue(L"startup", json::value(isStartupEnabled));
|
||||
if (!startupDisabledReason.empty())
|
||||
{
|
||||
@@ -53,6 +80,7 @@ json::JsonObject GeneralSettings::to_json()
|
||||
result.SetNamedValue(L"theme", json::value(theme));
|
||||
result.SetNamedValue(L"system_theme", json::value(systemTheme));
|
||||
result.SetNamedValue(L"powertoys_version", json::value(powerToysVersion));
|
||||
result.SetNamedValue(L"ignored_conflict_properties", json::value(ignoredProps));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -72,6 +100,17 @@ json::JsonObject load_general_settings()
|
||||
enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true);
|
||||
enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true);
|
||||
|
||||
if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object))
|
||||
{
|
||||
ignored_conflict_properties = loaded.GetNamedObject(L"ignored_conflict_properties");
|
||||
}
|
||||
else
|
||||
{
|
||||
ignored_conflict_properties = create_default_ignored_conflict_properties();
|
||||
}
|
||||
|
||||
ensure_ignored_conflict_properties_shape(ignored_conflict_properties);
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
@@ -91,9 +130,12 @@ GeneralSettings get_general_settings()
|
||||
.enableExperimentation = enable_experimentation,
|
||||
.theme = settings_theme,
|
||||
.systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light",
|
||||
.powerToysVersion = get_product_version()
|
||||
.powerToysVersion = get_product_version(),
|
||||
.ignoredConflictProperties = ignored_conflict_properties
|
||||
};
|
||||
|
||||
ensure_ignored_conflict_properties_shape(settings.ignoredConflictProperties);
|
||||
|
||||
settings.isStartupEnabled = is_auto_start_task_active_for_this_user();
|
||||
|
||||
for (auto& [name, powertoy] : modules())
|
||||
@@ -232,6 +274,12 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save)
|
||||
set_tray_icon_visible(show_tray_icon);
|
||||
}
|
||||
|
||||
if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object))
|
||||
{
|
||||
ignored_conflict_properties = general_configs.GetNamedObject(L"ignored_conflict_properties");
|
||||
ensure_ignored_conflict_properties_shape(ignored_conflict_properties);
|
||||
}
|
||||
|
||||
if (save)
|
||||
{
|
||||
GeneralSettings save_settings = get_general_settings();
|
||||
|
||||
@@ -19,6 +19,7 @@ struct GeneralSettings
|
||||
std::wstring theme;
|
||||
std::wstring systemTheme;
|
||||
std::wstring powerToysVersion;
|
||||
json::JsonObject ignoredConflictProperties;
|
||||
|
||||
json::JsonObject to_json();
|
||||
};
|
||||
|
||||
@@ -76,6 +76,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("enable_experimentation")]
|
||||
public bool EnableExperimentation { get; set; }
|
||||
|
||||
[JsonPropertyName("ignored_conflict_properties")]
|
||||
public ShortcutConflictProperties IgnoredConflictProperties { get; set; }
|
||||
|
||||
public GeneralSettings()
|
||||
{
|
||||
Startup = false;
|
||||
@@ -100,6 +103,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
|
||||
Enabled = new EnabledModules();
|
||||
CustomActionName = string.Empty;
|
||||
IgnoredConflictProperties = new ShortcutConflictProperties();
|
||||
}
|
||||
|
||||
// converts the current to a json string.
|
||||
@@ -137,6 +141,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
// If there is an issue with the version number format, don't migrate settings.
|
||||
}
|
||||
|
||||
// Ensure IgnoredConflictProperties is initialized (for backward compatibility)
|
||||
if (IgnoredConflictProperties == null)
|
||||
{
|
||||
IgnoredConflictProperties = new ShortcutConflictProperties();
|
||||
return true; // Indicate that settings were upgraded
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
// 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.HotkeyConflicts
|
||||
{
|
||||
@@ -16,6 +12,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts
|
||||
|
||||
public bool IsSystemConflict { get; set; }
|
||||
|
||||
public bool ConflictIgnored { get; set; }
|
||||
|
||||
public bool ConflictVisible => !ConflictIgnored;
|
||||
|
||||
public bool ShouldShowSysConflict => !ConflictIgnored && IsSystemConflict;
|
||||
|
||||
public List<ModuleHotkeyData> Modules { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
private bool _hasConflict;
|
||||
private string _conflictDescription;
|
||||
private bool _isSystemConflict;
|
||||
private bool _ignoreConflict;
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
@@ -57,6 +58,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
HasConflict = false;
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IgnoreConflict
|
||||
{
|
||||
get => _ignoreConflict;
|
||||
set
|
||||
{
|
||||
if (_ignoreConflict != value)
|
||||
{
|
||||
_ignoreConflict = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
@@ -70,9 +86,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string ConflictDescription
|
||||
{
|
||||
get => _conflictDescription ?? string.Empty;
|
||||
get => _ignoreConflict ? null : _conflictDescription;
|
||||
set
|
||||
{
|
||||
if (_conflictDescription != value)
|
||||
@@ -83,6 +100,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsSystemConflict
|
||||
{
|
||||
get => _isSystemConflict;
|
||||
|
||||
@@ -40,6 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("crosshairs_border_size")]
|
||||
public IntProperty CrosshairsBorderSize { get; set; }
|
||||
|
||||
[JsonPropertyName("crosshairs_orientation")]
|
||||
public IntProperty CrosshairsOrientation { get; set; }
|
||||
|
||||
[JsonPropertyName("crosshairs_auto_hide")]
|
||||
public BoolProperty CrosshairsAutoHide { get; set; }
|
||||
|
||||
@@ -68,6 +71,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
CrosshairsThickness = new IntProperty(5);
|
||||
CrosshairsBorderColor = new StringProperty("#FFFFFF");
|
||||
CrosshairsBorderSize = new IntProperty(1);
|
||||
CrosshairsOrientation = new IntProperty(0); // Default to both (0=Both, 1=Vertical, 2=Horizontal)
|
||||
CrosshairsAutoHide = new BoolProperty(false);
|
||||
CrosshairsIsFixedLengthEnabled = new BoolProperty(false);
|
||||
CrosshairsFixedLength = new IntProperty(1);
|
||||
|
||||
@@ -653,11 +653,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists, "\n" + appBasePath);
|
||||
}
|
||||
|
||||
var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
|
||||
if (!dirExists)
|
||||
// Only create the backup directory if this is not a dry run
|
||||
if (!dryRun)
|
||||
{
|
||||
Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
|
||||
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
|
||||
var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
|
||||
if (!dirExists)
|
||||
{
|
||||
Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
|
||||
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
|
||||
}
|
||||
}
|
||||
|
||||
// get data needed for process
|
||||
@@ -717,12 +721,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
var relativePath = currentFile.Value.Substring(appBasePath.Length + 1);
|
||||
var backupFullPath = Path.Combine(fullBackupDir, relativePath);
|
||||
|
||||
TryCreateDirectory(fullBackupDir);
|
||||
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
|
||||
|
||||
Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}.");
|
||||
if (!dryRun)
|
||||
{
|
||||
TryCreateDirectory(fullBackupDir);
|
||||
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
|
||||
File.WriteAllText(backupFullPath, currentSettingsFileToBackup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
{
|
||||
public class ShortcutConflictProperties
|
||||
{
|
||||
[JsonPropertyName("ignored_shortcuts")]
|
||||
public List<HotkeySettings> IgnoredShortcuts { get; set; }
|
||||
|
||||
public ShortcutConflictProperties()
|
||||
{
|
||||
IgnoredShortcuts = new List<HotkeySettings>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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.PowerToys.Settings.UI.Controls;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Converters
|
||||
{
|
||||
public partial class BoolToKeyVisualStateConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (value is bool b && parameter is string param)
|
||||
{
|
||||
if (b && param == "Warning")
|
||||
{
|
||||
return State.Warning;
|
||||
}
|
||||
else if (b && param == "Error")
|
||||
{
|
||||
return State.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
return State.Normal;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return State.Normal;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Static helper class to manage and check hotkey conflict ignore settings
|
||||
/// </summary>
|
||||
public static class HotkeyConflictIgnoreHelper
|
||||
{
|
||||
private static readonly ISettingsRepository<GeneralSettings> _generalSettingsRepository;
|
||||
private static readonly ISettingsUtils _settingsUtils;
|
||||
|
||||
static HotkeyConflictIgnoreHelper()
|
||||
{
|
||||
_settingsUtils = new SettingsUtils();
|
||||
_generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures ignored conflict properties are initialized
|
||||
/// </summary>
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
if (settings.IgnoredConflictProperties == null)
|
||||
{
|
||||
settings.IgnoredConflictProperties = new ShortcutConflictProperties();
|
||||
SaveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific hotkey setting is configured to ignore conflicts
|
||||
/// </summary>
|
||||
/// <param name="hotkeySettings">The hotkey settings to check</param>
|
||||
/// <returns>True if the hotkey is set to ignore conflicts, false otherwise</returns>
|
||||
public static bool IsIgnoringConflicts(HotkeySettings hotkeySettings)
|
||||
{
|
||||
if (hotkeySettings == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureInitialized();
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
return settings.IgnoredConflictProperties.IgnoredShortcuts
|
||||
.Any(h => AreHotkeySettingsEqual(h, hotkeySettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error checking if hotkey is ignoring conflicts: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a hotkey setting to the ignored shortcuts list
|
||||
/// </summary>
|
||||
/// <param name="hotkeySettings">The hotkey settings to add to the ignored list</param>
|
||||
/// <returns>True if successfully added, false if it was already ignored or on error</returns>
|
||||
public static bool AddToIgnoredList(HotkeySettings hotkeySettings)
|
||||
{
|
||||
if (hotkeySettings == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureInitialized();
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
|
||||
// Check if already ignored (avoid duplicates)
|
||||
if (IsIgnoringConflicts(hotkeySettings))
|
||||
{
|
||||
Logger.LogInfo($"Hotkey already in ignored list: {hotkeySettings}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add to ignored list
|
||||
settings.IgnoredConflictProperties.IgnoredShortcuts.Add(hotkeySettings);
|
||||
SaveSettings();
|
||||
|
||||
Logger.LogInfo($"Added hotkey to ignored list: {hotkeySettings}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error adding hotkey to ignored list: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a hotkey setting from the ignored shortcuts list
|
||||
/// </summary>
|
||||
/// <param name="hotkeySettings">The hotkey settings to remove from the ignored list</param>
|
||||
/// <returns>True if successfully removed, false if it wasn't in the list or on error</returns>
|
||||
public static bool RemoveFromIgnoredList(HotkeySettings hotkeySettings)
|
||||
{
|
||||
if (hotkeySettings == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureInitialized();
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
var ignoredShortcut = settings.IgnoredConflictProperties.IgnoredShortcuts
|
||||
.FirstOrDefault(h => AreHotkeySettingsEqual(h, hotkeySettings));
|
||||
|
||||
if (ignoredShortcut != null)
|
||||
{
|
||||
settings.IgnoredConflictProperties.IgnoredShortcuts.Remove(ignoredShortcut);
|
||||
SaveSettings();
|
||||
|
||||
Logger.LogInfo($"Removed hotkey from ignored list: {ignoredShortcut}");
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"Hotkey not found in ignored list: {hotkeySettings}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error removing hotkey from ignored list: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all hotkey settings that are currently being ignored
|
||||
/// </summary>
|
||||
/// <returns>List of ignored hotkey settings</returns>
|
||||
public static List<HotkeySettings> GetAllIgnoredShortcuts()
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureInitialized();
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
return new List<HotkeySettings>(settings.IgnoredConflictProperties.IgnoredShortcuts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error getting ignored shortcuts: {ex.Message}");
|
||||
return new List<HotkeySettings>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all ignored shortcuts from the list
|
||||
/// </summary>
|
||||
/// <returns>True if successfully cleared, false on error</returns>
|
||||
public static bool ClearAllIgnoredShortcuts()
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureInitialized();
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
var count = settings.IgnoredConflictProperties.IgnoredShortcuts.Count;
|
||||
settings.IgnoredConflictProperties.IgnoredShortcuts.Clear();
|
||||
SaveSettings();
|
||||
|
||||
Logger.LogInfo($"Cleared all {count} ignored shortcuts");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error clearing ignored shortcuts: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two HotkeySettings for equality
|
||||
/// </summary>
|
||||
/// <param name="hotkey1">First hotkey settings</param>
|
||||
/// <param name="hotkey2">Second hotkey settings</param>
|
||||
/// <returns>True if they represent the same shortcut, false otherwise</returns>
|
||||
private static bool AreHotkeySettingsEqual(HotkeySettings hotkey1, HotkeySettings hotkey2)
|
||||
{
|
||||
if (hotkey1 == null || hotkey2 == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return hotkey1.Win == hotkey2.Win &&
|
||||
hotkey1.Ctrl == hotkey2.Ctrl &&
|
||||
hotkey1.Alt == hotkey2.Alt &&
|
||||
hotkey1.Shift == hotkey2.Shift &&
|
||||
hotkey1.Code == hotkey2.Code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the general settings using PowerToys standard settings persistence
|
||||
/// </summary>
|
||||
private static void SaveSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _generalSettingsRepository.SettingsConfig;
|
||||
|
||||
// Send IPC message to notify runner of changes (this is thread-safe)
|
||||
var outgoing = new OutGoingGeneralSettings(settings);
|
||||
ShellPage.SendDefaultIPCMessage(outgoing.ToString());
|
||||
ShellPage.ShellHandler?.SignalGeneralDataUpdate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error saving shortcut conflict settings: {ex.Message}");
|
||||
Logger.LogError($"Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
|
||||
[JsonSerializable(typeof(PowerOcrSettings))]
|
||||
[JsonSerializable(typeof(PowerOcrSettings))]
|
||||
[JsonSerializable(typeof(RegistryPreviewSettings))]
|
||||
[JsonSerializable(typeof(ShortcutConflictProperties))]
|
||||
[JsonSerializable(typeof(ShortcutGuideSettings))]
|
||||
[JsonSerializable(typeof(WINDOWPLACEMENT))]
|
||||
[JsonSerializable(typeof(WorkspacesSettings))]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid Visibility="{x:Bind HasConflicts, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid>
|
||||
<Button Click="ShortcutConflictBtn_Click" Style="{StaticResource SubtleButtonStyle}">
|
||||
<Grid ColumnSpacing="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -16,10 +16,10 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
x:Name="Icon"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="20"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
Glyph="" />
|
||||
Glyph="" />
|
||||
<StackPanel Grid.Column="1" Orientation="Vertical">
|
||||
<TextBlock x:Uid="ShortcutConflictControl_Title" FontWeight="SemiBold" />
|
||||
<TextBlock
|
||||
@@ -29,5 +29,16 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="ConflictsStateGroup">
|
||||
<VisualState x:Name="NoConflictState" />
|
||||
<VisualState x:Name="ConflictState">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="Icon.Glyph" Value="" />
|
||||
<Setter Target="Icon.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -47,12 +47,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
int count = 0;
|
||||
if (AllHotkeyConflictsData.InAppConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.InAppConflicts.Count;
|
||||
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
|
||||
{
|
||||
if (!inAppConflict.ConflictIgnored)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.SystemConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.SystemConflicts.Count;
|
||||
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
|
||||
{
|
||||
if (!systemConflict.ConflictIgnored)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
@@ -95,7 +107,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
OnPropertyChanged(nameof(HasConflicts));
|
||||
|
||||
// Update visibility based on conflict count
|
||||
Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (HasConflicts)
|
||||
{
|
||||
VisualStateManager.GoToState(this, "ConflictState", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
VisualStateManager.GoToState(this, "NoConflictState", true);
|
||||
}
|
||||
|
||||
if (!_telemetryEventSent && HasConflicts)
|
||||
{
|
||||
@@ -119,13 +138,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
InitializeComponent();
|
||||
DataContext = this;
|
||||
|
||||
// Initially hide the control if no conflicts
|
||||
Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateProperties();
|
||||
}
|
||||
|
||||
private void ShortcutConflictBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (AllHotkeyConflictsData == null || !HasConflicts)
|
||||
if (AllHotkeyConflictsData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,34 +53,22 @@
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Title Bar Area -->
|
||||
<Grid
|
||||
x:Name="titleBar"
|
||||
Height="48"
|
||||
ColumnSpacing="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition x:Name="LeftPaddingColumn" Width="0" />
|
||||
<ColumnDefinition x:Name="IconColumn" Width="Auto" />
|
||||
<ColumnDefinition x:Name="TitleColumn" Width="Auto" />
|
||||
<ColumnDefinition x:Name="RightPaddingColumn" Width="0" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Image
|
||||
Grid.Column="1"
|
||||
Width="16"
|
||||
Height="16"
|
||||
VerticalAlignment="Center"
|
||||
Source="/Assets/Settings/icon.ico" />
|
||||
<TextBlock
|
||||
x:Uid="ShortcutConflictWindow_TitleTxt"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource CaptionTextBlockStyle}" />
|
||||
</Grid>
|
||||
<TitleBar x:Name="titleBar" x:Uid="ShortcutConflictWindow_TitleTxt">
|
||||
<!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource -->
|
||||
<TitleBar.LeftHeader>
|
||||
<ImageIcon
|
||||
Height="16"
|
||||
Margin="16,0,0,0"
|
||||
Source="/Assets/Settings/icon.ico" />
|
||||
</TitleBar.LeftHeader>
|
||||
</TitleBar>
|
||||
|
||||
<!-- Description text -->
|
||||
|
||||
<TextBlock
|
||||
x:Uid="ShortcutConflictWindow_Description"
|
||||
Grid.Row="1"
|
||||
Margin="16,24,16,24"
|
||||
Margin="16,8,16,24"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource BodyTextBlockStyle}"
|
||||
TextWrapping="Wrap" />
|
||||
@@ -97,22 +85,40 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="hotkeyConflicts:HotkeyConflictGroupData">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<StackPanel
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
Orientation="Vertical">
|
||||
<!-- Hotkey Header -->
|
||||
<controls:ShortcutWithTextLabelControl
|
||||
x:Uid="ShortcutConflictWindow_ModulesUsingShortcut"
|
||||
Margin="0,0,0,8"
|
||||
FontWeight="SemiBold"
|
||||
Keys="{x:Bind Hotkey.GetKeysList()}"
|
||||
LabelPlacement="Before" />
|
||||
<Grid Margin="16,12,16,12">
|
||||
<controls:ShortcutWithTextLabelControl
|
||||
x:Uid="ShortcutConflictWindow_ModulesUsingShortcut"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Keys="{x:Bind Hotkey.GetKeysList()}"
|
||||
LabelPlacement="Before" />
|
||||
<CheckBox
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Click="OnIgnoreConflictClicked"
|
||||
Content="Ignore shortcut"
|
||||
IsChecked="{x:Bind ConflictIgnored, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- PowerToys Module Cards -->
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind Modules}">
|
||||
<ItemsControl
|
||||
Grid.Row="1"
|
||||
IsEnabled="{x:Bind ConflictVisible, Mode=OneWay}"
|
||||
ItemsSource="{x:Bind Modules, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="hotkeyConflicts:ModuleHotkeyData">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,4"
|
||||
Background="Transparent"
|
||||
BorderThickness="0,1,0,0"
|
||||
Click="SettingsCard_Click"
|
||||
CornerRadius="0"
|
||||
Description="{x:Bind DisplayName}"
|
||||
Header="{x:Bind Header}"
|
||||
IsClickEnabled="True">
|
||||
@@ -137,15 +143,15 @@
|
||||
<tkcontrols:SettingsCard
|
||||
x:Name="SystemConflictCard"
|
||||
x:Uid="ShortcutConflictWindow_SystemCard"
|
||||
Visibility="{x:Bind IsSystemConflict}">
|
||||
Background="Transparent"
|
||||
BorderThickness="0,1,0,0"
|
||||
CornerRadius="0"
|
||||
IsEnabled="{x:Bind ShouldShowSysConflict, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<PathIcon Data="M9 20H0V11H9V20ZM20 20H11V11H20V20ZM9 9H0V0H9V9ZM20 9H11V0H20V9Z" Foreground="{ThemeResource WindowsLogoGradient}" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<!-- System shortcut message -->
|
||||
<TextBlock
|
||||
x:Uid="ShortcutConflictWindow_SystemShortcutMessage"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
<HyperlinkButton x:Uid="ShortcutConflictWindow_SystemShortcutLink" NavigateUri="https://support.microsoft.com/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -14,6 +14,7 @@ using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.Graphics;
|
||||
using WinUIEx;
|
||||
|
||||
@@ -21,8 +22,6 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
|
||||
{
|
||||
public sealed partial class ShortcutConflictWindow : WindowEx
|
||||
{
|
||||
public ShortcutConflictViewModel DataContext { get; }
|
||||
|
||||
public ShortcutConflictViewModel ViewModel { get; private set; }
|
||||
|
||||
public ShortcutConflictWindow()
|
||||
@@ -33,14 +32,17 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
|
||||
SettingsRepository<GeneralSettings>.GetInstance(settingsUtils),
|
||||
ShellPage.SendDefaultIPCMessage);
|
||||
|
||||
DataContext = ViewModel;
|
||||
InitializeComponent();
|
||||
|
||||
// Set DataContext on the root Grid instead of the Window
|
||||
RootGrid.DataContext = ViewModel;
|
||||
|
||||
this.Activated += Window_Activated_SetIcon;
|
||||
|
||||
// Set localized window title
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
this.ExtendsContentIntoTitleBar = true;
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(titleBar);
|
||||
|
||||
this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title");
|
||||
this.CenterOnScreen();
|
||||
@@ -74,6 +76,54 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
|
||||
}
|
||||
}
|
||||
|
||||
private void OnIgnoreConflictClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is CheckBox checkBox && checkBox.DataContext is HotkeyConflictGroupData conflictGroup)
|
||||
{
|
||||
// The Click event only fires from user interaction, not programmatic changes
|
||||
if (checkBox.IsChecked == true)
|
||||
{
|
||||
IgnoreConflictGroup(conflictGroup);
|
||||
}
|
||||
else
|
||||
{
|
||||
UnignoreConflictGroup(conflictGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void IgnoreConflictGroup(HotkeyConflictGroupData conflictGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ignore all hotkey settings in this conflict group
|
||||
if (conflictGroup.Modules != null)
|
||||
{
|
||||
HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key);
|
||||
ViewModel.IgnoreShortcut(hotkey);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void UnignoreConflictGroup(HotkeyConflictGroupData conflictGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Unignore all hotkey settings in this conflict group
|
||||
if (conflictGroup.Modules != null)
|
||||
{
|
||||
HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key);
|
||||
ViewModel.UnignoreShortcut(hotkey);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void WindowEx_Closed(object sender, WindowEventArgs args)
|
||||
{
|
||||
ViewModel?.Dispose();
|
||||
@@ -82,10 +132,7 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
|
||||
private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
// Set window icon
|
||||
var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd);
|
||||
AppWindow appWindow = AppWindow.GetFromWindowId(windowId);
|
||||
appWindow.SetIcon("Assets\\Settings\\icon.ico");
|
||||
AppWindow.SetIcon("Assets\\Settings\\icon.ico");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,18 @@
|
||||
<VisualState.Setters>
|
||||
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" />
|
||||
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="2" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="1" />
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Warning">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" />
|
||||
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="1" />
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
@@ -120,6 +128,11 @@
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Warning">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
@@ -177,10 +190,18 @@
|
||||
<VisualState.Setters>
|
||||
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" />
|
||||
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="2" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="1" />
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Warning">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" />
|
||||
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
<Setter Target="KeyHolder.BorderThickness" Value="1" />
|
||||
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
|
||||
@@ -12,12 +12,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
[TemplateVisualState(Name = NormalState, GroupName = "CommonStates")]
|
||||
[TemplateVisualState(Name = DisabledState, GroupName = "CommonStates")]
|
||||
[TemplateVisualState(Name = InvalidState, GroupName = "CommonStates")]
|
||||
[TemplateVisualState(Name = WarningState, GroupName = "CommonStates")]
|
||||
public sealed partial class KeyVisual : Control
|
||||
{
|
||||
private const string KeyPresenter = "KeyPresenter";
|
||||
private const string NormalState = "Normal";
|
||||
private const string DisabledState = "Disabled";
|
||||
private const string InvalidState = "Invalid";
|
||||
private const string WarningState = "Warning";
|
||||
private KeyCharPresenter _keyPresenter;
|
||||
|
||||
public object Content
|
||||
@@ -28,13 +30,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
|
||||
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(KeyVisual), new PropertyMetadata(default(string), OnContentChanged));
|
||||
|
||||
public bool IsInvalid
|
||||
public State State
|
||||
{
|
||||
get => (bool)GetValue(IsInvalidProperty);
|
||||
set => SetValue(IsInvalidProperty, value);
|
||||
get => (State)GetValue(StateProperty);
|
||||
set => SetValue(StateProperty, value);
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty IsInvalidProperty = DependencyProperty.Register(nameof(IsInvalid), typeof(bool), typeof(KeyVisual), new PropertyMetadata(false, OnIsInvalidChanged));
|
||||
public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(State), typeof(KeyVisual), new PropertyMetadata(State.Normal, OnStateChanged));
|
||||
|
||||
public bool RenderKeyAsGlyph
|
||||
{
|
||||
@@ -64,7 +66,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
((KeyVisual)d).SetVisualStates();
|
||||
}
|
||||
|
||||
private static void OnIsInvalidChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((KeyVisual)d).SetVisualStates();
|
||||
}
|
||||
@@ -73,10 +75,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
{
|
||||
if (this != null)
|
||||
{
|
||||
if (IsInvalid)
|
||||
if (State == State.Error)
|
||||
{
|
||||
VisualStateManager.GoToState(this, InvalidState, true);
|
||||
}
|
||||
else if (State == State.Warning)
|
||||
{
|
||||
VisualStateManager.GoToState(this, WarningState, true);
|
||||
}
|
||||
else if (!IsEnabled)
|
||||
{
|
||||
VisualStateManager.GoToState(this, DisabledState, true);
|
||||
@@ -177,4 +183,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
SetVisualStates();
|
||||
}
|
||||
}
|
||||
|
||||
public enum State
|
||||
{
|
||||
Normal,
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
|
||||
x:Name="LayoutRoot"
|
||||
d:DesignHeight="300"
|
||||
d:DesignWidth="400"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" />
|
||||
</UserControl.Resources>
|
||||
<Grid HorizontalAlignment="Right">
|
||||
<Button
|
||||
x:Name="EditButton"
|
||||
@@ -40,8 +43,8 @@
|
||||
Content="{Binding}"
|
||||
CornerRadius="{StaticResource ControlCornerRadius}"
|
||||
FontWeight="SemiBold"
|
||||
IsInvalid="{Binding ElementName=LayoutRoot, Path=HasConflict}"
|
||||
IsTabStop="False"
|
||||
State="{Binding ElementName=LayoutRoot, Path=KeyVisualShouldShowConflict, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Warning}"
|
||||
Style="{StaticResource AccentKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -49,6 +52,7 @@
|
||||
<StackPanel
|
||||
x:Name="PlaceholderPanel"
|
||||
Padding="8,4"
|
||||
BorderBrush="{ThemeResource ControlStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{StaticResource ControlCornerRadius}"
|
||||
Orientation="Horizontal"
|
||||
@@ -62,13 +66,15 @@
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<FontIcon
|
||||
<controls:IsEnabledTextBlock
|
||||
Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
AutomationProperties.Name=""
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
FontSize="14"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Glyph="" />
|
||||
Text="" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
|
||||
using Microsoft.PowerToys.Settings.UI.Services;
|
||||
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -51,6 +52,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged));
|
||||
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged));
|
||||
public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged));
|
||||
public static readonly DependencyProperty KeyVisualShouldShowConflictProperty = DependencyProperty.Register("KeyVisualShouldShowConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false));
|
||||
|
||||
// Dependency property to track the source/context of the ShortcutControl
|
||||
public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(ShortcutControlSource), typeof(ShortcutControl), new PropertyMetadata(ShortcutControlSource.SettingsPage));
|
||||
@@ -161,6 +164,18 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
set => SetValue(TooltipProperty, value);
|
||||
}
|
||||
|
||||
public bool KeyVisualShouldShowConflict
|
||||
{
|
||||
get => (bool)GetValue(KeyVisualShouldShowConflictProperty);
|
||||
set => SetValue(KeyVisualShouldShowConflictProperty, value);
|
||||
}
|
||||
|
||||
public bool IgnoreConflict
|
||||
{
|
||||
get => (bool)GetValue(IgnoreConflictProperty);
|
||||
set => SetValue(IgnoreConflictProperty, value);
|
||||
}
|
||||
|
||||
public ShortcutControlSource Source
|
||||
{
|
||||
get => (ShortcutControlSource)GetValue(SourceProperty);
|
||||
@@ -241,6 +256,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
// Update the ShortcutControl's conflict properties from HotkeySettings
|
||||
HasConflict = hotkeySettings.HasConflict;
|
||||
Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null;
|
||||
IgnoreConflict = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings);
|
||||
KeyVisualShouldShowConflict = !IgnoreConflict && HasConflict;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -257,6 +274,10 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
this.Unloaded += ShortcutControl_Unloaded;
|
||||
this.Loaded += ShortcutControl_Loaded;
|
||||
|
||||
c.ResetClick += C_ResetClick;
|
||||
c.ClearClick += C_ClearClick;
|
||||
c.LearnMoreClick += C_LearnMoreClick;
|
||||
|
||||
// We create the Dialog in C# because doing it in XAML is giving WinUI/XAML Island bugs when using dark theme.
|
||||
shortcutDialog = new ContentDialog
|
||||
{
|
||||
@@ -264,11 +285,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
Title = resourceLoader.GetString("Activation_Shortcut_Title"),
|
||||
Content = c,
|
||||
PrimaryButtonText = resourceLoader.GetString("Activation_Shortcut_Save"),
|
||||
SecondaryButtonText = resourceLoader.GetString("Activation_Shortcut_Reset"),
|
||||
CloseButtonText = resourceLoader.GetString("Activation_Shortcut_Cancel"),
|
||||
DefaultButton = ContentDialogButton.Primary,
|
||||
};
|
||||
shortcutDialog.SecondaryButtonClick += ShortcutDialog_Reset;
|
||||
shortcutDialog.RightTapped += ShortcutDialog_Disable;
|
||||
|
||||
AutomationProperties.SetName(EditButton, resourceLoader.GetString("Activation_Shortcut_Title"));
|
||||
@@ -276,6 +295,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
OnAllowDisableChanged(this, null);
|
||||
}
|
||||
|
||||
private void C_LearnMoreClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Close the current shortcut dialog
|
||||
shortcutDialog.Hide();
|
||||
|
||||
// Create and show the ShortcutConflictWindow
|
||||
var conflictWindow = new ShortcutConflictWindow();
|
||||
conflictWindow.Activate();
|
||||
}
|
||||
|
||||
private void UpdateKeyVisualStyles()
|
||||
{
|
||||
if (PreviewKeysControl?.ItemsSource != null)
|
||||
@@ -305,6 +334,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
shortcutDialog.Opened -= ShortcutDialog_Opened;
|
||||
shortcutDialog.Closing -= ShortcutDialog_Closing;
|
||||
|
||||
c.LearnMoreClick -= C_LearnMoreClick;
|
||||
|
||||
if (App.GetSettingsWindow() != null)
|
||||
{
|
||||
App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated;
|
||||
@@ -510,6 +541,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
else
|
||||
{
|
||||
EnableKeys();
|
||||
|
||||
if (lastValidSettings.IsValid())
|
||||
{
|
||||
if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
@@ -578,16 +610,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
{
|
||||
shortcutDialog.IsPrimaryButtonEnabled = true;
|
||||
c.IsError = false;
|
||||
|
||||
// WarningLabel.Style = (Style)App.Current.Resources["SecondaryTextStyle"];
|
||||
}
|
||||
|
||||
private void DisableKeys()
|
||||
{
|
||||
shortcutDialog.IsPrimaryButtonEnabled = false;
|
||||
c.IsError = true;
|
||||
|
||||
// WarningLabel.Style = (Style)App.Current.Resources["SecondaryWarningTextStyle"];
|
||||
}
|
||||
|
||||
private void Hotkey_KeyUp(int key)
|
||||
@@ -648,6 +676,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
c.Keys = null;
|
||||
c.Keys = HotkeySettings.GetKeysList();
|
||||
|
||||
c.IgnoreConflict = IgnoreConflict;
|
||||
c.HasConflict = hotkeySettings.HasConflict;
|
||||
c.ConflictMessage = hotkeySettings.ConflictDescription;
|
||||
|
||||
@@ -660,7 +689,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
await shortcutDialog.ShowAsync();
|
||||
}
|
||||
|
||||
private void ShortcutDialog_Reset(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
private void C_ResetClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
hotkeySettings = null;
|
||||
|
||||
@@ -674,6 +703,20 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
|
||||
}
|
||||
|
||||
private void C_ClearClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
hotkeySettings = new HotkeySettings();
|
||||
|
||||
SetValue(HotkeySettingsProperty, hotkeySettings);
|
||||
SetKeys();
|
||||
|
||||
lastValidSettings = hotkeySettings;
|
||||
shortcutDialog.Hide();
|
||||
|
||||
// Send RequestAllConflicts IPC to update the UI after changed hotkey settings.
|
||||
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
|
||||
}
|
||||
|
||||
private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
if (ComboIsValid(lastValidSettings))
|
||||
@@ -728,7 +771,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
args.Handled = true;
|
||||
if (args.WindowActivationState != WindowActivationState.Deactivated && (hook == null || hook.GetDisposedState() == true))
|
||||
{
|
||||
// If the PT settings window gets focussed/activated again, we enable the keyboard hook to catch the keyboard input.
|
||||
// If the PT settings window gets focused/activated again, we enable the keyboard hook to catch the keyboard input.
|
||||
hook = new HotkeySettingsControlHook(Hotkey_KeyDown, Hotkey_KeyUp, Hotkey_IsActive, FilterAccessibleKeyboardEvents);
|
||||
}
|
||||
else if (args.WindowActivationState == WindowActivationState.Deactivated && hook != null && hook.GetDisposedState() == false)
|
||||
@@ -742,6 +785,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
private void ShortcutDialog_Closing(ContentDialog sender, ContentDialogClosingEventArgs args)
|
||||
{
|
||||
_isActive = false;
|
||||
lastValidSettings = hotkeySettings;
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
|
||||
@@ -3,78 +3,332 @@
|
||||
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:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:tk7controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
|
||||
x:Name="ShortcutContentControl"
|
||||
mc:Ignorable="d">
|
||||
<Grid MinWidth="498" MinHeight="220">
|
||||
<UserControl.Resources>
|
||||
<converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" />
|
||||
<Style x:Key="CondensedInfoBarStyle" TargetType="InfoBar">
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource InfoBarBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="{ThemeResource InfoBarBorderThickness}" />
|
||||
<Setter Property="AutomationProperties.LandmarkType" Value="Custom" />
|
||||
<Setter Property="AutomationProperties.IsDialog" Value="True" />
|
||||
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="InfoBar">
|
||||
<Border
|
||||
x:Name="ContentRoot"
|
||||
VerticalAlignment="Top"
|
||||
Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<!-- Background is used here so that it overrides the severity status color if set. -->
|
||||
<Grid
|
||||
MinHeight="0"
|
||||
Padding="8"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<!-- Icon -->
|
||||
<ColumnDefinition Width="*" />
|
||||
<!-- Title, message, and action -->
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<!-- Close button -->
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid
|
||||
x:Name="StandardIconArea"
|
||||
Margin="0,0,8,0"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock
|
||||
x:Name="IconBackground"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
FontSize="{StaticResource InfoBarIconFontSize}"
|
||||
Foreground="{ThemeResource InfoBarInformationalSeverityIconBackground}"
|
||||
Text="{StaticResource InfoBarIconBackgroundGlyph}" />
|
||||
<TextBlock
|
||||
x:Name="StandardIcon"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
FontSize="{StaticResource InfoBarIconFontSize}"
|
||||
Foreground="{ThemeResource InfoBarInformationalSeverityIconForeground}"
|
||||
Text="{StaticResource InfoBarInformationalIconGlyph}" />
|
||||
</Grid>
|
||||
<Viewbox
|
||||
x:Name="UserIconBox"
|
||||
Grid.Column="0"
|
||||
MaxWidth="{ThemeResource InfoBarIconFontSize}"
|
||||
MaxHeight="{ThemeResource InfoBarIconFontSize}"
|
||||
Margin="0"
|
||||
VerticalAlignment="Top"
|
||||
Child="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.IconElement}"
|
||||
Visibility="Collapsed" />
|
||||
<InfoBarPanel
|
||||
Grid.Column="1"
|
||||
Margin="0,0,0,0"
|
||||
HorizontalOrientationPadding="0"
|
||||
VerticalOrientationPadding="0">
|
||||
<TextBlock
|
||||
x:Name="Title"
|
||||
Margin="0,-1,0,0"
|
||||
FontSize="{StaticResource InfoBarTitleFontSize}"
|
||||
FontWeight="{StaticResource InfoBarTitleFontWeight}"
|
||||
Foreground="{ThemeResource InfoBarTitleForeground}"
|
||||
InfoBarPanel.HorizontalOrientationMargin="0,0,8,0"
|
||||
InfoBarPanel.VerticalOrientationMargin="0,8,0,0"
|
||||
Text="{TemplateBinding Title}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
x:Name="Message"
|
||||
Margin="0,-1,0,0"
|
||||
FontSize="{StaticResource InfoBarMessageFontSize}"
|
||||
FontWeight="{StaticResource InfoBarMessageFontWeight}"
|
||||
Foreground="{ThemeResource InfoBarMessageForeground}"
|
||||
InfoBarPanel.HorizontalOrientationMargin="0"
|
||||
InfoBarPanel.VerticalOrientationMargin="0"
|
||||
Text="{TemplateBinding Message}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<ContentPresenter
|
||||
Content="{TemplateBinding ActionButton}"
|
||||
InfoBarPanel.HorizontalOrientationMargin="16,-2,0,0"
|
||||
InfoBarPanel.VerticalOrientationMargin="0,8,0,0" />
|
||||
</InfoBarPanel>
|
||||
<ContentPresenter
|
||||
x:Name="ContentArea"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
</Grid>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="SeverityLevels">
|
||||
<VisualState x:Name="Informational" />
|
||||
<VisualState x:Name="Error">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarErrorSeverityBackgroundBrush}" />
|
||||
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconBackground}" />
|
||||
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarErrorIconGlyph}" />
|
||||
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconForeground}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Warning">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarWarningSeverityBackgroundBrush}" />
|
||||
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconBackground}" />
|
||||
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarWarningIconGlyph}" />
|
||||
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconForeground}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Success">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarSuccessSeverityBackgroundBrush}" />
|
||||
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconBackground}" />
|
||||
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarSuccessIconGlyph}" />
|
||||
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconForeground}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup x:Name="IconStates">
|
||||
<VisualState x:Name="StandardIconVisible">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="UserIconBox.Visibility" Value="Collapsed" />
|
||||
<Setter Target="StandardIconArea.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="UserIconVisible">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="UserIconBox.Visibility" Value="Visible" />
|
||||
<Setter Target="StandardIconArea.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="NoIconVisible" />
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup>
|
||||
<VisualState x:Name="CloseButtonVisible" />
|
||||
<VisualState x:Name="CloseButtonCollapsed" />
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup x:Name="InfoBarVisibility">
|
||||
<VisualState x:Name="InfoBarVisible" />
|
||||
<VisualState x:Name="InfoBarCollapsed">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentRoot.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup>
|
||||
<VisualState x:Name="ForegroundNotSet" />
|
||||
<VisualState x:Name="ForegroundSet">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="Title.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" />
|
||||
<Setter Target="Message.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup>
|
||||
<VisualState x:Name="BannerContent" />
|
||||
<VisualState x:Name="NoBannerContent">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentArea.(Grid.Row)" Value="0" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
<Grid
|
||||
MinWidth="498"
|
||||
MinHeight="220"
|
||||
RowSpacing="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition MinHeight="110" />
|
||||
<RowDefinition Height="Auto" MinHeight="104" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ItemsControl
|
||||
x:Name="KeysControl"
|
||||
<tk7controls:MarkdownTextBlock x:Uid="InvalidShortcutWarningLabel" Background="Transparent" />
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
Height="56"
|
||||
Margin="0,64,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalContentAlignment="Center"
|
||||
ItemsSource="{x:Bind Keys, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:KeyVisual
|
||||
Padding="20,16"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Content="{Binding}"
|
||||
CornerRadius="{StaticResource ControlCornerRadius}"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
IsInvalid="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
|
||||
IsTabStop="False"
|
||||
Style="{StaticResource AccentKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
Margin="0,16,0,0"
|
||||
Background="{ThemeResource SolidBackgroundFillColorTertiaryBrush}"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}">
|
||||
<ItemsControl
|
||||
x:Name="KeysControl"
|
||||
Height="56"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalContentAlignment="Center"
|
||||
ItemsSource="{x:Bind Keys, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:KeyVisual
|
||||
Padding="20,16"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Content="{Binding}"
|
||||
CornerRadius="{StaticResource ControlCornerRadius}"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
IsTabStop="False"
|
||||
State="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Error}"
|
||||
Style="{StaticResource AccentKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextAlignment="Center"
|
||||
Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToInvertedVisibilityConverter}}" />
|
||||
</Grid>
|
||||
<StackPanel
|
||||
Grid.Row="2"
|
||||
Margin="0,24,0,0"
|
||||
VerticalAlignment="Top"
|
||||
Orientation="Vertical"
|
||||
Spacing="8">
|
||||
<Grid Height="62">
|
||||
<InfoBar
|
||||
x:Uid="InvalidShortcut"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
|
||||
Severity="Error" />
|
||||
<InfoBar
|
||||
x:Uid="WarningShortcutAltGr"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
<InfoBar
|
||||
x:Uid="WarningShortcutConflict"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
|
||||
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
</Grid>
|
||||
<tkcontrols:MarkdownTextBlock
|
||||
x:Uid="InvalidShortcutWarningLabel"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
HorizontalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<HyperlinkButton
|
||||
x:Name="ResetBtn"
|
||||
x:Uid="Shortcut_ResetBtn"
|
||||
Click="ResetBtn_Click">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="Shortcut_ResetToolTip" TextWrapping="Wrap" />
|
||||
</ToolTipService.ToolTip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<FontIcon FontSize="10" Glyph="" />
|
||||
<TextBlock
|
||||
x:Uid="Shortcut_Reset"
|
||||
Margin="0,-1,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
</HyperlinkButton>
|
||||
<HyperlinkButton
|
||||
x:Name="ClearBtn"
|
||||
x:Uid="Shortcut_ClearBtn"
|
||||
Click="ClearBtn_Click"
|
||||
Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToVisibilityConverter}}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="Shortcut_ClearToolTip" TextWrapping="Wrap" />
|
||||
</ToolTipService.ToolTip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<FontIcon FontSize="12" Glyph="" />
|
||||
<TextBlock
|
||||
x:Uid="Shortcut_Clear"
|
||||
Margin="0,-1,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
</HyperlinkButton>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="3" Margin="0,12,0,0">
|
||||
<InfoBar
|
||||
x:Uid="InvalidShortcut"
|
||||
BorderThickness="0"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
|
||||
Severity="Error"
|
||||
Style="{StaticResource CondensedInfoBarStyle}" />
|
||||
<InfoBar
|
||||
x:Uid="WarningShortcutAltGr"
|
||||
BorderThickness="0"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
|
||||
Severity="Warning"
|
||||
Style="{StaticResource CondensedInfoBarStyle}" />
|
||||
<InfoBar
|
||||
BorderThickness="0"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}"
|
||||
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
|
||||
Severity="Warning"
|
||||
Style="{StaticResource CondensedInfoBarStyle}" />
|
||||
<InfoBar
|
||||
x:Uid="WarningPotentialShortcutConflict"
|
||||
BorderThickness="0"
|
||||
IsClosable="False"
|
||||
IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}"
|
||||
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}"
|
||||
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
|
||||
Severity="Warning"
|
||||
Style="{StaticResource CondensedInfoBarStyle}">
|
||||
<InfoBar.ActionButton>
|
||||
<HyperlinkButton
|
||||
x:Uid="Shortcut_Conflict_LearnMore"
|
||||
Padding="0"
|
||||
Click="LearnMoreBtn_Click" />
|
||||
</InfoBar.ActionButton>
|
||||
</InfoBar>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -2,8 +2,10 @@
|
||||
// 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.Diagnostics.Eventing.Reader;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
@@ -14,8 +16,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string)));
|
||||
public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnConflictPropertyChanged));
|
||||
public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty));
|
||||
public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnIgnoreConflictChanged));
|
||||
|
||||
public static readonly DependencyProperty ShouldShowConflictProperty = DependencyProperty.Register("ShouldShowConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty ShouldShowPotentialConflictProperty = DependencyProperty.Register("ShouldShowPotentialConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
|
||||
|
||||
public event EventHandler<bool> IgnoreConflictChanged;
|
||||
|
||||
public event RoutedEventHandler LearnMoreClick;
|
||||
|
||||
public bool IgnoreConflict
|
||||
{
|
||||
get => (bool)GetValue(IgnoreConflictProperty);
|
||||
set => SetValue(IgnoreConflictProperty, value);
|
||||
}
|
||||
|
||||
public bool HasConflict
|
||||
{
|
||||
@@ -29,9 +45,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
set => SetValue(ConflictMessageProperty, value);
|
||||
}
|
||||
|
||||
public bool ShouldShowConflict
|
||||
{
|
||||
get => (bool)GetValue(ShouldShowConflictProperty);
|
||||
private set => SetValue(ShouldShowConflictProperty, value);
|
||||
}
|
||||
|
||||
public bool ShouldShowPotentialConflict
|
||||
{
|
||||
get => (bool)GetValue(ShouldShowPotentialConflictProperty);
|
||||
private set => SetValue(ShouldShowPotentialConflictProperty, value);
|
||||
}
|
||||
|
||||
public ShortcutDialogContentControl()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
UpdateShouldShowConflict();
|
||||
}
|
||||
|
||||
public List<object> Keys
|
||||
@@ -51,5 +80,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
get => (bool)GetValue(IsWarningAltGrProperty);
|
||||
set => SetValue(IsWarningAltGrProperty, value);
|
||||
}
|
||||
|
||||
public event RoutedEventHandler ResetClick;
|
||||
|
||||
public event RoutedEventHandler ClearClick;
|
||||
|
||||
private static void OnIgnoreConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var control = d as ShortcutDialogContentControl;
|
||||
if (control == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
control.UpdateShouldShowConflict();
|
||||
|
||||
control.IgnoreConflictChanged?.Invoke(control, (bool)e.NewValue);
|
||||
}
|
||||
|
||||
private static void OnConflictPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var control = d as ShortcutDialogContentControl;
|
||||
if (control == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
control.UpdateShouldShowConflict();
|
||||
}
|
||||
|
||||
private void UpdateShouldShowConflict()
|
||||
{
|
||||
ShouldShowConflict = !IgnoreConflict && HasConflict;
|
||||
ShouldShowPotentialConflict = IgnoreConflict && HasConflict;
|
||||
}
|
||||
|
||||
private void ResetBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ResetClick?.Invoke(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
private void ClearBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ClearClick?.Invoke(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
private void LearnMoreBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
LearnMoreClick?.Invoke(this, new RoutedEventArgs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ using Microsoft.PowerToys.Settings.UI.Services;
|
||||
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
@@ -29,6 +28,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData();
|
||||
private Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
|
||||
|
||||
private int _conflictCount;
|
||||
|
||||
public bool EnableDataDiagnostics
|
||||
{
|
||||
get
|
||||
@@ -60,6 +61,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
if (_allHotkeyConflictsData != value)
|
||||
{
|
||||
_allHotkeyConflictsData = value;
|
||||
|
||||
UpdateConflictCount();
|
||||
|
||||
OnPropertyChanged(nameof(AllHotkeyConflictsData));
|
||||
OnPropertyChanged(nameof(ConflictCount));
|
||||
OnPropertyChanged(nameof(ConflictText));
|
||||
@@ -71,28 +75,43 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
}
|
||||
}
|
||||
|
||||
public int ConflictCount
|
||||
public int ConflictCount => _conflictCount;
|
||||
|
||||
private void UpdateConflictCount()
|
||||
{
|
||||
get
|
||||
int count = 0;
|
||||
if (AllHotkeyConflictsData == null)
|
||||
{
|
||||
if (AllHotkeyConflictsData == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
if (AllHotkeyConflictsData.InAppConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.InAppConflicts.Count;
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.SystemConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.SystemConflicts.Count;
|
||||
}
|
||||
|
||||
return count;
|
||||
_conflictCount = count;
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.InAppConflicts != null)
|
||||
{
|
||||
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
|
||||
{
|
||||
var hotkey = inAppConflict.Hotkey;
|
||||
var hotkeySettings = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
if (!HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.SystemConflicts != null)
|
||||
{
|
||||
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
|
||||
{
|
||||
var hotkey = systemConflict.Hotkey;
|
||||
var hotkeySettings = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
if (!HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_conflictCount = count;
|
||||
}
|
||||
|
||||
public string ConflictText
|
||||
|
||||
@@ -16,7 +16,9 @@ using CommunityToolkit.WinUI.Controls;
|
||||
using global::PowerToys.GPOWrapper;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.SerializationContext;
|
||||
@@ -39,6 +41,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
|
||||
public bool ShowDataDiagnosticsInfoBar => GetShowDataDiagnosticsInfoBar();
|
||||
|
||||
private int _conflictCount;
|
||||
|
||||
public AllHotkeyConflictsData AllHotkeyConflictsData
|
||||
{
|
||||
get => _allHotkeyConflictsData;
|
||||
@@ -47,34 +51,48 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
if (_allHotkeyConflictsData != value)
|
||||
{
|
||||
_allHotkeyConflictsData = value;
|
||||
|
||||
UpdateConflictCount();
|
||||
|
||||
OnPropertyChanged(nameof(AllHotkeyConflictsData));
|
||||
OnPropertyChanged(nameof(HasConflicts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasConflicts
|
||||
public bool HasConflicts => _conflictCount > 0;
|
||||
|
||||
private void UpdateConflictCount()
|
||||
{
|
||||
get
|
||||
int count = 0;
|
||||
if (AllHotkeyConflictsData == null)
|
||||
{
|
||||
if (AllHotkeyConflictsData == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
if (AllHotkeyConflictsData.InAppConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.InAppConflicts.Count;
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.SystemConflicts != null)
|
||||
{
|
||||
count += AllHotkeyConflictsData.SystemConflicts.Count;
|
||||
}
|
||||
|
||||
return count > 0;
|
||||
_conflictCount = count;
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.InAppConflicts != null)
|
||||
{
|
||||
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
|
||||
{
|
||||
if (!inAppConflict.ConflictIgnored)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AllHotkeyConflictsData.SystemConflicts != null)
|
||||
{
|
||||
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
|
||||
{
|
||||
if (!systemConflict.ConflictIgnored)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_conflictCount = count;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
@@ -100,6 +118,21 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
{
|
||||
this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () =>
|
||||
{
|
||||
var allConflictData = e.Conflicts;
|
||||
foreach (var inAppConflict in allConflictData.InAppConflicts)
|
||||
{
|
||||
var hotkey = inAppConflict.Hotkey;
|
||||
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
|
||||
}
|
||||
|
||||
foreach (var systemConflict in allConflictData.SystemConflicts)
|
||||
{
|
||||
var hotkey = systemConflict.Hotkey;
|
||||
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
|
||||
}
|
||||
|
||||
AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
Severity="Informational"
|
||||
Visibility="{x:Bind ViewModel.IsAnimationEnabledBySystem, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}">
|
||||
<InfoBar.ActionButton>
|
||||
<HyperlinkButton x:Uid="OpenSettings" Click="OpenAnimationsSettings_Click" />
|
||||
<HyperlinkButton x:Uid="OpenAnimationsSettings" Click="OpenAnimationsSettings_Click" />
|
||||
</InfoBar.ActionButton>
|
||||
</InfoBar>
|
||||
<tkcontrols:SettingsExpander
|
||||
@@ -355,6 +355,14 @@
|
||||
Value="{x:Bind ViewModel.MousePointerCrosshairsBorderSize, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsOrientation" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.MousePointerCrosshairsOrientation, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both" />
|
||||
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical" />
|
||||
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left">
|
||||
<CheckBox x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsAutoHide" IsChecked="{x:Bind ViewModel.MousePointerCrosshairsAutoHide, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
@@ -2667,23 +2667,20 @@ From there, simply click on one of the supported files in the File Explorer and
|
||||
<value>Press a combination of keys to change this shortcut.
|
||||
Right-click to remove the key combination, thereby deactivating the shortcut.</value>
|
||||
</data>
|
||||
<data name="Activation_Shortcut_Reset" xml:space="preserve">
|
||||
<value>Reset</value>
|
||||
</data>
|
||||
<data name="Activation_Shortcut_Save" xml:space="preserve">
|
||||
<value>Save</value>
|
||||
</data>
|
||||
<data name="Activation_Shortcut_Title" xml:space="preserve">
|
||||
<value>Activation shortcut</value>
|
||||
</data>
|
||||
<data name="InvalidShortcut.Title" xml:space="preserve">
|
||||
<data name="InvalidShortcut.Message" xml:space="preserve">
|
||||
<value>Invalid shortcut</value>
|
||||
</data>
|
||||
<data name="InvalidShortcutWarningLabel.Text" xml:space="preserve">
|
||||
<value>Only shortcuts that start with **Windows key**, **Ctrl**, **Alt** or **Shift** are valid.</value>
|
||||
<value>A shortcut should start with **Windows key**, **Ctrl**, **Alt** or **Shift**.</value>
|
||||
<comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment>
|
||||
</data>
|
||||
<data name="WarningShortcutAltGr.Title" xml:space="preserve">
|
||||
<data name="WarningShortcutAltGr.Message" xml:space="preserve">
|
||||
<value>Possible shortcut interference with Alt Gr</value>
|
||||
<comment>Alt Gr refers to the right alt key on some international keyboards</comment>
|
||||
</data>
|
||||
@@ -2691,8 +2688,8 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<value>Shortcuts with **Ctrl** and **Alt** may remove functionality from some international keyboards, because **Ctrl** + **Alt** = **Alt Gr** in those keyboards.</value>
|
||||
<comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment>
|
||||
</data>
|
||||
<data name="WarningShortcutConflict.Title" xml:space="preserve">
|
||||
<value>Shortcut conflict</value>
|
||||
<data name="WarningPotentialShortcutConflict.Message" xml:space="preserve">
|
||||
<value>This shortcut has a potential conflict, but the warning is ignored.</value>
|
||||
</data>
|
||||
<data name="WarningShortcutConflict.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>A conflict has been detected for this shortcut.</value>
|
||||
@@ -2895,6 +2892,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsFixedLength.Header" xml:space="preserve">
|
||||
<value>Crosshairs fixed length (px)</value>
|
||||
<comment>px = pixels</comment>
|
||||
</data>
|
||||
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation" xml:space="preserve">
|
||||
<value>Crosshairs orientation</value>
|
||||
</data>
|
||||
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both.Content" xml:space="preserve">
|
||||
<value>Vertical and horizontal lines</value>
|
||||
</data>
|
||||
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical.Content" xml:space="preserve">
|
||||
<value>Vertical only</value>
|
||||
</data>
|
||||
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal.Content" xml:space="preserve">
|
||||
<value>Horizontal only</value>
|
||||
</data>
|
||||
<data name="MouseUtils_GlidingCursor.Header" xml:space="preserve">
|
||||
<value>Gliding cursor</value>
|
||||
@@ -5256,23 +5265,23 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
|
||||
<data name="ShortcutConflictWindow_Title" xml:space="preserve">
|
||||
<value>PowerToys shortcut conflicts</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_TitleTxt.Text" xml:space="preserve">
|
||||
<data name="ShortcutConflictWindow_TitleTxt.Title" xml:space="preserve">
|
||||
<value>PowerToys shortcut conflicts</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_Description.Text" xml:space="preserve">
|
||||
<value>Conflicting shortcuts may cause unexpected behavior. Edit them here or go to the module settings to update them.</value>
|
||||
<value>If any shortcut conflicts are detected, they’ll appear below. Conflicts can happen between PowerToys utilities or Windows system shortcuts, and may cause unexpected behavior. If everything works as expected, you can safely ignore the conflict.</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_ModulesUsingShortcut.Text" xml:space="preserve">
|
||||
<value>Conflicts found for</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_SystemCard.Header" xml:space="preserve">
|
||||
<value>System</value>
|
||||
<value>System shortcut</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_SystemCard.Description" xml:space="preserve">
|
||||
<value>Windows system shortcut</value>
|
||||
<value>This shortcut is reserved by Windows and can't be reassigned.</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_SystemShortcutMessage.Text" xml:space="preserve">
|
||||
<value>This shortcut can't be changed.</value>
|
||||
<data name="ShortcutConflictWindow_SystemShortcutLink.Content" xml:space="preserve">
|
||||
<value>See all Windows shortcuts</value>
|
||||
</data>
|
||||
<data name="ShortcutConflictWindow_SystemShortcutTooltip.Content" xml:space="preserve">
|
||||
<value>This shortcut is used by Windows and can't be changed.</value>
|
||||
@@ -5312,4 +5321,31 @@ 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="DismissConflictBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="DismissText.Text" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="Shortcut_ResetBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Reset shortcut</value>
|
||||
</data>
|
||||
<data name="Shortcut_ResetToolTip.Text" xml:space="preserve">
|
||||
<value>Reset to the default shortcut</value>
|
||||
</data>
|
||||
<data name="Shortcut_Reset.Text" xml:space="preserve">
|
||||
<value>Reset</value>
|
||||
</data>
|
||||
<data name="Shortcut_ClearBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Clear shortcut</value>
|
||||
</data>
|
||||
<data name="Shortcut_ClearToolTip.Text" xml:space="preserve">
|
||||
<value>Clear and unassign this shortcut</value>
|
||||
</data>
|
||||
<data name="Shortcut_Clear.Text" xml:space="preserve">
|
||||
<value>Clear</value>
|
||||
</data>
|
||||
<data name="Shortcut_Conflict_LearnMore.Content" xml:space="preserve">
|
||||
<value>Learn more</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -128,14 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
if (value != _hotkey)
|
||||
{
|
||||
if (value == null || value.IsEmpty())
|
||||
{
|
||||
_hotkey = AlwaysOnTopProperties.DefaultHotkeyValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_hotkey = value;
|
||||
}
|
||||
_hotkey = value ?? AlwaysOnTopProperties.DefaultHotkeyValue;
|
||||
|
||||
Settings.Properties.Hotkey.Value = _hotkey;
|
||||
NotifyPropertyChanged();
|
||||
|
||||
@@ -29,7 +29,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
protected override string ModuleName => "Dashboard";
|
||||
|
||||
private const string JsonFileType = ".json";
|
||||
private Dispatcher dispatcher;
|
||||
|
||||
public Func<string, int> SendConfigMSG { get; }
|
||||
@@ -88,6 +87,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
var allConflictData = e.Conflicts;
|
||||
foreach (var inAppConflict in allConflictData.InAppConflicts)
|
||||
{
|
||||
var hotkey = inAppConflict.Hotkey;
|
||||
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
|
||||
}
|
||||
|
||||
foreach (var systemConflict in allConflictData.SystemConflicts)
|
||||
{
|
||||
var hotkey = systemConflict.Hotkey;
|
||||
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
|
||||
systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
|
||||
}
|
||||
|
||||
AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -776,7 +776,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
if (value != _editorHotkey)
|
||||
{
|
||||
if (value == null || value.IsEmpty())
|
||||
if (value == null)
|
||||
{
|
||||
_editorHotkey = FZConfigProperties.DefaultEditorHotkeyValue;
|
||||
}
|
||||
@@ -822,7 +822,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
if (value != _nextTabHotkey)
|
||||
{
|
||||
if (value == null || value.IsEmpty())
|
||||
if (value == null)
|
||||
{
|
||||
_nextTabHotkey = FZConfigProperties.DefaultNextTabHotkeyValue;
|
||||
}
|
||||
@@ -848,7 +848,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
if (value != _prevTabHotkey)
|
||||
{
|
||||
if (value == null || value.IsEmpty())
|
||||
if (value == null)
|
||||
{
|
||||
_prevTabHotkey = FZConfigProperties.DefaultPrevTabHotkeyValue;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
_mousePointerCrosshairsAutoHide = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsAutoHide.Value;
|
||||
_mousePointerCrosshairsIsFixedLengthEnabled = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsIsFixedLengthEnabled.Value;
|
||||
_mousePointerCrosshairsFixedLength = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsFixedLength.Value;
|
||||
_mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value;
|
||||
_mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value;
|
||||
|
||||
int isEnabled = 0;
|
||||
@@ -869,6 +870,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public int MousePointerCrosshairsOrientation
|
||||
{
|
||||
get
|
||||
{
|
||||
return _mousePointerCrosshairsOrientation;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value != _mousePointerCrosshairsOrientation)
|
||||
{
|
||||
_mousePointerCrosshairsOrientation = value;
|
||||
MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value = value;
|
||||
NotifyMousePointerCrosshairsPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool MousePointerCrosshairsAutoActivate
|
||||
{
|
||||
get
|
||||
@@ -991,6 +1010,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
private bool _mousePointerCrosshairsAutoHide;
|
||||
private bool _mousePointerCrosshairsIsFixedLengthEnabled;
|
||||
private int _mousePointerCrosshairsFixedLength;
|
||||
private int _mousePointerCrosshairsOrientation;
|
||||
private bool _mousePointerCrosshairsAutoActivate;
|
||||
private bool _isAnimationEnabledBySystem;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
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.HotkeyConflicts;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
|
||||
using Microsoft.PowerToys.Settings.UI.SerializationContext;
|
||||
@@ -70,6 +68,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
protected override string ModuleName => "ShortcutConflictsWindow";
|
||||
|
||||
/// <summary>
|
||||
/// Ignore a specific HotkeySettings
|
||||
/// </summary>
|
||||
/// <param name="hotkeySettings">The HotkeySettings to ignore</param>
|
||||
public void IgnoreShortcut(HotkeySettings hotkeySettings)
|
||||
{
|
||||
if (hotkeySettings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HotkeyConflictIgnoreHelper.AddToIgnoredList(hotkeySettings);
|
||||
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a HotkeySettings from the ignored list
|
||||
/// </summary>
|
||||
/// <param name="hotkeySettings">The HotkeySettings to unignore</param>
|
||||
public void UnignoreShortcut(HotkeySettings hotkeySettings)
|
||||
{
|
||||
if (hotkeySettings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HotkeyConflictIgnoreHelper.RemoveFromIgnoredList(hotkeySettings);
|
||||
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
|
||||
}
|
||||
|
||||
private IHotkeyConfig GetModuleSettings(string moduleKey)
|
||||
{
|
||||
try
|
||||
@@ -120,20 +148,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
ProcessConflictGroup(conflict, isSystemConflict);
|
||||
HotkeySettings hotkey = new(conflict.Hotkey.Win, conflict.Hotkey.Ctrl, conflict.Hotkey.Alt, conflict.Hotkey.Shift, conflict.Hotkey.Key);
|
||||
var isIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkey);
|
||||
conflict.ConflictIgnored = isIgnored;
|
||||
|
||||
ProcessConflictGroup(conflict, isSystemConflict, isIgnored);
|
||||
items.Add(conflict);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict)
|
||||
private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict, bool isIgnored)
|
||||
{
|
||||
foreach (var module in conflict.Modules)
|
||||
{
|
||||
SetupModuleData(module, isSystemConflict);
|
||||
SetupModuleData(module, isSystemConflict, isIgnored);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict)
|
||||
private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict, bool isIgnored)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -220,55 +252,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveModuleSettingsAndNotify(string moduleName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = GetModuleSettings(moduleName);
|
||||
|
||||
if (settings is ISettingsConfig settingsConfig)
|
||||
{
|
||||
// No need to save settings here, the runner will call module interface to save it
|
||||
// SaveSettingsToFile(settings);
|
||||
|
||||
// Send IPC notification using the same format as other ViewModels
|
||||
SendConfigMSG(settingsConfig, moduleName);
|
||||
|
||||
System.Diagnostics.Debug.WriteLine($"Saved settings and sent IPC notification for module: {moduleName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error saving settings and notifying for {moduleName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveSettingsToFile(IHotkeyConfig settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the repository for this settings type using reflection
|
||||
var settingsType = settings.GetType();
|
||||
var repositoryMethod = typeof(SettingsFactory).GetMethod("GetRepository");
|
||||
if (repositoryMethod != null)
|
||||
{
|
||||
var genericMethod = repositoryMethod.MakeGenericMethod(settingsType);
|
||||
var repository = genericMethod.Invoke(_settingsFactory, null);
|
||||
|
||||
if (repository != null)
|
||||
{
|
||||
var saveMethod = repository.GetType().GetMethod("SaveSettingsToFile");
|
||||
saveMethod?.Invoke(repository, null);
|
||||
System.Diagnostics.Debug.WriteLine($"Saved settings to file for type: {settingsType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error saving settings to file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends IPC notification using the same format as other ViewModels
|
||||
/// </summary>
|
||||
|
||||
@@ -127,7 +127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
if (value != _hotkey)
|
||||
{
|
||||
if (value == null || value.IsEmpty())
|
||||
if (value == null)
|
||||
{
|
||||
_hotkey = WorkspacesProperties.DefaultHotkeyValue;
|
||||
}
|
||||
|
||||
76
tools/ReleaseNoteGeneration/Instruction.md
Normal file
76
tools/ReleaseNoteGeneration/Instruction.md
Normal file
@@ -0,0 +1,76 @@
|
||||
## Background
|
||||
This document describes how to collect pull requests for a milestone, request a GitHub Copilot code review for each, and produce release‑notes summaries grouped by label.
|
||||
|
||||
## Agent‑mode execution policy (important)
|
||||
- By default, do NOT run terminal commands or PowerShell scripts beside the ps1 in this folder. Perform all collection, parsing, grouping, and summarization entirely in Agent mode using available files and MCP capabilities.
|
||||
- Only execute existing scripts if the user explicitly asks you to (opt‑in). Otherwise, assume the input artifacts (milestone_prs.json, sorted_prs.csv, grouped_csv/*) are present or will be provided.
|
||||
- Do NOT create new scripts unless requested and justified.
|
||||
|
||||
## Prerequisites
|
||||
- Windows with PowerShell 7+ (pwsh)
|
||||
- GitHub CLI installed and authenticated to the target repo
|
||||
- gh version that supports Copilot review requests
|
||||
- Logged in: gh auth login (ensure repo scope)
|
||||
- Access to the repository configured in the scripts (default: `microsoft/PowerToys`)
|
||||
- GitHub Copilot code review enabled for the org/repo (required for requesting reviews)
|
||||
- 'MCP Server: github-remote' is installed, please find it at [github-mcp-server](https://github.com/github/github-mcp-server)
|
||||
|
||||
## Files in this repo (overview)
|
||||
- `dump-prs-information.ps1`: Fetches PRs for a milestone and outputs `milestone_prs.json` and `sorted_prs.csv`
|
||||
- CSV columns: `Id, Title, Labels, Author, Url, Body, CopilotSummary`
|
||||
- `diff_prs.ps1`: Creates an incremental CSV by diffing two CSVs (in case more PRs cherry pick to stable)
|
||||
- `MemberList.md`: Internal contributors list (used to decide when to add external thanks)
|
||||
- `SampleOutput.md`: Example formatting for summary content
|
||||
|
||||
## Step-by-step
|
||||
1) run `dump-prs-information.ps1` to export PRs for the target milestone (initial run, CopilotSummary likely empty)
|
||||
- Open `dump-prs-information.ps1` and set:
|
||||
- `$repo` (e.g., `microsoft/PowerToys`)
|
||||
- `$milestone` (milestone title exactly as in GitHub, e.g., `PowerToys 0.95`)
|
||||
- run the script in PowerShell; it will generate `milestone_prs.json` and `sorted_prs.csv`.
|
||||
|
||||
2) Request Copilot reviews for each PR listed in the CSV in Agent mode (MUST NOT generate or run any ps1)
|
||||
- Must use MCP tools "MCP Server: github-remote" in current Agent mode to request Copilot reviews for all PR Ids in `sorted_prs.csv`.
|
||||
|
||||
3) run `dump-prs-information.ps1` again
|
||||
- This refresh collects the latest Copilot review body into the `CopilotSummary` column in `sorted_prs.csv`.
|
||||
|
||||
4) run `group-prs-by-label.ps1` to generate `grouped_csv/`
|
||||
|
||||
5) Summarize PRs into per‑label Markdown files in Agent mode (MUST NOT generate or run any script in terminal nor ps1)
|
||||
- Read the the csv files in the folder grouped_csv one by one
|
||||
- For each label group, create a markdown file under a new folder `grouped_md/` (create if missing). File name: sanitized label group name (same pattern as CSV) with `.md` extension. Example: `Area-Build.md`.
|
||||
- Each markdown file content must follow the structure below (two sections) and preserve the PR order from the source CSV.
|
||||
- Do not embed PR numbers in the bullet list lines; only link them in the table.
|
||||
- If re-running, overwrite existing markdown files (idempotent generation).
|
||||
- After generation, you should have a 1:1 correspondence between files in `grouped_csv/` and `grouped_md/` (excluding any intentionally skipped groups—document if skipped).
|
||||
- Generate the summary md file as the following instruction in two parts:
|
||||
1. Markdown list: one concise, user‑facing line per PR (no deep technical jargon). Use "Verbed" + "Scenario" + "Impact" as setence structure. Use `Title`, `Body`, and `CopilotSummary` as sources.
|
||||
- If `Author` is NOT in `**/MemberList.md`, append a "Thanks @handle!" see `**/SampleOutput.md` as example.
|
||||
- Do NOT include PR numbers or IDs in the list line; keep the PR link only in the table mentioned in 2. below, please refer to `**/SampleOutput.md` as example.
|
||||
- If confidence to have enough information for summarization according to guideline above is < 70%, write: `Human Summary Needed: <PR full link>` on that line.
|
||||
2. Three‑column table (in the same PR order):
|
||||
- Column 1: The concise, user‑facing summary (the "cut version")
|
||||
- Column 2: PR link
|
||||
- Column 3: Confidence (e.g., `High/Medium/Low`) and the reasoning if < 70%
|
||||
6) According the generated grouped_md/*.md, update back the repo root's `Readme.md`. Here is the guideline:
|
||||
a. Replace all versioned references in `README.md`:
|
||||
- Bump current release heading (e.g. **Version 0.xx**) by +0.01.
|
||||
- Shift link references: previous `[github-current-release-work]` becomes old version; increment `[github-next-release-work]` to point to the following milestone.
|
||||
- Update download asset filenames (e.g. `PowerToysSetup-0.94.0-...` → `PowerToysSetup-0.95.0-...`).
|
||||
b. Build the What's New content from `grouped_md`:
|
||||
- Combine `Area-Build` and `Area-Tests` entries under a single `Development` subsection (keep bullet order from CSV).
|
||||
- Each other `Product-*` group gets its own subsection titled by the module name.
|
||||
- Order subsections alphabetically by their heading text, with **Highlights** always first and **Development** always last (e.g., Environment Variables, File Locksmith, Find My Mouse, ... , ZoomIt, Development).
|
||||
- Copy bullet lines verbatim from the corresponding `grouped_md` files (preserve punctuation and any trailing `Thanks @handle!`). Do NOT add, remove, or re‑evaluate thanks in the README stage.
|
||||
c. Highlights: choose up to 10 bullets focused on user-visible feature additions or impactful fixes (avoid purely internal refactors). Use pattern: `Module/Feature <past-tense verb> <scenario> <impact>`.
|
||||
d. Keep wording concise (aim 1 line per bullet), no PR numbers, no deep implementation details.
|
||||
e. After updating, verify total highlight count ≤ 10 and that all internal contributors are not thanked.
|
||||
|
||||
## Notes and conventions
|
||||
- Terminal usage: Disabled by default. Do NOT run terminal commands or ps1 scripts unless the user explicitly instructs you to.
|
||||
- Do NOT generate/add new ps1 until instructed (and explain why a new script is needed).
|
||||
- Label filtering in `dump-prs-information.ps1` currently keeps labels matching: `Product-*`, `Area-*`, `Github*`, `*Plugin`, `Issue-*`.
|
||||
- CSV columns are single‑line (line breaks removed) for easier processing.
|
||||
- Keep PRs in the same order as in `sorted_prs.csv` when building summaries.
|
||||
- Sanitize filenames: replace spaces with `-`, strip or replace characters that are invalid on Windows (`<>:"/\\|?*`).
|
||||
26
tools/ReleaseNoteGeneration/MemberList.md
Normal file
26
tools/ReleaseNoteGeneration/MemberList.md
Normal file
@@ -0,0 +1,26 @@
|
||||
cinnamon-msft
|
||||
craigloewen-msft
|
||||
niels9001
|
||||
dhowett
|
||||
yeelam-gordon
|
||||
jamrobot
|
||||
lei9444
|
||||
shuaiyuanxx
|
||||
moooyo
|
||||
haoliuu
|
||||
chenmy77
|
||||
chemwolf6922
|
||||
yaqingmi
|
||||
zhaoqpcn
|
||||
urnotdfs
|
||||
zhaopy536
|
||||
wang563681252
|
||||
vanzue
|
||||
zadjii-msft
|
||||
khmyznikov
|
||||
chatasweetie
|
||||
michaeljolley
|
||||
Jaylyn-Barbee
|
||||
zateutsch
|
||||
crutkas
|
||||
app/copilot-swe-agent
|
||||
9
tools/ReleaseNoteGeneration/SampleOutput.md
Normal file
9
tools/ReleaseNoteGeneration/SampleOutput.md
Normal file
@@ -0,0 +1,9 @@
|
||||
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
|
||||
|
||||
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
|
||||
|
||||
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
|
||||
|
||||
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
|
||||
|
||||
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!
|
||||
100
tools/ReleaseNoteGeneration/diff_prs.ps1
Normal file
100
tools/ReleaseNoteGeneration/diff_prs.ps1
Normal file
@@ -0,0 +1,100 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Produce an incremental PR CSV containing rows present in a newer full export but absent from a baseline export.
|
||||
|
||||
.DESCRIPTION
|
||||
Compares two previously generated sorted PR CSV files (same schema). Any row whose key column value
|
||||
(defaults to 'Number') does not exist in the baseline file is emitted to a new incremental CSV, preserving
|
||||
the original column order. If no new rows are found, an empty CSV (with headers when determinable) is written.
|
||||
|
||||
.PARAMETER BaseCsv
|
||||
Path to the baseline (earlier) PR CSV.
|
||||
|
||||
.PARAMETER AllCsv
|
||||
Path to the newer full PR CSV containing superset (or equal set) of rows.
|
||||
|
||||
.PARAMETER OutCsv
|
||||
Path to write the incremental CSV containing only new rows.
|
||||
|
||||
.PARAMETER Key
|
||||
Column name used as unique identifier (defaults to 'Number'). Must exist in both CSVs.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./diff_prs.ps1 -BaseCsv sorted_prs_prev.csv -AllCsv sorted_prs.csv -OutCsv sorted_prs_incremental.csv
|
||||
|
||||
.NOTES
|
||||
Requires: PowerShell 7+, both CSVs with identical column schemas.
|
||||
Exit code 0 on success (even if zero incremental rows). Throws on missing files.
|
||||
#>
|
||||
|
||||
[CmdletBinding()] param(
|
||||
[Parameter(Mandatory=$false)][string]$BaseCsv = "./sorted_prs_93_round1.csv",
|
||||
[Parameter(Mandatory=$false)][string]$AllCsv = "./sorted_prs.csv",
|
||||
[Parameter(Mandatory=$false)][string]$OutCsv = "./sorted_prs_93_incremental.csv",
|
||||
[Parameter(Mandatory=$false)][string]$Key = "Number"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($m) { Write-Host "[info] $m" -ForegroundColor Cyan }
|
||||
function Write-Warn($m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BaseCsv)) { throw "Base CSV not found: $BaseCsv" }
|
||||
if (-not (Test-Path -LiteralPath $AllCsv)) { throw "All CSV not found: $AllCsv" }
|
||||
|
||||
# Load CSVs
|
||||
$baseRows = Import-Csv -LiteralPath $BaseCsv
|
||||
$allRows = Import-Csv -LiteralPath $AllCsv
|
||||
|
||||
if (-not $baseRows) { Write-Warn "Base CSV has no rows." }
|
||||
if (-not $allRows) { Write-Warn "All CSV has no rows." }
|
||||
|
||||
# Validate key presence
|
||||
if ($baseRows -and -not ($baseRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in base CSV." }
|
||||
if ($allRows -and -not ($allRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in all CSV." }
|
||||
|
||||
# Build a set of existing keys from base
|
||||
$set = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
foreach ($row in $baseRows) {
|
||||
$val = [string]($row.$Key)
|
||||
if ($null -ne $val) { [void]$set.Add($val) }
|
||||
}
|
||||
|
||||
# Filter rows in AllCsv whose key is not in base (these are the new / incremental rows)
|
||||
$incremental = @()
|
||||
foreach ($row in $allRows) {
|
||||
$val = [string]($row.$Key)
|
||||
if (-not $set.Contains($val)) { $incremental += $row }
|
||||
}
|
||||
|
||||
# Preserve column order from the All CSV
|
||||
$columns = @()
|
||||
if ($allRows.Count -gt 0) {
|
||||
$columns = $allRows[0].PSObject.Properties.Name
|
||||
}
|
||||
|
||||
try {
|
||||
if ($incremental.Count -gt 0) {
|
||||
if ($columns.Count -gt 0) {
|
||||
$incremental | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
} else {
|
||||
$incremental | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
}
|
||||
} else {
|
||||
# Write an empty CSV with headers if we know them (facilitates downstream tooling expecting header row)
|
||||
if ($columns.Count -gt 0) {
|
||||
$obj = [PSCustomObject]@{}
|
||||
foreach ($c in $columns) { $obj | Add-Member -NotePropertyName $c -NotePropertyValue $null }
|
||||
$obj | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
} else {
|
||||
'' | Out-File -LiteralPath $OutCsv -Encoding UTF8
|
||||
}
|
||||
}
|
||||
Write-Info ("Incremental rows: {0}" -f $incremental.Count)
|
||||
Write-Info ("Output: {0}" -f (Resolve-Path -LiteralPath $OutCsv))
|
||||
}
|
||||
catch {
|
||||
Write-Host "[error] Failed writing output CSV: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
123
tools/ReleaseNoteGeneration/dump-prs-information.ps1
Normal file
123
tools/ReleaseNoteGeneration/dump-prs-information.ps1
Normal file
@@ -0,0 +1,123 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Export merged pull requests for a milestone into JSON and CSV (sorted) with optional Copilot review summarization.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the GitHub CLI (gh) to list merged PRs for the specified milestone, captures basic metadata,
|
||||
attempts to obtain a Copilot review summary (choosing the longest Copilot-authored review body),
|
||||
filters labels to a predefined allow-list, and outputs:
|
||||
* Raw JSON list (for traceability)
|
||||
* Sorted CSV (first label alphabetical) used by downstream grouping scripts.
|
||||
|
||||
.PARAMETER Repo
|
||||
GitHub repository in the form 'owner/name'. Default: 'microsoft/PowerToys'.
|
||||
|
||||
.PARAMETER Milestone
|
||||
Exact milestone title (as it appears on GitHub), e.g. 'PowerToys 0.95'.
|
||||
|
||||
.PARAMETER OutputJson
|
||||
Path for raw JSON output. Default: 'milestone_prs.json'.
|
||||
|
||||
.PARAMETER OutputCsv
|
||||
Path for sorted CSV output. Default: 'sorted_prs.csv'.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-information.ps1 -Milestone 'PowerToys 0.95'
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-information.ps1 -Repo microsoft/PowerToys -Milestone 'PowerToys 0.95' -OutputCsv m1.csv
|
||||
|
||||
.NOTES
|
||||
Requires: gh CLI authenticated with repo read access.
|
||||
This script intentionally does NOT use Set-StrictMode (per current repository guidance for release tooling).
|
||||
#>
|
||||
[CmdletBinding()] param(
|
||||
[Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys',
|
||||
[Parameter(Mandatory=$true)][string]$Milestone,
|
||||
[Parameter(Mandatory=$false)][string]$OutputJson = 'milestone_prs.json',
|
||||
[Parameter(Mandatory=$false)][string]$OutputCsv = 'sorted_prs.csv'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan }
|
||||
function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red }
|
||||
|
||||
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found in PATH."; exit 1 }
|
||||
|
||||
Write-Info "Fetching merged PRs for milestone '$Milestone' from $Repo ..."
|
||||
$searchQuery = "milestone:`"$Milestone`""
|
||||
$ghCommand = "gh pr list --repo $Repo --state merged --search '$searchQuery' --json number,title,labels,author,url,body --limit 200"
|
||||
try {
|
||||
Invoke-Expression $ghCommand | Out-File -Encoding UTF8 -FilePath $OutputJson
|
||||
}
|
||||
catch {
|
||||
Write-Err "Failed querying PRs: $_"; exit 1
|
||||
}
|
||||
|
||||
# === STEP 1: Query PRs from GitHub ===
|
||||
if (-not (Test-Path -LiteralPath $OutputJson)) { Write-Err "JSON output not created: $OutputJson"; exit 1 }
|
||||
|
||||
Write-Info "Parsing JSON ..."
|
||||
$prs = Get-Content $OutputJson | ConvertFrom-Json
|
||||
if (-not $prs) { Write-Warn "No PRs returned for milestone '$Milestone'"; exit 0 }
|
||||
$sorted = $prs | Sort-Object { $_.labels[0]?.name }
|
||||
|
||||
Write-Info "Fetching Copilot reviews for each PR (longest Copilot-authored body)."
|
||||
$csvData = $sorted | ForEach-Object {
|
||||
$prNumber = $_.number
|
||||
Write-Info "Processing PR #$prNumber ..."
|
||||
|
||||
# Get Copilot review for this PR
|
||||
$copilotOverview = ""
|
||||
try {
|
||||
$reviewsCommand = "gh pr view $prNumber --repo $repo --json reviews"
|
||||
$reviewsJson = Invoke-Expression $reviewsCommand | ConvertFrom-Json
|
||||
|
||||
# Collect Copilot reviews (match various author logins). Choose the LONGEST body (more content) vs newest.
|
||||
$copilotReviews = $reviewsJson.reviews | Where-Object {
|
||||
($_.author.login -eq "github-copilot[bot]" -or
|
||||
$_.author.login -eq "copilot" -or
|
||||
$_.author.login -eq "github-copilot" -or
|
||||
$_.author.login -like "*copilot*") -and
|
||||
$_.body -and
|
||||
$_.body.Trim() -ne ""
|
||||
}
|
||||
if ($copilotReviews -and $copilotReviews.Count -gt 0) {
|
||||
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
$copilotOverview = $longest.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' '
|
||||
Write-Info " Copilot review selected (author=$($longest.author.login) length=$($longest.body.Length))"
|
||||
} else {
|
||||
Write-Warn " No Copilot reviews found for PR #$prNumber"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn " Could not fetch reviews for PR #$prNumber"
|
||||
}
|
||||
|
||||
# Filter labels to only include specific patterns
|
||||
$filteredLabels = $_.labels | Where-Object {
|
||||
($_.name -like "Product-*") -or
|
||||
($_.name -like "Area-*") -or
|
||||
($_.name -like "Github*") -or
|
||||
($_.name -like "*Plugin") -or
|
||||
($_.name -like "Issue-*")
|
||||
}
|
||||
|
||||
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
|
||||
[PSCustomObject]@{
|
||||
Id = $_.number
|
||||
Title = $_.title
|
||||
Labels = $labelNames
|
||||
Author = $_.author.login
|
||||
Url = $_.url
|
||||
Body = $_.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' ' # Make body single-line
|
||||
CopilotSummary = $copilotOverview
|
||||
}
|
||||
}
|
||||
|
||||
# === STEP 3: Output CSV ===
|
||||
Write-Info "Saving CSV to $OutputCsv ..."
|
||||
$csvData | Export-Csv $OutputCsv -NoTypeInformation -Encoding UTF8
|
||||
Write-Info "Done. Rows: $($csvData.Count). CSV: $(Resolve-Path -LiteralPath $OutputCsv)"
|
||||
275
tools/ReleaseNoteGeneration/dump-prs-since-commit.ps1
Normal file
275
tools/ReleaseNoteGeneration/dump-prs-since-commit.ps1
Normal file
@@ -0,0 +1,275 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Export merged PR metadata between two commits (exclusive start, inclusive end) to JSON and CSV.
|
||||
|
||||
.DESCRIPTION
|
||||
Identifies merge/squash commits reachable from EndCommit but not StartCommit, extracts PR numbers,
|
||||
queries GitHub for metadata plus (optionally) Copilot review/comment summaries, filters labels, then
|
||||
emits a JSON artifact and a sorted CSV (first label alphabetical) analogous to dump-prs-information.ps1.
|
||||
|
||||
.PARAMETER StartCommit
|
||||
Exclusive starting commit (SHA, tag, or ref). Commits AFTER this one are considered.
|
||||
|
||||
.PARAMETER EndCommit
|
||||
Inclusive ending commit (SHA, tag, or ref). Default: HEAD.
|
||||
|
||||
.PARAMETER Repo
|
||||
GitHub repository (owner/name). Default: microsoft/PowerToys.
|
||||
|
||||
.PARAMETER OutputCsv
|
||||
Destination CSV path. Default: sorted_prs.csv.
|
||||
|
||||
.PARAMETER OutputJson
|
||||
Destination JSON path containing raw PR objects. Default: milestone_prs.json.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv delta.csv
|
||||
|
||||
.NOTES
|
||||
Requires: git, gh (authenticated). No Set-StrictMode to keep parity with existing release scripts.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StartCommit, # exclusive start (commits AFTER this one)
|
||||
[string]$EndCommit = "HEAD",
|
||||
[string]$Repo = "microsoft/PowerToys",
|
||||
[string]$OutputCsv = "sorted_prs.csv",
|
||||
[string]$OutputJson = "milestone_prs.json"
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
|
||||
.DESCRIPTION
|
||||
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
|
||||
queries GitHub (gh CLI) for details, then outputs a CSV similar to dump-prs-information.ps1.
|
||||
|
||||
PR merge commit messages in PowerToys generally contain patterns like:
|
||||
Merge pull request #12345 from ...
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
|
||||
|
||||
.NOTES
|
||||
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
|
||||
CopilotSummary behavior:
|
||||
- Attempts to locate the latest GitHub Copilot authored review (preferred).
|
||||
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
|
||||
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
|
||||
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
|
||||
#>
|
||||
|
||||
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
|
||||
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
|
||||
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
|
||||
function Write-DebugMsg($msg) { if ($PSBoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue') { Write-Host "[VERBOSE] $msg" -ForegroundColor DarkGray } }
|
||||
|
||||
# Validate we are in a git repo
|
||||
#if (-not (Test-Path .git)) {
|
||||
# Write-Err "Current directory does not appear to be the root of a git repository."
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
# Resolve commits
|
||||
try {
|
||||
$startSha = (git rev-parse --verify $StartCommit) 2>$null
|
||||
if (-not $startSha) { throw "StartCommit '$StartCommit' not found" }
|
||||
$endSha = (git rev-parse --verify $EndCommit) 2>$null
|
||||
if (-not $endSha) { throw "EndCommit '$EndCommit' not found" }
|
||||
}
|
||||
catch {
|
||||
Write-Err $_
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
|
||||
# Get list of commits reachable from end but not from start.
|
||||
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
|
||||
# `$startSha..$endSha` will expand unexpectedly (often to empty/undesired) instead of passing the literal "sha1..sha2".
|
||||
# Therefore we build the range explicitly as a single string argument.
|
||||
$rangeArg = "$startSha..$endSha"
|
||||
$commitList = git rev-list $rangeArg
|
||||
|
||||
# Normalize list (filter out empty strings)
|
||||
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
|
||||
$commitCount = ($normalizedCommits | Measure-Object).Count
|
||||
Write-DebugMsg ("Raw commitList length (including blanks): {0}" -f (($commitList | Measure-Object).Count))
|
||||
Write-DebugMsg ("Normalized commit count: {0}" -f $commitCount)
|
||||
if ($commitCount -eq 0) {
|
||||
Write-Warn "No commits found in specified range ($startSha..$endSha)."; exit 0
|
||||
}
|
||||
Write-DebugMsg ("First 5 commits: {0}" -f (($normalizedCommits | Select-Object -First 5) -join ', '))
|
||||
|
||||
<#
|
||||
Extract PR numbers from commits.
|
||||
Patterns handled:
|
||||
1. Merge commits: 'Merge pull request #12345 from ...'
|
||||
2. Squash commits: 'Some feature change (#12345)' (GitHub default squash format)
|
||||
We collect both. If a commit matches both (unlikely), it's deduped later.
|
||||
#>
|
||||
# Extract PR numbers from merge or squash commits
|
||||
$mergeCommits = @()
|
||||
foreach ($c in $normalizedCommits) {
|
||||
$subject = git show -s --format=%s $c
|
||||
$matched = $false
|
||||
# Pattern 1: Traditional merge commit
|
||||
if ($subject -match 'Merge pull request #([0-9]+) ') {
|
||||
$prNumber = [int]$matches[1]
|
||||
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber; Subject = $subject; Pattern = 'merge' }
|
||||
Write-DebugMsg "Matched merge PR #$prNumber in commit $c"
|
||||
$matched = $true
|
||||
}
|
||||
# Pattern 2: Squash merge subject line with ' (#12345)' at end (allow possible whitespace before paren)
|
||||
if ($subject -match '\(#([0-9]+)\)$') {
|
||||
$prNumber2 = [int]$matches[1]
|
||||
# Avoid duplicate object if pattern 1 already captured same number for same commit
|
||||
if (-not ($mergeCommits | Where-Object { $_.Sha -eq $c -and $_.Pr -eq $prNumber2 })) {
|
||||
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber2; Subject = $subject; Pattern = 'squash' }
|
||||
Write-DebugMsg "Matched squash PR #$prNumber2 in commit $c"
|
||||
}
|
||||
$matched = $true
|
||||
}
|
||||
if (-not $matched) {
|
||||
Write-DebugMsg "No PR pattern in commit $c : $subject"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $mergeCommits -or $mergeCommits.Count -eq 0) {
|
||||
Write-Warn "No merge commits with PR numbers found in range."; exit 0
|
||||
}
|
||||
|
||||
# Deduplicate PR numbers (in case of revert or merges across branches)
|
||||
$prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Object
|
||||
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
|
||||
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
|
||||
|
||||
# Query GitHub for each PR
|
||||
$prDetails = @()
|
||||
function Get-CopilotSummaryFromPrJson {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]$PrJson,
|
||||
[switch]$VerboseMode
|
||||
)
|
||||
# Returns a hashtable with Summary and Source keys.
|
||||
$result = @{ Summary = ""; Source = "" }
|
||||
if (-not $PrJson) { return $result }
|
||||
|
||||
$candidateAuthors = @(
|
||||
'github-copilot[bot]', 'github-copilot', 'copilot'
|
||||
)
|
||||
|
||||
# 1. Reviews (preferred) – pick the LONGEST valid Copilot body, not the most recent
|
||||
$reviews = $PrJson.reviews
|
||||
if ($reviews) {
|
||||
$copilotReviews = $reviews | Where-Object {
|
||||
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
|
||||
}
|
||||
if ($copilotReviews) {
|
||||
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
if ($longest) {
|
||||
$body = $longest.body
|
||||
$norm = ($body -replace "`r", '') -replace "`n", ' '
|
||||
$norm = $norm -replace '\s+', ' '
|
||||
$result.Summary = $norm
|
||||
$result.Source = 'review'
|
||||
if ($VerboseMode) { Write-DebugMsg "Selected Copilot review length=$($body.Length) (longest)." }
|
||||
return $result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Comments fallback (some repos surface Copilot summaries as PR comments rather than review objects)
|
||||
if ($null -eq $PrJson.comments) {
|
||||
try {
|
||||
# Lazy fetch comments only if needed
|
||||
$commentsJson = gh pr view $PrJson.number --repo $Repo --json comments 2>$null | ConvertFrom-Json
|
||||
if ($commentsJson -and $commentsJson.comments) {
|
||||
$PrJson | Add-Member -NotePropertyName comments -NotePropertyValue $commentsJson.comments -Force
|
||||
}
|
||||
} catch {
|
||||
if ($VerboseMode) { Write-DebugMsg "Failed to fetch comments for PR #$($PrJson.number): $_" }
|
||||
}
|
||||
}
|
||||
if ($PrJson.comments) {
|
||||
$copilotComments = $PrJson.comments | Where-Object {
|
||||
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
|
||||
}
|
||||
if ($copilotComments) {
|
||||
$longestC = $copilotComments | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
if ($longestC) {
|
||||
$body = $longestC.body
|
||||
$norm = ($body -replace "`r", '') -replace "`n", ' '
|
||||
$norm = $norm -replace '\s+', ' '
|
||||
$result.Summary = $norm
|
||||
$result.Source = 'comment'
|
||||
if ($VerboseMode) { Write-DebugMsg "Selected Copilot comment length=$($body.Length) (longest)." }
|
||||
return $result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
foreach ($pr in $prNumbers) {
|
||||
Write-Info "Fetching PR #$pr ..."
|
||||
try {
|
||||
# Include comments only if Verbose asked, otherwise we lazily pull if reviews missing
|
||||
$fields = 'number,title,labels,author,url,body,reviews'
|
||||
if ($PSBoundParameters.ContainsKey('Verbose')) { $fields += ',comments' }
|
||||
$json = gh pr view $pr --repo $Repo --json $fields 2>$null | ConvertFrom-Json
|
||||
if ($null -eq $json) { throw "Empty response" }
|
||||
|
||||
$copilot = Get-CopilotSummaryFromPrJson -PrJson $json -VerboseMode:($PSBoundParameters.ContainsKey('Verbose'))
|
||||
if ($copilot.Summary -and $copilot.Source -and $PSBoundParameters.ContainsKey('Verbose')) {
|
||||
Write-DebugMsg "Copilot summary source=$($copilot.Source) chars=$($copilot.Summary.Length)"
|
||||
} elseif (-not $copilot.Summary) {
|
||||
Write-DebugMsg "No Copilot summary found for PR #$pr"
|
||||
}
|
||||
|
||||
# Filter labels
|
||||
$filteredLabels = $json.labels | Where-Object {
|
||||
($_.name -like "Product-*") -or
|
||||
($_.name -like "Area-*") -or
|
||||
($_.name -like "Github*") -or
|
||||
($_.name -like "*Plugin") -or
|
||||
($_.name -like "Issue-*")
|
||||
}
|
||||
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
|
||||
|
||||
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
|
||||
$bodyValue = $bodyValue -replace '\s+', ' '
|
||||
|
||||
$prDetails += [PSCustomObject]@{
|
||||
Id = $json.number
|
||||
Title = $json.title
|
||||
Labels = $labelNames
|
||||
Author = $json.author.login
|
||||
Url = $json.url
|
||||
Body = $bodyValue
|
||||
CopilotSummary = $copilot.Summary
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$err = $_
|
||||
Write-Warn ("Failed to fetch PR #{0}: {1}" -f $pr, $err)
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $prDetails) { Write-Warn "No PR details fetched."; exit 0 }
|
||||
|
||||
# Sort by Labels like original script (first label alphabetical)
|
||||
$sorted = $prDetails | Sort-Object { ($_.Labels -split ',')[0] }
|
||||
|
||||
# Output JSON raw (optional)
|
||||
$sorted | ConvertTo-Json -Depth 6 | Out-File -Encoding UTF8 $OutputJson
|
||||
|
||||
Write-Info "Saving CSV to $OutputCsv ..."
|
||||
$sorted | Export-Csv $OutputCsv -NoTypeInformation
|
||||
Write-Host "✅ Done. Generated $($prDetails.Count) PR rows." -ForegroundColor Green
|
||||
85
tools/ReleaseNoteGeneration/group-prs-by-label.ps1
Normal file
85
tools/ReleaseNoteGeneration/group-prs-by-label.ps1
Normal file
@@ -0,0 +1,85 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Group PR rows by their Labels column and emit per-label CSV files.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads a milestone PR CSV (usually produced by dump-prs-information / dump-prs-since-commit scripts),
|
||||
splits rows by label list, normalizes/sorts individual labels, and writes one CSV per unique label combination.
|
||||
Each output preserves the original row ordering within that subset and column order from the source.
|
||||
|
||||
.PARAMETER CsvPath
|
||||
Input CSV containing PR rows with a 'Labels' column (comma-separated list).
|
||||
|
||||
.PARAMETER OutDir
|
||||
Output directory to place grouped CSVs (created if missing). Default: 'grouped_csv'.
|
||||
|
||||
.NOTES
|
||||
Label combinations are joined using ' | ' when multiple labels present. Filenames are sanitized (invalid characters,
|
||||
whitespace collapsed) and truncated to <= 120 characters.
|
||||
#>
|
||||
param(
|
||||
[string]$CsvPath = "sorted_prs.csv",
|
||||
[string]$OutDir = "grouped_csv"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
|
||||
function Write-Warn($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" }
|
||||
|
||||
Write-Info "Reading CSV: $CsvPath"
|
||||
$rows = Import-Csv -LiteralPath $CsvPath
|
||||
Write-Info ("Loaded {0} rows" -f $rows.Count)
|
||||
|
||||
function ConvertTo-SafeFileName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Name
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($Name)) { return 'Unnamed' }
|
||||
$s = $Name -replace '[<>:"/\\|?*]', '-' # invalid path chars
|
||||
$s = $s -replace '\s+', '-' # spaces to dashes
|
||||
$s = $s -replace '-{2,}', '-' # collapse dashes
|
||||
$s = $s.Trim('-')
|
||||
if ($s.Length -gt 120) { $s = $s.Substring(0,120).Trim('-') }
|
||||
if ([string]::IsNullOrWhiteSpace($s)) { return 'Unnamed' }
|
||||
return $s
|
||||
}
|
||||
|
||||
# Build groups keyed by normalized, sorted label combinations. Preserve original CSV row order.
|
||||
$groups = @{}
|
||||
foreach ($row in $rows) {
|
||||
$labelsRaw = $row.Labels
|
||||
if ([string]::IsNullOrWhiteSpace($labelsRaw)) {
|
||||
$labelParts = @('Unlabeled')
|
||||
} else {
|
||||
$parts = $labelsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
if (-not $parts -or $parts.Count -eq 0) { $labelParts = @('Unlabeled') }
|
||||
else { $labelParts = $parts | Sort-Object }
|
||||
}
|
||||
|
||||
$key = ($labelParts -join ' | ')
|
||||
if (-not $groups.ContainsKey($key)) { $groups[$key] = New-Object System.Collections.ArrayList }
|
||||
[void]$groups[$key].Add($row)
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutDir)) {
|
||||
Write-Info "Creating output directory: $OutDir"
|
||||
New-Item -ItemType Directory -Path $OutDir | Out-Null
|
||||
}
|
||||
|
||||
Write-Info ("Generating {0} grouped CSV file(s) into: {1}" -f $groups.Count, $OutDir)
|
||||
|
||||
foreach ($key in $groups.Keys) {
|
||||
$labelParts = if ($key -eq 'Unlabeled') { @('Unlabeled') } else { $key -split '\s\|\s' }
|
||||
$safeName = ($labelParts | ForEach-Object { ConvertTo-SafeFileName -Name $_ }) -join '-'
|
||||
$filePath = Join-Path $OutDir ("$safeName.csv")
|
||||
|
||||
# Keep same columns and order
|
||||
$groups[$key] | Export-Csv -LiteralPath $filePath -NoTypeInformation -Encoding UTF8
|
||||
}
|
||||
|
||||
Write-Info "Done. Sample output files:"
|
||||
Get-ChildItem -LiteralPath $OutDir | Select-Object -First 10 Name | Format-Table -HideTableHeaders
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*
|
||||
@@ -1,130 +0,0 @@
|
||||
<#!
|
||||
.SYNOPSIS
|
||||
Remove a git worktree (and optionally its local branch and orphan fork remote).
|
||||
|
||||
.DESCRIPTION
|
||||
Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository
|
||||
root is never removed. Optionally discards local changes with -Force. Deletes associated branch
|
||||
unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking
|
||||
branches, that remote is removed unless -KeepRemote.
|
||||
|
||||
.PARAMETER Pattern
|
||||
Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed
|
||||
and no deletion occurs.
|
||||
|
||||
.PARAMETER Force
|
||||
Discard uncommitted changes and attempt aggressive cleanup on failure.
|
||||
|
||||
.PARAMETER KeepBranch
|
||||
Preserve the local branch (only remove the worktree directory entry).
|
||||
|
||||
.PARAMETER KeepRemote
|
||||
Preserve any orphan fork remote even if no branches still track it.
|
||||
|
||||
.EXAMPLE
|
||||
./Delete-Worktree.ps1 -Pattern feature/login
|
||||
|
||||
.EXAMPLE
|
||||
./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force
|
||||
|
||||
.EXAMPLE
|
||||
./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch
|
||||
|
||||
.NOTES
|
||||
Manual recovery:
|
||||
git worktree list --porcelain
|
||||
git worktree prune
|
||||
Remove-Item -LiteralPath <path> -Recurse -Force
|
||||
git branch -D <branch>
|
||||
git remote remove <remote>
|
||||
git worktree prune
|
||||
#>
|
||||
|
||||
param(
|
||||
[string] $Pattern,
|
||||
[switch] $Force,
|
||||
[switch] $KeepBranch,
|
||||
[switch] $KeepRemote,
|
||||
[switch] $Help
|
||||
)
|
||||
. "$PSScriptRoot/WorktreeLib.ps1"
|
||||
if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
|
||||
try {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$entries = Get-WorktreeEntries
|
||||
if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' }
|
||||
$hasWildcard = $Pattern -match '[\*\?]'
|
||||
$matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" }
|
||||
$found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) }
|
||||
if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" }
|
||||
if ($found.Count -gt 1) {
|
||||
Warn 'Pattern matches multiple worktrees:'
|
||||
$found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) }
|
||||
return
|
||||
}
|
||||
$target = $found | Select-Object -First 1
|
||||
$branch = $target.Branch
|
||||
$folder = $target.Path
|
||||
if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' }
|
||||
try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {}
|
||||
$primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath
|
||||
if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' }
|
||||
$status = git -C $folder status --porcelain 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" }
|
||||
if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' }
|
||||
if ($Force -and $status) {
|
||||
Warn '[Force] Discarding local changes'
|
||||
git -C $folder reset --hard HEAD | Out-Null
|
||||
git -C $folder clean -fdx | Out-Null
|
||||
}
|
||||
if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$exit1 = $LASTEXITCODE
|
||||
$errMsg = "git worktree remove failed (exit $exit1)"
|
||||
if ($Force) {
|
||||
Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).'
|
||||
try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {}
|
||||
try { git -C $folder clean -dfx 2>$null | Out-Null } catch {}
|
||||
try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {}
|
||||
if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } }
|
||||
git worktree prune 2>$null | Out-Null
|
||||
if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." }
|
||||
} else {
|
||||
throw "$errMsg. Rerun with -Force to attempt aggressive cleanup."
|
||||
}
|
||||
}
|
||||
# Determine upstream before potentially deleting branch
|
||||
$upRemote = Get-BranchUpstreamRemote -Branch $branch
|
||||
$looksForkName = $branch -like 'fork-*'
|
||||
|
||||
if (-not $KeepBranch) {
|
||||
git branch -D $branch 2>$null | Out-Null
|
||||
if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') {
|
||||
$otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null |
|
||||
Where-Object { $_ -and ($_ -notmatch "^$branch\|") } |
|
||||
ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?<r>[^/]+)/'){ $parts[0],$Matches.r } } |
|
||||
Where-Object { $_[1] -eq $upRemote }
|
||||
if (-not $otherTracking) {
|
||||
Warn "Removing orphan remote '$upRemote' (no more tracking branches)"
|
||||
git remote remove $upRemote 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." }
|
||||
} else { Info "Remote '$upRemote' retained (other branches still track it)." }
|
||||
} elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) {
|
||||
Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.'
|
||||
}
|
||||
}
|
||||
|
||||
Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' }
|
||||
Show-WorktreeExecutionSummary -CurrentBranch $branch
|
||||
} catch {
|
||||
Err "Error: $($_.Exception.Message)"
|
||||
Warn 'Manual cleanup guidelines:'
|
||||
Info ' git worktree list --porcelain'
|
||||
Info ' git worktree prune'
|
||||
Info ' # If still present:'
|
||||
Info ' Remove-Item -LiteralPath <path> -Recurse -Force'
|
||||
Info ' git branch -D <branch> (if you also want to drop local branch)'
|
||||
Info ' git remote remove <remote> (if orphan fork remote remains)'
|
||||
Info ' git worktree prune'
|
||||
exit 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*
|
||||
@@ -1,78 +0,0 @@
|
||||
<#!
|
||||
.SYNOPSIS
|
||||
Create (or reuse) a worktree for an existing local or remote (origin) branch.
|
||||
|
||||
.DESCRIPTION
|
||||
Normalizes origin/<name> to <name>. If the branch does not exist locally (and -NoFetch is not
|
||||
provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree
|
||||
bound to the branch; otherwise creates a new one adjacent to the repository root.
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch name (local or origin/<name> form) to materialize as a worktree.
|
||||
|
||||
.PARAMETER VSCodeProfile
|
||||
VS Code profile to open (Default).
|
||||
|
||||
.PARAMETER NoFetch
|
||||
Skip fetch if branch missing locally; script will error instead of creating it.
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromBranch.ps1 -Branch feature/login
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch
|
||||
|
||||
.NOTES
|
||||
Manual recovery:
|
||||
git fetch origin && git checkout <branch>
|
||||
git worktree add ../RepoName-XX <branch>
|
||||
code ../RepoName-XX --profile Default
|
||||
#>
|
||||
|
||||
param(
|
||||
[string] $Branch,
|
||||
[Alias('Profile')][string] $VSCodeProfile = 'Default',
|
||||
[switch] $NoFetch,
|
||||
[switch] $Help
|
||||
)
|
||||
. "$PSScriptRoot/WorktreeLib.ps1"
|
||||
|
||||
if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
|
||||
|
||||
# Normalize origin/<name> to <name>
|
||||
if ($Branch -match '^(origin|upstream|main|master)/.+') {
|
||||
if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] }
|
||||
}
|
||||
|
||||
try {
|
||||
git show-ref --verify --quiet "refs/heads/$Branch"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (-not $NoFetch) {
|
||||
Warn "Local branch '$Branch' not found; attempting remote fetch..."
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
$remoteRef = "origin/$Branch"
|
||||
git show-ref --verify --quiet "refs/remotes/$remoteRef"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
git branch --track $Branch $remoteRef 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" }
|
||||
Info "Created local tracking branch '$Branch' from $remoteRef."
|
||||
} else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." }
|
||||
} else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." }
|
||||
}
|
||||
|
||||
New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile
|
||||
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch }
|
||||
$path = ($after | Select-Object -First 1).Path
|
||||
Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path
|
||||
} catch {
|
||||
Err "Error: $($_.Exception.Message)"
|
||||
Warn 'Manual steps:'
|
||||
Info ' git fetch origin'
|
||||
Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)"
|
||||
Info ' git worktree add ../<Repo>-XX <branch>'
|
||||
Info ' code ../<Repo>-XX'
|
||||
exit 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*
|
||||
@@ -1,127 +0,0 @@
|
||||
<#!
|
||||
.SYNOPSIS
|
||||
Create (or reuse) a worktree from a branch in a personal fork: <ForkUser>:<ForkBranch>.
|
||||
|
||||
.DESCRIPTION
|
||||
Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified.
|
||||
Fetches only the target branch (fallback full fetch once if needed), creates a local tracking
|
||||
branch (fork-<user>-<sanitized-branch> or custom alias), and delegates worktree creation/reuse
|
||||
to shared helpers in WorktreeLib.
|
||||
|
||||
.PARAMETER Spec
|
||||
Fork spec in the form <ForkUser>:<ForkBranch>.
|
||||
|
||||
.PARAMETER ForkRepo
|
||||
Repository name in the fork (default: PowerToys).
|
||||
|
||||
.PARAMETER RemoteName
|
||||
Desired remote name; if left as 'fork' a unique suffix will be generated.
|
||||
|
||||
.PARAMETER BranchAlias
|
||||
Optional local branch name override; defaults to fork-<user>-<sanitized-branch>.
|
||||
|
||||
.PARAMETER VSCodeProfile
|
||||
VS Code profile to pass through to worktree opening (Default profile by default).
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash
|
||||
|
||||
.NOTES
|
||||
Manual equivalent if this script fails:
|
||||
git remote add fork-temp https://github.com/<user>/<repo>.git
|
||||
git fetch fork-temp
|
||||
git branch --track fork-<user>-<branch> fork-temp/<branch>
|
||||
git worktree add ../Repo-XX fork-<user>-<branch>
|
||||
code ../Repo-XX
|
||||
#>
|
||||
param(
|
||||
[string] $Spec,
|
||||
[string] $ForkRepo = 'PowerToys',
|
||||
[string] $RemoteName = 'fork',
|
||||
[string] $BranchAlias,
|
||||
[Alias('Profile')][string] $VSCodeProfile = 'Default',
|
||||
[switch] $Help
|
||||
)
|
||||
|
||||
. "$PSScriptRoot/WorktreeLib.ps1"
|
||||
if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
|
||||
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $repoRoot) { throw 'Not inside a git repository.' }
|
||||
|
||||
# Parse spec
|
||||
if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be <ForkUser>:<ForkBranch>, got '$Spec'" }
|
||||
$ForkUser,$ForkBranch = $Spec.Split(':',2)
|
||||
|
||||
$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git"
|
||||
|
||||
# Auto-suffix remote name if user left default 'fork'
|
||||
$allRemotes = @(git remote 2>$null)
|
||||
if ($RemoteName -eq 'fork') {
|
||||
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
do {
|
||||
$suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] })
|
||||
$candidate = "fork-$suffix"
|
||||
} while ($allRemotes -contains $candidate)
|
||||
$RemoteName = $candidate
|
||||
Info "Assigned unique remote name: $RemoteName"
|
||||
}
|
||||
|
||||
$existing = $allRemotes | Where-Object { $_ -eq $RemoteName }
|
||||
if (-not $existing) {
|
||||
Info "Adding remote $RemoteName -> $forkUrl"
|
||||
git remote add $RemoteName $forkUrl | Out-Null
|
||||
} else {
|
||||
$currentUrl = git remote get-url $RemoteName 2>$null
|
||||
if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." }
|
||||
}
|
||||
|
||||
## Note: Verbose fetch & stale lock auto-clean removed for simplicity.
|
||||
|
||||
try {
|
||||
Info "Fetching branch '$ForkBranch' from $RemoteName..."
|
||||
& git fetch $RemoteName $ForkBranch 1>$null 2>$null
|
||||
$fetchExit = $LASTEXITCODE
|
||||
if ($fetchExit -ne 0) {
|
||||
# Retry full fetch silently once (covers servers not supporting branch-only fetch syntax)
|
||||
& git fetch $RemoteName 1>$null 2>$null
|
||||
$fetchExit = $LASTEXITCODE
|
||||
}
|
||||
if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." }
|
||||
|
||||
$remoteRef = "refs/remotes/$RemoteName/$ForkBranch"
|
||||
git show-ref --verify --quiet $remoteRef
|
||||
if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" }
|
||||
|
||||
$sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-')
|
||||
if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" }
|
||||
|
||||
git show-ref --verify --quiet "refs/heads/$localBranch"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch"
|
||||
git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" }
|
||||
} else { Info "Local branch $localBranch already exists." }
|
||||
|
||||
New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile
|
||||
# Ensure upstream so future 'git push' works
|
||||
Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch
|
||||
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch }
|
||||
$path = ($after | Select-Object -First 1).Path
|
||||
Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path
|
||||
Warn "Remote $RemoteName ready (URL: $forkUrl)"
|
||||
$hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null
|
||||
if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u <remote> <local>:<remoteBranch>' }
|
||||
} catch {
|
||||
Err "Error: $($_.Exception.Message)"
|
||||
Warn 'Manual steps:'
|
||||
Info " git remote add temp-fork $forkUrl"
|
||||
Info " git fetch temp-fork"
|
||||
Info " git branch --track fork-<user>-<branch> temp-fork/$ForkBranch"
|
||||
Info ' git worktree add ../<Repo>-XX fork-<user>-<branch>'
|
||||
Info ' code ../<Repo>-XX'
|
||||
exit 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*
|
||||
@@ -1,78 +0,0 @@
|
||||
<#!
|
||||
.SYNOPSIS
|
||||
Create (or reuse) a worktree for a new issue branch derived from a base ref.
|
||||
|
||||
.DESCRIPTION
|
||||
Composes a branch name as issue/<number> or issue/<number>-<slug> (slug from optional -Title).
|
||||
If the branch does not already exist, it is created from -Base (default origin/main). Then a
|
||||
worktree is created or reused.
|
||||
|
||||
.PARAMETER Number
|
||||
Issue number used to construct the branch name.
|
||||
|
||||
.PARAMETER Title
|
||||
Optional descriptive title; slug into the branch name.
|
||||
|
||||
.PARAMETER Base
|
||||
Base ref to branch from (default origin/main).
|
||||
|
||||
.PARAMETER VSCodeProfile
|
||||
VS Code profile to open (Default).
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
|
||||
|
||||
.EXAMPLE
|
||||
./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop
|
||||
|
||||
.NOTES
|
||||
Manual recovery:
|
||||
git fetch origin
|
||||
git checkout -b issue/<num>-<slug> <base>
|
||||
git worktree add ../Repo-XX issue/<num>-<slug>
|
||||
code ../Repo-XX
|
||||
#>
|
||||
|
||||
param(
|
||||
[int] $Number,
|
||||
[string] $Title,
|
||||
[string] $Base = 'origin/main',
|
||||
[Alias('Profile')][string] $VSCodeProfile = 'Default',
|
||||
[switch] $Help
|
||||
)
|
||||
. "$PSScriptRoot/WorktreeLib.ps1"
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return }
|
||||
|
||||
# Compose branch name
|
||||
if ($Title) {
|
||||
$slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-'
|
||||
$branch = "issue/$Number-$slug"
|
||||
} else {
|
||||
$branch = "issue/$Number"
|
||||
}
|
||||
|
||||
try {
|
||||
# Create branch if missing
|
||||
git show-ref --verify --quiet "refs/heads/$branch"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Info "Creating branch $branch from $Base"
|
||||
git branch $branch $Base 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" }
|
||||
} else {
|
||||
Info "Branch $branch already exists locally."
|
||||
}
|
||||
|
||||
New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile
|
||||
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch }
|
||||
$path = ($after | Select-Object -First 1).Path
|
||||
Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path
|
||||
} catch {
|
||||
Err "Error: $($_.Exception.Message)"
|
||||
Warn 'Manual steps:'
|
||||
Info " git fetch origin"
|
||||
Info " git checkout -b $branch $Base (if branch missing)"
|
||||
Info " git worktree add ../<Repo>-XX $branch"
|
||||
Info ' code ../<Repo>-XX'
|
||||
exit 1
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
# PowerToys Worktree Helper Scripts
|
||||
|
||||
This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time.
|
||||
|
||||
## Why worktree?
|
||||
Git worktree let you have several checked‑out branches sharing a single `.git` object store. Benefits:
|
||||
- Fast context switching: no re-clone, no duplicate large binary/object downloads.
|
||||
- Lower disk usage versus multiple full clones.
|
||||
- Keeps each change isolated in its own folder so you can run builds/tests independently.
|
||||
- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean.
|
||||
|
||||
Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations.
|
||||
|
||||
## Scripts Overview
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`<User>:<branch>` spec). Adds a temporary unique remote (e.g. `fork-abc12`). |
|
||||
| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. |
|
||||
| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/<number>-<slug>`. |
|
||||
| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. |
|
||||
| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. |
|
||||
|
||||
## Typical Flows
|
||||
### 1. Create from a fork branch
|
||||
```
|
||||
./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak
|
||||
```
|
||||
Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root.
|
||||
|
||||
### 2. Create from an existing or remote branch
|
||||
```
|
||||
./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui
|
||||
```
|
||||
Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree.
|
||||
|
||||
### 3. Start a new issue branch
|
||||
```
|
||||
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
|
||||
```
|
||||
Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree.
|
||||
|
||||
### 4. Delete a worktree when done
|
||||
```
|
||||
./Delete-Worktree.ps1 -Pattern feature/perf-tweak
|
||||
```
|
||||
If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote.
|
||||
|
||||
## After Creating a Worktree
|
||||
Inside the new worktree directory:
|
||||
1. Run the minimal build bootstrap in VSCode terminal:
|
||||
```
|
||||
tools\build\build-essentials.cmd
|
||||
```
|
||||
2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise.
|
||||
3. Make changes, commit, push.
|
||||
4. Finally delete the worktree when done.
|
||||
|
||||
## Naming & Locations
|
||||
- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions.
|
||||
- Fork-based branches get local names `fork-<user>-<sanitized-branch>`.
|
||||
- Issue branches: `issue/<number>` or `issue/<number>-<slug>`.
|
||||
|
||||
## Scenarios Covered / Limitations
|
||||
Covered scenarios:
|
||||
1. From a fork branch (personal fork on GitHub).
|
||||
2. From an existing local or origin remote branch.
|
||||
3. Creating a new branch for an issue.
|
||||
|
||||
Not covered (manual steps needed):
|
||||
- Creating from a non-origin upstream other than a fork (add remote manually then use branch script).
|
||||
- Batch creation of multiple worktree in one command.
|
||||
- Automatic rebase / sync of many worktree at once (do that manually or script separately).
|
||||
|
||||
## Best Practices
|
||||
- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone.
|
||||
- Delete stale worktree early; each adds file watchers & potential incremental build churn.
|
||||
- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction.
|
||||
- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree.
|
||||
|
||||
## Troubleshooting
|
||||
| Symptom | Hint |
|
||||
|---------|------|
|
||||
| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch <remote> <branch>`.
|
||||
| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry.
|
||||
| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate.
|
||||
| Local branch missing for remote | Use `git branch --track <name> origin/<name>` then re-run the branch script.
|
||||
|
||||
## Security & Safety Notes
|
||||
- Scripts avoid force-deleting unless you pass `-Force` (Delete script).
|
||||
- No network credentials are stored; they rely on your existing Git credential helper.
|
||||
- Always review a new fork remote URL before pushing.
|
||||
|
||||
---
|
||||
Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change.
|
||||
@@ -1,151 +0,0 @@
|
||||
# WorktreeLib.ps1 - shared helpers
|
||||
|
||||
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
|
||||
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
|
||||
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
|
||||
|
||||
function Get-RepoRoot {
|
||||
$root = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $root) { throw 'Not inside a git repository.' }
|
||||
return $root
|
||||
}
|
||||
|
||||
function Get-WorktreeBasePath {
|
||||
param([string]$RepoRoot)
|
||||
# Always use parent of repo root (folder that contains the main repo directory)
|
||||
$parent = Split-Path -Parent $RepoRoot
|
||||
if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" }
|
||||
return (Resolve-Path $parent).ProviderPath
|
||||
}
|
||||
|
||||
function Get-ShortHashFromString {
|
||||
param([Parameter(Mandatory)][string]$Text)
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
try {
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
|
||||
$digest = $md5.ComputeHash($bytes)
|
||||
return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') })
|
||||
} finally { $md5.Dispose() }
|
||||
}
|
||||
|
||||
function Initialize-SubmodulesIfAny {
|
||||
param([string]$RepoRoot,[string]$WorktreePath)
|
||||
$hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules')
|
||||
if ($hasGitmodules) {
|
||||
git -C $WorktreePath submodule sync --recursive | Out-Null
|
||||
git -C $WorktreePath submodule update --init --recursive | Out-Null
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function New-WorktreeForExistingBranch {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Branch,
|
||||
[Parameter(Mandatory)][string] $VSCodeProfile
|
||||
)
|
||||
$repoRoot = Get-RepoRoot
|
||||
git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." }
|
||||
|
||||
# Detect existing worktree for this branch
|
||||
$entries = Get-WorktreeEntries
|
||||
$match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1
|
||||
if ($match) {
|
||||
Info "Reusing existing worktree for '$Branch': $($match.Path)"
|
||||
code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
$safeBranch = ($Branch -replace '[\\/:*?"<>|]','-')
|
||||
$hash = Get-ShortHashFromString -Text $safeBranch
|
||||
$folderName = "$(Split-Path -Leaf $repoRoot)-$hash"
|
||||
$base = Get-WorktreeBasePath -RepoRoot $repoRoot
|
||||
$folder = Join-Path $base $folderName
|
||||
git worktree add $folder $Branch
|
||||
$inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder
|
||||
code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null
|
||||
Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' }
|
||||
}
|
||||
|
||||
function Get-WorktreeEntries {
|
||||
# Returns objects with Path and Branch (branch without refs/heads/ prefix)
|
||||
$lines = git worktree list --porcelain 2>$null
|
||||
if (-not $lines) { return @() }
|
||||
$entries = @(); $current=@{}
|
||||
foreach($l in $lines){
|
||||
if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue }
|
||||
if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] }
|
||||
elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() }
|
||||
}
|
||||
if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }
|
||||
return ($entries | Sort-Object Path,Branch -Unique)
|
||||
}
|
||||
|
||||
function Get-BranchUpstreamRemote {
|
||||
param([Parameter(Mandatory)][string]$Branch)
|
||||
# Returns remote name if branch has an upstream, else $null
|
||||
$ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null }
|
||||
if ($ref -match '^(?<remote>[^/]+)/.+$') { return $Matches.remote }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Show-IssueFarmCommonFooter {
|
||||
Info '--- Common Manual Steps ---'
|
||||
Info 'List worktree: git worktree list --porcelain'
|
||||
Info 'List branches: git branch -vv'
|
||||
Info 'List remotes: git remote -v'
|
||||
Info 'Prune worktree: git worktree prune'
|
||||
Info 'Remove worktree dir: Remove-Item -Recurse -Force <path>'
|
||||
Info 'Reset branch: git reset --hard HEAD'
|
||||
}
|
||||
|
||||
function Show-WorktreeExecutionSummary {
|
||||
param(
|
||||
[string]$CurrentBranch,
|
||||
[string]$WorktreePath
|
||||
)
|
||||
Info '--- Summary ---'
|
||||
if ($CurrentBranch) { Info "Branch: $CurrentBranch" }
|
||||
if ($WorktreePath) { Info "Worktree path: $WorktreePath" }
|
||||
$entries = Get-WorktreeEntries
|
||||
if ($entries.Count -gt 0) {
|
||||
Info 'Existing worktrees:'
|
||||
$entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) }
|
||||
}
|
||||
Info 'Remotes:'
|
||||
git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" }
|
||||
}
|
||||
|
||||
function Show-FileEmbeddedHelp {
|
||||
param([string]$ScriptPath)
|
||||
if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" }
|
||||
$content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop
|
||||
$inBlock=$false
|
||||
foreach($line in $content){
|
||||
if ($line -match '^<#!') { $inBlock=$true; continue }
|
||||
if ($line -match '#>$') { break }
|
||||
if ($inBlock) { Write-Host $line }
|
||||
}
|
||||
Show-IssueFarmCommonFooter
|
||||
}
|
||||
|
||||
function Set-BranchUpstream {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$LocalBranch,
|
||||
[Parameter(Mandatory)][string]$RemoteName,
|
||||
[Parameter(Mandatory)][string]$RemoteBranchPath
|
||||
)
|
||||
$current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null
|
||||
if (-not $current) {
|
||||
Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath"
|
||||
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" }
|
||||
return
|
||||
}
|
||||
if ($current -ne "$RemoteName/$RemoteBranchPath") {
|
||||
Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..."
|
||||
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' }
|
||||
} else { Info "Upstream already: $current" }
|
||||
}
|
||||
Reference in New Issue
Block a user