Compare commits
25 Commits
pt-team/ui
...
dev/crutka
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4798b09c | ||
|
|
a0d17406ba | ||
|
|
4a27c5d5f9 | ||
|
|
8bd5c1be6f | ||
|
|
7b19b4c219 | ||
|
|
b73fd670be | ||
|
|
a46a4437e5 | ||
|
|
3bf682048e | ||
|
|
28a9bbe8f0 | ||
|
|
536e768cac | ||
|
|
70ff4013b9 | ||
|
|
7a04d4c270 | ||
|
|
8c434cd6f4 | ||
|
|
d983dbc285 | ||
|
|
fb6843b0f1 | ||
|
|
6dd1ce5dd1 | ||
|
|
9ea30ec523 | ||
|
|
c777fcc1e4 | ||
|
|
28e078897a | ||
|
|
64f1243bdf | ||
|
|
e1074bc835 | ||
|
|
2390aacbfc | ||
|
|
a864d421fc | ||
|
|
3331bdf02a | ||
|
|
9ee0c7259b |
9
.github/actions/spell-check/expect.txt
vendored
@@ -135,6 +135,7 @@ BITMAPINFO
|
||||
BITMAPINFOHEADER
|
||||
BITSPERPEL
|
||||
BITSPIXEL
|
||||
Blackmagic
|
||||
bla
|
||||
BLENDFUNCTION
|
||||
blittable
|
||||
@@ -539,6 +540,7 @@ EXTRINSICPROPERTIES
|
||||
eyetracker
|
||||
FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
Fairlight
|
||||
FARPROC
|
||||
fdw
|
||||
fdx
|
||||
@@ -621,7 +623,9 @@ GETPROPERTYSTOREFLAGS
|
||||
GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTASKBARPOS
|
||||
GETTEXTLENGTH
|
||||
GETWORKAREA
|
||||
gfx
|
||||
GHND
|
||||
gitmodules
|
||||
@@ -1230,6 +1234,8 @@ NOTSRCCOPY
|
||||
NOTSRCERASE
|
||||
Notupdated
|
||||
notwindows
|
||||
NOTXORPEN
|
||||
Nouveaut
|
||||
nowarn
|
||||
NOZORDER
|
||||
NPH
|
||||
@@ -1637,6 +1643,7 @@ SETPOWEROFFACTIVE
|
||||
SETRANGE
|
||||
SETREDRAW
|
||||
SETRULES
|
||||
SETAUTOHIDEBAREX
|
||||
SETSCREENSAVEACTIVE
|
||||
SETSTICKYKEYS
|
||||
SETTEXT
|
||||
@@ -1913,6 +1920,7 @@ tracerpt
|
||||
trackbar
|
||||
trafficmanager
|
||||
transicc
|
||||
transitioning
|
||||
TRAYMOUSEMESSAGE
|
||||
triaging
|
||||
trl
|
||||
@@ -2174,6 +2182,7 @@ xclip
|
||||
xcopy
|
||||
xdf
|
||||
xfd
|
||||
xhair
|
||||
xmp
|
||||
Xoshiro
|
||||
xsi
|
||||
|
||||
6
.github/copilot-instructions.md
vendored
@@ -30,6 +30,12 @@ These are auto-applied based on file location:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
|
||||
## Shortcut Guide V2 Manifests
|
||||
|
||||
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
|
||||
|
||||
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
|
||||
|
||||
## Detailed Documentation
|
||||
|
||||
- [Architecture](../doc/devdocs/core/architecture.md)
|
||||
|
||||
7
.github/workflows/auto-labeler.yml
vendored
@@ -73,6 +73,13 @@ jobs:
|
||||
|
||||
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
|
||||
|
||||
// Skip pull requests that already have labels applied.
|
||||
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
|
||||
const existingLabels = issue.labels.map(l => l.name).join(', ');
|
||||
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@
|
||||
<Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
<Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
|
||||
<!--
|
||||
Onboarding guard: PowerToys has deeply nested source paths that exceed the legacy
|
||||
260-character MAX_PATH limit. Without Windows long path support enabled, the build
|
||||
fails with cryptic "path too long" / "could not find file" errors that are hard for
|
||||
new contributors to diagnose. Detect the missing registry setting up front and emit a
|
||||
clear, actionable error before the confusing failures occur.
|
||||
|
||||
- Covers both Visual Studio (Ctrl+Shift+B) and the command-line build scripts.
|
||||
- Runs only during real builds (skips design-time/IntelliSense passes).
|
||||
- Bypass with /p:SkipLongPathsCheck=true if you know what you're doing.
|
||||
See tools\build\setup-dev-environment.ps1 to enable everything automatically.
|
||||
-->
|
||||
<Target Name="EnsureLongPathsEnabled"
|
||||
BeforeTargets="PrepareForBuild"
|
||||
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipLongPathsCheck)' != 'true' and '$(OS)' == 'Windows_NT'">
|
||||
<PropertyGroup>
|
||||
<_LongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64))</_LongPathsEnabled>
|
||||
</PropertyGroup>
|
||||
<Error Condition="'$(_LongPathsEnabled)' != '1'"
|
||||
Code="PTLONGPATH"
|
||||
Text="Windows long path support is not enabled. PowerToys source paths exceed the 260-character MAX_PATH limit, so the build will fail with cryptic 'path too long' errors. Fix it by running (from an elevated PowerShell): .\tools\build\setup-dev-environment.ps1 -- or set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 (DWORD) and restart Windows. To bypass this check, build with /p:SkipLongPathsCheck=true." />
|
||||
</Target>
|
||||
|
||||
<!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. -->
|
||||
<PropertyGroup Label="ManifestToolOverride">
|
||||
<ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool>
|
||||
@@ -25,6 +48,26 @@
|
||||
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Restore" Properties="RestoreInProgress=true" BuildInParallel="false" />
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
The Microsoft.Web.WebView2 package's managed .targets unconditionally references the WPF
|
||||
wrapper (Microsoft.Web.WebView2.Wpf.dll) for every non-WinRT .NET project. That wrapper
|
||||
depends on WPF's WindowsBase, which only ships in the WPF profile of the WindowsDesktop
|
||||
reference pack. WinForms-only or plain projects therefore resolve WindowsBase to the
|
||||
4.0.0.0 facade from Microsoft.NETCore.App, producing an MSB3277 conflict against the
|
||||
wrapper's 5.0.0.0 reference. A project that doesn't enable WPF can't use the WPF WebView2
|
||||
control anyway, so drop that unused reference before RAR runs (WPF projects keep it).
|
||||
WinUI/WinAppSDK projects use the CsWinRT projection and never get this reference, so this
|
||||
is a no-op for them.
|
||||
-->
|
||||
<Target
|
||||
Name="RemoveUnusedWebView2WpfReference"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(UseWPF)' != 'true'">
|
||||
<ItemGroup>
|
||||
<Reference Remove="@(Reference)" Condition="'%(Reference.Filename)' == 'Microsoft.Web.WebView2.Wpf'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup Condition="'$(IgnoreExperimentalWarnings)' == 'true'">
|
||||
<NoWarn>$(NoWarn);CS8305;SA1500;CA1852</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
@@ -38,7 +38,7 @@
|
||||
<PackageVersion Include="Mages" Version="3.0.0" />
|
||||
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
|
||||
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
|
||||
<PackageVersion Include="MessagePack" Version="3.1.3" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
@@ -76,7 +76,7 @@
|
||||
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
|
||||
@@ -151,4 +151,4 @@
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
64
NOTICE.md
@@ -12,6 +12,7 @@ This software incorporates material from third parties.
|
||||
- Peek
|
||||
- PowerDisplay
|
||||
- Registry Preview
|
||||
- ZoomIt
|
||||
|
||||
## Utility: Color Picker
|
||||
|
||||
@@ -1549,6 +1550,69 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Utility: ZoomIt
|
||||
|
||||
### libwebp
|
||||
|
||||
ZoomIt uses libwebp to encode screenshots in the WebP image format.
|
||||
|
||||
**Source**: <https://github.com/webmproject/libwebp>
|
||||
|
||||
BSD-3-Clause License
|
||||
|
||||
Copyright (c) 2010, Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of Google nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Additional IP Rights Grant (Patents)
|
||||
|
||||
"These implementations" means the copyrightable works that implement the WebM
|
||||
codecs distributed by Google as part of the WebM Project.
|
||||
|
||||
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
|
||||
royalty-free, irrevocable (except as stated in this section) patent license to
|
||||
make, have made, use, offer to sell, sell, import, transfer, and otherwise
|
||||
run, modify and propagate the contents of these implementations of WebM, where
|
||||
such license applies only to those patent claims, both currently owned by
|
||||
Google and acquired in the future, licensable by Google that are necessarily
|
||||
infringed by these implementations of WebM. This grant does not include claims
|
||||
that would be infringed only as a consequence of further modification of these
|
||||
implementations. If you or your agent or exclusive licensee institute or order
|
||||
or agree to the institution of patent litigation or any other patent
|
||||
enforcement activity against any entity (including a cross-claim or
|
||||
counterclaim in a lawsuit) alleging that any of these implementations of WebM
|
||||
or any code incorporated within any of these implementations of WebM
|
||||
constitute direct or contributory patent infringement, or inducement of
|
||||
patent infringement, then any patent rights granted to you under this License
|
||||
for these implementations of WebM shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
## NuGet Packages used by PowerToys
|
||||
|
||||
- AdaptiveCards.ObjectModel.WinUI3
|
||||
|
||||
@@ -29,8 +29,30 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
|
||||
/// </remarks>
|
||||
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the <see cref="Kind"/> dependency property.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
|
||||
nameof(Kind),
|
||||
typeof(DesktopAcrylicKind),
|
||||
typeof(AlwaysActiveDesktopAcrylicBackdrop),
|
||||
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
|
||||
|
||||
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the desktop acrylic material variant to render. Defaults to
|
||||
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
|
||||
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
|
||||
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
|
||||
/// material. Changing this updates any live backdrop targets immediately.
|
||||
/// </summary>
|
||||
public DesktopAcrylicKind Kind
|
||||
{
|
||||
get => (DesktopAcrylicKind)GetValue(KindProperty);
|
||||
set => SetValue(KindProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
|
||||
{
|
||||
base.OnTargetConnected(connectedTarget, xamlRoot);
|
||||
@@ -41,7 +63,10 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
Theme = ResolveTheme(xamlRoot),
|
||||
};
|
||||
|
||||
var controller = new DesktopAcrylicController();
|
||||
var controller = new DesktopAcrylicController
|
||||
{
|
||||
Kind = Kind,
|
||||
};
|
||||
controller.SetSystemBackdropConfiguration(configuration);
|
||||
controller.AddSystemBackdropTarget(connectedTarget);
|
||||
|
||||
@@ -70,6 +95,17 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
|
||||
var kind = (DesktopAcrylicKind)e.NewValue;
|
||||
|
||||
foreach (var target in self._targets.Values)
|
||||
{
|
||||
target.Controller.Kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
|
||||
xamlRoot.Content is FrameworkElement rootElement
|
||||
? rootElement.ActualTheme switch
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
|
||||
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
|
||||
|
||||
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
|
||||
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
|
||||
|
||||
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
|
||||
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:TransparentCard">
|
||||
<ControlTemplate TargetType="local:TransientSurface">
|
||||
<Grid
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
@@ -27,7 +27,7 @@
|
||||
</Grid.Shadow>
|
||||
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<SystemBackdropElement.SystemBackdrop>
|
||||
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
|
||||
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
|
||||
</SystemBackdropElement.SystemBackdrop>
|
||||
</SystemBackdropElement>
|
||||
<ContentPresenter
|
||||
@@ -41,5 +41,4 @@
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,467 @@
|
||||
// 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 CommunityToolkit.WinUI;
|
||||
using CommunityToolkit.WinUI.Animations;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Hosting;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A floating, self-animating "pseudo window" surface for transient PowerToys
|
||||
/// overlays (toasts, banners, indicators). It looks like a control but behaves
|
||||
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
|
||||
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
|
||||
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
|
||||
/// its own show/hide animations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Designed to be declared as the root content of a
|
||||
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
|
||||
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
|
||||
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
|
||||
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
|
||||
/// animates itself in/out whenever the window is shown or hidden, and uses the
|
||||
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
|
||||
/// until its out-animation finishes.</para>
|
||||
/// <para>The show transition comes from the window's
|
||||
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
|
||||
/// <see cref="ShowTransition"/> when shown without one); the hide transition
|
||||
/// always comes from <see cref="HideTransition"/>. Animations target the
|
||||
/// surface itself, so the entire surface (border, acrylic, shadow, inner
|
||||
/// content) animates as one. Apps that want a different look supply their own
|
||||
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
|
||||
/// restyle path.</para>
|
||||
/// </remarks>
|
||||
public sealed partial class TransientSurface : ContentControl
|
||||
{
|
||||
private const float ShadowDepth = 32f;
|
||||
private const double SlideInOffset = 24;
|
||||
private const double SlideOutOffset = 12;
|
||||
|
||||
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
|
||||
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
|
||||
// fade is kept fast so the surface reads as an instant, light pop.
|
||||
//
|
||||
// The fade must run at least as long as the scale: if the scale outlasted the
|
||||
// fade, the surface would reach full opacity while still visibly growing,
|
||||
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
|
||||
// hides the growth under the opacity ramp, so by the time it is fully opaque
|
||||
// it is already at 100% size.
|
||||
private const float PopScaleFrom = 0.96f;
|
||||
private const double PopFadeShowMs = 180;
|
||||
private const double PopScaleShowMs = 150;
|
||||
private const double PopFadeHideMs = 120;
|
||||
private const double PopScaleHideMs = 120;
|
||||
|
||||
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
|
||||
nameof(ShowTransition),
|
||||
typeof(Transition),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(Transition.None, OnTransitionChanged));
|
||||
|
||||
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
|
||||
nameof(HideTransition),
|
||||
typeof(Transition),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(Transition.None, OnTransitionChanged));
|
||||
|
||||
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
|
||||
nameof(AcrylicKind),
|
||||
typeof(DesktopAcrylicKind),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(DesktopAcrylicKind.Thin));
|
||||
|
||||
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
|
||||
|
||||
private readonly ImplicitAnimationSet _noAnimations = new();
|
||||
|
||||
private ImplicitAnimationSet _showAnimations = new();
|
||||
private ImplicitAnimationSet _hideAnimations = new();
|
||||
private bool _hasCustomShowAnimations;
|
||||
private bool _hasCustomHideAnimations;
|
||||
private Action? _abandonPendingHide;
|
||||
|
||||
public TransientSurface()
|
||||
{
|
||||
DefaultStyleKey = typeof(TransientSurface);
|
||||
|
||||
RebuildDefaultAnimations();
|
||||
|
||||
// Pin the scale center to the surface's center so the "Pop" transition
|
||||
// grows/shrinks from the middle, not the top-left corner. An expression
|
||||
// animation bound to the visual's own size keeps the center correct from
|
||||
// the very first frame (a SizeChanged handler would race the show
|
||||
// animation and let the first pop scale from 0,0).
|
||||
PinScaleCenter();
|
||||
|
||||
// Start hidden so the first Show() animates in from the configured pose.
|
||||
Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised after <see cref="Hide"/> once the longest animation in
|
||||
/// <see cref="HideAnimations"/> (delay + duration) has completed.
|
||||
/// </summary>
|
||||
public event EventHandler? HideCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transition played when the surface is shown without an
|
||||
/// explicit one (see <see cref="Show()"/>). Defaults to
|
||||
/// <see cref="Transition.None"/>, which plays no animation at all (the
|
||||
/// surface appears instantly); a directional value adds a fade plus a slide
|
||||
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
|
||||
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
|
||||
/// unless it has been set explicitly.
|
||||
/// </summary>
|
||||
public Transition ShowTransition
|
||||
{
|
||||
get => (Transition)GetValue(ShowTransitionProperty);
|
||||
set => SetValue(ShowTransitionProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transition played when the surface is hidden (see
|
||||
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
|
||||
/// plays no animation at all (the surface disappears instantly); a
|
||||
/// directional value adds a fade plus a slide out toward that edge, and
|
||||
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
|
||||
/// this regenerates the default <see cref="HideAnimations"/> unless it has
|
||||
/// been set explicitly.
|
||||
/// </summary>
|
||||
public Transition HideTransition
|
||||
{
|
||||
get => (Transition)GetValue(HideTransitionProperty);
|
||||
set => SetValue(HideTransitionProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the desktop acrylic material variant painted behind the
|
||||
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
|
||||
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
|
||||
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
|
||||
/// for the base material. Has no effect when a custom template without the
|
||||
/// default acrylic backdrop is applied.
|
||||
/// </summary>
|
||||
public DesktopAcrylicKind AcrylicKind
|
||||
{
|
||||
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
|
||||
set => SetValue(AcrylicKindProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played when <see cref="Show()"/> flips the
|
||||
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
|
||||
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
|
||||
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet ShowAnimations
|
||||
{
|
||||
get => _showAnimations;
|
||||
set
|
||||
{
|
||||
_showAnimations = value ?? new ImplicitAnimationSet();
|
||||
_hasCustomShowAnimations = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played when <see cref="Hide"/> flips the
|
||||
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
|
||||
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
|
||||
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet HideAnimations
|
||||
{
|
||||
get => _hideAnimations;
|
||||
set
|
||||
{
|
||||
_hideAnimations = value ?? new ImplicitAnimationSet();
|
||||
_hasCustomHideAnimations = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
|
||||
/// animates itself in and out in response to the window's
|
||||
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
|
||||
/// events. Call this once after the surface has been set as (or placed within)
|
||||
/// the window's content.
|
||||
/// </summary>
|
||||
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
|
||||
public void SubscribeTo(TransparentWindow host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
host.Showing += OnHostShowing;
|
||||
host.Hiding += OnHostHiding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the surface to its hidden pose and flips it to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
|
||||
/// using <paramref name="transition"/> as the show transition.
|
||||
/// </summary>
|
||||
/// <param name="transition">The transition to play when showing.</param>
|
||||
public void Show(Transition transition)
|
||||
{
|
||||
ShowTransition = transition;
|
||||
Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the surface to its hidden pose and flips it to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
|
||||
/// Repeated calls re-trigger the show animation cleanly and cancel any
|
||||
/// pending <see cref="HideCompleted"/> notification.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
_hideCompletedTimer.Stop();
|
||||
|
||||
// If a hide from a previous cycle is still in flight, abandon it: drop its
|
||||
// pending HideCompleted handler so the outstanding deferral is never
|
||||
// completed. We are showing again, so the host must keep the window
|
||||
// visible instead of later hiding it for this interrupted cycle.
|
||||
_abandonPendingHide?.Invoke();
|
||||
_abandonPendingHide = null;
|
||||
|
||||
// Attach the show animation and detach any hide animation: when Show() is
|
||||
// called while the surface is still visible, the Collapsed -> Visible
|
||||
// restart below would otherwise play the hide animation (a fade/scale out)
|
||||
// immediately before the show, producing a visible flash. The real hide
|
||||
// animation is re-attached just-in-time in Hide().
|
||||
Implicit.SetShowAnimations(this, _showAnimations);
|
||||
Implicit.SetHideAnimations(this, _noAnimations);
|
||||
|
||||
// Reset to the hidden pose so the show animation always animates from the
|
||||
// configured starting frame.
|
||||
Visibility = Visibility.Collapsed;
|
||||
Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
|
||||
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
|
||||
/// once the longest animation in <see cref="HideAnimations"/> (delay +
|
||||
/// duration) has completed.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
// Attach the hide animation just before collapsing (Show() detaches it to
|
||||
// avoid a flash when re-showing an already-visible surface).
|
||||
Implicit.SetHideAnimations(this, _hideAnimations);
|
||||
|
||||
Visibility = Visibility.Collapsed;
|
||||
|
||||
_hideCompletedTimer.Debounce(
|
||||
() => HideCompleted?.Invoke(this, EventArgs.Empty),
|
||||
interval: GetAnimationSetTotalDuration(_hideAnimations),
|
||||
immediate: false);
|
||||
}
|
||||
|
||||
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((TransientSurface)d).RebuildDefaultAnimations();
|
||||
}
|
||||
|
||||
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
|
||||
{
|
||||
TimeSpan longest = TimeSpan.Zero;
|
||||
foreach (var animation in set)
|
||||
{
|
||||
if (animation is Animation anim)
|
||||
{
|
||||
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
|
||||
if (total > longest)
|
||||
{
|
||||
longest = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
|
||||
{
|
||||
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
|
||||
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
|
||||
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
|
||||
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
|
||||
_ => (null, null),
|
||||
};
|
||||
|
||||
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
|
||||
{
|
||||
if (e.Transition is Transition transition)
|
||||
{
|
||||
Show(transition);
|
||||
}
|
||||
else
|
||||
{
|
||||
Show();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
|
||||
{
|
||||
// Take a deferral so the host keeps its window visible until our
|
||||
// out-animation has finished, then complete it from HideCompleted.
|
||||
var deferral = e.GetDeferral();
|
||||
|
||||
void OnHideCompleted(object? s, EventArgs args)
|
||||
{
|
||||
HideCompleted -= OnHideCompleted;
|
||||
_abandonPendingHide = null;
|
||||
deferral.Complete();
|
||||
}
|
||||
|
||||
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
|
||||
// handler so the deferral is never completed (the window stays visible)
|
||||
// rather than firing AppWindow.Hide for an interrupted cycle.
|
||||
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
|
||||
|
||||
HideCompleted += OnHideCompleted;
|
||||
Hide();
|
||||
}
|
||||
|
||||
private void RebuildDefaultAnimations()
|
||||
{
|
||||
if (!_hasCustomShowAnimations)
|
||||
{
|
||||
_showAnimations = BuildShowAnimations(ShowTransition);
|
||||
}
|
||||
|
||||
if (!_hasCustomHideAnimations)
|
||||
{
|
||||
_hideAnimations = BuildHideAnimations(HideTransition);
|
||||
}
|
||||
}
|
||||
|
||||
private void PinScaleCenter()
|
||||
{
|
||||
var visual = ElementCompositionPreview.GetElementVisual(this);
|
||||
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
|
||||
visual.StartAnimation("CenterPoint", center);
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
|
||||
{
|
||||
var animations = new ImplicitAnimationSet();
|
||||
|
||||
if (transition == Transition.None)
|
||||
{
|
||||
// No animation at all.
|
||||
return animations;
|
||||
}
|
||||
|
||||
if (transition == Transition.Pop)
|
||||
{
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new ScaleAnimation
|
||||
{
|
||||
From = $"{PopScaleFrom},{PopScaleFrom},1",
|
||||
To = "1,1,1",
|
||||
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
var (slideFrom, _) = GetSlideOffsets(transition);
|
||||
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new TranslationAnimation
|
||||
{
|
||||
From = slideFrom,
|
||||
To = $"0,0,{ShadowDepth}",
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
|
||||
{
|
||||
var animations = new ImplicitAnimationSet();
|
||||
|
||||
if (transition == Transition.None)
|
||||
{
|
||||
// No animation at all.
|
||||
return animations;
|
||||
}
|
||||
|
||||
if (transition == Transition.Pop)
|
||||
{
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new ScaleAnimation
|
||||
{
|
||||
From = "1,1,1",
|
||||
To = $"{PopScaleFrom},{PopScaleFrom},1",
|
||||
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
var (_, slideTo) = GetSlideOffsets(transition);
|
||||
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new TranslationAnimation
|
||||
{
|
||||
From = $"0,0,{ShadowDepth}",
|
||||
To = slideTo,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
|
||||
/// plays when it is shown or hidden. The directional values describe an edge —
|
||||
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
|
||||
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
|
||||
/// </summary>
|
||||
public enum Transition
|
||||
{
|
||||
/// <summary>No animation; the surface appears and disappears instantly.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
|
||||
Top,
|
||||
|
||||
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
|
||||
Right,
|
||||
|
||||
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
|
||||
Bottom,
|
||||
|
||||
/// <summary>
|
||||
/// A subtle "pop": a quick fade combined with a small scale between 96% and
|
||||
/// 100% from the surface's center. Stays in place — no slide.
|
||||
/// </summary>
|
||||
Pop,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A floating "card" surface for transient PowerToys overlays (toasts,
|
||||
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
|
||||
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
|
||||
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
|
||||
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
|
||||
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
|
||||
/// in resources — the standard WinUI restyle path.
|
||||
/// </remarks>
|
||||
public sealed partial class TransparentCard : ContentControl
|
||||
{
|
||||
public TransparentCard()
|
||||
{
|
||||
DefaultStyleKey = typeof(TransparentCard);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
|
||||
@@ -187,16 +187,13 @@ public static partial class FlyoutWindowHelper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
|
||||
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
|
||||
/// rect the effect is invisible). Then sets the real position+size while the window
|
||||
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
|
||||
/// handler doesn't fire and overwrite our computed rect.
|
||||
///
|
||||
/// Skips the teleport when the window is already on the target display, since there
|
||||
/// is no boundary to cross.
|
||||
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
|
||||
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
|
||||
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
|
||||
/// target display (invisible at that size), then the real position+size while the window
|
||||
/// is already on that monitor. Skips the teleport when already on the target display.
|
||||
/// </summary>
|
||||
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
|
||||
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
|
||||
{
|
||||
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
|
||||
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;
|
||||
@@ -1,290 +0,0 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
using CommunityToolkit.WinUI;
|
||||
using CommunityToolkit.WinUI.Animations;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Markup;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable transparent host window for transient overlays
|
||||
/// (toasts, banners, indicators) that should not steal foreground.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
|
||||
/// currently hand-roll:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
|
||||
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
|
||||
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
|
||||
/// <item>Extend content into the title bar and collapse the title bar.</item>
|
||||
/// </list>
|
||||
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
|
||||
/// in a <see cref="TransparentCard"/> that the constructor assigns to
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
|
||||
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
|
||||
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
|
||||
/// be written as <c><common:TransparentWindow><TextBlock/></common:TransparentWindow></c>.</para>
|
||||
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
|
||||
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
|
||||
/// fully see-through. That buffer area is NOT click-through, so consumers
|
||||
/// should keep it as small as possible (just enough to give the card's
|
||||
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
|
||||
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
|
||||
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
|
||||
/// toggle on the card, and a debounced
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
|
||||
/// animation in <see cref="HideAnimations"/>. Animations target the card so
|
||||
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
|
||||
/// </remarks>
|
||||
[ContentProperty(Name = nameof(InnerContent))]
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
private const uint DwmwaColorNone = 0xFFFFFFFE;
|
||||
private const int DwmwaWindowCornerPreference = 33;
|
||||
private const int DwmwaBorderColor = 34;
|
||||
private const int DwmwcpDoNotRound = 1;
|
||||
|
||||
private const int GwlExStyle = -20;
|
||||
private const int WsExToolWindow = 0x00000080;
|
||||
|
||||
private const int SwShowNa = 8;
|
||||
|
||||
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
|
||||
private readonly nint _hwnd;
|
||||
private readonly TransparentCard _card;
|
||||
|
||||
private ImplicitAnimationSet _showAnimations;
|
||||
private ImplicitAnimationSet _hideAnimations;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
|
||||
|
||||
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
|
||||
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
|
||||
|
||||
unsafe
|
||||
{
|
||||
uint borderColor = DwmwaColorNone;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
|
||||
|
||||
int cornerPref = DwmwcpDoNotRound;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
|
||||
}
|
||||
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
_showAnimations = BuildDefaultShowAnimations();
|
||||
_hideAnimations = BuildDefaultHideAnimations();
|
||||
|
||||
_card = new TransparentCard();
|
||||
Content = _card;
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="TransparentCard"/> that provides the window's
|
||||
/// visible chrome (acrylic + border + shadow). Consumers can configure
|
||||
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
|
||||
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
|
||||
/// window, or apply a custom <c>Style</c> to change its look.
|
||||
/// </summary>
|
||||
public TransparentCard Card => _card;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visual hosted inside the window's
|
||||
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
|
||||
/// child elements declared between the opening and closing
|
||||
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
|
||||
/// </summary>
|
||||
public object? InnerContent
|
||||
{
|
||||
get => _card.Content;
|
||||
set => _card.Content = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played against
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
|
||||
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
|
||||
/// fade-in plus a 250 ms slide-up of 24 px.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet ShowAnimations
|
||||
{
|
||||
get => _showAnimations;
|
||||
set => _showAnimations = value ?? new ImplicitAnimationSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played against
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
|
||||
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
|
||||
/// fade-out plus a 180 ms slide-down of 12 px.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet HideAnimations
|
||||
{
|
||||
get => _hideAnimations;
|
||||
set => _hideAnimations = value ?? new ImplicitAnimationSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
|
||||
/// Repeated calls reset the content to its hidden pose first so the show
|
||||
/// animation re-triggers cleanly. Any pending hide is cancelled.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_hideCloseTimer.Stop();
|
||||
|
||||
if (Content is UIElement content)
|
||||
{
|
||||
// Re-apply each call so swapping animation collections at
|
||||
// runtime takes effect on the next show/hide cycle.
|
||||
Implicit.SetShowAnimations(content, _showAnimations);
|
||||
Implicit.SetHideAnimations(content, _hideAnimations);
|
||||
|
||||
// Reset to the hidden pose so the show animation always
|
||||
// animates from the configured starting frame.
|
||||
content.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
|
||||
if (Content is UIElement c2)
|
||||
{
|
||||
c2.Visibility = Visibility.Visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
|
||||
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
|
||||
/// plays, then hides the underlying
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
|
||||
/// animation in <see cref="HideAnimations"/> (delay + duration) has
|
||||
/// completed.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
if (Content is UIElement content)
|
||||
{
|
||||
content.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
_hideCloseTimer.Debounce(
|
||||
AppWindow.Hide,
|
||||
interval: GetAnimationSetTotalDuration(_hideAnimations),
|
||||
immediate: false);
|
||||
});
|
||||
}
|
||||
|
||||
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
|
||||
{
|
||||
TimeSpan longest = TimeSpan.Zero;
|
||||
foreach (var animation in set)
|
||||
{
|
||||
if (animation is Animation anim)
|
||||
{
|
||||
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
|
||||
if (total > longest)
|
||||
{
|
||||
longest = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
|
||||
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
|
||||
if (updated != exStyle)
|
||||
{
|
||||
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
|
||||
{
|
||||
new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
new TranslationAnimation
|
||||
{
|
||||
From = "0,24,32",
|
||||
To = "0,0,32",
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
};
|
||||
|
||||
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
|
||||
{
|
||||
new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
new TranslationAnimation
|
||||
{
|
||||
From = "0,0,32",
|
||||
To = "0,12,32",
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
};
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("dwmapi.dll")]
|
||||
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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.Threading;
|
||||
using Deferral = global::Windows.Foundation.Deferral;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
|
||||
/// animated surface can keep the window visible until its out-animation has
|
||||
/// finished. If no handler takes a deferral, the window hides immediately.
|
||||
/// </summary>
|
||||
public sealed class HidingEventArgs : EventArgs
|
||||
{
|
||||
private int _outstanding;
|
||||
private bool _raised;
|
||||
private Action? _continuation;
|
||||
|
||||
/// <summary>
|
||||
/// Requests that the window stay visible until the returned deferral is
|
||||
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
|
||||
/// has finished.
|
||||
/// </summary>
|
||||
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
|
||||
public Deferral GetDeferral()
|
||||
{
|
||||
Interlocked.Increment(ref _outstanding);
|
||||
return new Deferral(OnDeferralCompleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the window after raising the event to register what should run
|
||||
/// once every outstanding deferral has completed (or immediately if none
|
||||
/// were taken).
|
||||
/// </summary>
|
||||
internal void RunWhenComplete(Action continuation)
|
||||
{
|
||||
_continuation = continuation;
|
||||
_raised = true;
|
||||
TryComplete();
|
||||
}
|
||||
|
||||
private void OnDeferralCompleted()
|
||||
{
|
||||
Interlocked.Decrement(ref _outstanding);
|
||||
TryComplete();
|
||||
}
|
||||
|
||||
private void TryComplete()
|
||||
{
|
||||
if (_raised && Volatile.Read(ref _outstanding) == 0)
|
||||
{
|
||||
var continuation = _continuation;
|
||||
_continuation = null;
|
||||
continuation?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
|
||||
/// content should play, or <see langword="null"/> to let the content use its own
|
||||
/// configured show transition.
|
||||
/// </summary>
|
||||
public sealed class ShowingEventArgs : EventArgs
|
||||
{
|
||||
public ShowingEventArgs(Transition? transition)
|
||||
{
|
||||
Transition = transition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition the content should play, or <see langword="null"/> to
|
||||
/// use the content's own configured show transition.
|
||||
/// </summary>
|
||||
public Transition? Transition { get; }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Windows.Foundation;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable transparent host window for transient overlays
|
||||
/// (toasts, banners, indicators) that should not steal foreground.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
|
||||
/// currently hand-roll:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
|
||||
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
|
||||
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
|
||||
/// <item>Extend content into the title bar and collapse the title bar.</item>
|
||||
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
|
||||
/// see-through and the visible chrome can be drawn by the content.</item>
|
||||
/// </list>
|
||||
/// <para>This window is intentionally animation-agnostic: it does not own any
|
||||
/// chrome or motion. Consumers supply their own content (typically a
|
||||
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
|
||||
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
|
||||
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
|
||||
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
|
||||
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
|
||||
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
|
||||
/// deferrals, so the underlying
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
|
||||
/// content has finished animating out. With no listener the window simply shows
|
||||
/// or hides immediately.</para>
|
||||
/// </remarks>
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
private const uint DwmwaColorNone = 0xFFFFFFFE;
|
||||
private const int DwmwaWindowCornerPreference = 33;
|
||||
private const int DwmwaBorderColor = 34;
|
||||
private const int DwmwcpDoNotRound = 1;
|
||||
|
||||
private const int GwlExStyle = -20;
|
||||
private const int WsExToolWindow = 0x00000080;
|
||||
|
||||
private const int SwShowNa = 8;
|
||||
|
||||
private readonly nint _hwnd;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
|
||||
|
||||
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
|
||||
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
|
||||
|
||||
unsafe
|
||||
{
|
||||
uint borderColor = DwmwaColorNone;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
|
||||
|
||||
int cornerPref = DwmwcpDoNotRound;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
|
||||
}
|
||||
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised (without activation) when <see cref="Show()"/> makes the window
|
||||
/// visible. A content surface subscribes to this to play its in-animation,
|
||||
/// using <see cref="ShowingEventArgs.Transition"/>.
|
||||
/// </summary>
|
||||
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
|
||||
/// surface subscribes to this to play its out-animation, taking a deferral
|
||||
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
|
||||
/// visible until the animation completes.
|
||||
/// </summary>
|
||||
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
|
||||
/// <see cref="Showing"/> without a transition, so subscribed content animates
|
||||
/// in using its own configured show transition.
|
||||
/// </summary>
|
||||
public void Show() => RaiseShow(null);
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
|
||||
/// <see cref="Showing"/> so subscribed content animates in using
|
||||
/// <paramref name="transition"/>, overriding its configured show transition.
|
||||
/// </summary>
|
||||
/// <param name="transition">The transition the content should play.</param>
|
||||
public void Show(Transition transition) => RaiseShow(transition);
|
||||
|
||||
private void RaiseShow(Transition? transition)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
Showing?.Invoke(this, new ShowingEventArgs(transition));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
|
||||
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
|
||||
/// deferral taken by a handler has completed (immediately if none were taken).
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
var args = new HidingEventArgs();
|
||||
Hiding?.Invoke(this, args);
|
||||
args.RunWhenComplete(AppWindow.Hide);
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
|
||||
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
|
||||
if (updated != exStyle)
|
||||
{
|
||||
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("dwmapi.dll")]
|
||||
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 328 B After Width: | Height: | Size: 2.3 KiB |
@@ -373,6 +373,13 @@ static int g_overlayRenderedH = 0;
|
||||
// Always On Top (WindowCornerUtils::CornersRadius).
|
||||
static int CornerRadiusForWindow(HWND hwnd)
|
||||
{
|
||||
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
|
||||
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
|
||||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pref = 0; // DWMWCP_DEFAULT
|
||||
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include "trace.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#ifdef COMPOSITION
|
||||
namespace winrt
|
||||
@@ -48,6 +50,18 @@ private:
|
||||
void ClearDrawingPoint();
|
||||
void ClearDrawing();
|
||||
void BringToFront();
|
||||
// Ripple mode: spawn the press/hold ring + glow at the click point and
|
||||
// continue the animation into a fade-out on release. The held ring may
|
||||
// optionally follow the cursor while held (gated by m_rippleShowDragTrail).
|
||||
void SpawnRippleHoldDot(MouseButton button);
|
||||
void FadeRippleHoldDot(MouseButton button);
|
||||
// Ripple mode: emit a single self-contained ripple (grow + fade) for a quick
|
||||
// click, independent of any held indicator.
|
||||
void EmitSingleRipple(MouseButton button);
|
||||
// Spotlight mode: pressed-state animation that shrinks the mask while
|
||||
// a mouse button is held and restores it on release.
|
||||
void SpotlightAnimatePress();
|
||||
void SpotlightAnimateRelease();
|
||||
HHOOK m_mouseHook = NULL;
|
||||
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
|
||||
// Helpers for spotlight overlay
|
||||
@@ -71,6 +85,16 @@ private:
|
||||
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
|
||||
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
|
||||
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
|
||||
// Ellipse geometries kept alongside the pointer shapes so press-down /
|
||||
// release animations can target the radius directly.
|
||||
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
|
||||
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
|
||||
// Ripple-mode held glow (the soft halo behind the ring) — paired with
|
||||
// m_left/rightPointer (which holds the ring shape) while a button is held.
|
||||
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
|
||||
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
|
||||
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
|
||||
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
|
||||
// Spotlight overlay (mask with soft feathered edge)
|
||||
winrt::SpriteVisual m_overlay{ nullptr };
|
||||
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
|
||||
@@ -84,9 +108,22 @@ private:
|
||||
bool m_rightPointerEnabled = true;
|
||||
bool m_alwaysPointerEnabled = true;
|
||||
bool m_spotlightMode = false;
|
||||
bool m_spotlightPressed = false;
|
||||
bool m_rippleMode = true;
|
||||
bool m_rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
|
||||
bool m_rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
|
||||
float m_rippleSize = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
|
||||
float m_rippleIntensity = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
|
||||
int m_rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
|
||||
|
||||
bool m_leftButtonPressed = false;
|
||||
bool m_rightButtonPressed = false;
|
||||
// Pending hold-detection timers. A ripple "held indicator" is only spawned
|
||||
// once the button has been held past a short threshold; a quick click that
|
||||
// releases before then emits a single self-contained ripple instead. This
|
||||
// prevents a single click from rendering two ripples (press + release).
|
||||
UINT_PTR m_leftHoldTimer = 0;
|
||||
UINT_PTR m_rightHoldTimer = 0;
|
||||
UINT_PTR m_timer_id = 0;
|
||||
|
||||
bool m_visible = false;
|
||||
@@ -102,6 +139,11 @@ private:
|
||||
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
|
||||
};
|
||||
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
|
||||
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
|
||||
static const uint32_t HOLD_RIPPLE_TIMER_RIGHT = 125;
|
||||
// How long a ripple button must be held before the persistent "held indicator"
|
||||
// is shown. Releasing before this is treated as a quick click (single ripple).
|
||||
static const uint32_t HOLD_RIPPLE_THRESHOLD_MS = 180;
|
||||
Highlighter* Highlighter::instance = nullptr;
|
||||
|
||||
bool Highlighter::CreateHighlighter()
|
||||
@@ -194,11 +236,34 @@ void Highlighter::AddDrawingPoint(MouseButton button)
|
||||
{
|
||||
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
|
||||
m_leftPointer = circleShape;
|
||||
m_leftGeometry = circleGeometry;
|
||||
|
||||
// Niels-style press-down shrink: holding the button squeezes the
|
||||
// circle to 70% over 180ms after a 150ms delay so quick clicks skip
|
||||
// it. StartDrawingPointFading stops this animation on release.
|
||||
const float pressedRadius = m_radius * 0.70f;
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
|
||||
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
|
||||
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
|
||||
anim.Duration(std::chrono::milliseconds(180));
|
||||
anim.DelayTime(std::chrono::milliseconds(150));
|
||||
circleGeometry.StartAnimation(L"Radius", anim);
|
||||
}
|
||||
else if (button == MouseButton::Right)
|
||||
{
|
||||
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
|
||||
m_rightPointer = circleShape;
|
||||
m_rightGeometry = circleGeometry;
|
||||
|
||||
const float pressedRadius = m_radius * 0.70f;
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
|
||||
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
|
||||
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
|
||||
anim.Duration(std::chrono::milliseconds(180));
|
||||
anim.DelayTime(std::chrono::milliseconds(150));
|
||||
circleGeometry.StartAnimation(L"Radius", anim);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -238,17 +303,36 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
|
||||
if (button == MouseButton::Left)
|
||||
{
|
||||
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
|
||||
if (m_leftRippleGlow)
|
||||
{
|
||||
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
|
||||
}
|
||||
}
|
||||
else if (button == MouseButton::Right)
|
||||
{
|
||||
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
|
||||
if (m_rightRippleGlow)
|
||||
{
|
||||
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// always / spotlight idle
|
||||
if (m_spotlightMode)
|
||||
{
|
||||
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
|
||||
if (m_spotlightPressed)
|
||||
{
|
||||
// Only update position while pressed — radius is being animated
|
||||
if (m_spotlightMaskGradient)
|
||||
{
|
||||
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
|
||||
}
|
||||
}
|
||||
else if (m_alwaysPointer)
|
||||
{
|
||||
@@ -259,14 +343,24 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
|
||||
void Highlighter::StartDrawingPointFading(MouseButton button)
|
||||
{
|
||||
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
|
||||
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
|
||||
if (button == MouseButton::Left)
|
||||
{
|
||||
circleShape = m_leftPointer;
|
||||
geom = m_leftGeometry;
|
||||
}
|
||||
else
|
||||
{
|
||||
// right
|
||||
circleShape = m_rightPointer;
|
||||
geom = m_rightGeometry;
|
||||
}
|
||||
|
||||
// Stop any in-flight press-down shrink so the geometry doesn't keep
|
||||
// animating while the fill is being faded out.
|
||||
if (geom && m_compositor)
|
||||
{
|
||||
geom.StopAnimation(L"Radius");
|
||||
}
|
||||
|
||||
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
|
||||
@@ -329,6 +423,30 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
|
||||
switch (wParam)
|
||||
{
|
||||
case WM_LBUTTONDOWN:
|
||||
if (instance->m_spotlightMode)
|
||||
{
|
||||
instance->SpotlightAnimatePress();
|
||||
break;
|
||||
}
|
||||
if (instance->m_rippleMode)
|
||||
{
|
||||
if (instance->m_leftPointerEnabled)
|
||||
{
|
||||
// Defer the held indicator: only spawn it if the button is
|
||||
// still down after the hold threshold. A quick click handled
|
||||
// on button-up emits a single ripple instead.
|
||||
instance->m_leftButtonPressed = true;
|
||||
if (instance->m_leftHoldTimer == 0)
|
||||
{
|
||||
instance->m_leftHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_LEFT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
|
||||
}
|
||||
if (instance->m_timer_id == 0)
|
||||
{
|
||||
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (instance->m_leftPointerEnabled)
|
||||
{
|
||||
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
|
||||
@@ -354,6 +472,28 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
|
||||
}
|
||||
break;
|
||||
case WM_RBUTTONDOWN:
|
||||
if (instance->m_spotlightMode)
|
||||
{
|
||||
instance->SpotlightAnimatePress();
|
||||
break;
|
||||
}
|
||||
if (instance->m_rippleMode)
|
||||
{
|
||||
if (instance->m_rightPointerEnabled)
|
||||
{
|
||||
// Defer the held indicator (see WM_LBUTTONDOWN).
|
||||
instance->m_rightButtonPressed = true;
|
||||
if (instance->m_rightHoldTimer == 0)
|
||||
{
|
||||
instance->m_rightHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_RIGHT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
|
||||
}
|
||||
if (instance->m_timer_id == 0)
|
||||
{
|
||||
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (instance->m_rightPointerEnabled)
|
||||
{
|
||||
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
|
||||
@@ -376,6 +516,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEMOVE:
|
||||
if (instance->m_rippleMode)
|
||||
{
|
||||
// Held ripple ring follows the cursor while a button is down,
|
||||
// gated by the "follow cursor while held" setting. When the
|
||||
// setting is off, the ring stays anchored at the click point.
|
||||
if (instance->m_rippleShowDragTrail)
|
||||
{
|
||||
if (instance->m_leftButtonPressed && instance->m_leftPointer)
|
||||
{
|
||||
instance->UpdateDrawingPointPosition(MouseButton::Left);
|
||||
}
|
||||
if (instance->m_rightButtonPressed && instance->m_rightPointer)
|
||||
{
|
||||
instance->UpdateDrawingPointPosition(MouseButton::Right);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (instance->m_leftButtonPressed)
|
||||
{
|
||||
instance->UpdateDrawingPointPosition(MouseButton::Left);
|
||||
@@ -390,11 +548,33 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONUP:
|
||||
if (instance->m_spotlightPressed)
|
||||
{
|
||||
instance->SpotlightAnimateRelease();
|
||||
}
|
||||
if (instance->m_leftButtonPressed)
|
||||
{
|
||||
instance->StartDrawingPointFading(MouseButton::Left);
|
||||
if (instance->m_rippleMode)
|
||||
{
|
||||
if (instance->m_leftHoldTimer != 0)
|
||||
{
|
||||
// Released before the hold threshold => quick click.
|
||||
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
|
||||
instance->m_leftHoldTimer = 0;
|
||||
instance->EmitSingleRipple(MouseButton::Left);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Held indicator was already shown; expand + fade it.
|
||||
instance->FadeRippleHoldDot(MouseButton::Left);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
instance->StartDrawingPointFading(MouseButton::Left);
|
||||
}
|
||||
instance->m_leftButtonPressed = false;
|
||||
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
|
||||
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
|
||||
{
|
||||
// Add AlwaysPointer only when it's enabled and RightPointer is not active
|
||||
instance->AddDrawingPoint(MouseButton::None);
|
||||
@@ -402,11 +582,32 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
|
||||
}
|
||||
break;
|
||||
case WM_RBUTTONUP:
|
||||
if (instance->m_spotlightPressed)
|
||||
{
|
||||
instance->SpotlightAnimateRelease();
|
||||
}
|
||||
if (instance->m_rightButtonPressed)
|
||||
{
|
||||
instance->StartDrawingPointFading(MouseButton::Right);
|
||||
if (instance->m_rippleMode)
|
||||
{
|
||||
if (instance->m_rightHoldTimer != 0)
|
||||
{
|
||||
// Released before the hold threshold => quick click.
|
||||
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
|
||||
instance->m_rightHoldTimer = 0;
|
||||
instance->EmitSingleRipple(MouseButton::Right);
|
||||
}
|
||||
else
|
||||
{
|
||||
instance->FadeRippleHoldDot(MouseButton::Right);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
instance->StartDrawingPointFading(MouseButton::Right);
|
||||
}
|
||||
instance->m_rightButtonPressed = false;
|
||||
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
|
||||
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
|
||||
{
|
||||
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
|
||||
instance->AddDrawingPoint(MouseButton::None);
|
||||
@@ -448,9 +649,16 @@ void Highlighter::StopDrawing()
|
||||
m_visible = false;
|
||||
m_leftButtonPressed = false;
|
||||
m_rightButtonPressed = false;
|
||||
m_spotlightPressed = false;
|
||||
m_leftPointer = nullptr;
|
||||
m_rightPointer = nullptr;
|
||||
m_alwaysPointer = nullptr;
|
||||
m_leftGeometry = nullptr;
|
||||
m_rightGeometry = nullptr;
|
||||
m_leftRippleGlow = nullptr;
|
||||
m_rightRippleGlow = nullptr;
|
||||
m_leftGlowGeometry = nullptr;
|
||||
m_rightGlowGeometry = nullptr;
|
||||
if (m_overlay)
|
||||
{
|
||||
m_overlay.IsVisible(false);
|
||||
@@ -478,6 +686,16 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
|
||||
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
|
||||
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
|
||||
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
|
||||
m_rippleMode = settings.rippleMode && !m_spotlightMode;
|
||||
m_rippleSize = (settings.rippleSize > 0) ? static_cast<float>(settings.rippleSize) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
|
||||
m_rippleIntensity = (settings.rippleIntensity > 0.0) ? static_cast<float>(settings.rippleIntensity) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
|
||||
m_rippleDurationMs = (settings.rippleDurationMs > 0) ? settings.rippleDurationMs : MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
|
||||
m_rippleShowDragTrail = settings.rippleShowDragTrail;
|
||||
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
|
||||
|
||||
// Reset transient pressed-state flag so a settings change while a button
|
||||
// happens to be down doesn't leave the spotlight stuck at a shrunken size.
|
||||
m_spotlightPressed = false;
|
||||
|
||||
if (m_spotlightMode)
|
||||
{
|
||||
@@ -548,6 +766,7 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
|
||||
// If we would use a timer with a 50 ms period, there would be a flickering on the UI, as in most of the cases
|
||||
// the pinned window hides our window in a few milliseconds.
|
||||
case BRING_TO_FRONT_TIMER_ID:
|
||||
{
|
||||
static int fireCount = 0;
|
||||
if (fireCount++ >= 4)
|
||||
{
|
||||
@@ -558,6 +777,24 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
|
||||
instance->BringToFront();
|
||||
break;
|
||||
}
|
||||
case HOLD_RIPPLE_TIMER_LEFT:
|
||||
// Button held past the threshold: show the persistent held indicator.
|
||||
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
|
||||
instance->m_leftHoldTimer = 0;
|
||||
if (instance->m_leftButtonPressed)
|
||||
{
|
||||
instance->SpawnRippleHoldDot(MouseButton::Left);
|
||||
}
|
||||
break;
|
||||
case HOLD_RIPPLE_TIMER_RIGHT:
|
||||
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
|
||||
instance->m_rightHoldTimer = 0;
|
||||
if (instance->m_rightButtonPressed)
|
||||
{
|
||||
instance->SpawnRippleHoldDot(MouseButton::Right);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -643,6 +880,548 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
|
||||
}
|
||||
}
|
||||
|
||||
// Spotlight press-down: shrink the mask radius briefly while a button is held.
|
||||
void Highlighter::SpotlightAnimatePress()
|
||||
{
|
||||
if (!m_spotlightMode || !m_spotlightMaskGradient)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_spotlightPressed = true;
|
||||
const float pressedRadius = m_radius * 0.85f;
|
||||
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
|
||||
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
|
||||
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
|
||||
anim.Duration(std::chrono::milliseconds(120));
|
||||
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
|
||||
}
|
||||
|
||||
// Spotlight release: animate the mask back to the configured radius.
|
||||
void Highlighter::SpotlightAnimateRelease()
|
||||
{
|
||||
m_spotlightPressed = false;
|
||||
|
||||
if (!m_spotlightMode || !m_spotlightMaskGradient)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
|
||||
auto current = m_spotlightMaskGradient.EllipseRadius();
|
||||
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
anim.InsertKeyFrame(0.0f, current);
|
||||
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
|
||||
anim.Duration(std::chrono::milliseconds(200));
|
||||
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
|
||||
}
|
||||
|
||||
// Spawn the press/hold ring + glow at the click point. The shapes persist
|
||||
// until FadeRippleHoldDot is called (button-up). While held they can be
|
||||
// re-positioned to follow the cursor (UpdateDrawingPointPosition).
|
||||
void Highlighter::SpawnRippleHoldDot(MouseButton button)
|
||||
{
|
||||
if (!m_compositor || !m_shape)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
|
||||
if (color.A == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
POINT pt{};
|
||||
if (!GetCursorPos(&pt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
ScreenToClient(m_hwnd, &pt);
|
||||
const float fx = static_cast<float>(pt.x);
|
||||
const float fy = static_cast<float>(pt.y);
|
||||
|
||||
// Resolve sizing/intensity from the ripple-specific settings so they're
|
||||
// independent of the legacy "always-on dot" controls.
|
||||
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
|
||||
float intensity = m_rippleIntensity;
|
||||
if (intensity < 0.15f) intensity = 0.15f;
|
||||
if (intensity > 1.35f) intensity = 1.35f;
|
||||
|
||||
const float ringHeld = baseSize * 0.55f;
|
||||
const float glowHeld = baseSize * 0.65f;
|
||||
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
|
||||
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
|
||||
// Held indicator: appears once the button has been held past the hold
|
||||
// threshold and sits at the held radius until release. It must NOT expand
|
||||
// outward on appearance — it only FADES IN at the held size. The single
|
||||
// outward "ripple" expansion happens exclusively on release
|
||||
// (FadeRippleHoldDot). If this grew outward, a slow single click (release
|
||||
// shortly after the threshold) would show grow-to-held + release as two
|
||||
// expansions — the double-ripple bug.
|
||||
auto dur = std::chrono::milliseconds(120);
|
||||
|
||||
auto clampByte = [](float v) -> uint8_t {
|
||||
if (v < 0.0f) v = 0.0f;
|
||||
if (v > 255.0f) v = 255.0f;
|
||||
return static_cast<uint8_t>(v);
|
||||
};
|
||||
|
||||
// Glow color is the click color, lower alpha (×0.30), scaled by intensity.
|
||||
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
|
||||
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
|
||||
auto glowTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
|
||||
|
||||
// Ring color uses full base alpha (alphaMul like the press recipe).
|
||||
const float alphaMul = 0.18f + intensity * 0.78f;
|
||||
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
|
||||
auto ringTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
|
||||
|
||||
// Clean up any stray "still held" shapes for this button — guards against
|
||||
// stray button-down without matching button-up (e.g. focus loss).
|
||||
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
|
||||
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
|
||||
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
|
||||
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
|
||||
|
||||
if (m_shape && m_shape.Shapes())
|
||||
{
|
||||
auto shapes = m_shape.Shapes();
|
||||
uint32_t idx = 0;
|
||||
if (heldRing && shapes.IndexOf(heldRing, idx))
|
||||
{
|
||||
shapes.RemoveAt(idx);
|
||||
}
|
||||
if (heldGlow && shapes.IndexOf(heldGlow, idx))
|
||||
{
|
||||
shapes.RemoveAt(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Glow (filled) — added first so the ring renders on top. Sits at the held
|
||||
// radius and fades its alpha in (no outward size growth).
|
||||
auto glowGeom = m_compositor.CreateEllipseGeometry();
|
||||
glowGeom.Radius({ glowHeld, glowHeld });
|
||||
auto glowBrush = m_compositor.CreateColorBrush(glowTransparent);
|
||||
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
|
||||
glowShape.Offset({ fx, fy });
|
||||
glowShape.FillBrush(glowBrush);
|
||||
m_shape.Shapes().Append(glowShape);
|
||||
|
||||
auto glowFadeIn = m_compositor.CreateColorKeyFrameAnimation();
|
||||
glowFadeIn.InsertKeyFrame(0.0f, glowTransparent);
|
||||
glowFadeIn.InsertKeyFrame(1.0f, glowColor, ease);
|
||||
glowFadeIn.Duration(dur);
|
||||
glowBrush.StartAnimation(L"Color", glowFadeIn);
|
||||
|
||||
// Ring (stroked) — same: fixed at held radius, alpha fade-in only.
|
||||
auto ringGeom = m_compositor.CreateEllipseGeometry();
|
||||
ringGeom.Radius({ ringHeld, ringHeld });
|
||||
auto ringBrush = m_compositor.CreateColorBrush(ringTransparent);
|
||||
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
|
||||
ringShape.Offset({ fx, fy });
|
||||
ringShape.StrokeBrush(ringBrush);
|
||||
ringShape.StrokeThickness(lineWidth);
|
||||
ringShape.IsStrokeNonScaling(true);
|
||||
m_shape.Shapes().Append(ringShape);
|
||||
|
||||
auto ringFadeIn = m_compositor.CreateColorKeyFrameAnimation();
|
||||
ringFadeIn.InsertKeyFrame(0.0f, ringTransparent);
|
||||
ringFadeIn.InsertKeyFrame(1.0f, ringColor, ease);
|
||||
ringFadeIn.Duration(dur);
|
||||
ringBrush.StartAnimation(L"Color", ringFadeIn);
|
||||
|
||||
heldRing = ringShape;
|
||||
heldGlow = glowShape;
|
||||
heldGeom = ringGeom;
|
||||
heldGlowGeom = glowGeom;
|
||||
}
|
||||
|
||||
// Continue the held-ring/glow animation outward and fade both to transparent.
|
||||
// For right-click, optionally spawn the expanding crosshair lines.
|
||||
void Highlighter::FadeRippleHoldDot(MouseButton button)
|
||||
{
|
||||
if (!m_compositor || !m_shape)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
|
||||
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
|
||||
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
|
||||
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
|
||||
|
||||
if (!heldRing && !heldGlow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
|
||||
|
||||
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
|
||||
float intensity = m_rippleIntensity;
|
||||
if (intensity < 0.15f) intensity = 0.15f;
|
||||
if (intensity > 1.35f) intensity = 1.35f;
|
||||
|
||||
int durationMs = m_rippleDurationMs;
|
||||
if (durationMs < 60) durationMs = 60;
|
||||
if (durationMs > 2000) durationMs = 2000;
|
||||
auto dur = std::chrono::milliseconds(durationMs);
|
||||
|
||||
const float ringHeld = baseSize * 0.55f;
|
||||
const float ringEnd = baseSize * 1.05f;
|
||||
const float glowHeld = baseSize * 0.65f;
|
||||
const float glowEnd = baseSize * 1.40f;
|
||||
|
||||
auto clampByte = [](float v) -> uint8_t {
|
||||
if (v < 0.0f) v = 0.0f;
|
||||
if (v > 255.0f) v = 255.0f;
|
||||
return static_cast<uint8_t>(v);
|
||||
};
|
||||
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
|
||||
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
|
||||
|
||||
// Track everything spawned by this fade (and the held shapes themselves)
|
||||
// so the completion callback can remove them in one pass.
|
||||
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
|
||||
|
||||
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
|
||||
|
||||
if (heldGlow && heldGlowGeom)
|
||||
{
|
||||
// The held indicator has settled at the held radius; expand it outward
|
||||
// from there and fade it to transparent.
|
||||
heldGlowGeom.StopAnimation(L"Radius");
|
||||
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
glowAnim.InsertKeyFrame(0.0f, { glowHeld, glowHeld });
|
||||
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
|
||||
glowAnim.Duration(dur);
|
||||
heldGlowGeom.StartAnimation(L"Radius", glowAnim);
|
||||
|
||||
auto brush = heldGlow.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
|
||||
auto startColor = brush.Color();
|
||||
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
colorAnim.InsertKeyFrame(0.0f, startColor);
|
||||
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
colorAnim.Duration(dur);
|
||||
brush.StartAnimation(L"Color", colorAnim);
|
||||
|
||||
spawned->push_back(heldGlow);
|
||||
}
|
||||
|
||||
if (heldRing && heldGeom)
|
||||
{
|
||||
heldGeom.StopAnimation(L"Radius");
|
||||
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
ringAnim.InsertKeyFrame(0.0f, { ringHeld, ringHeld });
|
||||
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
|
||||
ringAnim.Duration(dur);
|
||||
heldGeom.StartAnimation(L"Radius", ringAnim);
|
||||
|
||||
auto brush = heldRing.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
|
||||
auto startColor = brush.Color();
|
||||
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
colorAnim.InsertKeyFrame(0.0f, startColor);
|
||||
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
colorAnim.Duration(dur);
|
||||
brush.StartAnimation(L"Color", colorAnim);
|
||||
|
||||
spawned->push_back(heldRing);
|
||||
}
|
||||
|
||||
// Right-click only: spawn expanding crosshair lines centered on the ring.
|
||||
// Gated by the "show crosshairs on right-click release" toggle.
|
||||
if (button == MouseButton::Right && m_rippleShowReleasePulse && heldRing)
|
||||
{
|
||||
const float xhairAlphaMul = 0.18f + intensity * 0.78f;
|
||||
auto xhairColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * xhairAlphaMul), color.R, color.G, color.B);
|
||||
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
|
||||
|
||||
auto center = heldRing.Offset();
|
||||
const float startSpan = ringHeld * 0.85f;
|
||||
const float endSpan = ringEnd * 0.85f;
|
||||
|
||||
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
|
||||
float bx1, float by1, float bx2, float by2) {
|
||||
auto lineGeom = m_compositor.CreateLineGeometry();
|
||||
lineGeom.Start({ ax1, ay1 });
|
||||
lineGeom.End({ ax2, ay2 });
|
||||
|
||||
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
|
||||
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
|
||||
lineShape.StrokeBrush(lineBrush);
|
||||
lineShape.StrokeThickness(xhairThickness);
|
||||
lineShape.IsStrokeNonScaling(true);
|
||||
m_shape.Shapes().Append(lineShape);
|
||||
spawned->push_back(lineShape);
|
||||
|
||||
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
|
||||
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
|
||||
startAnim.Duration(dur);
|
||||
lineGeom.StartAnimation(L"Start", startAnim);
|
||||
|
||||
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
|
||||
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
|
||||
endAnim.Duration(dur);
|
||||
lineGeom.StartAnimation(L"End", endAnim);
|
||||
|
||||
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
colorAnim.InsertKeyFrame(0.0f, xhairColor);
|
||||
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
colorAnim.Duration(dur);
|
||||
lineBrush.StartAnimation(L"Color", colorAnim);
|
||||
};
|
||||
|
||||
// Horizontal line (left half, right half).
|
||||
makeLine(center.x - startSpan, center.y, center.x - startSpan * 0.30f, center.y,
|
||||
center.x - endSpan, center.y, center.x - endSpan * 0.30f, center.y);
|
||||
makeLine(center.x + startSpan * 0.30f, center.y, center.x + startSpan, center.y,
|
||||
center.x + endSpan * 0.30f, center.y, center.x + endSpan, center.y);
|
||||
// Vertical line (top half, bottom half).
|
||||
makeLine(center.x, center.y - startSpan, center.x, center.y - startSpan * 0.30f,
|
||||
center.x, center.y - endSpan, center.x, center.y - endSpan * 0.30f);
|
||||
makeLine(center.x, center.y + startSpan * 0.30f, center.x, center.y + startSpan,
|
||||
center.x, center.y + endSpan * 0.30f, center.x, center.y + endSpan);
|
||||
}
|
||||
|
||||
// Detach our member handles BEFORE the batch completes so subsequent
|
||||
// press events on this button create fresh shapes rather than racing.
|
||||
heldRing = nullptr;
|
||||
heldGlow = nullptr;
|
||||
heldGeom = nullptr;
|
||||
heldGlowGeom = nullptr;
|
||||
|
||||
batch.End();
|
||||
|
||||
if (spawned->empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
|
||||
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
|
||||
dispatcher.TryEnqueue([spawned]() {
|
||||
try
|
||||
{
|
||||
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
auto shapes = Highlighter::instance->m_shape.Shapes();
|
||||
for (auto const& s : *spawned)
|
||||
{
|
||||
uint32_t index = 0;
|
||||
if (shapes.IndexOf(s, index))
|
||||
{
|
||||
shapes.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Highlighter may have torn down between batch completion and dispatch — ignore.
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Self-contained single ripple for a quick click (press + release before the
|
||||
// hold threshold). Spawns a fresh ring + glow that grow from the click point
|
||||
// outward and fade to transparent in one continuous animation — no held
|
||||
// indicator, so a single click produces exactly one ripple. For right-click,
|
||||
// optionally spawns the expanding crosshair lines too.
|
||||
void Highlighter::EmitSingleRipple(MouseButton button)
|
||||
{
|
||||
if (!m_compositor || !m_shape)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
|
||||
if (color.A == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
POINT pt{};
|
||||
if (!GetCursorPos(&pt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
ScreenToClient(m_hwnd, &pt);
|
||||
const float fx = static_cast<float>(pt.x);
|
||||
const float fy = static_cast<float>(pt.y);
|
||||
|
||||
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
|
||||
float intensity = m_rippleIntensity;
|
||||
if (intensity < 0.15f) intensity = 0.15f;
|
||||
if (intensity > 1.35f) intensity = 1.35f;
|
||||
|
||||
int durationMs = m_rippleDurationMs;
|
||||
if (durationMs < 60) durationMs = 60;
|
||||
if (durationMs > 2000) durationMs = 2000;
|
||||
auto dur = std::chrono::milliseconds(durationMs);
|
||||
|
||||
const float ringStart = baseSize * 0.20f;
|
||||
const float ringEnd = baseSize * 1.05f;
|
||||
const float glowStart = baseSize * 0.30f;
|
||||
const float glowEnd = baseSize * 1.40f;
|
||||
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
|
||||
|
||||
auto clampByte = [](float v) -> uint8_t {
|
||||
if (v < 0.0f) v = 0.0f;
|
||||
if (v > 255.0f) v = 255.0f;
|
||||
return static_cast<uint8_t>(v);
|
||||
};
|
||||
|
||||
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
|
||||
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
|
||||
const float alphaMul = 0.18f + intensity * 0.78f;
|
||||
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
|
||||
|
||||
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
|
||||
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
|
||||
|
||||
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
|
||||
|
||||
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
|
||||
|
||||
// Glow (filled) — added first so the ring renders on top.
|
||||
auto glowGeom = m_compositor.CreateEllipseGeometry();
|
||||
glowGeom.Radius({ glowStart, glowStart });
|
||||
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
|
||||
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
|
||||
glowShape.Offset({ fx, fy });
|
||||
glowShape.FillBrush(glowBrush);
|
||||
m_shape.Shapes().Append(glowShape);
|
||||
spawned->push_back(glowShape);
|
||||
|
||||
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
|
||||
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
|
||||
glowAnim.Duration(dur);
|
||||
glowGeom.StartAnimation(L"Radius", glowAnim);
|
||||
|
||||
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
|
||||
glowColorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
glowColorAnim.Duration(dur);
|
||||
glowBrush.StartAnimation(L"Color", glowColorAnim);
|
||||
|
||||
// Ring (stroked).
|
||||
auto ringGeom = m_compositor.CreateEllipseGeometry();
|
||||
ringGeom.Radius({ ringStart, ringStart });
|
||||
auto ringBrush = m_compositor.CreateColorBrush(ringColor);
|
||||
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
|
||||
ringShape.Offset({ fx, fy });
|
||||
ringShape.StrokeBrush(ringBrush);
|
||||
ringShape.StrokeThickness(lineWidth);
|
||||
ringShape.IsStrokeNonScaling(true);
|
||||
m_shape.Shapes().Append(ringShape);
|
||||
spawned->push_back(ringShape);
|
||||
|
||||
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
|
||||
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
|
||||
ringAnim.Duration(dur);
|
||||
ringGeom.StartAnimation(L"Radius", ringAnim);
|
||||
|
||||
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
ringColorAnim.InsertKeyFrame(0.0f, ringColor);
|
||||
ringColorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
ringColorAnim.Duration(dur);
|
||||
ringBrush.StartAnimation(L"Color", ringColorAnim);
|
||||
|
||||
// Right-click only: spawn expanding crosshair lines centered on the click
|
||||
// point. Gated by the "show crosshairs on right-click release" toggle.
|
||||
if (button == MouseButton::Right && m_rippleShowReleasePulse)
|
||||
{
|
||||
auto xhairColor = ringColor;
|
||||
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
|
||||
|
||||
const float startSpan = (baseSize * 0.55f) * 0.85f;
|
||||
const float endSpan = ringEnd * 0.85f;
|
||||
|
||||
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
|
||||
float bx1, float by1, float bx2, float by2) {
|
||||
auto lineGeom = m_compositor.CreateLineGeometry();
|
||||
lineGeom.Start({ ax1, ay1 });
|
||||
lineGeom.End({ ax2, ay2 });
|
||||
|
||||
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
|
||||
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
|
||||
lineShape.StrokeBrush(lineBrush);
|
||||
lineShape.StrokeThickness(xhairThickness);
|
||||
lineShape.IsStrokeNonScaling(true);
|
||||
m_shape.Shapes().Append(lineShape);
|
||||
spawned->push_back(lineShape);
|
||||
|
||||
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
|
||||
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
|
||||
startAnim.Duration(dur);
|
||||
lineGeom.StartAnimation(L"Start", startAnim);
|
||||
|
||||
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
|
||||
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
|
||||
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
|
||||
endAnim.Duration(dur);
|
||||
lineGeom.StartAnimation(L"End", endAnim);
|
||||
|
||||
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
|
||||
colorAnim.InsertKeyFrame(0.0f, xhairColor);
|
||||
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
|
||||
colorAnim.Duration(dur);
|
||||
lineBrush.StartAnimation(L"Color", colorAnim);
|
||||
};
|
||||
|
||||
// Horizontal line (left half, right half).
|
||||
makeLine(fx - startSpan, fy, fx - startSpan * 0.30f, fy,
|
||||
fx - endSpan, fy, fx - endSpan * 0.30f, fy);
|
||||
makeLine(fx + startSpan * 0.30f, fy, fx + startSpan, fy,
|
||||
fx + endSpan * 0.30f, fy, fx + endSpan, fy);
|
||||
// Vertical line (top half, bottom half).
|
||||
makeLine(fx, fy - startSpan, fx, fy - startSpan * 0.30f,
|
||||
fx, fy - endSpan, fx, fy - endSpan * 0.30f);
|
||||
makeLine(fx, fy + startSpan * 0.30f, fx, fy + startSpan,
|
||||
fx, fy + endSpan * 0.30f, fx, fy + endSpan);
|
||||
}
|
||||
|
||||
batch.End();
|
||||
|
||||
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
|
||||
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
|
||||
dispatcher.TryEnqueue([spawned]() {
|
||||
try
|
||||
{
|
||||
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
auto shapes = Highlighter::instance->m_shape.Shapes();
|
||||
for (auto const& s : *spawned)
|
||||
{
|
||||
uint32_t index = 0;
|
||||
if (shapes.IndexOf(s, index))
|
||||
{
|
||||
shapes.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Highlighter may have torn down between batch completion and dispatch — ignore.
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#pragma region MouseHighlighter_API
|
||||
|
||||
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
|
||||
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
|
||||
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
|
||||
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
|
||||
// Ripple-specific defaults (independent of the always-on circle settings above).
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE = 60;
|
||||
constexpr double MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY = 0.7;
|
||||
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS = 480;
|
||||
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL = true;
|
||||
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE = true;
|
||||
|
||||
struct MouseHighlighterSettings
|
||||
{
|
||||
@@ -19,6 +25,12 @@ struct MouseHighlighterSettings
|
||||
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
|
||||
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
|
||||
bool spotlightMode = false;
|
||||
bool rippleMode = true;
|
||||
int rippleSize = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE;
|
||||
double rippleIntensity = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY;
|
||||
int rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
|
||||
bool rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
|
||||
bool rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
|
||||
};
|
||||
|
||||
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);
|
||||
|
||||
@@ -21,6 +21,12 @@ namespace
|
||||
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
|
||||
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
|
||||
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
|
||||
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
|
||||
const wchar_t JSON_KEY_RIPPLE_SIZE[] = L"ripple_size";
|
||||
const wchar_t JSON_KEY_RIPPLE_INTENSITY[] = L"ripple_intensity";
|
||||
const wchar_t JSON_KEY_RIPPLE_DURATION_MS[] = L"ripple_duration_ms";
|
||||
const wchar_t JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL[] = L"ripple_show_drag_trail";
|
||||
const wchar_t JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE[] = L"ripple_show_release_pulse";
|
||||
}
|
||||
|
||||
extern "C" IMAGE_DOS_HEADER __ImageBase;
|
||||
@@ -392,6 +398,90 @@ public:
|
||||
{
|
||||
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple mode
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
|
||||
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple size
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SIZE);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value > 0)
|
||||
{
|
||||
highlightSettings.rippleSize = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid ripple size value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple size from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple intensity
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_INTENSITY);
|
||||
double value = jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE);
|
||||
if (value > 0.0)
|
||||
{
|
||||
highlightSettings.rippleIntensity = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid ripple intensity value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple intensity from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple duration
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_DURATION_MS);
|
||||
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
if (value > 0)
|
||||
{
|
||||
highlightSettings.rippleDurationMs = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error("Invalid ripple duration value");
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple duration from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple show drag trail
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL);
|
||||
highlightSettings.rippleShowDragTrail = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple show drag trail from settings. Will use default value");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Parse ripple show release pulse
|
||||
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE);
|
||||
highlightSettings.rippleShowReleasePulse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</resheader>
|
||||
<data name="context_menu_item_new" xml:space="preserve">
|
||||
<value>New+</value>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
|
||||
</data>
|
||||
<data name="context_menu_item_open_templates" xml:space="preserve">
|
||||
<value>Open templates</value>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</resheader>
|
||||
<data name="context_menu_item_new" xml:space="preserve">
|
||||
<value>New+</value>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
|
||||
</data>
|
||||
<data name="context_menu_item_open_templates" xml:space="preserve">
|
||||
<value>Open templates</value>
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
PackageName: BlackmagicDesign.DaVinciResolve
|
||||
Name: DaVinci Resolve
|
||||
WindowFilter: "Resolve.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Popular shortcuts
|
||||
Properties:
|
||||
- Name: Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F5
|
||||
- Name: Color
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F6
|
||||
- Name: Fairlight
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F7
|
||||
- Name: Deliver
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F8
|
||||
- Name: Play / Pause
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Space
|
||||
- Name: Play Reverse
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- J
|
||||
- Name: Stop
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Play Forward
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Import Media
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Export / Deliver
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Save Project
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Cut Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Blade Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Backslash
|
||||
- Name: Ripple Delete
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Delete
|
||||
- Name: Undo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Redo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Mark In
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Mark Out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- O
|
||||
- Name: Marker
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- M
|
||||
- Name: Select All
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Go to Beginning
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Home
|
||||
- Name: Go to End
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- End
|
||||
- Name: Snapping
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Selection Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Trim Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- Name: Change Clip Speed
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- SectionName: Timeline navigation
|
||||
Properties:
|
||||
- Name: Go to Next Frame
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Right
|
||||
- Name: Go to Previous Frame
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Left
|
||||
- Name: Jump Forward 5 Frames
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Right
|
||||
- Name: Jump Back 5 Frames
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Left
|
||||
- Name: Go to Next Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Go to Previous Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Go to Next Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Go to Previous Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Zoom In Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Equals
|
||||
- Name: Zoom Out Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- Name: Full Screen Playback
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Space
|
||||
- Name: Go to Previous Edit Point
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- PageUp
|
||||
- Name: Go to Next Edit Point
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- PageDown
|
||||
- SectionName: Edit
|
||||
Properties:
|
||||
- Name: Delete
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Delete
|
||||
- Name: Copy
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Paste
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Cut
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Duplicate Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Render in Place
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Add Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Backslash
|
||||
- Name: Append to End of Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- End
|
||||
- Name: Replace Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Move Clip Up One Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Move Clip Down One Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Split Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Link Clips
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Create Compound Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- G
|
||||
- SectionName: Color
|
||||
Properties:
|
||||
- Name: Add Serial Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- S
|
||||
- Name: Add Parallel Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- P
|
||||
- Name: Add Layer Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- L
|
||||
- Name: Select Node 1
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: Select Node 2
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: Select Node 3
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: Select Node 4
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Select Node 5
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "5"
|
||||
- Name: Enable/Disable Current Grade
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Preview Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- W
|
||||
- Name: Grade All Frames in Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Keyframe Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Select Color Wheels
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: Select Curves
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: Select Qualifier
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: Select Power Window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Select Tracking
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "5"
|
||||
- Name: Reset Color Grade
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- U
|
||||
- SectionName: Fairlight
|
||||
Properties:
|
||||
- Name: Mute Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- M
|
||||
- Name: Solo Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Automation Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Record Arm Selected Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Headphones Solo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- H
|
||||
- Name: Add Marker
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Insert
|
||||
- Name: Add Audio Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Bounce Mix
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- SectionName: Fusion
|
||||
Properties:
|
||||
- Name: Switch Between Spline and Keyframes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Add Keyframe
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Shift
|
||||
- Name: View Current Tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: View Node Flow
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: View Keyframes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: View Spline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Merge Selected Tools
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Bypass Selected Tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- SectionName: Media
|
||||
Properties:
|
||||
- Name: Reveal in Explorer
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Smart Bin
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Rename Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F2
|
||||
- Name: Import XML / AAF
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Create New Bin
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Add Clip to Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Viewer Zoom In
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Equals
|
||||
- Name: Viewer Zoom Out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- SectionName: Deliver
|
||||
Properties:
|
||||
- Name: Add to Render Queue
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Start Render
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Select Preset
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F2
|
||||
- Name: Render Settings
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Browse Output Location
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
@@ -365,6 +365,13 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
var oldWasEmpty = string.IsNullOrEmpty(oldSearch);
|
||||
var newWasEmpty = string.IsNullOrEmpty(newSearch);
|
||||
if (oldWasEmpty != newWasEmpty)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<ExpandCompactModeMessage>(new(!newWasEmpty));
|
||||
}
|
||||
|
||||
UpdateSearchTextCore(oldSearch, newSearch, isUserInput: true);
|
||||
}
|
||||
|
||||
@@ -436,7 +443,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
{
|
||||
specialFallbacks.Add(s);
|
||||
}
|
||||
else
|
||||
else if (s.IsEnabled)
|
||||
{
|
||||
commonFallbacks.Add(s);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,13 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
|
||||
|
||||
public string? Homepage => _entry.Homepage;
|
||||
|
||||
// Validated, browser-openable homepage uri. Null when the entry has no
|
||||
// homepage or it is not a web uri. NavigateUri bindings must use this
|
||||
// (a Uri) rather than the raw Homepage string: x:Bind evaluates bindings
|
||||
// regardless of element visibility, and converting a null/invalid string
|
||||
// to Uri throws and crashes the page.
|
||||
public Uri? HomepageUri => _homepageHttpUri;
|
||||
|
||||
public Uri IconUri { get; }
|
||||
|
||||
public ImageSource IconSource
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Sent when auto-hide registration fails because another app already owns the
|
||||
/// auto-hide slot on the target edge. The dock falls back to pinned mode.
|
||||
/// </summary>
|
||||
public record DockAutoHideConflictMessage(bool IsConflict);
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
|
||||
public record ExpandCompactModeMessage(bool Expanded)
|
||||
{
|
||||
}
|
||||
@@ -24,6 +24,8 @@ public record DockSettings
|
||||
|
||||
public bool AlwaysOnTop { get; set; } = true;
|
||||
|
||||
public bool AutoHide { get; set; }
|
||||
|
||||
// <Theme settings>
|
||||
public DockBackdrop Backdrop { get; init; } = DockBackdrop.Acrylic;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
/// migration from legacy GDI device names (e.g. <c>\\.\DISPLAY1</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All operations are pure — they return new immutable lists rather than
|
||||
/// All operations are pure: they return new immutable lists rather than
|
||||
/// mutating input collections.
|
||||
/// </remarks>
|
||||
public static class MonitorConfigReconciler
|
||||
@@ -29,13 +29,6 @@ public static class MonitorConfigReconciler
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles persisted monitor configs against the current set of connected monitors.
|
||||
/// <para>
|
||||
/// <b>Phase 1</b>: Exact StableId matching — keep IsPrimary up-to-date.<br/>
|
||||
/// <b>Phase 1.5</b>: Legacy migration — match configs with GDI-style IDs by GDI name, then rewrite to StableId.<br/>
|
||||
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
|
||||
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
|
||||
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static ImmutableList<DockMonitorConfig> Reconcile(
|
||||
ImmutableList<DockMonitorConfig>? existingConfigs,
|
||||
@@ -73,7 +66,7 @@ public static class MonitorConfigReconciler
|
||||
var matchedConfigIndices = new HashSet<int>();
|
||||
var result = new List<DockMonitorConfig>(currentMonitors.Count);
|
||||
|
||||
// Phase 1: Exact match on StableId (configs already migrated to stable paths)
|
||||
// Exact match on StableId (configs already migrated to stable paths)
|
||||
for (var mi = 0; mi < currentMonitors.Count; mi++)
|
||||
{
|
||||
var monitor = currentMonitors[mi];
|
||||
@@ -85,7 +78,7 @@ public static class MonitorConfigReconciler
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1.5: Legacy migration — match configs that still have GDI-style IDs
|
||||
// Legacy migration: match configs that still have GDI-style IDs
|
||||
// (e.g. "\\.\DISPLAY1") by matching against the monitor's GDI DeviceId,
|
||||
// then rewrite the MonitorDeviceId to the monitor's stable hardware path.
|
||||
for (var mi = 0; mi < currentMonitors.Count; mi++)
|
||||
@@ -110,7 +103,7 @@ public static class MonitorConfigReconciler
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Fuzzy match — recover primary monitor config when its ID changed.
|
||||
// Fuzzy match: recover primary monitor config when its ID changed.
|
||||
// Windows can reassign device paths across driver updates or cable swaps.
|
||||
// When the primary monitor's StableId no longer matches any saved config,
|
||||
// we look for an unmatched config that was previously marked as primary and
|
||||
@@ -145,9 +138,9 @@ public static class MonitorConfigReconciler
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Create defaults for new monitors with no matching config.
|
||||
// Create defaults for new monitors with no matching config.
|
||||
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
|
||||
// upgrade path. Secondary monitors start disabled with empty band lists —
|
||||
// upgrade path. Secondary monitors start disabled with empty band lists;
|
||||
// users opt-in via Settings when they want the dock on additional displays.
|
||||
for (var mi = 0; mi < currentMonitors.Count; mi++)
|
||||
{
|
||||
@@ -183,7 +176,7 @@ public static class MonitorConfigReconciler
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Retain disconnected monitor configs so settings survive reconnection.
|
||||
// Retain disconnected monitor configs so settings survive reconnection.
|
||||
// Prune entries not seen for longer than StaleThreshold (6 months).
|
||||
for (var ci = 0; ci < existingConfigs.Count; ci++)
|
||||
{
|
||||
|
||||
@@ -42,6 +42,14 @@ public record SettingsModel
|
||||
|
||||
public bool AllowExternalReload { get; init; }
|
||||
|
||||
public bool CompactMode { get; set; } = true;
|
||||
|
||||
// When compact mode is on and the palette is centered on launch, this is the relative
|
||||
// height from the bottom of the screen (as a percentage) at which the collapsed search
|
||||
// box is vertically centered. 75 places it in the upper portion of the display. Ignored
|
||||
// when compact mode is off.
|
||||
public int CompactCenterHeightPercentage { get; set; } = 75;
|
||||
|
||||
private ImmutableDictionary<string, ProviderSettings>? _providerSettings
|
||||
= ImmutableDictionary<string, ProviderSettings>.Empty;
|
||||
|
||||
@@ -137,6 +145,18 @@ public record SettingsModel
|
||||
|
||||
// </Gallery settings>
|
||||
|
||||
// Internal diagnostics settings
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the main window's HWND chrome (title bar, border,
|
||||
/// system-drawn rounded corners) is visible. <strong>For internal debugging only.</strong>
|
||||
/// Off by default. The setting is persisted but only honored in non-CI builds; release /
|
||||
/// CI builds always force the borderless / transparent host window.
|
||||
/// </summary>
|
||||
public bool ShowHwndFrame { get; init; }
|
||||
|
||||
// </Internal diagnostics settings>
|
||||
|
||||
// END SETTINGS
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
public partial class SettingsViewModel : INotifyPropertyChanged,
|
||||
IRecipient<DockAutoHideConflictMessage>
|
||||
{
|
||||
private static readonly List<TimeSpan> AutoGoHomeIntervals =
|
||||
[
|
||||
@@ -131,6 +132,25 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public bool CompactMode
|
||||
{
|
||||
get => _settingsService.Settings.CompactMode;
|
||||
set
|
||||
{
|
||||
_settingsService.UpdateSettings(s => s with { CompactMode = value });
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CompactMode)));
|
||||
}
|
||||
}
|
||||
|
||||
public double CompactCenterHeightPercentage
|
||||
{
|
||||
get => _settingsService.Settings.CompactCenterHeightPercentage;
|
||||
set
|
||||
{
|
||||
_settingsService.UpdateSettings(s => s with { CompactCenterHeightPercentage = (int)value });
|
||||
}
|
||||
}
|
||||
|
||||
public bool IgnoreShortcutWhenFullscreen
|
||||
{
|
||||
get => _settingsService.Settings.IgnoreShortcutWhenFullscreen;
|
||||
@@ -238,6 +258,30 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public bool Dock_AutoHide
|
||||
{
|
||||
get => _settingsService.Settings.DockSettings.AutoHide;
|
||||
set
|
||||
{
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { AutoHide = value } });
|
||||
}
|
||||
}
|
||||
|
||||
private bool _dockAutoHideConflict;
|
||||
|
||||
public bool Dock_AutoHideConflict
|
||||
{
|
||||
get => _dockAutoHideConflict;
|
||||
private set
|
||||
{
|
||||
if (_dockAutoHideConflict != value)
|
||||
{
|
||||
_dockAutoHideConflict = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Dock_AutoHideConflict)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool EnableDock
|
||||
{
|
||||
get => _settingsService.Settings.EnableDock;
|
||||
@@ -328,6 +372,13 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
{
|
||||
ApplyFallbackSort();
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Register<DockAutoHideConflictMessage>(this);
|
||||
}
|
||||
|
||||
public void Receive(DockAutoHideConflictMessage message)
|
||||
{
|
||||
Dock_AutoHideConflict = message.IsConflict;
|
||||
}
|
||||
|
||||
private IEnumerable<CommandProviderWrapper> GetCommandProviders()
|
||||
|
||||
@@ -85,6 +85,8 @@ public partial class ShellViewModel : ObservableObject,
|
||||
|
||||
public bool IsNested => _isNested && !_currentlyTransient;
|
||||
|
||||
public bool IsTransient => _currentlyTransient;
|
||||
|
||||
public PageViewModel NullPage { get; private set; }
|
||||
|
||||
public ShellViewModel(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="Microsoft.CmdPal.UI.Controls.CmdPalMainControl"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
|
||||
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Background="Transparent"
|
||||
IsTabStop="False"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<ThemeShadow x:Key="CardShadow" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<!-- Outer transparent host. Padding leaves room for the drop shadow. -->
|
||||
<Grid x:Name="ShadowHost" Padding="{x:Bind ShadowPadding, Mode=OneWay}">
|
||||
|
||||
<!--
|
||||
The "card" — this is what looks like the cmdpal window.
|
||||
Border draws the 1px stroke and clips children to the rounded shape.
|
||||
ThemeShadow + a Translation on Z casts the drop shadow outside the card.
|
||||
-->
|
||||
<Border
|
||||
x:Name="CardBorder"
|
||||
VerticalAlignment="Top"
|
||||
Background="Transparent"
|
||||
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{x:Bind CardCornerRadius, Mode=OneWay}"
|
||||
Shadow="{StaticResource CardShadow}"
|
||||
Translation="0,0,32">
|
||||
|
||||
<Grid x:Name="CardContent">
|
||||
<!-- System backdrop (Mica / Acrylic / etc.) drawn only behind the card -->
|
||||
<controls:SystemBackdropElement x:Name="BackdropElement" CornerRadius="{x:Bind CardCornerRadius, Mode=OneWay}" />
|
||||
|
||||
<!-- Optional background image (sits between backdrop and content) -->
|
||||
<ContentPresenter
|
||||
x:Name="BackgroundLayerPresenter"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Content="{x:Bind BackgroundLayer, Mode=OneWay}"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<!-- Main UI content (e.g. ShellPage) -->
|
||||
<ContentPresenter
|
||||
x:Name="MainContentPresenter"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Content="{x:Bind MainContent, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.UI;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// The visible "card" of the Command Palette — a control that renders the rounded
|
||||
/// corners, border, shadow and system backdrop. The HWND that hosts it is borderless
|
||||
/// and transparent, so all the chrome lives here instead of in window non-client area.
|
||||
/// </summary>
|
||||
public sealed partial class CmdPalMainControl : UserControl
|
||||
{
|
||||
public static readonly DependencyProperty MainContentProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(MainContent),
|
||||
typeof(object),
|
||||
typeof(CmdPalMainControl),
|
||||
new PropertyMetadata(null));
|
||||
|
||||
public static readonly DependencyProperty BackgroundLayerProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(BackgroundLayer),
|
||||
typeof(object),
|
||||
typeof(CmdPalMainControl),
|
||||
new PropertyMetadata(null));
|
||||
|
||||
public static readonly DependencyProperty ShadowPaddingProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(ShadowPadding),
|
||||
typeof(Thickness),
|
||||
typeof(CmdPalMainControl),
|
||||
new PropertyMetadata(new Thickness(16)));
|
||||
|
||||
public static readonly DependencyProperty CardCornerRadiusProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(CardCornerRadius),
|
||||
typeof(CornerRadius),
|
||||
typeof(CmdPalMainControl),
|
||||
new PropertyMetadata(new CornerRadius(8)));
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the main UI content hosted inside the card (e.g. the ShellPage).
|
||||
/// </summary>
|
||||
public object? MainContent
|
||||
{
|
||||
get => GetValue(MainContentProperty);
|
||||
set => SetValue(MainContentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a background layer rendered between the backdrop and the main content
|
||||
/// (e.g. the BlurImageControl). Hit-testing is disabled on this layer.
|
||||
/// </summary>
|
||||
public object? BackgroundLayer
|
||||
{
|
||||
get => GetValue(BackgroundLayerProperty);
|
||||
set => SetValue(BackgroundLayerProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the amount of transparent padding around the card. The drop shadow
|
||||
/// is rendered into this padded area.
|
||||
/// </summary>
|
||||
public Thickness ShadowPadding
|
||||
{
|
||||
get => (Thickness)GetValue(ShadowPaddingProperty);
|
||||
set => SetValue(ShadowPaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius of the card. Applied to both the clipping border
|
||||
/// and the backdrop element.
|
||||
/// </summary>
|
||||
public CornerRadius CardCornerRadius
|
||||
{
|
||||
get => (CornerRadius)GetValue(CardCornerRadiusProperty);
|
||||
set => SetValue(CardCornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the visible card border. Drag regions should be computed against this element
|
||||
/// so they line up with what the user sees, not the (larger, transparent) HWND.
|
||||
/// </summary>
|
||||
public FrameworkElement CardElement => CardBorder;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the panel inside the card that hosts the backdrop, background layer, and main
|
||||
/// content. Overlay UI (e.g. the dev ribbon) can be added to this panel so it draws
|
||||
/// inside the rounded card.
|
||||
/// </summary>
|
||||
public Panel CardContentPanel => CardContent;
|
||||
|
||||
public CmdPalMainControl()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamps the maximum height of the visible card (in DIPs). Use this to keep an expanded
|
||||
/// compact card from growing past the bottom of the display. Pass
|
||||
/// <see cref="double.PositiveInfinity"/> to remove the clamp.
|
||||
/// </summary>
|
||||
public void SetCardMaxHeight(double maxHeightDip)
|
||||
{
|
||||
CardBorder.MaxHeight = maxHeightDip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current height of the visible card (in DIPs). When the card is in its
|
||||
/// compact layout this is the height of just the search box, which callers use to center
|
||||
/// the collapsed card on screen.
|
||||
/// </summary>
|
||||
public double GetCardHeight()
|
||||
{
|
||||
CardBorder.UpdateLayout();
|
||||
return CardBorder.ActualHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards the host window's activation state to the current backdrop so the system can
|
||||
/// render its active / inactive appearance correctly.
|
||||
/// </summary>
|
||||
public void SetIsInputActive(bool isActive)
|
||||
{
|
||||
if (BackdropElement.SystemBackdrop is TintedControllerBackdrop tinted)
|
||||
{
|
||||
tinted.IsInputActive = isActive;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detaches any backdrop from the embedded element. Used during shutdown to release the
|
||||
/// underlying controller eagerly.
|
||||
/// </summary>
|
||||
public void ClearBackdrop()
|
||||
{
|
||||
BackdropElement.SystemBackdrop = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a backdrop configuration to the embedded <see cref="SystemBackdropElement"/>.
|
||||
/// </summary>
|
||||
/// <param name="backdrop">Tint / opacity / fallback parameters from the theme service.</param>
|
||||
/// <param name="kind">The controller kind selected by the user's backdrop style.</param>
|
||||
/// <param name="isImageMode">When true, the background image control draws the tint, so no tint is applied to the backdrop itself.</param>
|
||||
/// <param name="hasColorization">When true, custom tint properties are applied to Mica backdrops.</param>
|
||||
public void ApplyBackdrop(BackdropParameters backdrop, BackdropControllerKind kind, bool isImageMode, bool hasColorization)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackdropElement.SystemBackdrop = CreateBackdrop(backdrop, kind, isImageMode, hasColorization);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to apply backdrop to CmdPalMainControl", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Microsoft.UI.Xaml.Media.SystemBackdrop? CreateBackdrop(BackdropParameters backdrop, BackdropControllerKind kind, bool isImageMode, bool hasColorization)
|
||||
{
|
||||
// Image mode: don't tint here, BlurImageControl handles it (avoids double-tinting).
|
||||
var effectiveTintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case BackdropControllerKind.Solid:
|
||||
var solidTint = Color.FromArgb(
|
||||
(byte)(backdrop.EffectiveOpacity * 255),
|
||||
backdrop.TintColor.R,
|
||||
backdrop.TintColor.G,
|
||||
backdrop.TintColor.B);
|
||||
return new TransparentTintBackdrop { TintColor = solidTint };
|
||||
|
||||
case BackdropControllerKind.Mica:
|
||||
case BackdropControllerKind.MicaAlt:
|
||||
if (!MicaController.IsSupported())
|
||||
{
|
||||
return new TransparentTintBackdrop { TintColor = backdrop.FallbackColor };
|
||||
}
|
||||
|
||||
return new TintedMicaBackdrop
|
||||
{
|
||||
Kind = kind == BackdropControllerKind.MicaAlt ? MicaKind.BaseAlt : MicaKind.Base,
|
||||
ApplyTint = hasColorization || isImageMode,
|
||||
TintColor = backdrop.TintColor,
|
||||
TintOpacity = effectiveTintOpacity,
|
||||
FallbackColor = backdrop.FallbackColor,
|
||||
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
|
||||
};
|
||||
|
||||
case BackdropControllerKind.Acrylic:
|
||||
case BackdropControllerKind.AcrylicThin:
|
||||
default:
|
||||
if (!DesktopAcrylicController.IsSupported())
|
||||
{
|
||||
return new TransparentTintBackdrop { TintColor = backdrop.FallbackColor };
|
||||
}
|
||||
|
||||
return new TintedDesktopAcrylicBackdrop
|
||||
{
|
||||
Kind = kind == BackdropControllerKind.AcrylicThin
|
||||
? DesktopAcrylicKind.Thin
|
||||
: DesktopAcrylicKind.Default,
|
||||
TintColor = backdrop.TintColor,
|
||||
TintOpacity = effectiveTintOpacity,
|
||||
FallbackColor = backdrop.FallbackColor,
|
||||
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,8 +156,9 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
private void ContextMenuFlyout_Opened(object sender, object e)
|
||||
{
|
||||
// We need to wait until our flyout is opened to try and toss focus
|
||||
// at its search box. The control isn't in the UI tree before that
|
||||
// Focus the filter box so the flyout captures keyboard input,
|
||||
// then fire a single consolidated Narrator announcement.
|
||||
ContextControl.FocusSearchBox();
|
||||
ContextControl.AnnounceOpened();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +139,23 @@
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!--
|
||||
Hidden element used solely for raising Narrator notifications.
|
||||
It must be Content-visible in UIA but has no visual presence.
|
||||
-->
|
||||
<TextBlock
|
||||
x:Name="NarratorAnnouncer"
|
||||
Width="0"
|
||||
Height="0"
|
||||
AutomationProperties.AccessibilityView="Content"
|
||||
AutomationProperties.LiveSetting="Assertive" />
|
||||
|
||||
<ListView
|
||||
x:Name="CommandsDropdown"
|
||||
MinWidth="248"
|
||||
Margin="0,4,0,2"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="CommandsDropdown_ItemClick"
|
||||
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
|
||||
@@ -168,6 +181,7 @@
|
||||
x:Uid="ContextFilterBox"
|
||||
Margin="0"
|
||||
Padding="10,7,6,8"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
|
||||
BorderThickness="0,0,0,2"
|
||||
CornerRadius="8, 8, 0, 0"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// 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.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.WinUI;
|
||||
using Microsoft.CmdPal.Common.Text;
|
||||
@@ -11,6 +13,7 @@ using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.System;
|
||||
@@ -27,6 +30,15 @@ public sealed partial class ContextMenu : UserControl,
|
||||
public static readonly DependencyProperty SubscribeToCommandBarProperty =
|
||||
DependencyProperty.Register(nameof(SubscribeToCommandBar), typeof(bool), typeof(ContextMenu), new PropertyMetadata(true, OnSubscribeToCommandBarChanged));
|
||||
|
||||
private static readonly CompositeFormat _contextMenuOpenedFormat =
|
||||
CompositeFormat.Parse(ResourceLoaderInstance.GetString("ScreenReader_Announcement_ContextMenuOpened"));
|
||||
|
||||
/// <summary>
|
||||
/// True while the context menu is transitioning from PrepareForOpen to AnnounceOpened.
|
||||
/// Prevents ViewModel_PropertyChanged from triggering UIA-visible selection changes.
|
||||
/// </summary>
|
||||
private bool _isOpening;
|
||||
|
||||
public bool ShowFilterBox
|
||||
{
|
||||
get => (bool)GetValue(ShowFilterBoxProperty);
|
||||
@@ -103,12 +115,47 @@ public sealed partial class ContextMenu : UserControl,
|
||||
|
||||
internal void PrepareForOpen(ContextMenuFilterLocation filterLocation)
|
||||
{
|
||||
_isOpening = true;
|
||||
|
||||
ViewModel.FilterOnTop = filterLocation == ContextMenuFilterLocation.Top;
|
||||
ViewModel.ResetContextMenu();
|
||||
|
||||
UpdateUiForStackChange();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires a single consolidated Narrator announcement.
|
||||
/// Call this after the flyout is opened and focus has been set.
|
||||
/// </summary>
|
||||
internal void AnnounceOpened()
|
||||
{
|
||||
// Defer the announcement to the next dispatcher cycle. This ensures
|
||||
// any pending FilteredItems updates have completed and the flyout
|
||||
// content is fully materialized in the UIA tree.
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
_isOpening = false;
|
||||
|
||||
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
|
||||
var itemCount = commandItems.Count;
|
||||
var selectedItem = CommandsDropdown.SelectedItem as CommandContextItemViewModel;
|
||||
var selectedName = selectedItem?.Title ?? string.Empty;
|
||||
var selectedIndex = selectedItem is not null ? commandItems.IndexOf(selectedItem) + 1 : 0;
|
||||
|
||||
var announcement = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
_contextMenuOpenedFormat,
|
||||
itemCount,
|
||||
selectedName,
|
||||
selectedIndex);
|
||||
|
||||
RaiseNarratorNotification(
|
||||
AutomationNotificationKind.ActionCompleted,
|
||||
announcement,
|
||||
"ContextMenuOpened");
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(UpdateCommandBarMessage message)
|
||||
{
|
||||
UpdateUiForStackChange();
|
||||
@@ -197,7 +244,7 @@ public sealed partial class ContextMenu : UserControl,
|
||||
{
|
||||
var prop = e.PropertyName;
|
||||
|
||||
if (prop == nameof(ContextMenuViewModel.FilteredItems))
|
||||
if (prop == nameof(ContextMenuViewModel.FilteredItems) && !_isOpening)
|
||||
{
|
||||
UpdateUiForStackChange();
|
||||
}
|
||||
@@ -255,12 +302,14 @@ public sealed partial class ContextMenu : UserControl,
|
||||
if (e.Key == VirtualKey.Up)
|
||||
{
|
||||
NavigateUp();
|
||||
AnnounceSelectedItem();
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Down)
|
||||
{
|
||||
NavigateDown();
|
||||
AnnounceSelectedItem();
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
@@ -347,6 +396,46 @@ public sealed partial class ContextMenu : UserControl,
|
||||
return item is SeparatorViewModel;
|
||||
}
|
||||
|
||||
private void AnnounceSelectedItem()
|
||||
{
|
||||
if (CommandsDropdown.SelectedItem is not CommandContextItemViewModel selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
|
||||
var position = commandItems.IndexOf(selected) + 1;
|
||||
var total = commandItems.Count;
|
||||
var announcement = $"{selected.Title}, {position} of {total}";
|
||||
|
||||
RaiseNarratorNotification(
|
||||
AutomationNotificationKind.ItemAdded,
|
||||
announcement,
|
||||
"ContextMenuSelectionChanged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a UIA notification via the dedicated NarratorAnnouncer element.
|
||||
/// Ensures the element has a peer (forcing layout if needed on first use).
|
||||
/// </summary>
|
||||
private void RaiseNarratorNotification(AutomationNotificationKind kind, string announcement, string activityId)
|
||||
{
|
||||
// On first flyout open the announcer may not have a peer yet.
|
||||
// UpdateLayout ensures the element is materialized in the UIA tree.
|
||||
var peer = FrameworkElementAutomationPeer.FromElement(NarratorAnnouncer);
|
||||
if (peer is null)
|
||||
{
|
||||
NarratorAnnouncer.UpdateLayout();
|
||||
peer = FrameworkElementAutomationPeer.CreatePeerForElement(NarratorAnnouncer);
|
||||
}
|
||||
|
||||
peer?.RaiseNotificationEvent(
|
||||
kind,
|
||||
AutomationNotificationProcessing.ImportantMostRecent,
|
||||
announcement,
|
||||
activityId);
|
||||
}
|
||||
|
||||
private void UpdateUiForStackChange()
|
||||
{
|
||||
ContextFilterBox.Text = string.Empty;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Windows.UI;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tinted backdrops that wrap a controller from
|
||||
/// <see cref="Microsoft.UI.Composition.SystemBackdrops"/> so they can be applied
|
||||
/// to a single control via <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stock <see cref="MicaBackdrop"/> / <see cref="DesktopAcrylicBackdrop"/> classes
|
||||
/// don't expose tint color / opacity / luminosity customization. This base type plugs
|
||||
/// the lower-level controllers into the new <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>
|
||||
/// extensibility surface so we can keep all of CmdPal's theme-driven tinting.
|
||||
/// </remarks>
|
||||
internal abstract partial class TintedControllerBackdrop : SystemBackdrop
|
||||
{
|
||||
private SystemBackdropConfiguration? _config;
|
||||
|
||||
public Color TintColor { get; init; }
|
||||
|
||||
public float TintOpacity { get; init; }
|
||||
|
||||
public Color FallbackColor { get; init; }
|
||||
|
||||
public float LuminosityOpacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether tint properties should be applied. Mica without
|
||||
/// colorization wants the system defaults; in that case set this to false.
|
||||
/// </summary>
|
||||
public bool ApplyTint { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the host window is currently activated. The
|
||||
/// system uses this to decide between the active and inactive backdrop appearance.
|
||||
/// </summary>
|
||||
public bool IsInputActive
|
||||
{
|
||||
get => _config?.IsInputActive ?? true;
|
||||
set
|
||||
{
|
||||
if (_config is not null)
|
||||
{
|
||||
_config.IsInputActive = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected SystemBackdropConfiguration? Configuration => _config;
|
||||
|
||||
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
|
||||
{
|
||||
base.OnTargetConnected(connectedTarget, xamlRoot);
|
||||
_config = new SystemBackdropConfiguration
|
||||
{
|
||||
IsInputActive = true,
|
||||
Theme = xamlRoot.Content is FrameworkElement fe
|
||||
? ToBackdropTheme(fe.ActualTheme)
|
||||
: SystemBackdropTheme.Default,
|
||||
};
|
||||
AttachController(connectedTarget, xamlRoot);
|
||||
}
|
||||
|
||||
protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget)
|
||||
{
|
||||
DetachController(disconnectedTarget);
|
||||
_config = null;
|
||||
base.OnTargetDisconnected(disconnectedTarget);
|
||||
}
|
||||
|
||||
protected abstract void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot);
|
||||
|
||||
protected abstract void DetachController(ICompositionSupportsSystemBackdrop target);
|
||||
|
||||
private static SystemBackdropTheme ToBackdropTheme(ElementTheme theme) => theme switch
|
||||
{
|
||||
ElementTheme.Dark => SystemBackdropTheme.Dark,
|
||||
ElementTheme.Light => SystemBackdropTheme.Light,
|
||||
_ => SystemBackdropTheme.Default,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A tinted <see cref="DesktopAcrylicController"/> exposed as a <see cref="SystemBackdrop"/>
|
||||
/// so it can be hosted by <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
|
||||
/// </summary>
|
||||
internal sealed partial class TintedDesktopAcrylicBackdrop : TintedControllerBackdrop, IDisposable
|
||||
{
|
||||
private DesktopAcrylicController? _controller;
|
||||
|
||||
public DesktopAcrylicKind Kind { get; init; } = DesktopAcrylicKind.Default;
|
||||
|
||||
protected override void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot)
|
||||
{
|
||||
if (!DesktopAcrylicController.IsSupported())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_controller = new DesktopAcrylicController
|
||||
{
|
||||
Kind = Kind,
|
||||
TintColor = TintColor,
|
||||
TintOpacity = TintOpacity,
|
||||
FallbackColor = FallbackColor,
|
||||
LuminosityOpacity = LuminosityOpacity,
|
||||
};
|
||||
|
||||
_controller.AddSystemBackdropTarget(target);
|
||||
_controller.SetSystemBackdropConfiguration(Configuration);
|
||||
}
|
||||
|
||||
protected override void DetachController(ICompositionSupportsSystemBackdrop target)
|
||||
{
|
||||
if (_controller is not null)
|
||||
{
|
||||
_controller.RemoveSystemBackdropTarget(target);
|
||||
_controller.Dispose();
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_controller?.Dispose();
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Windows.UI;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A tinted <see cref="MicaController"/> exposed as a <see cref="SystemBackdrop"/>
|
||||
/// so it can be hosted by <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
|
||||
/// </summary>
|
||||
internal sealed partial class TintedMicaBackdrop : TintedControllerBackdrop, IDisposable
|
||||
{
|
||||
private MicaController? _controller;
|
||||
|
||||
public MicaKind Kind { get; init; } = MicaKind.Base;
|
||||
|
||||
protected override void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot)
|
||||
{
|
||||
if (!MicaController.IsSupported())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_controller = new MicaController { Kind = Kind };
|
||||
|
||||
// Only set tint properties when colorization is active.
|
||||
// Otherwise let the system handle light/dark theme defaults automatically.
|
||||
if (ApplyTint)
|
||||
{
|
||||
_controller.TintColor = TintColor;
|
||||
_controller.TintOpacity = TintOpacity;
|
||||
_controller.FallbackColor = FallbackColor;
|
||||
_controller.LuminosityOpacity = LuminosityOpacity;
|
||||
}
|
||||
|
||||
_controller.AddSystemBackdropTarget(target);
|
||||
_controller.SetSystemBackdropConfiguration(Configuration);
|
||||
}
|
||||
|
||||
protected override void DetachController(ICompositionSupportsSystemBackdrop target)
|
||||
{
|
||||
if (_controller is not null)
|
||||
{
|
||||
_controller.RemoveSystemBackdropTarget(target);
|
||||
_controller.Dispose();
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_controller?.Dispose();
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
|
||||
namespace Microsoft.CmdPal.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a boolean to a <see cref="GridLength"/>: <c>true</c> yields a star (*) row that
|
||||
/// fills the available space, while <c>false</c> yields an Auto row that sizes to its content.
|
||||
/// This lets the expandable content row collapse to zero in compact mode so the card can
|
||||
/// shrink to just the search box (a star row would otherwise reserve space during measure
|
||||
/// even when its only child is collapsed).
|
||||
/// </summary>
|
||||
public partial class BoolToStarOrAutoGridLengthConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
var expanded = value is bool b && b;
|
||||
return expanded ? new GridLength(1, GridUnitType.Star) : GridLength.Auto;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
|
||||
}
|
||||
@@ -39,6 +39,14 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
/// </summary>
|
||||
internal IntPtr OwnerHwnd { get; set; }
|
||||
|
||||
internal bool HasOpenTransientUi =>
|
||||
ContextMenuFlyout.IsOpen ||
|
||||
AddBandFlyout.IsOpen ||
|
||||
EditModeContextMenu.IsOpen ||
|
||||
EditButtonsTeachingTip.IsOpen;
|
||||
|
||||
internal bool IsDragOperationActive => _draggedBand is not null;
|
||||
|
||||
public static readonly DependencyProperty ItemsOrientationProperty =
|
||||
DependencyProperty.Register(nameof(ItemsOrientation), typeof(Orientation), typeof(DockControl), new PropertyMetadata(Orientation.Horizontal));
|
||||
|
||||
@@ -492,9 +500,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
|
||||
private void ContextMenuFlyout_Opened(object sender, object e)
|
||||
{
|
||||
// We need to wait until our flyout is opened to try and toss focus
|
||||
// at its search box. The control isn't in the UI tree before that
|
||||
// Focus the filter box so the flyout captures keyboard input,
|
||||
// then fire a single consolidated Narrator announcement.
|
||||
ContextControl.FocusSearchBox();
|
||||
ContextControl.AnnounceOpened();
|
||||
}
|
||||
|
||||
public void Receive(CloseContextMenuMessage message)
|
||||
|
||||
@@ -24,17 +24,24 @@ using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Dwm;
|
||||
using Windows.Win32.UI.Accessibility;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.Shell;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using WinRT;
|
||||
using WinRT.Interop;
|
||||
using WinUIEx;
|
||||
using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo;
|
||||
using POINT = Microsoft.PowerToys.Settings.UI.Helpers.POINT;
|
||||
using RECT = Windows.Win32.Foundation.RECT;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Dock;
|
||||
|
||||
#pragma warning disable SA1402 // File may only contain a single type
|
||||
|
||||
/// <summary>
|
||||
/// The main window for the dock feature. Uses the Windows AppBar API to reserve
|
||||
/// screen work area and position itself at the edge of the display.
|
||||
/// </summary>
|
||||
public sealed partial class DockWindow : WindowEx,
|
||||
IRecipient<BringToTopMessage>,
|
||||
IRecipient<RequestShowPaletteAtMessage>,
|
||||
@@ -42,6 +49,13 @@ public sealed partial class DockWindow : WindowEx,
|
||||
IRecipient<QuitMessage>,
|
||||
IDisposable
|
||||
{
|
||||
private enum DockAppBarMode
|
||||
{
|
||||
None,
|
||||
Pinned,
|
||||
AutoHide,
|
||||
}
|
||||
|
||||
#pragma warning disable SA1306 // Field names should begin with lower-case letter
|
||||
#pragma warning disable SA1310 // Field names should not contain underscore
|
||||
private readonly uint WM_TASKBAR_RESTART;
|
||||
@@ -69,6 +83,27 @@ public sealed partial class DockWindow : WindowEx,
|
||||
private BackdropParameters? _lastAppliedAcrylicBackdrop;
|
||||
private DockSize _lastSize;
|
||||
private bool _isDisposed;
|
||||
private DockAppBarMode _appBarMode;
|
||||
private bool _autoHideRegistrationSucceeded;
|
||||
private bool _isDockRevealed = true;
|
||||
private bool _trackingMouseLeave;
|
||||
private RECT _revealedRect;
|
||||
private RECT _collapsedRect;
|
||||
private DispatcherQueueTimer? _collapseTimer;
|
||||
private DispatcherQueueTimer? _revealPollTimer;
|
||||
private DispatcherQueueTimer? _slideTimer;
|
||||
private RECT _slideFromRect;
|
||||
private RECT _slideToRect;
|
||||
private bool _slideIsRevealing;
|
||||
private System.Diagnostics.Stopwatch? _slideStopwatch;
|
||||
private bool _paletteOpenedFromDock;
|
||||
private const int AutoHideCollapsedThicknessDips = 0;
|
||||
private const int RevealHitTestMarginPixels = 1;
|
||||
private static readonly TimeSpan AutoHideCollapseDelay = TimeSpan.FromMilliseconds(250);
|
||||
private static readonly TimeSpan RevealPollInterval = TimeSpan.FromMilliseconds(50);
|
||||
private static readonly TimeSpan SlideRevealDuration = TimeSpan.FromMilliseconds(200);
|
||||
private static readonly TimeSpan SlideCollapseDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly TimeSpan SlideFrameInterval = TimeSpan.FromMilliseconds(8);
|
||||
|
||||
/// <summary>
|
||||
/// The monitor this dock window is displayed on. Null means primary monitor (legacy behavior).
|
||||
@@ -111,7 +146,7 @@ public sealed partial class DockWindow : WindowEx,
|
||||
_settingsService.SettingsChanged += SettingsChangedHandler;
|
||||
_monitorService = serviceProvider.GetRequiredService<IMonitorService>();
|
||||
_settings = mainSettings.DockSettings;
|
||||
_lastSize = EffectiveDockSize(_settings);
|
||||
_lastSize = EffectiveDockSize(_settings, EffectiveSide);
|
||||
|
||||
viewModel = dockViewModel;
|
||||
_themeService = serviceProvider.GetRequiredService<IThemeService>();
|
||||
@@ -230,13 +265,22 @@ public sealed partial class DockWindow : WindowEx,
|
||||
_dock.UpdateSettings(_settings, EffectiveSide);
|
||||
|
||||
var side = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
|
||||
var desiredMode = GetDesiredAppBarMode();
|
||||
var effectiveSize = EffectiveDockSize(_settings, EffectiveSide);
|
||||
|
||||
if (_appBarData.hWnd != IntPtr.Zero)
|
||||
{
|
||||
var sameEdge = _appBarData.uEdge == side;
|
||||
var sameSize = _lastSize == EffectiveDockSize(_settings);
|
||||
if (sameEdge && sameSize)
|
||||
var sameSize = _lastSize == effectiveSize;
|
||||
var sameMode = _appBarMode == desiredMode;
|
||||
|
||||
if (sameEdge && sameSize && sameMode)
|
||||
{
|
||||
if (_appBarMode == DockAppBarMode.AutoHide)
|
||||
{
|
||||
UpdateWindowPosition();
|
||||
}
|
||||
|
||||
UpdateTopmostState();
|
||||
return;
|
||||
}
|
||||
@@ -378,6 +422,11 @@ public sealed partial class DockWindow : WindowEx,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers this window as a Windows AppBar. In pinned mode, the dock
|
||||
/// reserves work area so maximized windows do not overlap it. In auto-hide
|
||||
/// mode, <c>ABM_SETAUTOHIDEBAR</c> is used and no work area is reserved.
|
||||
/// </summary>
|
||||
private void CreateAppBar(HWND hwnd)
|
||||
{
|
||||
_appBarData = new APPBARDATA
|
||||
@@ -387,20 +436,71 @@ public sealed partial class DockWindow : WindowEx,
|
||||
uCallbackMessage = _callbackMessageId,
|
||||
};
|
||||
|
||||
// Register this window as an app bar
|
||||
PInvoke.SHAppBarMessage(PInvoke.ABM_NEW, ref _appBarData);
|
||||
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_NEW, ref _appBarData);
|
||||
|
||||
// Stash the last size we created the bar at, so we know when to hot-
|
||||
// reload it
|
||||
_lastSize = EffectiveDockSize(_settings);
|
||||
_appBarMode = DockAppBarMode.None;
|
||||
_autoHideRegistrationSucceeded = false;
|
||||
|
||||
if (GetDesiredAppBarMode() == DockAppBarMode.AutoHide
|
||||
&& !IsTaskbarAutoHideOnSameEdge(EffectiveSide)
|
||||
&& TryRegisterAutoHideAppBar())
|
||||
{
|
||||
_appBarMode = DockAppBarMode.AutoHide;
|
||||
_autoHideRegistrationSucceeded = true;
|
||||
WeakReferenceMessenger.Default.Send(new ViewModels.Messages.DockAutoHideConflictMessage(false));
|
||||
}
|
||||
else
|
||||
{
|
||||
_appBarMode = DockAppBarMode.Pinned;
|
||||
if (_settings.AutoHide)
|
||||
{
|
||||
var reason = IsTaskbarAutoHideOnSameEdge(EffectiveSide)
|
||||
? "taskbar auto-hide conflict"
|
||||
: "registration rejected";
|
||||
Logger.LogWarning($"Dock auto-hide unavailable ({reason}) on edge {EffectiveSide} for monitor {MonitorForLogs()}. Falling back to pinned mode.");
|
||||
WeakReferenceMessenger.Default.Send(new ViewModels.Messages.DockAutoHideConflictMessage(true));
|
||||
}
|
||||
}
|
||||
|
||||
_lastSize = EffectiveDockSize(_settings, EffectiveSide);
|
||||
UpdateWindowPosition();
|
||||
|
||||
if (_appBarMode == DockAppBarMode.AutoHide)
|
||||
{
|
||||
// Briefly show the dock at the new position so users can confirm
|
||||
// the move, then schedule a collapse after the standard delay.
|
||||
_isDockRevealed = true;
|
||||
ApplyAutoHideRect(_revealedRect);
|
||||
ScheduleCollapseAutoHideDock();
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyAppBar(HWND hwnd)
|
||||
{
|
||||
PInvoke.SHAppBarMessage(PInvoke.ABM_REMOVE, ref _appBarData);
|
||||
StopCollapseTimer();
|
||||
StopRevealPollTimer();
|
||||
StopSlideAnimation();
|
||||
|
||||
// If the window was hidden via SW_HIDE (auto-hide collapsed state),
|
||||
// make it visible again before transitioning to a new mode.
|
||||
if (_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed)
|
||||
{
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
|
||||
}
|
||||
|
||||
if (_appBarMode == DockAppBarMode.AutoHide && _autoHideRegistrationSucceeded)
|
||||
{
|
||||
_ = TrySetAutoHideRegistration(register: false);
|
||||
_autoHideRegistrationSucceeded = false;
|
||||
}
|
||||
|
||||
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_REMOVE, ref _appBarData);
|
||||
_appBarData = default;
|
||||
_appBarMode = DockAppBarMode.None;
|
||||
_trackingMouseLeave = false;
|
||||
_isDockRevealed = true;
|
||||
_revealedRect = default;
|
||||
_collapsedRect = default;
|
||||
}
|
||||
|
||||
private void UpdateTopmostState(bool bringToFront = false)
|
||||
@@ -440,23 +540,27 @@ public sealed partial class DockWindow : WindowEx,
|
||||
|
||||
private void UpdateWindowPosition()
|
||||
{
|
||||
Logger.LogDebug("UpdateWindowPosition");
|
||||
Logger.LogDebug($"UpdateWindowPosition mode={_appBarMode} autoHideRequested={_settings.AutoHide} monitor={MonitorForLogs()}");
|
||||
|
||||
var dpi = PInvoke.GetDpiForWindow(_hwnd);
|
||||
|
||||
var scaleFactor = dpi / 96.0;
|
||||
var effectiveSize = EffectiveDockSize(_settings);
|
||||
var effectiveSize = EffectiveDockSize(_settings, EffectiveSide);
|
||||
|
||||
if (_appBarMode == DockAppBarMode.AutoHide)
|
||||
{
|
||||
UpdateAutoHideWindowPosition(effectiveSize, scaleFactor);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdatePinnedWindowPosition(effectiveSize, scaleFactor);
|
||||
}
|
||||
|
||||
private void UpdatePinnedWindowPosition(DockSize effectiveSize, double scaleFactor)
|
||||
{
|
||||
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
|
||||
|
||||
// Query and set position
|
||||
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
|
||||
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
|
||||
|
||||
// ABM_QUERYPOS adjusts our rect so we don't overlap other app bars,
|
||||
// but it may have shifted our anchored edge without updating the
|
||||
// opposite edge. We need to re-apply our desired thickness so the
|
||||
// bar keeps its correct size. Without this, a second bar docked to
|
||||
// the same side would get a zero-height/width rect and fail to
|
||||
// reserve work-area space.
|
||||
switch (EffectiveSide)
|
||||
{
|
||||
case DockSide.Top:
|
||||
@@ -473,18 +577,8 @@ public sealed partial class DockWindow : WindowEx,
|
||||
break;
|
||||
}
|
||||
|
||||
PInvoke.SHAppBarMessage(PInvoke.ABM_SETPOS, ref _appBarData);
|
||||
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_SETPOS, ref _appBarData);
|
||||
|
||||
// TODO: investigate ABS_AUTOHIDE and auto hide bars.
|
||||
// I think it's something like this, but I don't totally know
|
||||
// _appBarData.lParam = ABS_ALWAYSONTOP;
|
||||
// _appBarData.lParam = (LPARAM)(int)PInvoke.ABS_AUTOHIDE;
|
||||
// PInvoke.SHAppBarMessage(ABM_SETSTATE, ref _appBarData);
|
||||
// PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
|
||||
|
||||
// The dock window is borderless (SetBorderAndTitleBar(false, false),
|
||||
// IsResizable = false) so no frame compensation is needed — the
|
||||
// app bar rect matches the window rect exactly.
|
||||
PInvoke.MoveWindow(
|
||||
_hwnd,
|
||||
_appBarData.rc.left,
|
||||
@@ -492,6 +586,8 @@ public sealed partial class DockWindow : WindowEx,
|
||||
_appBarData.rc.right - _appBarData.rc.left,
|
||||
_appBarData.rc.bottom - _appBarData.rc.top,
|
||||
true);
|
||||
|
||||
_isDockRevealed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -530,19 +626,550 @@ public sealed partial class DockWindow : WindowEx,
|
||||
/// Compact mode is only supported for Top/Bottom dock positions.
|
||||
/// For Left/Right, always use Default size.
|
||||
/// </summary>
|
||||
private static DockSize EffectiveDockSize(DockSettings settings)
|
||||
private static DockSize EffectiveDockSize(DockSettings settings, DockSide side)
|
||||
{
|
||||
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
|
||||
var isHorizontal = side == DockSide.Top || side == DockSide.Bottom;
|
||||
return isHorizontal ? settings.DockSize : DockSize.Default;
|
||||
}
|
||||
|
||||
private DockAppBarMode GetDesiredAppBarMode()
|
||||
{
|
||||
return _settings.AutoHide ? DockAppBarMode.AutoHide : DockAppBarMode.Pinned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the Windows taskbar is set to auto-hide on the same
|
||||
/// edge as the dock. When true, the dock should not use auto-hide mode
|
||||
/// to avoid competing for the same screen-edge reveal zone.
|
||||
/// </summary>
|
||||
private bool IsTaskbarAutoHideOnSameEdge(DockSide side)
|
||||
{
|
||||
var stateAbd = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||
var state = PInvoke.SHAppBarMessage(PInvoke.ABM_GETSTATE, ref stateAbd);
|
||||
if ((state & PInvoke.ABS_AUTOHIDE) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Taskbar is auto-hiding; check which edge
|
||||
var posAbd = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
|
||||
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_GETTASKBARPOS, ref posAbd);
|
||||
|
||||
var taskbarEdge = posAbd.uEdge;
|
||||
var dockEdge = DockSettingsToViews.GetAppBarEdge(side);
|
||||
|
||||
return taskbarEdge == dockEdge;
|
||||
}
|
||||
|
||||
private bool TryRegisterAutoHideAppBar()
|
||||
{
|
||||
return TrySetAutoHideRegistration(register: true);
|
||||
}
|
||||
|
||||
private bool TrySetAutoHideRegistration(bool register)
|
||||
{
|
||||
_appBarData.rc = GetMonitorBoundsRect();
|
||||
_appBarData.uEdge = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
|
||||
_appBarData.lParam = register ? new LPARAM(1) : new LPARAM(0);
|
||||
|
||||
if (_targetMonitor is null)
|
||||
{
|
||||
var result = PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
|
||||
return result != 0;
|
||||
}
|
||||
|
||||
var exResult = PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAREX, ref _appBarData);
|
||||
return exResult != 0;
|
||||
}
|
||||
|
||||
private string MonitorForLogs()
|
||||
{
|
||||
return _targetMonitor?.StableId ?? "primary";
|
||||
}
|
||||
|
||||
private RECT GetMonitorBoundsRect()
|
||||
{
|
||||
if (_targetMonitor is not null)
|
||||
{
|
||||
return new RECT
|
||||
{
|
||||
left = _targetMonitor.Bounds.Left,
|
||||
top = _targetMonitor.Bounds.Top,
|
||||
right = _targetMonitor.Bounds.Right,
|
||||
bottom = _targetMonitor.Bounds.Bottom,
|
||||
};
|
||||
}
|
||||
|
||||
return new RECT
|
||||
{
|
||||
left = 0,
|
||||
top = 0,
|
||||
right = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN),
|
||||
bottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN),
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateAutoHideWindowPosition(DockSize effectiveSize, double scaleFactor)
|
||||
{
|
||||
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
|
||||
|
||||
_revealedRect = _appBarData.rc;
|
||||
_collapsedRect = BuildCollapsedRect(_revealedRect, EffectiveSide, scaleFactor);
|
||||
|
||||
ApplyAutoHideRect(_isDockRevealed ? _revealedRect : _collapsedRect);
|
||||
|
||||
if (_isDockRevealed)
|
||||
{
|
||||
EnsureMouseLeaveTracking();
|
||||
}
|
||||
}
|
||||
|
||||
private RECT BuildCollapsedRect(RECT revealedRect, DockSide side, double scaleFactor)
|
||||
{
|
||||
var collapsedRect = revealedRect;
|
||||
var thickness = (int)Math.Round(AutoHideCollapsedThicknessDips * scaleFactor);
|
||||
|
||||
switch (side)
|
||||
{
|
||||
case DockSide.Top:
|
||||
collapsedRect.bottom = collapsedRect.top + thickness;
|
||||
break;
|
||||
case DockSide.Bottom:
|
||||
collapsedRect.top = collapsedRect.bottom - thickness;
|
||||
break;
|
||||
case DockSide.Left:
|
||||
collapsedRect.right = collapsedRect.left + thickness;
|
||||
break;
|
||||
case DockSide.Right:
|
||||
collapsedRect.left = collapsedRect.right - thickness;
|
||||
break;
|
||||
}
|
||||
|
||||
return collapsedRect;
|
||||
}
|
||||
|
||||
private void ApplyAutoHideRect(RECT rect)
|
||||
{
|
||||
var width = rect.right - rect.left;
|
||||
var height = rect.bottom - rect.top;
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
// Window is fully collapsed - hide it
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
|
||||
return;
|
||||
}
|
||||
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
|
||||
PInvoke.MoveWindow(
|
||||
_hwnd,
|
||||
rect.left,
|
||||
rect.top,
|
||||
width,
|
||||
height,
|
||||
true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the window without forcing a synchronous repaint.
|
||||
/// Used during animation frames for smoother sliding.
|
||||
/// </summary>
|
||||
private void ApplyAutoHideRectNoRepaint(RECT rect)
|
||||
{
|
||||
var width = rect.right - rect.left;
|
||||
var height = rect.bottom - rect.top;
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
|
||||
return;
|
||||
}
|
||||
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
|
||||
const SET_WINDOW_POS_FLAGS flags = SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOCOPYBITS;
|
||||
PInvoke.SetWindowPos(_hwnd, HWND.Null, rect.left, rect.top, width, height, flags);
|
||||
}
|
||||
|
||||
private void RevealAutoHideDock(bool immediate = false)
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide || _isDockRevealed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopCollapseTimer();
|
||||
StopRevealPollTimer();
|
||||
_isDockRevealed = true;
|
||||
|
||||
if (immediate)
|
||||
{
|
||||
StopSlideAnimation();
|
||||
ApplyAutoHideRect(_revealedRect);
|
||||
UpdateTopmostState(bringToFront: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartSlideAnimation(_collapsedRect, _revealedRect, isRevealing: true);
|
||||
UpdateTopmostState(bringToFront: true);
|
||||
}
|
||||
|
||||
EnsureMouseLeaveTracking();
|
||||
}
|
||||
|
||||
private void CollapseAutoHideDock(bool immediate = false)
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide || !_isDockRevealed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!immediate && !CanCollapseAutoHideDock())
|
||||
{
|
||||
// Cursor is still over the dock or a blocking condition exists.
|
||||
// Reschedule so we retry when conditions change.
|
||||
ScheduleCollapseAutoHideDock();
|
||||
return;
|
||||
}
|
||||
|
||||
StopCollapseTimer();
|
||||
_isDockRevealed = false;
|
||||
|
||||
if (immediate)
|
||||
{
|
||||
StopSlideAnimation();
|
||||
ApplyAutoHideRect(_collapsedRect);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartSlideAnimation(_revealedRect, _collapsedRect, isRevealing: false);
|
||||
}
|
||||
|
||||
// Only start the reveal poll timer if no fullscreen app is blocking this monitor
|
||||
if (!_isFullScreenAppOpen)
|
||||
{
|
||||
StartRevealPollTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCollapseAutoHideDock()
|
||||
{
|
||||
if (_dock.IsEditMode || _dock.HasOpenTransientUi || _dock.IsDragOperationActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_paletteOpenedFromDock)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PInvoke.GetCursorPos(out var cursor))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only block collapse if cursor is over our actual window (not just in the same screen area)
|
||||
var cursorPoint = new POINT(cursor.X, cursor.Y);
|
||||
if (IsPointInRect(_revealedRect, cursorPoint))
|
||||
{
|
||||
var windowUnderCursor = PInvoke.WindowFromPoint(new System.Drawing.Point(cursor.X, cursor.Y));
|
||||
|
||||
// WindowFromPoint may return a child control (button, panel, etc.)
|
||||
// inside the dock. Walk up to the top-level window to compare.
|
||||
var rootWindow = PInvoke.GetAncestor(windowUnderCursor, GET_ANCESTOR_FLAGS.GA_ROOT);
|
||||
if (rootWindow == _hwnd)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPointInRect(RECT rect, POINT point)
|
||||
{
|
||||
return point.X >= rect.left && point.X < rect.right && point.Y >= rect.top && point.Y < rect.bottom;
|
||||
}
|
||||
|
||||
private void ScheduleCollapseAutoHideDock()
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide || !_isDockRevealed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collapseTimer ??= CreateCollapseTimer();
|
||||
_collapseTimer.Stop();
|
||||
_collapseTimer.Start();
|
||||
}
|
||||
|
||||
private DispatcherQueueTimer CreateCollapseTimer()
|
||||
{
|
||||
var timer = DispatcherQueue.CreateTimer();
|
||||
timer.Interval = AutoHideCollapseDelay;
|
||||
timer.IsRepeating = false;
|
||||
timer.Tick += (sender, _) =>
|
||||
{
|
||||
sender.Stop();
|
||||
CollapseAutoHideDock();
|
||||
};
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void StopCollapseTimer()
|
||||
{
|
||||
_collapseTimer?.Stop();
|
||||
}
|
||||
|
||||
private void StartRevealPollTimer()
|
||||
{
|
||||
_revealPollTimer ??= CreateRevealPollTimer();
|
||||
_revealPollTimer.Start();
|
||||
}
|
||||
|
||||
private void StopRevealPollTimer()
|
||||
{
|
||||
_revealPollTimer?.Stop();
|
||||
}
|
||||
|
||||
private DispatcherQueueTimer CreateRevealPollTimer()
|
||||
{
|
||||
var timer = DispatcherQueue.CreateTimer();
|
||||
timer.Interval = RevealPollInterval;
|
||||
timer.IsRepeating = true;
|
||||
timer.Tick += (_, _) => PollCursorForReveal();
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void PollCursorForReveal()
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide || _isDockRevealed)
|
||||
{
|
||||
StopRevealPollTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PInvoke.GetCursorPos(out var cursor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCursorAtDockEdge(new POINT(cursor.X, cursor.Y)))
|
||||
{
|
||||
StopRevealPollTimer();
|
||||
RevealAutoHideDock(immediate: false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCursorAtDockEdge(POINT cursor)
|
||||
{
|
||||
// Use the revealed rect's edge position for detection. This already
|
||||
// accounts for work area offsets (e.g., dock positioned above taskbar).
|
||||
switch (EffectiveSide)
|
||||
{
|
||||
case DockSide.Top:
|
||||
return cursor.Y <= _revealedRect.top + RevealHitTestMarginPixels
|
||||
&& cursor.X >= _revealedRect.left && cursor.X < _revealedRect.right;
|
||||
case DockSide.Bottom:
|
||||
return cursor.Y >= _revealedRect.bottom - RevealHitTestMarginPixels
|
||||
&& cursor.X >= _revealedRect.left && cursor.X < _revealedRect.right;
|
||||
case DockSide.Left:
|
||||
return cursor.X <= _revealedRect.left + RevealHitTestMarginPixels
|
||||
&& cursor.Y >= _revealedRect.top && cursor.Y < _revealedRect.bottom;
|
||||
case DockSide.Right:
|
||||
return cursor.X >= _revealedRect.right - RevealHitTestMarginPixels
|
||||
&& cursor.Y >= _revealedRect.top && cursor.Y < _revealedRect.bottom;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartSlideAnimation(RECT from, RECT to, bool isRevealing)
|
||||
{
|
||||
StopSlideAnimation();
|
||||
|
||||
_slideFromRect = from;
|
||||
_slideToRect = to;
|
||||
_slideIsRevealing = isRevealing;
|
||||
|
||||
// Ensure window is visible at start of reveal animation
|
||||
if (isRevealing)
|
||||
{
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
|
||||
}
|
||||
|
||||
_slideStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
_slideTimer ??= CreateSlideTimer();
|
||||
_slideTimer.Start();
|
||||
}
|
||||
|
||||
private void StopSlideAnimation()
|
||||
{
|
||||
_slideTimer?.Stop();
|
||||
_slideStopwatch?.Stop();
|
||||
}
|
||||
|
||||
private DispatcherQueueTimer CreateSlideTimer()
|
||||
{
|
||||
var timer = DispatcherQueue.CreateTimer();
|
||||
timer.Interval = SlideFrameInterval;
|
||||
timer.IsRepeating = true;
|
||||
timer.Tick += (_, _) => OnSlideTimerTick();
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void OnSlideTimerTick()
|
||||
{
|
||||
if (_slideStopwatch is null)
|
||||
{
|
||||
StopSlideAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
var duration = _slideIsRevealing ? SlideRevealDuration : SlideCollapseDuration;
|
||||
var elapsed = _slideStopwatch.Elapsed.TotalMilliseconds;
|
||||
var progress = Math.Min(1.0, elapsed / duration.TotalMilliseconds);
|
||||
|
||||
var easedProgress = _slideIsRevealing
|
||||
? EaseOutCubic(progress)
|
||||
: EaseInCubic(progress);
|
||||
|
||||
var currentRect = LerpRect(_slideFromRect, _slideToRect, easedProgress);
|
||||
|
||||
if (progress >= 1.0)
|
||||
{
|
||||
StopSlideAnimation();
|
||||
|
||||
// Final frame: apply exact position with full repaint
|
||||
ApplyAutoHideRect(_slideToRect);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Intermediate frames: move without synchronous repaint for smoothness
|
||||
ApplyAutoHideRectNoRepaint(currentRect);
|
||||
}
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double t) => 1.0 - Math.Pow(1.0 - t, 3);
|
||||
|
||||
private static double EaseInCubic(double t) => t * t * t;
|
||||
|
||||
private static RECT LerpRect(RECT a, RECT b, double t)
|
||||
{
|
||||
return new RECT
|
||||
{
|
||||
left = Lerp(a.left, b.left, t),
|
||||
top = Lerp(a.top, b.top, t),
|
||||
right = Lerp(a.right, b.right, t),
|
||||
bottom = Lerp(a.bottom, b.bottom, t),
|
||||
};
|
||||
}
|
||||
|
||||
private static int Lerp(int a, int b, double t) => (int)Math.Round(double.Lerp(a, b, t));
|
||||
|
||||
private void EnsureMouseLeaveTracking()
|
||||
{
|
||||
if (_trackingMouseLeave)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var track = new TRACKMOUSEEVENT
|
||||
{
|
||||
cbSize = (uint)Marshal.SizeOf<TRACKMOUSEEVENT>(),
|
||||
dwFlags = TRACKMOUSEEVENT_FLAGS.TME_LEAVE,
|
||||
hwndTrack = _hwnd,
|
||||
dwHoverTime = 0,
|
||||
};
|
||||
|
||||
if (PInvoke.TrackMouseEvent(ref track))
|
||||
{
|
||||
_trackingMouseLeave = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleWorkAreaChanged()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var desiredMode = GetDesiredAppBarMode();
|
||||
var taskbarConflict = desiredMode == DockAppBarMode.AutoHide
|
||||
&& IsTaskbarAutoHideOnSameEdge(EffectiveSide);
|
||||
|
||||
if (taskbarConflict && _appBarMode == DockAppBarMode.AutoHide)
|
||||
{
|
||||
// Taskbar started auto-hiding on our edge, switch to pinned
|
||||
DestroyAppBar(_hwnd);
|
||||
CreateAppBar(_hwnd);
|
||||
}
|
||||
else if (!taskbarConflict && _appBarMode == DockAppBarMode.Pinned && desiredMode == DockAppBarMode.AutoHide)
|
||||
{
|
||||
// Taskbar stopped auto-hiding on our edge, try auto-hide again
|
||||
DestroyAppBar(_hwnd);
|
||||
CreateAppBar(_hwnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateWindowPosition();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMouseMoveForAutoHide()
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The poll timer handles reveal when the dock is collapsed/hidden.
|
||||
// WM_MOUSEMOVE only arrives when the dock is visible (revealed),
|
||||
// so we just reset the collapse timer to keep it open while active.
|
||||
if (!_isDockRevealed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopCollapseTimer();
|
||||
EnsureMouseLeaveTracking();
|
||||
}
|
||||
|
||||
private void HandleMouseLeaveForAutoHide()
|
||||
{
|
||||
_trackingMouseLeave = false;
|
||||
|
||||
if (_appBarMode != DockAppBarMode.AutoHide)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduleCollapseAutoHideDock();
|
||||
}
|
||||
|
||||
private void HandleDeactivationForAutoHide()
|
||||
{
|
||||
if (_appBarMode != DockAppBarMode.AutoHide)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// When the dock loses activation, the palette (if opened from dock) is closing
|
||||
_paletteOpenedFromDock = false;
|
||||
ScheduleCollapseAutoHideDock();
|
||||
}
|
||||
|
||||
private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleFactor)
|
||||
{
|
||||
Logger.LogDebug("UpdateAppBarDataForEdge");
|
||||
var horizontalHeightDips = DockSettingsToViews.HeightForSize(size);
|
||||
var verticalWidthDips = DockSettingsToViews.WidthForSize(size);
|
||||
|
||||
// Use monitor-specific bounds when available; fall back to primary screen metrics
|
||||
// Use monitor-specific bounds when available; fall back to primary screen metrics.
|
||||
// In auto-hide mode, use work area on the dock's edge so the dock
|
||||
// positions inward of the taskbar (if the taskbar is on the same edge).
|
||||
int monLeft, monTop, monRight, monBottom;
|
||||
if (_targetMonitor is not null)
|
||||
{
|
||||
@@ -550,6 +1177,27 @@ public sealed partial class DockWindow : WindowEx,
|
||||
monTop = _targetMonitor.Bounds.Top;
|
||||
monRight = _targetMonitor.Bounds.Right;
|
||||
monBottom = _targetMonitor.Bounds.Bottom;
|
||||
|
||||
if (_appBarMode == DockAppBarMode.AutoHide || GetDesiredAppBarMode() == DockAppBarMode.AutoHide)
|
||||
{
|
||||
// Use work area edge only on the side where the dock is positioned.
|
||||
// This keeps the dock inward of the taskbar on the shared edge.
|
||||
switch (side)
|
||||
{
|
||||
case DockSide.Top:
|
||||
monTop = _targetMonitor.WorkArea.Top;
|
||||
break;
|
||||
case DockSide.Bottom:
|
||||
monBottom = _targetMonitor.WorkArea.Bottom;
|
||||
break;
|
||||
case DockSide.Left:
|
||||
monLeft = _targetMonitor.WorkArea.Left;
|
||||
break;
|
||||
case DockSide.Right:
|
||||
monRight = _targetMonitor.WorkArea.Right;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -557,6 +1205,33 @@ public sealed partial class DockWindow : WindowEx,
|
||||
monTop = 0;
|
||||
monRight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
|
||||
monBottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
|
||||
|
||||
if (_appBarMode == DockAppBarMode.AutoHide || GetDesiredAppBarMode() == DockAppBarMode.AutoHide)
|
||||
{
|
||||
// For primary monitor without MonitorInfo, use the system work area
|
||||
unsafe
|
||||
{
|
||||
RECT workArea = default;
|
||||
if (PInvoke.SystemParametersInfo(SYSTEM_PARAMETERS_INFO_ACTION.SPI_GETWORKAREA, 0, &workArea, 0))
|
||||
{
|
||||
switch (side)
|
||||
{
|
||||
case DockSide.Top:
|
||||
monTop = workArea.top;
|
||||
break;
|
||||
case DockSide.Bottom:
|
||||
monBottom = workArea.bottom;
|
||||
break;
|
||||
case DockSide.Left:
|
||||
monLeft = workArea.left;
|
||||
break;
|
||||
case DockSide.Right:
|
||||
monRight = workArea.right;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (side == DockSide.Top)
|
||||
@@ -612,8 +1287,9 @@ public sealed partial class DockWindow : WindowEx,
|
||||
{
|
||||
Logger.LogDebug($"WM_SETTINGCHANGE(SPI_SETWORKAREA)");
|
||||
|
||||
// Use debounced call to throttle rapid successive calls
|
||||
DispatcherQueue.TryEnqueue(() => UpdateWindowPosition());
|
||||
// Work area changed - taskbar may have toggled auto-hide or moved.
|
||||
// Re-evaluate whether our auto-hide mode is still valid.
|
||||
DispatcherQueue.TryEnqueue(HandleWorkAreaChanged);
|
||||
}
|
||||
}
|
||||
else if (msg == PInvoke.WM_DISPLAYCHANGE)
|
||||
@@ -639,9 +1315,38 @@ public sealed partial class DockWindow : WindowEx,
|
||||
}
|
||||
|
||||
RefreshTargetMonitor();
|
||||
UpdateWindowPosition();
|
||||
|
||||
if (_appBarData.hWnd != IntPtr.Zero)
|
||||
{
|
||||
// The Shell caches the monitor coordinates from the original
|
||||
// ABM_NEW registration, so after a topology change the stale
|
||||
// AppBar rect cannot be repositioned correctly. Destroy and
|
||||
// recreate to re-register with the new monitor geometry.
|
||||
DestroyAppBar(_hwnd);
|
||||
CreateAppBar(_hwnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateWindowPosition();
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (msg == PInvoke.WM_MOUSEMOVE)
|
||||
{
|
||||
HandleMouseMoveForAutoHide();
|
||||
}
|
||||
else if (msg == PInvoke.WM_MOUSELEAVE)
|
||||
{
|
||||
HandleMouseLeaveForAutoHide();
|
||||
}
|
||||
else if (msg == PInvoke.WM_ACTIVATEAPP && wParam.Value == 0)
|
||||
{
|
||||
HandleDeactivationForAutoHide();
|
||||
}
|
||||
else if (msg == PInvoke.WM_ACTIVATE && (wParam.Value & 0xFFFF) == PInvoke.WA_INACTIVE)
|
||||
{
|
||||
HandleDeactivationForAutoHide();
|
||||
}
|
||||
|
||||
// Intercept WM_SYSCOMMAND to prevent minimize and maximize
|
||||
else if (msg == PInvoke.WM_SYSCOMMAND)
|
||||
@@ -661,8 +1366,10 @@ public sealed partial class DockWindow : WindowEx,
|
||||
{
|
||||
var pWindowPos = (WINDOWPOS*)lParam.Value;
|
||||
|
||||
// Check if the window is being hidden (minimized) or if flags suggest minimize/maximize
|
||||
if ((pWindowPos->flags & SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW) != 0)
|
||||
// Check if the window is being hidden (minimized) or if flags suggest minimize/maximize.
|
||||
// Allow hiding when auto-hide is collapsing the dock intentionally.
|
||||
if ((pWindowPos->flags & SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW) != 0
|
||||
&& !(_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed))
|
||||
{
|
||||
// Prevent hiding the window (minimize)
|
||||
pWindowPos->flags &= ~SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW;
|
||||
@@ -694,9 +1401,9 @@ public sealed partial class DockWindow : WindowEx,
|
||||
else if (msg == PInvoke.WM_SHOWWINDOW)
|
||||
{
|
||||
var isBeingShown = wParam.Value != 0;
|
||||
if (!isBeingShown)
|
||||
if (!isBeingShown && !(_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed))
|
||||
{
|
||||
// Prevent hiding the window
|
||||
// Prevent hiding the window (unless auto-hide is intentionally collapsing)
|
||||
return new LRESULT(0);
|
||||
}
|
||||
}
|
||||
@@ -741,6 +1448,17 @@ public sealed partial class DockWindow : WindowEx,
|
||||
else if (wParam.Value == PInvoke.ABN_FULLSCREENAPP)
|
||||
{
|
||||
_isFullScreenAppOpen = lParam != 0;
|
||||
if (_isFullScreenAppOpen)
|
||||
{
|
||||
StopRevealPollTimer();
|
||||
CollapseAutoHideDock(immediate: true);
|
||||
}
|
||||
else if (_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed)
|
||||
{
|
||||
// Fullscreen app exited - restart edge detection
|
||||
StartRevealPollTimer();
|
||||
}
|
||||
|
||||
UpdateTopmostState();
|
||||
}
|
||||
}
|
||||
@@ -748,7 +1466,15 @@ public sealed partial class DockWindow : WindowEx,
|
||||
{
|
||||
Logger.LogDebug("WM_TASKBAR_RESTART");
|
||||
|
||||
DispatcherQueue.TryEnqueue(() => CreateAppBar(_hwnd));
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (_appBarData.hWnd != IntPtr.Zero)
|
||||
{
|
||||
DestroyAppBar(_hwnd);
|
||||
}
|
||||
|
||||
CreateAppBar(_hwnd);
|
||||
});
|
||||
|
||||
WeakReferenceMessenger.Default.Send<BringToTopMessage>(new(false));
|
||||
}
|
||||
@@ -761,6 +1487,11 @@ public sealed partial class DockWindow : WindowEx,
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (message.BringToFront)
|
||||
{
|
||||
RevealAutoHideDock(immediate: true);
|
||||
}
|
||||
|
||||
UpdateTopmostState(message.BringToFront);
|
||||
});
|
||||
}
|
||||
@@ -782,7 +1513,12 @@ public sealed partial class DockWindow : WindowEx,
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => RequestShowPaletteOnUiThread(message.PosDips));
|
||||
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
_paletteOpenedFromDock = true;
|
||||
RevealAutoHideDock(immediate: true);
|
||||
RequestShowPaletteOnUiThread(message.PosDips);
|
||||
});
|
||||
}
|
||||
|
||||
void IRecipient<ShowDockMonitorLabelsMessage>.Receive(ShowDockMonitorLabelsMessage message)
|
||||
@@ -965,6 +1701,7 @@ public sealed partial class DockWindow : WindowEx,
|
||||
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
|
||||
StopCollapseTimer();
|
||||
DisposeAcrylic();
|
||||
_windowViewModel.Dispose();
|
||||
|
||||
|
||||
@@ -15,22 +15,32 @@
|
||||
Activated="MainWindow_Activated"
|
||||
Closed="MainWindow_Closed"
|
||||
mc:Ignorable="d">
|
||||
<Grid x:Name="RootElement">
|
||||
|
||||
<controls:BlurImageControl
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}"
|
||||
ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}"
|
||||
ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}"
|
||||
ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}"
|
||||
ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}"
|
||||
IsHitTestVisible="False"
|
||||
IsHoldingEnabled="False"
|
||||
TintColor="{x:Bind ViewModel.BackgroundImageTint, Mode=OneWay}"
|
||||
TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}"
|
||||
Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" />
|
||||
|
||||
<pages:ShellPage HostWindow="{x:Bind}" />
|
||||
</Grid>
|
||||
<!--
|
||||
The whole window is borderless and transparent (see MainWindow.xaml.cs).
|
||||
CmdPalMainControl is the visible "card" — it draws the rounded corners,
|
||||
border, drop shadow, and hosts the SystemBackdropElement that paints
|
||||
Mica / Acrylic / etc. behind the content.
|
||||
-->
|
||||
<controls:CmdPalMainControl x:Name="RootElement">
|
||||
<controls:CmdPalMainControl.BackgroundLayer>
|
||||
<controls:BlurImageControl
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}"
|
||||
ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}"
|
||||
ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}"
|
||||
ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}"
|
||||
ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}"
|
||||
IsHitTestVisible="False"
|
||||
IsHoldingEnabled="False"
|
||||
TintColor="{x:Bind ViewModel.BackgroundImageTint, Mode=OneWay}"
|
||||
TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}"
|
||||
Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" />
|
||||
</controls:CmdPalMainControl.BackgroundLayer>
|
||||
<controls:CmdPalMainControl.MainContent>
|
||||
<pages:ShellPage HostWindow="{x:Bind}" />
|
||||
</controls:CmdPalMainControl.MainContent>
|
||||
</controls:CmdPalMainControl>
|
||||
</winuiex:WindowEx>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ using Microsoft.CmdPal.UI.Dock;
|
||||
using Microsoft.CmdPal.UI.Events;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.CmdPal.UI.Pages;
|
||||
using Microsoft.CmdPal.UI.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
@@ -22,8 +23,7 @@ using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CmdPal.ViewModels.Messages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Input;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -32,13 +32,11 @@ using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Graphics;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Dwm;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using WinRT;
|
||||
using WinUIEx;
|
||||
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
|
||||
|
||||
@@ -58,6 +56,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
IRecipient<DragCompletedMessage>,
|
||||
IRecipient<ToggleDevRibbonMessage>,
|
||||
IRecipient<GetHwndMessage>,
|
||||
IRecipient<ExpandCompactModeMessage>,
|
||||
IDisposable,
|
||||
IHostWindow
|
||||
{
|
||||
@@ -90,12 +89,19 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private int _sessionMaxNavigationDepth;
|
||||
private int _sessionErrorCount;
|
||||
|
||||
private DesktopAcrylicController? _acrylicController;
|
||||
private MicaController? _micaController;
|
||||
private SystemBackdropConfiguration? _configurationSource;
|
||||
private bool _isUpdatingBackdrop;
|
||||
private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan;
|
||||
|
||||
// Tracks the chrome mode currently applied to the HWND. Nullable so the first
|
||||
// call to ApplyHwndFrameMode always runs, regardless of which mode we land in.
|
||||
private bool? _hwndFrameVisible;
|
||||
|
||||
// Thickness (in DIPs) of the resize grip around the visible card's border. Shared
|
||||
// by the InputNonClientPointerSource region registration (so WM_NCHITTEST actually
|
||||
// fires over the border) and the WM_NCHITTEST handler (so it returns resize codes
|
||||
// over the same band). These MUST match or the two disagree about where resizing is.
|
||||
private const int ResizeBorderThicknessDip = 8;
|
||||
|
||||
private WindowPosition _currentWindowPosition = new();
|
||||
|
||||
private bool _preventHideWhenDeactivated;
|
||||
@@ -127,7 +133,13 @@ public sealed partial class MainWindow : WindowEx,
|
||||
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
|
||||
}
|
||||
|
||||
InitializeBackdropSupport();
|
||||
// The HWND itself is borderless / transparent — the visible card lives inside
|
||||
// RootElement (CmdPalMainControl) and draws its own corners, border, shadow, and
|
||||
// backdrop via the SystemBackdropElement. The frame can be re-enabled via an
|
||||
// internal-only setting (hot-reloaded through HotReloadSettings) to make the
|
||||
// HWND bounds visible while debugging.
|
||||
var initialSettings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
ApplyHwndFrameMode(ShouldShowHwndFrame(initialSettings));
|
||||
|
||||
_hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached);
|
||||
|
||||
@@ -164,6 +176,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<ToggleDevRibbonMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<GetHwndMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
|
||||
|
||||
// Hide our titlebar.
|
||||
// We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed
|
||||
@@ -232,16 +245,24 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// the DIP size won't trigger it, leaving drag regions at the old physical coordinates.
|
||||
RootElement.XamlRoot.Changed += XamlRoot_Changed;
|
||||
|
||||
// Add dev ribbon if enabled
|
||||
// The visible card resizes inside the fixed-size HWND (e.g. compact <-> expanded),
|
||||
// which does not raise WindowSizeChanged. Recompute the drag regions and the HWND
|
||||
// clip region whenever the card's own size changes so they keep tracking it.
|
||||
RootElement.CardElement.SizeChanged += CardElement_SizeChanged;
|
||||
|
||||
// Add dev ribbon if enabled. The ribbon lives inside the visible card so it
|
||||
// doesn't draw into the transparent shadow area outside the rounded border.
|
||||
if (!BuildInfo.IsCiBuild)
|
||||
{
|
||||
_devRibbon = new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) };
|
||||
RootElement.Children.Add(_devRibbon);
|
||||
RootElement.CardContentPanel.Children.Add(_devRibbon);
|
||||
}
|
||||
}
|
||||
|
||||
private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
|
||||
|
||||
private void CardElement_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateRegionsForCustomTitleBar();
|
||||
|
||||
private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
|
||||
|
||||
private void PositionCentered()
|
||||
@@ -275,10 +296,77 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
if (rect is not null)
|
||||
{
|
||||
MoveAndResizeDpiAware(rect.Value);
|
||||
var finalRect = rect.Value;
|
||||
|
||||
// In compact mode, center the *visible collapsed card* (the search box) on the
|
||||
// display, not the much larger transparent HWND. The card is anchored to the top
|
||||
// of the HWND, so we offset the HWND upward by the card's center so that growing
|
||||
// the card downward (when results appear) keeps the search box where it was.
|
||||
if (TryGetCompactCardCenterOffsetPhysical(windowDpi, out var cardCenterFromHwndTop))
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
var workArea = displayArea.WorkArea;
|
||||
|
||||
// The setting is the relative height measured from the *bottom* of the screen,
|
||||
// so a larger percentage places the search box higher up the display.
|
||||
var fractionFromTop = GetCompactCenterFractionFromTop(settings);
|
||||
var desiredCardCenterY = workArea.Y + (int)Math.Round(workArea.Height * fractionFromTop);
|
||||
finalRect.Y = desiredCardCenterY - cardCenterFromHwndTop;
|
||||
|
||||
if (finalRect.Y < workArea.Y)
|
||||
{
|
||||
finalRect.Y = workArea.Y;
|
||||
}
|
||||
}
|
||||
|
||||
MoveAndResizeDpiAware(finalRect);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the palette is in compact mode and is being centered on launch, computes the
|
||||
/// distance (in physical pixels) from the top of the HWND to the vertical center of the
|
||||
/// collapsed card, so the caller can position the HWND such that the card is centered.
|
||||
/// Returns false when the card should not be re-centered (compact mode off, or a summon
|
||||
/// behavior that restores the last position).
|
||||
/// </summary>
|
||||
private bool TryGetCompactCardCenterOffsetPhysical(int windowDpi, out int cardCenterFromHwndTop)
|
||||
{
|
||||
cardCenterFromHwndTop = 0;
|
||||
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
if (!settings.CompactMode || !IsCenteringSummon(settings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure the card is actually collapsed before we measure it.
|
||||
(RootElement.MainContent as ShellPage)?.EnsureCompactLayout();
|
||||
|
||||
var cardHeightDip = RootElement.GetCardHeight();
|
||||
if (cardHeightDip <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var scale = windowDpi / 96.0;
|
||||
var cardTopDip = RootElement.ShadowPadding.Top;
|
||||
cardCenterFromHwndTop = (int)Math.Round((cardTopDip + (cardHeightDip / 2.0)) * scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Every summon behavior except ToLast centers the window on its target display.
|
||||
private static bool IsCenteringSummon(SettingsModel settings) => settings.SummonOn != MonitorBehavior.ToLast;
|
||||
|
||||
// Converts the "center height" setting (a percentage measured up from the bottom of the
|
||||
// screen) into the fraction of the work area, measured from the top, at which the
|
||||
// collapsed search box should be centered.
|
||||
private static double GetCompactCenterFractionFromTop(SettingsModel settings)
|
||||
{
|
||||
var pct = Math.Clamp(settings.CompactCenterHeightPercentage, 0, 100);
|
||||
return 1.0 - (pct / 100.0);
|
||||
}
|
||||
|
||||
private void RestoreWindowPosition(WindowPosition? savedPosition)
|
||||
{
|
||||
if (savedPosition?.IsSizeValid != true)
|
||||
@@ -380,17 +468,104 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
_autoGoHomeInterval = settings.AutoGoHomeInterval;
|
||||
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
|
||||
|
||||
ApplyHwndFrameMode(ShouldShowHwndFrame(settings));
|
||||
|
||||
// Start collapsed: the card shrinks to just the search box until there is a query.
|
||||
HandleExpandCompactOnUiThread(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user has opted in to seeing the OS-drawn HWND chrome (an internal
|
||||
/// debugging setting). Always false in CI / release builds.
|
||||
/// </summary>
|
||||
private static bool ShouldShowHwndFrame(SettingsModel settings) =>
|
||||
!BuildInfo.IsCiBuild && settings.ShowHwndFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the HWND for the borderless / transparent main-window mode and (when
|
||||
/// the internal debug toggle is enabled) overlays the OS-drawn chrome so the HWND's
|
||||
/// real bounds are easy to spot. Hit testing is always handled by
|
||||
/// <see cref="HitTestForCardResize"/> — the frame flag is purely visual.
|
||||
/// </summary>
|
||||
private void ApplyHwndFrameMode(bool showFrame)
|
||||
{
|
||||
if (_hwndFrameVisible == showFrame)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hwndFrameVisible = showFrame;
|
||||
|
||||
// The HWND itself never paints — the card draws the backdrop. Re-applying this
|
||||
// each toggle is safe (it just reassigns SystemBackdrop) and guards against the
|
||||
// OS replacing it when chrome changes.
|
||||
InitializeBackdropSupport();
|
||||
|
||||
if (AppWindow.Presenter is OverlappedPresenter overlappedPresenter)
|
||||
{
|
||||
// When the debug flag is off we hide the OS chrome (no title bar, no border).
|
||||
// When on we let the OS draw both so the HWND outline is obvious.
|
||||
// This must actually be applied (not just relied on via WM_NCCALCSIZE): now
|
||||
// that the HWND is clipped to the card region, the OS-drawn title bar / frame
|
||||
// is no longer covered by our full-window transparent content, so DWM would
|
||||
// otherwise repaint it (most visibly the inactive caption) behind the card
|
||||
// when the window loses focus.
|
||||
overlappedPresenter.SetBorderAndTitleBar(showFrame, showFrame);
|
||||
|
||||
// IsResizable must stay true so WS_THICKFRAME is present. The OS only honors
|
||||
// resize-style WM_NCHITTEST results (HTLEFT, HTRIGHT, HT{TOP,BOTTOM}{,LEFT,RIGHT})
|
||||
// when the window has a sizing frame, even though we drive the resize from a
|
||||
// custom NCHITTEST handler. Setting it after SetBorderAndTitleBar makes sure a
|
||||
// borderless window still keeps its sizing frame.
|
||||
overlappedPresenter.IsResizable = true;
|
||||
}
|
||||
|
||||
ApplyHwndBorderAttributes(showFrame);
|
||||
|
||||
// Drag regions are computed relative to the visible card; the chrome change can
|
||||
// shift its on-screen position, so refresh.
|
||||
UpdateRegionsForCustomTitleBar();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the DWM corner and border attributes for the current frame mode. This is
|
||||
/// split out from <see cref="ApplyHwndFrameMode"/> because the DWM border color does
|
||||
/// not reliably "take" when first set during window construction (before the HWND has
|
||||
/// been shown on a cold process start) — leaving the faint OS outline visible until
|
||||
/// the chrome is toggled. Re-applying it each time the window is shown guarantees the
|
||||
/// borderless look on a cold start.
|
||||
/// </summary>
|
||||
private void ApplyHwndBorderAttributes(bool showFrame)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
// Rounded corners: let the OS pick when the debug frame is on, suppress
|
||||
// otherwise so the card's CornerRadius isn't doubled by an OS rounding.
|
||||
var corner = (uint)(showFrame
|
||||
? DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT
|
||||
: DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DONOTROUND);
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(uint));
|
||||
|
||||
// DWMWA_BORDER_COLOR: 0xFFFFFFFE = DWMWA_COLOR_NONE (no border drawn);
|
||||
// 0xFFFFFFFF = DWMWA_COLOR_DEFAULT (system default). With WS_THICKFRAME still
|
||||
// on, DWM otherwise draws a faint 1px outline around the HWND — which the
|
||||
// user sees as the "frame still appears around the sides" even when our
|
||||
// ShowHwndFrame setting is off. Setting COLOR_NONE removes it.
|
||||
const uint DWMWA_COLOR_NONE = 0xFFFFFFFEu;
|
||||
const uint DWMWA_COLOR_DEFAULT = 0xFFFFFFFFu;
|
||||
var borderColor = showFrame ? DWMWA_COLOR_DEFAULT : DWMWA_COLOR_NONE;
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_BORDER_COLOR, &borderColor, sizeof(uint));
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeBackdropSupport()
|
||||
{
|
||||
if (DesktopAcrylicController.IsSupported() || MicaController.IsSupported())
|
||||
{
|
||||
_configurationSource = new SystemBackdropConfiguration
|
||||
{
|
||||
IsInputActive = true,
|
||||
};
|
||||
}
|
||||
// The window itself paints nothing (it's transparent). All actual backdrop
|
||||
// rendering lives on the SystemBackdropElement inside CmdPalMainControl, so the
|
||||
// mica/acrylic only fills the rounded card instead of the whole HWND. The empty
|
||||
// tint here keeps the HWND fully transparent.
|
||||
SystemBackdrop = new TransparentTintBackdrop { TintColor = Colors.Transparent };
|
||||
}
|
||||
|
||||
private void UpdateBackdrop()
|
||||
@@ -403,35 +578,14 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
_isUpdatingBackdrop = true;
|
||||
|
||||
var backdrop = _themeService.Current.BackdropParameters;
|
||||
var isImageMode = ViewModel.ShowBackgroundImage;
|
||||
var config = BackdropStyles.Get(backdrop.Style);
|
||||
|
||||
try
|
||||
{
|
||||
switch (config.ControllerKind)
|
||||
{
|
||||
case BackdropControllerKind.Solid:
|
||||
CleanupBackdropControllers();
|
||||
var tintColor = Color.FromArgb(
|
||||
(byte)(backdrop.EffectiveOpacity * 255),
|
||||
backdrop.TintColor.R,
|
||||
backdrop.TintColor.G,
|
||||
backdrop.TintColor.B);
|
||||
SetupTransparentBackdrop(tintColor);
|
||||
break;
|
||||
var backdrop = _themeService.Current.BackdropParameters;
|
||||
var isImageMode = ViewModel.ShowBackgroundImage;
|
||||
var config = BackdropStyles.Get(backdrop.Style);
|
||||
var hasColorization = _themeService.Current.HasColorization;
|
||||
|
||||
case BackdropControllerKind.Mica:
|
||||
case BackdropControllerKind.MicaAlt:
|
||||
SetupMica(backdrop, isImageMode, config.ControllerKind);
|
||||
break;
|
||||
|
||||
case BackdropControllerKind.Acrylic:
|
||||
case BackdropControllerKind.AcrylicThin:
|
||||
default:
|
||||
SetupDesktopAcrylic(backdrop, isImageMode, config.ControllerKind);
|
||||
break;
|
||||
}
|
||||
RootElement.ApplyBackdrop(backdrop, config.ControllerKind, isImageMode, hasColorization);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -443,111 +597,6 @@ public sealed partial class MainWindow : WindowEx,
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupTransparentBackdrop(Color tintColor)
|
||||
{
|
||||
if (SystemBackdrop is TransparentTintBackdrop existingBackdrop)
|
||||
{
|
||||
existingBackdrop.TintColor = tintColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
SystemBackdrop = new TransparentTintBackdrop { TintColor = tintColor };
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupBackdropControllers()
|
||||
{
|
||||
if (_acrylicController is not null)
|
||||
{
|
||||
_acrylicController.RemoveAllSystemBackdropTargets();
|
||||
_acrylicController.Dispose();
|
||||
_acrylicController = null;
|
||||
}
|
||||
|
||||
if (_micaController is not null)
|
||||
{
|
||||
_micaController.RemoveAllSystemBackdropTargets();
|
||||
_micaController.Dispose();
|
||||
_micaController = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupDesktopAcrylic(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
|
||||
{
|
||||
CleanupBackdropControllers();
|
||||
|
||||
// Fall back to solid color if acrylic not supported
|
||||
if (_configurationSource is null || !DesktopAcrylicController.IsSupported())
|
||||
{
|
||||
SetupTransparentBackdrop(backdrop.FallbackColor);
|
||||
return;
|
||||
}
|
||||
|
||||
// DesktopAcrylicController and SystemBackdrop can't be active simultaneously
|
||||
SystemBackdrop = null;
|
||||
|
||||
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
|
||||
var effectiveTintOpacity = isImageMode
|
||||
? 0.0f
|
||||
: backdrop.EffectiveOpacity;
|
||||
|
||||
_acrylicController = new DesktopAcrylicController
|
||||
{
|
||||
Kind = kind == BackdropControllerKind.AcrylicThin
|
||||
? DesktopAcrylicKind.Thin
|
||||
: DesktopAcrylicKind.Default,
|
||||
TintColor = backdrop.TintColor,
|
||||
TintOpacity = effectiveTintOpacity,
|
||||
FallbackColor = backdrop.FallbackColor,
|
||||
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
|
||||
};
|
||||
|
||||
// Requires "using WinRT;" for Window.As<>()
|
||||
_acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
|
||||
_acrylicController.SetSystemBackdropConfiguration(_configurationSource);
|
||||
}
|
||||
|
||||
private void SetupMica(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
|
||||
{
|
||||
CleanupBackdropControllers();
|
||||
|
||||
// Fall back to solid color if Mica not supported
|
||||
if (_configurationSource is null || !MicaController.IsSupported())
|
||||
{
|
||||
SetupTransparentBackdrop(backdrop.FallbackColor);
|
||||
return;
|
||||
}
|
||||
|
||||
// MicaController and SystemBackdrop can't be active simultaneously
|
||||
SystemBackdrop = null;
|
||||
_configurationSource.Theme = _themeService.Current.Theme == ElementTheme.Dark
|
||||
? SystemBackdropTheme.Dark
|
||||
: SystemBackdropTheme.Light;
|
||||
|
||||
var hasColorization = _themeService.Current.HasColorization || isImageMode;
|
||||
|
||||
_micaController = new MicaController
|
||||
{
|
||||
Kind = kind == BackdropControllerKind.MicaAlt
|
||||
? MicaKind.BaseAlt
|
||||
: MicaKind.Base,
|
||||
};
|
||||
|
||||
// Only set tint properties when colorization is active
|
||||
// Otherwise let system handle light/dark theme defaults automatically
|
||||
if (hasColorization)
|
||||
{
|
||||
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
|
||||
_micaController.TintColor = backdrop.TintColor;
|
||||
_micaController.TintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
|
||||
_micaController.FallbackColor = backdrop.FallbackColor;
|
||||
_micaController.LuminosityOpacity = backdrop.EffectiveLuminosityOpacity;
|
||||
}
|
||||
|
||||
_micaController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
|
||||
_micaController.SetSystemBackdropConfiguration(_configurationSource);
|
||||
}
|
||||
|
||||
private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target)
|
||||
{
|
||||
var positionWindowForTargetMonitor = (HWND hwnd) =>
|
||||
@@ -654,15 +703,29 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// Just to be sure, SHOW our hwnd.
|
||||
PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW);
|
||||
|
||||
// Re-apply the borderless DWM attributes now that the window is
|
||||
// actually shown. On a cold launch these are first set during
|
||||
// construction before the HWND has ever been displayed, and DWM doesn't
|
||||
// reliably honor the border color until the window exists on-screen —
|
||||
// which left the faint OS outline visible until the chrome was toggled.
|
||||
ApplyHwndBorderAttributes(_hwndFrameVisible ?? false);
|
||||
|
||||
// Once we're done, uncloak to avoid all animations
|
||||
Uncloak();
|
||||
|
||||
PInvoke.SetForegroundWindow(hwnd);
|
||||
PInvoke.SetActiveWindow(hwnd);
|
||||
|
||||
// Push our window to the top of the Z-order and make it the topmost, so that it appears above all other windows.
|
||||
// We want to remove the topmost status when we hide the window (because we cloak it instead of hiding it).
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
// Push our window to the top of the Z-order and make it the topmost, so
|
||||
// that it appears above all other windows. We want to remove the
|
||||
// topmost status when we hide the window (because we cloak it instead
|
||||
// of hiding it).
|
||||
//
|
||||
// SWP_FRAMECHANGED is load-bearing for the borderless look on a cold
|
||||
// start. Asking for SWP_FRAMECHANGED here re-sends WM_NCCALCSIZE and
|
||||
// forces the NC repaint every time we show, so the frame is gone from
|
||||
// the very first summon.
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED);
|
||||
}
|
||||
|
||||
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
@@ -942,8 +1005,17 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
private void DisposeAcrylic()
|
||||
{
|
||||
CleanupBackdropControllers();
|
||||
_configurationSource = null!;
|
||||
// The backdrop controllers now live on the SystemBackdropElement inside
|
||||
// CmdPalMainControl. Clearing its SystemBackdrop fires OnTargetDisconnected on the
|
||||
// current backdrop, which removes targets and disposes the underlying controller.
|
||||
try
|
||||
{
|
||||
RootElement?.ClearBackdrop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; ignore errors during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
// Updates our window s.t. the top of the window is draggable.
|
||||
@@ -958,31 +1030,134 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// Specify the interactive regions of the title bar.
|
||||
var scaleAdjustment = xamlRoot.RasterizationScale;
|
||||
|
||||
// Get the rectangle around our XAML content. We're going to mark this
|
||||
// rectangle as "Passthrough", so that the normal window operations
|
||||
// (resizing, dragging) don't apply in this space.
|
||||
var transform = RootElement.TransformToVisual(null);
|
||||
|
||||
// Reserve 16px of space at the top for dragging.
|
||||
var topHeight = 16;
|
||||
var bounds = transform.TransformBounds(new Rect(
|
||||
0,
|
||||
topHeight,
|
||||
RootElement.ActualWidth,
|
||||
RootElement.ActualHeight));
|
||||
var contentRect = GetRect(bounds, scaleAdjustment);
|
||||
var rectArray = new RectInt32[] { contentRect };
|
||||
var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id);
|
||||
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray);
|
||||
|
||||
// Add a drag-able region on top
|
||||
var w = RootElement.ActualWidth;
|
||||
_ = RootElement.ActualHeight;
|
||||
var dragSides = new RectInt32[]
|
||||
// Drag/passthrough regions are computed against the visible card (the rounded
|
||||
// border inside CmdPalMainControl), not the whole HWND. The HWND extends beyond
|
||||
// the card to make room for the drop shadow, and we don't want that transparent
|
||||
// shadow area to be draggable.
|
||||
var card = RootElement.CardElement;
|
||||
if (card.ActualWidth <= 0 || card.ActualHeight <= 0)
|
||||
{
|
||||
GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall
|
||||
return;
|
||||
}
|
||||
|
||||
// All coordinates below are in the card's own (DIP) space: (0,0) is the
|
||||
// top-left of the visible card, (w,h) is the bottom-right. GetRect transforms
|
||||
// them into the physical-pixel client coordinates that
|
||||
// InputNonClientPointerSource expects.
|
||||
var transform = card.TransformToVisual(null);
|
||||
var w = card.ActualWidth;
|
||||
var h = card.ActualHeight;
|
||||
|
||||
RectInt32 CardRect(double x, double y, double rw, double rh) =>
|
||||
GetRect(transform.TransformBounds(new Rect(x, y, rw, rh)), scaleAdjustment);
|
||||
|
||||
// Reserve some space at the top for dragging the window (caption).
|
||||
const double dragHeight = 16;
|
||||
|
||||
// The resize grip straddles each card edge by `grip` DIPs on either side so the
|
||||
// affordance reaches a little into the drop-shadow padding too.
|
||||
const double grip = ResizeBorderThicknessDip;
|
||||
|
||||
var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id);
|
||||
|
||||
// Mark the card's border ring + top drag bar as Caption. Only regions
|
||||
// registered here generate WM_NCHITTEST on our wndproc - without the
|
||||
// side/bottom strips the XAML island swallows the pointer and we never
|
||||
// get a resize. HotKeyPrc's WM_NCHITTEST then picks drag vs. resize
|
||||
// per-pixel.
|
||||
var caption = new RectInt32[]
|
||||
{
|
||||
CardRect(0, 0, w, dragHeight), // top drag bar
|
||||
CardRect(-grip, -grip, 2 * grip, h + (2 * grip)), // left edge
|
||||
CardRect(w - grip, -grip, 2 * grip, h + (2 * grip)), // right edge
|
||||
CardRect(-grip, -grip, w + (2 * grip), 2 * grip), // top edge
|
||||
CardRect(-grip, h - grip, w + (2 * grip), 2 * grip), // bottom edge
|
||||
};
|
||||
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, dragSides);
|
||||
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, caption);
|
||||
|
||||
// Everything inside the border ring (and below the drag bar) is
|
||||
// interactive content. Marking it Passthrough keeps the search box,
|
||||
// list, etc. clickable and explicitly carves it out of the caption
|
||||
// regions above.
|
||||
var interiorWidth = Math.Max(0, w - (2 * grip));
|
||||
var interiorHeight = Math.Max(0, h - dragHeight - grip);
|
||||
var passthrough = new RectInt32[]
|
||||
{
|
||||
CardRect(grip, dragHeight, interiorWidth, interiorHeight),
|
||||
};
|
||||
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, passthrough);
|
||||
|
||||
// Clip the HWND to the card + its shadow. Card is inset by
|
||||
// ShadowPadding on each side, so card + full padding lands flush with
|
||||
// the HWND edge - and a region flush with the edge makes WS_THICKFRAME
|
||||
// draw its border there. So inset 1px on every side to hide that.
|
||||
// Bottom is clamped to 1px inside the HWND so the border doesn't
|
||||
// reappear as the card grows tall.
|
||||
var shadowPadding = RootElement.ShadowPadding;
|
||||
var cardPhysical = CardRect(0, 0, w, h);
|
||||
|
||||
const int EdgeInsetPx = 1;
|
||||
var windowWidthPx = AppWindow.Size.Width;
|
||||
var windowHeightPx = AppWindow.Size.Height;
|
||||
|
||||
var clipLeft = EdgeInsetPx;
|
||||
var clipTop = EdgeInsetPx;
|
||||
var clipRight = windowWidthPx - EdgeInsetPx;
|
||||
|
||||
var bottomShadowPx = (int)Math.Round(shadowPadding.Bottom * scaleAdjustment);
|
||||
var clipBottom = Math.Min(
|
||||
cardPhysical.Y + cardPhysical.Height + bottomShadowPx,
|
||||
windowHeightPx - EdgeInsetPx);
|
||||
|
||||
ApplyCardWindowRegion(new RectInt32(
|
||||
clipLeft,
|
||||
clipTop,
|
||||
Math.Max(0, clipRight - clipLeft),
|
||||
Math.Max(0, clipBottom - clipTop)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts the HWND's visible / hit-testable area to the supplied
|
||||
/// rectangle (in physical client pixels), which covers the visible card and
|
||||
/// its drop-shadow margin. Everything outside — the empty transparent area
|
||||
/// of the (larger) HWND — becomes click-through and is excluded from the
|
||||
/// window region. When the debug HWND frame is enabled the clip is removed
|
||||
/// so the full window stays visible.
|
||||
/// </summary>
|
||||
private void ApplyCardWindowRegion(RectInt32 regionPhysical)
|
||||
{
|
||||
nint hwnd;
|
||||
unsafe
|
||||
{
|
||||
hwnd = (nint)_hwnd.Value;
|
||||
}
|
||||
|
||||
// Debug frame mode: keep the whole window visible / interactive, no clip.
|
||||
if (_hwndFrameVisible == true)
|
||||
{
|
||||
_ = SetWindowRgn(hwnd, IntPtr.Zero, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// CreateRectRgn coordinates are relative to the window's top-left. For this
|
||||
// borderless popup the client origin coincides with the window origin, so the
|
||||
// region's client-space physical rect maps directly into window space.
|
||||
var region = CreateRectRgn(
|
||||
regionPhysical.X,
|
||||
regionPhysical.Y,
|
||||
regionPhysical.X + regionPhysical.Width,
|
||||
regionPhysical.Y + regionPhysical.Height);
|
||||
if (region == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// On success SetWindowRgn takes ownership of the region (the OS frees it), so we
|
||||
// only delete it ourselves if the call failed.
|
||||
if (SetWindowRgn(hwnd, region, true) == 0)
|
||||
{
|
||||
_ = DeleteObject(region);
|
||||
}
|
||||
}
|
||||
|
||||
private static RectInt32 GetRect(Rect bounds, double scale)
|
||||
@@ -994,6 +1169,19 @@ public sealed partial class MainWindow : WindowEx,
|
||||
_Height: (int)Math.Round(bounds.Height * scale));
|
||||
}
|
||||
|
||||
// Raw interop for the window-region clip. Declared here (rather than via CsWin32)
|
||||
// because SetWindowRgn transfers ownership of the HRGN to the OS on success, which is
|
||||
// awkward to express through CsWin32's SafeHandle-returning region creator.
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, [MarshalAs(UnmanagedType.Bool)] bool bRedraw);
|
||||
|
||||
[DllImport("gdi32.dll")]
|
||||
private static extern IntPtr CreateRectRgn(int nLeftRect, int nTopRect, int nRightRect, int nBottomRect);
|
||||
|
||||
[DllImport("gdi32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DeleteObject(IntPtr hObject);
|
||||
|
||||
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
|
||||
@@ -1046,9 +1234,9 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus());
|
||||
}
|
||||
|
||||
if (_configurationSource is not null)
|
||||
if (RootElement is not null)
|
||||
{
|
||||
_configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated;
|
||||
RootElement.SetIsInputActive(args.WindowActivationState != WindowActivationState.Deactivated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1336,6 +1524,29 @@ public sealed partial class MainWindow : WindowEx,
|
||||
case PInvoke.WM_DPICHANGED when _suppressDpiChange:
|
||||
return (LRESULT)IntPtr.Zero;
|
||||
|
||||
case PInvoke.WM_NCHITTEST:
|
||||
{
|
||||
var ht = HitTestForCardResize(lParam);
|
||||
if (ht != 0)
|
||||
{
|
||||
return (LRESULT)(nint)ht;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Borderless mode: claim the entire window rectangle as client area.
|
||||
// A resizable window has WS_THICKFRAME, which makes the OS reserve a
|
||||
// non-client sizing frame *and* gives the window a DWM drop shadow / a thin
|
||||
// frame line along the top. We keep WS_THICKFRAME (so our custom WM_NCHITTEST
|
||||
// can still drive resizing) but tell the OS the whole window is client by
|
||||
// returning 0 from WM_NCCALCSIZE — which removes that frame and its shadow.
|
||||
// The visible card draws its own border + shadow inside the transparent HWND.
|
||||
// When the debug frame is on we fall through to the default handling so the
|
||||
// real OS chrome appears.
|
||||
case PInvoke.WM_NCCALCSIZE when wParam.Value != 0 && _hwndFrameVisible != true:
|
||||
return (LRESULT)0;
|
||||
|
||||
case PInvoke.WM_HOTKEY:
|
||||
{
|
||||
var hotkeyIndex = (int)wParam.Value;
|
||||
@@ -1360,6 +1571,116 @@ public sealed partial class MainWindow : WindowEx,
|
||||
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom WM_NCHITTEST handler that turns the visible card's border (the rounded
|
||||
/// stroke drawn by <see cref="CmdPalMainControl"/>) into the window's resize handles.
|
||||
/// Without this the borderless / transparent HWND has no visible resize affordance,
|
||||
/// even though the OS still allows resizing along the (invisible) HWND edges.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A non-zero HT* value to override the system hit test, or 0 to fall through to
|
||||
/// the default WndProc (which lets the InputNonClientPointerSource Caption /
|
||||
/// Passthrough regions decide caption vs. client behavior inside the card).
|
||||
/// </returns>
|
||||
private uint HitTestForCardResize(LPARAM lParam)
|
||||
{
|
||||
// NB: We intentionally do *not* short-circuit when the debug frame is showing.
|
||||
// The HWND frame toggle is purely a visual diagnostic; resize hit-testing
|
||||
// remains ours in both modes so the card's border is always the grab area.
|
||||
if (RootElement is null || RootElement.XamlRoot is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// LPARAM packs the screen-space pointer position: low word = x, high word = y,
|
||||
// both as signed 16-bit ints.
|
||||
var ptX = (short)(lParam.Value & 0xFFFF);
|
||||
var ptY = (short)((lParam.Value >> 16) & 0xFFFF);
|
||||
|
||||
if (!PInvoke.GetWindowRect(_hwnd, out var windowRect))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convert the card's ShadowPadding (DIPs) into screen pixels so we can locate
|
||||
// the visible card rect within the (larger, transparent) HWND.
|
||||
var dpi = PInvoke.GetDpiForWindow(_hwnd);
|
||||
var scale = dpi / 96.0;
|
||||
var padding = RootElement.ShadowPadding;
|
||||
|
||||
var cardLeft = windowRect.left + (int)Math.Round(padding.Left * scale);
|
||||
var cardTop = windowRect.top + (int)Math.Round(padding.Top * scale);
|
||||
var cardRight = windowRect.right - (int)Math.Round(padding.Right * scale);
|
||||
var cardBottom = windowRect.bottom - (int)Math.Round(padding.Bottom * scale);
|
||||
|
||||
// Width of the resize grip around the card's visible border, in screen pixels.
|
||||
// Shared with the InputNonClientPointerSource region registration in
|
||||
// UpdateRegionsForCustomTitleBar so the band where WM_NCHITTEST fires lines up
|
||||
// exactly with the band where we return resize codes.
|
||||
var grip = (int)Math.Round(ResizeBorderThicknessDip * scale);
|
||||
|
||||
var onLeftEdge = ptX >= cardLeft - grip && ptX < cardLeft + grip;
|
||||
var onRightEdge = ptX > cardRight - grip && ptX <= cardRight + grip;
|
||||
var onTopEdge = ptY >= cardTop - grip && ptY < cardTop + grip;
|
||||
var onBottomEdge = ptY > cardBottom - grip && ptY <= cardBottom + grip;
|
||||
|
||||
// Corners get priority over edges.
|
||||
if (onTopEdge && onLeftEdge)
|
||||
{
|
||||
return PInvoke.HTTOPLEFT;
|
||||
}
|
||||
|
||||
if (onTopEdge && onRightEdge)
|
||||
{
|
||||
return PInvoke.HTTOPRIGHT;
|
||||
}
|
||||
|
||||
if (onBottomEdge && onLeftEdge)
|
||||
{
|
||||
return PInvoke.HTBOTTOMLEFT;
|
||||
}
|
||||
|
||||
if (onBottomEdge && onRightEdge)
|
||||
{
|
||||
return PInvoke.HTBOTTOMRIGHT;
|
||||
}
|
||||
|
||||
var withinHorizontalSpan = ptX >= cardLeft - grip && ptX <= cardRight + grip;
|
||||
var withinVerticalSpan = ptY >= cardTop - grip && ptY <= cardBottom + grip;
|
||||
|
||||
if (onTopEdge && withinHorizontalSpan)
|
||||
{
|
||||
return PInvoke.HTTOP;
|
||||
}
|
||||
|
||||
if (onBottomEdge && withinHorizontalSpan)
|
||||
{
|
||||
return PInvoke.HTBOTTOM;
|
||||
}
|
||||
|
||||
if (onLeftEdge && withinVerticalSpan)
|
||||
{
|
||||
return PInvoke.HTLEFT;
|
||||
}
|
||||
|
||||
if (onRightEdge && withinVerticalSpan)
|
||||
{
|
||||
return PInvoke.HTRIGHT;
|
||||
}
|
||||
|
||||
// Pointer is inside the card but away from the border: defer to the default
|
||||
// hit test so the InputNonClientPointerSource Caption/Passthrough regions take
|
||||
// effect for dragging vs. normal input.
|
||||
if (ptX >= cardLeft && ptX <= cardRight && ptY >= cardTop && ptY <= cardBottom)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pointer is in the transparent shadow padding around the card. Make that area
|
||||
// click-through so the window behind us receives the mouse input.
|
||||
return unchecked((uint)PInvoke.HTTRANSPARENT);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_localKeyboardListener.Dispose();
|
||||
@@ -1418,4 +1739,51 @@ public sealed partial class MainWindow : WindowEx,
|
||||
{
|
||||
message.Hwnd = this.GetWindowHandle();
|
||||
}
|
||||
|
||||
public void Receive(ExpandCompactModeMessage message)
|
||||
{
|
||||
this.DispatcherQueue.TryEnqueue(() => HandleExpandCompactOnUiThread(message.Expanded));
|
||||
}
|
||||
|
||||
// The HWND is already as large as it will ever need to be (and it's transparent), so
|
||||
// instead of resizing the window we simply shrink or grow the visible card inside it.
|
||||
private void HandleExpandCompactOnUiThread(bool expanded)
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
|
||||
// Only the compact + centered configuration needs a screen-fit clamp. There the card
|
||||
// is anchored near the vertical center of the display, so an expanded list could run
|
||||
// off the bottom edge; cap its height so it always fits. In every other case the card
|
||||
// is free to fill the (fixed-size) HWND as before.
|
||||
if (expanded && settings.CompactMode && IsCenteringSummon(settings))
|
||||
{
|
||||
RootElement.SetCardMaxHeight(ComputeExpandedCardMaxHeightDip());
|
||||
}
|
||||
else
|
||||
{
|
||||
RootElement.SetCardMaxHeight(double.PositiveInfinity);
|
||||
}
|
||||
}
|
||||
|
||||
// Computes how tall (in DIPs) the visible card may grow before it would extend past the
|
||||
// bottom of the work area, given the card's current top on screen.
|
||||
private double ComputeExpandedCardMaxHeightDip()
|
||||
{
|
||||
var dpi = (int)this.GetDpiForWindow();
|
||||
var scale = dpi / 96.0;
|
||||
|
||||
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest);
|
||||
var workArea = displayArea.WorkArea;
|
||||
|
||||
var padding = RootElement.ShadowPadding;
|
||||
var cardTopPhysical = AppWindow.Position.Y + (padding.Top * scale);
|
||||
var availablePhysical = (workArea.Y + workArea.Height) - cardTopPhysical - (padding.Bottom * scale);
|
||||
|
||||
if (availablePhysical <= 0)
|
||||
{
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
return availablePhysical / scale;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ GetForegroundWindow
|
||||
SetForegroundWindow
|
||||
GetWindowRect
|
||||
GetCursorPos
|
||||
WindowFromPoint
|
||||
GetAncestor
|
||||
GET_ANCESTOR_FLAGS
|
||||
SetWindowPos
|
||||
HWND_TOPMOST
|
||||
HWND_BOTTOM
|
||||
@@ -24,6 +27,8 @@ GetMonitorInfo
|
||||
GetDpiForMonitor
|
||||
WM_HOTKEY
|
||||
WM_NCLBUTTONDBLCLK
|
||||
SetWindowRgn
|
||||
CreateRectRgn
|
||||
|
||||
Shell_NotifyIcon
|
||||
LoadIcon
|
||||
@@ -70,10 +75,13 @@ MoveWindow
|
||||
GetSystemMetrics
|
||||
SHAppBarMessage
|
||||
ABM_NEW
|
||||
ABM_GETSTATE
|
||||
ABM_GETTASKBARPOS
|
||||
ABM_QUERYPOS
|
||||
ABM_SETPOS
|
||||
ABM_REMOVE
|
||||
ABM_SETAUTOHIDEBAR
|
||||
ABM_SETAUTOHIDEBAREX
|
||||
ABS_AUTOHIDE
|
||||
ABN_POSCHANGED
|
||||
ABN_FULLSCREENAPP
|
||||
@@ -86,7 +94,16 @@ SYSTEM_METRICS_INDEX
|
||||
GetDpiForWindow
|
||||
SHQueryUserNotificationState
|
||||
SYSTEM_PARAMETERS_INFO_ACTION
|
||||
SystemParametersInfo
|
||||
WINDOWPOS
|
||||
WM_MOUSEMOVE
|
||||
WM_MOUSELEAVE
|
||||
TrackMouseEvent
|
||||
TRACKMOUSEEVENT
|
||||
TRACKMOUSEEVENT_FLAGS
|
||||
WM_ACTIVATE
|
||||
WM_ACTIVATEAPP
|
||||
WA_INACTIVE
|
||||
WM_DISPLAYCHANGE
|
||||
WM_SYSCOMMAND
|
||||
WM_SETTINGCHANGE
|
||||
@@ -105,6 +122,19 @@ SIZE_MINIMIZED
|
||||
HWND_NOTOPMOST
|
||||
HWND_TOP
|
||||
HTCAPTION
|
||||
HTCLIENT
|
||||
HTTRANSPARENT
|
||||
HTNOWHERE
|
||||
HTLEFT
|
||||
HTRIGHT
|
||||
HTTOP
|
||||
HTBOTTOM
|
||||
HTTOPLEFT
|
||||
HTTOPRIGHT
|
||||
HTBOTTOMLEFT
|
||||
HTBOTTOMRIGHT
|
||||
WM_NCHITTEST
|
||||
WM_NCCALCSIZE
|
||||
GetClassName
|
||||
EVENT_SYSTEM_FOREGROUND
|
||||
WINEVENT_OUTOFCONTEXT
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
<cmdpalUI:DetailsSizeToGridLengthConverter x:Key="SizeToWidthConverter" />
|
||||
<cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" />
|
||||
<cmdpalUI:BoolToStarOrAutoGridLengthConverter x:Key="ExpandedModeToRowHeightConverter" />
|
||||
|
||||
<cmdpalUI:DetailsDataTemplateSelector
|
||||
x:Key="DetailsDataTemplateSelector"
|
||||
@@ -183,15 +184,19 @@
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<!--
|
||||
In compact mode this row collapses to Auto so the card can shrink to just the
|
||||
search box. A star row would otherwise reserve space during measure even when
|
||||
its only child (the collapsed content) is hidden.
|
||||
-->
|
||||
<RowDefinition Height="{x:Bind ExpandedMode, Mode=OneWay, Converter={StaticResource ExpandedModeToRowHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Back button and search box -->
|
||||
@@ -383,6 +388,18 @@
|
||||
</animations:Implicit.HideAnimations>
|
||||
</ProgressBar>
|
||||
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"
|
||||
Visibility="{x:Bind ExpandedMode, Mode=OneWay}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid x:Name="ContentGrid" Grid.Row="1">
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -516,12 +533,13 @@
|
||||
See https://github.com/microsoft/microsoft-ui-xaml/issues/5741
|
||||
-->
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Grid.Row="1"
|
||||
Margin="16,8,16,8"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
|
||||
CornerRadius="{ThemeResource ControlCornerRadius}">
|
||||
CornerRadius="{ThemeResource ControlCornerRadius}"
|
||||
Visibility="Collapsed">
|
||||
<InfoBar
|
||||
CornerRadius="{ThemeResource ControlCornerRadius}"
|
||||
IsOpen="{x:Bind ViewModel.CurrentPage.HasStatusMessage, Mode=OneWay}"
|
||||
@@ -540,10 +558,11 @@
|
||||
</StackPanel>
|
||||
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
Grid.Row="2"
|
||||
Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}"
|
||||
BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,1,0,0">
|
||||
BorderThickness="0,1,0,0"
|
||||
Visibility="{x:Bind ExpandedMode, Mode=OneWay}">
|
||||
<cpcontrols:CommandBar CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
IRecipient<NavigateToPageMessage>,
|
||||
IRecipient<ShowHideDockMessage>,
|
||||
IRecipient<ShowPinToDockDialogMessage>,
|
||||
IRecipient<ExpandCompactModeMessage>,
|
||||
INotifyPropertyChanged,
|
||||
IDisposable
|
||||
{
|
||||
@@ -71,6 +72,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
private CancellationTokenSource? _focusAfterLoadedCts;
|
||||
private WeakReference<Page>? _lastNavigatedPageRef;
|
||||
|
||||
// When the shell goes from compact (collapsed) to expanded, the content frame's page
|
||||
// — which was collapsed and therefore never laid out — finally fires its Loaded event.
|
||||
// That late Loaded would otherwise run the post-navigation focus/select logic and
|
||||
// select-all the character the user just typed (which triggered the expand). This
|
||||
// one-shot flag suppresses that select for the expand-driven load.
|
||||
private bool _suppressSelectOnNextLoad;
|
||||
private bool _isDisposed;
|
||||
|
||||
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
|
||||
@@ -79,8 +87,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
public IHostWindow? HostWindow { get; set; }
|
||||
|
||||
public bool ExpandedMode { get; set; }
|
||||
|
||||
public ShellPage()
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
this.ExpandedMode = !settings.CompactMode;
|
||||
|
||||
this.InitializeComponent();
|
||||
|
||||
// how we are doing navigation around
|
||||
@@ -104,6 +117,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
WeakReferenceMessenger.Default.Register<ShowHideDockMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<ShowPinToDockDialogMessage>(this);
|
||||
|
||||
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
|
||||
|
||||
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
|
||||
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
|
||||
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
|
||||
@@ -482,6 +497,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
}
|
||||
}
|
||||
|
||||
// When re-showing the palette, the previous session's query may still be present
|
||||
// (e.g. after a light dismiss with HighlightSearchOnActivate). Recompute the
|
||||
// compact/expanded state so a retained query restores the expanded results instead
|
||||
// of being stuck in the collapsed search-only layout.
|
||||
UpdateCompactModeForCurrentPage();
|
||||
|
||||
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
|
||||
}
|
||||
|
||||
@@ -593,6 +614,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
|
||||
{
|
||||
// A real navigation always loads a fresh page that we do want to focus/select, so
|
||||
// clear any stale suppression left over from a prior compact expand. (If this
|
||||
// navigation itself expands compact mode, UpdateCompactModeForCurrentPage below
|
||||
// will re-arm the flag for the page that's about to load.)
|
||||
_suppressSelectOnNextLoad = false;
|
||||
|
||||
// This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter.
|
||||
// This is currently used for both forward and backward navigation.
|
||||
// As when we go back that we restore ourselves to the proper state within our VM
|
||||
@@ -629,6 +656,42 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
_lastNavigatedPageRef = new WeakReference<Page>(element);
|
||||
element.Loaded += FocusAfterLoaded;
|
||||
}
|
||||
|
||||
UpdateCompactModeForCurrentPage();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the compact/expanded state after a navigation. On any nested (sub) page we
|
||||
/// always show the full expanded UI; on the root page the search box drives the state,
|
||||
/// so we collapse to the compact search box only when the query is empty. Driving this
|
||||
/// from navigation (rather than only from search-text changes) makes alias-based
|
||||
/// navigation expand correctly — an alias clears the search box before navigating, so
|
||||
/// the search-text transition alone would otherwise leave the palette collapsed.
|
||||
/// Transient pages always show the expanded UI, ignoring the compact setting entirely.
|
||||
/// </summary>
|
||||
private void UpdateCompactModeForCurrentPage()
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
if (!settings.CompactMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Transient pages ignore compact mode and always present as expanded.
|
||||
if (ViewModel.IsTransient)
|
||||
{
|
||||
HandleExpandCompactOnUiThread(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// The ShellViewModel's IsNested flag is only updated on forward navigation and is
|
||||
// never cleared when navigating back to the root page. Gate it on the current
|
||||
// page's own root-ness so a stale IsNested can't keep the home page expanded after
|
||||
// returning to it (e.g. after following a 1-character alias and going back).
|
||||
var isRootPage = ViewModel.CurrentPage?.IsRootPage ?? false;
|
||||
var nested = ViewModel.IsNested && !isRootPage;
|
||||
var hasQuery = !string.IsNullOrEmpty(ViewModel.CurrentPage?.SearchTextBox);
|
||||
HandleExpandCompactOnUiThread(nested || hasQuery);
|
||||
}
|
||||
|
||||
private void FocusAfterLoaded(object sender, RoutedEventArgs e)
|
||||
@@ -661,6 +724,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
return;
|
||||
}
|
||||
|
||||
// This Loaded can fire late when expanding out of compact mode (the page was
|
||||
// collapsed and never laid out). In that case the user is mid-typing in the
|
||||
// already-focused search box, so don't steal focus / select-all their input.
|
||||
if (_suppressSelectOnNextLoad)
|
||||
{
|
||||
_suppressSelectOnNextLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
SearchBox.Focus(FocusState.Programmatic);
|
||||
SearchBox.SelectSearch();
|
||||
}
|
||||
@@ -854,6 +926,50 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(ExpandCompactModeMessage message)
|
||||
{
|
||||
// Re-evaluate from the current authoritative page state rather than applying the
|
||||
// message's snapshot directly. The message can race with navigation: following a
|
||||
// 1-character alias clears the home search (sending a "collapse") right as we
|
||||
// navigate to a nested page that must stay expanded. Recomputing here keeps the
|
||||
// final state consistent regardless of message/navigation ordering.
|
||||
this.DispatcherQueue.TryEnqueue(UpdateCompactModeForCurrentPage);
|
||||
}
|
||||
|
||||
private void HandleExpandCompactOnUiThread(bool expanded)
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
var newExpanded = settings.CompactMode ? expanded : true;
|
||||
|
||||
// Going from collapsed to expanded realizes the (previously collapsed) content
|
||||
// page for the first time, which fires its deferred Loaded event. Suppress the
|
||||
// resulting focus/select so we don't select-all the character the user just typed.
|
||||
if (!this.ExpandedMode && newExpanded)
|
||||
{
|
||||
_suppressSelectOnNextLoad = true;
|
||||
}
|
||||
|
||||
this.ExpandedMode = newExpanded;
|
||||
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the shell into its compact (collapsed) layout and flushes layout so the host can
|
||||
/// read the resulting card height. Only has an effect when compact mode is enabled.
|
||||
/// </summary>
|
||||
public void EnsureCompactLayout()
|
||||
{
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
if (!settings.CompactMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.ExpandedMode = false;
|
||||
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
|
||||
this.UpdateLayout();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
|
||||
@@ -249,6 +249,16 @@
|
||||
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_DockSettingsPage_AlwaysOnTop" IsOn="{x:Bind ViewModel.Dock_AlwaysOnTop, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard x:Uid="DockBehavior_AutoHide_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_DockSettingsPage_AutoHide" IsOn="{x:Bind ViewModel.Dock_AutoHide, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<InfoBar
|
||||
x:Uid="DockBehavior_AutoHideConflict_InfoBar"
|
||||
IsClosable="True"
|
||||
IsOpen="{x:Bind ViewModel.Dock_AutoHideConflict, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
|
||||
<!-- Monitors Section -->
|
||||
<TextBlock x:Uid="DockMonitors_Header" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
Grid.Row="3"
|
||||
Padding="0"
|
||||
AutomationProperties.AutomationId="CmdPal_GalleryItemPage_ViewRepository"
|
||||
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
|
||||
NavigateUri="{x:Bind ViewModel.HomepageUri, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="{x:Bind ViewModel.Homepage, Mode=OneWay}"
|
||||
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasHomepage), Mode=OneWay, FallbackValue=Collapsed}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
@@ -332,7 +332,7 @@
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="232" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock
|
||||
@@ -365,6 +365,7 @@
|
||||
<SolidColorBrush x:Key="ItemContainerBackgroundPressed" Color="Transparent" />
|
||||
</ItemContainer.Resources>
|
||||
<Border
|
||||
Width="356"
|
||||
Height="200"
|
||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="Microsoft.CmdPal.UI.Settings.GeneralPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
@@ -119,6 +119,29 @@
|
||||
</ComboBox>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsExpander x:Uid="Settings_GeneralPage_CompactMode_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_GeneralPage_CompactMode" IsOn="{x:Bind viewModel.CompactMode, Mode=TwoWay}" />
|
||||
<controls:SettingsExpander.Items>
|
||||
<controls:SettingsCard
|
||||
x:Uid="Settings_GeneralPage_CompactCenterHeight_SettingsCard"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsEnabled="{x:Bind viewModel.CompactMode, Mode=OneWay}">
|
||||
<Slider
|
||||
Width="100"
|
||||
Height="100"
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
AutomationProperties.AutomationId="CmdPal_GeneralPage_CompactCenterHeight"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Orientation="Vertical"
|
||||
StepFrequency="5"
|
||||
TickFrequency="25"
|
||||
TickPlacement="Outside"
|
||||
Value="{x:Bind viewModel.CompactCenterHeightPercentage, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
</controls:SettingsExpander.Items>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<!-- 'Behavior' section -->
|
||||
|
||||
<TextBlock x:Uid="BehaviorSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
Click="ToggleDevRibbonClicked"
|
||||
Content="Toggle dev ribbon" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard
|
||||
x:Name="ShowHwndFrameSettingsCard"
|
||||
Description="Shows the OS-drawn title bar, border, and rounded corners on the Command Palette's HWND so its actual bounds are visible. Always off in CI / release builds."
|
||||
Header="Show HWND frame"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch
|
||||
x:Name="ShowHwndFrameToggle"
|
||||
AutomationProperties.AutomationId="CmdPal_InternalPage_ShowHwndFrame"
|
||||
IsOn="{x:Bind ShowHwndFrame, Mode=OneTime}"
|
||||
Toggled="ShowHwndFrameToggle_Toggled" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- Gallery Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Extension Gallery" />
|
||||
|
||||
@@ -26,6 +26,8 @@ public sealed partial class InternalPage : Page
|
||||
|
||||
public string GalleryFeedUrl => _settingsService.Settings.GalleryFeedUrl ?? string.Empty;
|
||||
|
||||
public bool ShowHwndFrame => _settingsService.Settings.ShowHwndFrame;
|
||||
|
||||
public InternalPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -120,4 +122,16 @@ public sealed partial class InternalPage : Page
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage());
|
||||
}
|
||||
|
||||
private void ShowHwndFrameToggle_Toggled(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is ToggleSwitch toggle)
|
||||
{
|
||||
var newValue = toggle.IsOn;
|
||||
if (newValue != _settingsService.Settings.ShowHwndFrame)
|
||||
{
|
||||
_settingsService.UpdateSettings(s => s with { ShowHwndFrame = newValue });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +160,24 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageType is not null)
|
||||
if (pageType is null)
|
||||
{
|
||||
NavFrame.Navigate(pageType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now, make sure to actually select the correct menu item too
|
||||
foreach (var obj in NavView.MenuItems)
|
||||
if (NavFrame.Content?.GetType() == pageType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NavFrame.Navigate(pageType);
|
||||
|
||||
// Now, make sure to actually select the correct menu item too
|
||||
foreach (var obj in NavView.MenuItems)
|
||||
{
|
||||
if (obj is NavigationViewItem item && item.Tag is string s && s == page)
|
||||
{
|
||||
if (obj is NavigationViewItem item)
|
||||
{
|
||||
if (item.Tag is string s && s == page)
|
||||
{
|
||||
NavView.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
NavView.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +380,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_GeneralPage_HighlightSearch_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Selects the previous search text at launch</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_CompactMode_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Compact mode</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_CompactMode_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Shrinks the palette to just the search box until you start typing</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_CompactCenterHeight_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Search box position</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_CompactCenterHeight_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Relative height from the bottom of the screen where the collapsed search box is centered. Only applies in compact mode.</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_KeepPreviousQuery_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Keep previous query</value>
|
||||
</data>
|
||||
@@ -476,6 +488,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="DockBehavior_AlwaysOnTop_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Keep the dock above other windows, except while an app is fullscreen</value>
|
||||
</data>
|
||||
<data name="DockBehavior_AutoHide_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Auto-hide dock</value>
|
||||
</data>
|
||||
<data name="DockBehavior_AutoHide_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Collapse the dock until you hover over its screen edge</value>
|
||||
</data>
|
||||
<data name="DockBehavior_AutoHideConflict_InfoBar.Title" xml:space="preserve">
|
||||
<value>Auto-hide unavailable</value>
|
||||
</data>
|
||||
<data name="DockBehavior_AutoHideConflict_InfoBar.Message" xml:space="preserve">
|
||||
<value>The taskbar or another application is using auto-hide on this edge. The dock is using pinned mode instead.</value>
|
||||
</data>
|
||||
<data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
@@ -535,6 +559,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="ScreenReader_Announcement_NavigatedToPage0" xml:space="preserve">
|
||||
<value>Navigated to {0} page</value>
|
||||
</data>
|
||||
<data name="ScreenReader_Announcement_ContextMenuOpened" xml:space="preserve">
|
||||
<value>Menu, {0} commands. {1}, {2} of {0}.</value>
|
||||
</data>
|
||||
<data name="SettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Settings (Ctrl+,)</value>
|
||||
</data>
|
||||
|
||||
@@ -4,13 +4,23 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:common="using:Microsoft.PowerToys.Common.UI.Controls.Window"
|
||||
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:Microsoft.CmdPal.UI"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
<TextBlock
|
||||
Margin="16,10,20,12"
|
||||
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<controls:TransientSurface
|
||||
x:Name="Surface"
|
||||
MaxWidth="560"
|
||||
Margin="24,24,24,16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
HideTransition="Bottom"
|
||||
ShowTransition="Bottom">
|
||||
<TextBlock
|
||||
Margin="16,10,20,12"
|
||||
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:TransientSurface>
|
||||
</common:TransparentWindow>
|
||||
|
||||
@@ -17,11 +17,14 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
|
||||
namespace Microsoft.CmdPal.UI;
|
||||
|
||||
/// <summary>
|
||||
/// CmdPal's transient toast banner. Inherits all of its chrome, click-through,
|
||||
/// acrylic, and fade/slide animations from
|
||||
/// <see cref="TransparentWindow"/>; adds only the bits that are bespoke to
|
||||
/// CmdPal toasts: a bound message <c>TextBlock</c>, a 2.5 s auto-dismiss timer,
|
||||
/// bottom-center positioning, and <see cref="QuitMessage"/> handling.
|
||||
/// CmdPal's transient toast notification. It is a bare
|
||||
/// <see cref="TransparentWindow"/> host whose content is a
|
||||
/// <see cref="Microsoft.PowerToys.Common.UI.Controls.TransientSurface"/> — the
|
||||
/// surface supplies the acrylic, border, corners, shadow, and the fade/slide
|
||||
/// animation, driven automatically off the window's show/hide events. This class
|
||||
/// adds only the bits bespoke to CmdPal toasts: a bound message <c>TextBlock</c>,
|
||||
/// a 2.5 s auto-dismiss timer, bottom-center positioning, and
|
||||
/// <see cref="QuitMessage"/> handling.
|
||||
/// </summary>
|
||||
public sealed partial class ToastWindow : TransparentWindow,
|
||||
IRecipient<QuitMessage>
|
||||
@@ -39,13 +42,10 @@ public sealed partial class ToastWindow : TransparentWindow,
|
||||
AppWindow.Title = RS_.GetString("ToastWindowTitle");
|
||||
this.SetWindowSize(600, 180);
|
||||
|
||||
// Pin the chrome card to bottom-center with the toast's classic 560-wide
|
||||
// pill shape. The window itself stays 600x180 so the slide animations
|
||||
// have headroom and we don't have to chase SizeToContent.
|
||||
Card.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center;
|
||||
Card.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom;
|
||||
Card.MaxWidth = 560;
|
||||
Card.Margin = new Microsoft.UI.Xaml.Thickness(24, 24, 24, 16);
|
||||
// Let the surface animate itself in/out in response to this window's
|
||||
// Show()/Hide(). The 600x180 window leaves the bottom-center 560-wide
|
||||
// pill (positioned in XAML) room for its slide + shadow.
|
||||
Surface.SubscribeTo(this);
|
||||
|
||||
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
|
||||
}
|
||||
|
||||
@@ -324,6 +324,53 @@ public class DockMultiMonitorTests
|
||||
Assert.AreEqual("c1", deserialized.StartBands![0].CommandId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DockSettings_AutoHide_DefaultsToFalse()
|
||||
{
|
||||
var settings = CreateMinimalDockSettings();
|
||||
|
||||
Assert.IsFalse(settings.AutoHide);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DockSettings_AutoHide_JsonRoundTrip_PreservesValue()
|
||||
{
|
||||
var settings = CreateMinimalDockSettings() with
|
||||
{
|
||||
AutoHide = true,
|
||||
MonitorConfigs = ImmutableList.Create(
|
||||
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
|
||||
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.DockSettings);
|
||||
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings);
|
||||
|
||||
Assert.IsNotNull(deserialized);
|
||||
Assert.IsTrue(deserialized!.AutoHide);
|
||||
Assert.AreEqual(2, deserialized.MonitorConfigs.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DockSettings_WithUpdatedMonitorConfigs_PreservesAutoHide()
|
||||
{
|
||||
var settings = CreateMinimalDockSettings() with
|
||||
{
|
||||
AutoHide = true,
|
||||
MonitorConfigs = ImmutableList.Create(
|
||||
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
|
||||
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
|
||||
};
|
||||
|
||||
var updated = settings with
|
||||
{
|
||||
MonitorConfigs = settings.MonitorConfigs.SetItem(1, settings.MonitorConfigs[1] with { Enabled = true }),
|
||||
};
|
||||
|
||||
Assert.IsTrue(updated.AutoHide);
|
||||
Assert.IsTrue(updated.MonitorConfigs[1].Enabled);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DockSettings_MonitorConfigs_JsonRoundTrip()
|
||||
{
|
||||
@@ -719,7 +766,7 @@ public class DockMultiMonitorTests
|
||||
|
||||
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
|
||||
|
||||
// Phase 1.5 should detect GDI-style names and rewrite to stable IDs
|
||||
// Legacy migration should detect GDI-style names and rewrite to stable IDs
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId, "Primary should be migrated to stable ID");
|
||||
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId, "Secondary should be migrated to stable ID");
|
||||
|
||||
@@ -119,6 +119,7 @@ public class ExtensionGalleryItemViewModelTests
|
||||
var viewModel = CreateViewModel(entry);
|
||||
|
||||
Assert.IsFalse(viewModel.HasHomepage);
|
||||
Assert.IsNull(viewModel.HomepageUri);
|
||||
Assert.IsFalse(viewModel.HasAuthorUrl);
|
||||
Assert.IsFalse(viewModel.HasUrlSource);
|
||||
Assert.IsFalse(viewModel.HasActionableSourceDetails);
|
||||
@@ -131,6 +132,32 @@ public class ExtensionGalleryItemViewModelTests
|
||||
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_SetsHomepageUri_WhenHomepageIsWebUri()
|
||||
{
|
||||
var entry = CreateEntry(iconUrl: null);
|
||||
entry.Homepage = "https://example.com/extension";
|
||||
|
||||
var viewModel = CreateViewModel(entry);
|
||||
|
||||
Assert.IsTrue(viewModel.HasHomepage);
|
||||
Assert.AreEqual(new Uri("https://example.com/extension"), viewModel.HomepageUri);
|
||||
Assert.IsTrue(viewModel.OpenHomepageCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_LeavesHomepageUriNull_WhenHomepageIsMissing()
|
||||
{
|
||||
var entry = CreateEntry(iconUrl: null);
|
||||
entry.Homepage = null;
|
||||
|
||||
var viewModel = CreateViewModel(entry);
|
||||
|
||||
Assert.IsFalse(viewModel.HasHomepage);
|
||||
Assert.IsNull(viewModel.HomepageUri);
|
||||
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
|
||||
{
|
||||
|
||||
@@ -126,4 +126,28 @@ public class BasicTests : CommandPaletteTestBase
|
||||
|
||||
Assert.IsNotNull(this.Find<NavigationViewItem>("Put computer to sleep"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DockSettingsAutoHideToggleTest()
|
||||
{
|
||||
OpenSettingsWindow();
|
||||
NavigateToDockSettings();
|
||||
|
||||
var autoHideToggle = FindDockAutoHideToggle();
|
||||
Assert.IsNotNull(autoHideToggle);
|
||||
|
||||
var initialState = autoHideToggle.IsOn;
|
||||
autoHideToggle.Toggle(!initialState);
|
||||
Assert.AreEqual(!initialState, autoHideToggle.IsOn);
|
||||
|
||||
this.Find<NavigationViewItem>("General").Click();
|
||||
NavigateToDockSettings();
|
||||
|
||||
autoHideToggle = FindDockAutoHideToggle();
|
||||
Assert.IsNotNull(autoHideToggle);
|
||||
Assert.AreEqual(!initialState, autoHideToggle.IsOn);
|
||||
|
||||
autoHideToggle.Toggle(initialState);
|
||||
Assert.AreEqual(initialState, autoHideToggle.IsOn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,18 @@ public class CommandPaletteTestBase : UITestBase
|
||||
|
||||
protected void SetTimeAndDaterExtensionSearchBox(string text) => SetSearchBoxText(text);
|
||||
|
||||
protected void OpenSettingsWindow()
|
||||
{
|
||||
this.Find<Button>(By.AccessibilityId("SettingsIconButton")).Click();
|
||||
}
|
||||
|
||||
protected void NavigateToDockSettings()
|
||||
{
|
||||
this.Find<NavigationViewItem>("Dock (Preview)").Click();
|
||||
}
|
||||
|
||||
protected ToggleSwitch FindDockAutoHideToggle() => this.Find<ToggleSwitch>(By.AccessibilityId("CmdPal_DockSettingsPage_AutoHide"));
|
||||
|
||||
private void SetSearchBoxText(string text)
|
||||
{
|
||||
Assert.AreEqual(this.Find<TextBox>(By.AccessibilityId("MainSearchBox")).SetText(text, true).Text, text);
|
||||
|
||||
@@ -134,6 +134,25 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
if (isBandPage)
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage.Commands,
|
||||
};
|
||||
}
|
||||
|
||||
_networkPage.Updated += (s, e) =>
|
||||
{
|
||||
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
|
||||
@@ -253,22 +272,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
}
|
||||
else
|
||||
{
|
||||
_networkUpItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkUpSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
|
||||
Icon = Icons.NetworkUpIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
_networkDownItem = new ListItem(_networkPage!)
|
||||
{
|
||||
Title = $"{_networkDownSpeed}",
|
||||
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
|
||||
Icon = Icons.NetworkDownIcon,
|
||||
MoreCommands = _networkPage!.Commands,
|
||||
};
|
||||
|
||||
return _batteryItem is not null
|
||||
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
|
||||
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 328 B After Width: | Height: | Size: 2.7 KiB |
@@ -122,6 +122,21 @@ namespace KeyboardEventHandlers
|
||||
key_count = std::get<Shortcut>(it->second).Size();
|
||||
}
|
||||
|
||||
const DWORD sourceKey = data->lParam->vkCode;
|
||||
const bool isKeyUp = (data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP);
|
||||
|
||||
// If the matching key-down injection was blocked earlier, we passed the
|
||||
// original key-down through to the foreground app to keep the key alive.
|
||||
// The corresponding key-up must be passed through as well; otherwise the
|
||||
// physical key is stranded DOWN (its down reached the app, but its up would
|
||||
// be swallowed by the remap). Key-down and key-up arrive as separate hook
|
||||
// events, so this is the cross-invocation counterpart of the key-down
|
||||
// passthrough handled below.
|
||||
if (isKeyUp && state.ConsumeSingleKeyRemapInjectionFailed(sourceKey))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<INPUT> keyEventList;
|
||||
|
||||
// Handle remaps to VK_WIN_BOTH
|
||||
@@ -177,7 +192,25 @@ namespace KeyboardEventHandlers
|
||||
}
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
// Injection was blocked (e.g. by UIPI). Return 0 so the ORIGINAL key is
|
||||
// passed through instead of being swallowed, leaving no dead key. For a
|
||||
// key-down, remember that we passed it through so the matching key-up is
|
||||
// passed through too (handled above), preventing a key stranded DOWN.
|
||||
if (!isKeyUp)
|
||||
{
|
||||
state.SetSingleKeyRemapInjectionFailed(sourceKey, true);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Injection succeeded; drop any stale passthrough marker for this key so its
|
||||
// key-up follows the normal (suppressed) path.
|
||||
if (!isKeyUp)
|
||||
{
|
||||
state.SetSingleKeyRemapInjectionFailed(sourceKey, false);
|
||||
}
|
||||
|
||||
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
|
||||
{
|
||||
@@ -552,9 +585,12 @@ namespace KeyboardEventHandlers
|
||||
|
||||
// Send modifier release events first, then send text directly
|
||||
// (SendTextInput handles multiline by flushing between chunks)
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
keyEventList.clear();
|
||||
Helpers::SendTextInput(remapping);
|
||||
Helpers::SendTextInput(remapping, ii);
|
||||
}
|
||||
|
||||
it->second.isShortcutInvoked = true;
|
||||
@@ -566,7 +602,10 @@ namespace KeyboardEventHandlers
|
||||
|
||||
Logger::trace(L"ChordKeyboardHandler:keyEventList.size:{}", keyEventList.size());
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (activatedApp.has_value())
|
||||
{
|
||||
if (remapToKey)
|
||||
@@ -705,7 +744,10 @@ namespace KeyboardEventHandlers
|
||||
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -735,12 +777,14 @@ namespace KeyboardEventHandlers
|
||||
else if (remapToText)
|
||||
{
|
||||
auto& remapping = std::get<std::wstring>(it->second.targetShortcut);
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
Helpers::SendTextInput(remapping);
|
||||
Helpers::SendTextInput(remapping, ii);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -827,7 +871,10 @@ namespace KeyboardEventHandlers
|
||||
}
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -952,7 +999,10 @@ namespace KeyboardEventHandlers
|
||||
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
@@ -1021,7 +1071,10 @@ namespace KeyboardEventHandlers
|
||||
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
|
||||
}
|
||||
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
if (!ii.SendVirtualInput(keyEventList))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
@@ -1799,8 +1852,9 @@ namespace KeyboardEventHandlers
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only send the text on keydown event
|
||||
if (data->wParam != WM_KEYDOWN)
|
||||
// Only send the text on key-down events. WM_SYSKEYDOWN is sent instead of
|
||||
// WM_KEYDOWN while Alt is held, so accept it too or the remap silently drops.
|
||||
if (data->wParam != WM_KEYDOWN && data->wParam != WM_SYSKEYDOWN)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
@@ -1811,7 +1865,43 @@ namespace KeyboardEventHandlers
|
||||
return 0;
|
||||
}
|
||||
|
||||
Helpers::SendTextInput(*remapping);
|
||||
// Release held modifiers before text injection to prevent Ctrl+text corruption
|
||||
constexpr int modifierKeys[] = { VK_LCONTROL, VK_RCONTROL, VK_LSHIFT, VK_RSHIFT, VK_LMENU, VK_RMENU, VK_LWIN, VK_RWIN };
|
||||
std::vector<INPUT> releaseEvents;
|
||||
|
||||
// A dummy key event must precede the modifier releases so that releasing a
|
||||
// held Win (Start Menu) or Alt (menu bar) does not trigger its lone-press
|
||||
// action when we inject the modifier key-up.
|
||||
Helpers::SetDummyKeyEvent(releaseEvents, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
|
||||
|
||||
bool anyModifierHeld = false;
|
||||
for (int vk : modifierKeys)
|
||||
{
|
||||
if (ii.GetVirtualKeyState(vk))
|
||||
{
|
||||
Helpers::SetKeyEvent(releaseEvents, INPUT_KEYBOARD, static_cast<WORD>(vk), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
|
||||
anyModifierHeld = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only inject the dummy + modifier releases when a modifier was actually held.
|
||||
if (anyModifierHeld)
|
||||
{
|
||||
if (!ii.SendVirtualInput(releaseEvents))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Helpers::SendTextInput(*remapping, ii);
|
||||
|
||||
// Intentionally do NOT re-press the released modifiers. Once we inject a
|
||||
// KEYUP for a modifier, GetAsyncKeyState (and therefore GetVirtualKeyState)
|
||||
// reports it as up, so there is no reliable way to tell whether the user is
|
||||
// still physically holding the key or has released it. Re-pressing
|
||||
// unconditionally would risk leaving a modifier stuck down if the user let
|
||||
// go during injection — the exact failure this change set prevents. Leaving
|
||||
// the modifier released is always safe: the user taps it again to re-engage.
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -73,3 +73,20 @@ std::wstring State::GetActivatedApp()
|
||||
{
|
||||
return activatedAppSpecificShortcutTarget;
|
||||
}
|
||||
|
||||
void State::SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed)
|
||||
{
|
||||
if (failed)
|
||||
{
|
||||
singleKeyRemapInjectionFailedKeys.insert(sourceKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
singleKeyRemapInjectionFailedKeys.erase(sourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
bool State::ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey)
|
||||
{
|
||||
return singleKeyRemapInjectionFailedKeys.erase(sourceKey) > 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <keyboardmanager/common/MappingConfiguration.h>
|
||||
#include <unordered_set>
|
||||
|
||||
class State : public MappingConfiguration
|
||||
{
|
||||
@@ -7,6 +8,12 @@ private:
|
||||
// Stores the activated target application in app-specific shortcut
|
||||
std::wstring activatedAppSpecificShortcutTarget;
|
||||
|
||||
// Source keys whose single-key remap key-down injection was blocked, so the original
|
||||
// key-down was passed through to the foreground app. The matching key-up must be
|
||||
// passed through too; otherwise the physical key is stranded DOWN. Only accessed from
|
||||
// the (serialized) low-level keyboard hook thread.
|
||||
std::unordered_set<DWORD> singleKeyRemapInjectionFailedKeys;
|
||||
|
||||
public:
|
||||
// Function to get the iterator of a single key remap given the source key. Returns nullopt if it isn't remapped
|
||||
std::optional<SingleKeyRemapTable::iterator> GetSingleKeyRemap(const DWORD& originalKey);
|
||||
@@ -26,4 +33,14 @@ public:
|
||||
|
||||
// Gets the activated target application in app-specific shortcut
|
||||
std::wstring GetActivatedApp();
|
||||
|
||||
// Records (failed == true) or clears (failed == false) that the single-key remap
|
||||
// key-down injection for sourceKey was blocked and the original key-down was passed
|
||||
// through to the foreground app.
|
||||
void SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed);
|
||||
|
||||
// Returns true and clears the marker if sourceKey's single-key remap key-down
|
||||
// injection was previously blocked, indicating that its key-up should be passed
|
||||
// through as well.
|
||||
bool ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey);
|
||||
};
|
||||
@@ -10,8 +10,14 @@ void MockedInput::SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> ho
|
||||
}
|
||||
|
||||
// Function to simulate keyboard input - arguments and return value based on SendInput function (https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-sendinput)
|
||||
void MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
|
||||
bool MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
|
||||
{
|
||||
// Simulate an injection failure (e.g. SendInput blocked) when configured.
|
||||
if (sendVirtualInputShouldFail != nullptr && sendVirtualInputShouldFail(inputs))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Iterate over inputs
|
||||
for (const INPUT& input : inputs)
|
||||
{
|
||||
@@ -107,6 +113,7 @@ void MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function to simulate keyboard hook behavior
|
||||
@@ -129,6 +136,12 @@ bool MockedInput::GetVirtualKeyState(int key)
|
||||
return keyboardState[key];
|
||||
}
|
||||
|
||||
// Function to set the state of a particular key for test setup
|
||||
void MockedInput::SetKeyboardState(int key, bool state)
|
||||
{
|
||||
keyboardState[key] = state;
|
||||
}
|
||||
|
||||
// Function to reset the mocked keyboard state
|
||||
void MockedInput::ResetKeyboardState()
|
||||
{
|
||||
@@ -142,6 +155,12 @@ void MockedInput::SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyb
|
||||
sendVirtualInputCallCondition = condition;
|
||||
}
|
||||
|
||||
// Function to force SendVirtualInput to fail for calls matching a predicate
|
||||
void MockedInput::SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition)
|
||||
{
|
||||
sendVirtualInputShouldFail = condition;
|
||||
}
|
||||
|
||||
// Function to get SendVirtualInput call count
|
||||
int MockedInput::GetSendVirtualInputCallCount()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,10 @@ namespace KeyboardManagerInput
|
||||
int sendVirtualInputCallCount = 0;
|
||||
std::function<bool(LowlevelKeyboardEvent*)> sendVirtualInputCallCondition;
|
||||
|
||||
// Optional predicate; when set and it returns true for a SendVirtualInput
|
||||
// call, that call fails (returns false) to simulate a SendInput failure.
|
||||
std::function<bool(const std::vector<INPUT>&)> sendVirtualInputShouldFail;
|
||||
|
||||
std::wstring currentProcess;
|
||||
|
||||
public:
|
||||
@@ -34,7 +38,7 @@ namespace KeyboardManagerInput
|
||||
void SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> hookProcedure);
|
||||
|
||||
// Function to simulate keyboard input
|
||||
void SendVirtualInput(const std::vector<INPUT>& inputs);
|
||||
bool SendVirtualInput(const std::vector<INPUT>& inputs);
|
||||
|
||||
// Function to simulate keyboard hook behavior
|
||||
intptr_t MockedKeyboardHook(LowlevelKeyboardEvent* data);
|
||||
@@ -42,12 +46,18 @@ namespace KeyboardManagerInput
|
||||
// Function to get the state of a particular key
|
||||
bool GetVirtualKeyState(int key);
|
||||
|
||||
// Function to set the state of a particular key for test setup
|
||||
void SetKeyboardState(int key, bool state);
|
||||
|
||||
// Function to reset the mocked keyboard state
|
||||
void ResetKeyboardState();
|
||||
|
||||
// Function to set SendVirtualInput call count condition
|
||||
void SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyboardEvent*)> condition);
|
||||
|
||||
// Function to force SendVirtualInput to fail for calls matching a predicate
|
||||
void SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition);
|
||||
|
||||
// Function to get SendVirtualInput call count
|
||||
int GetSendVirtualInputCallCount();
|
||||
|
||||
|
||||
@@ -63,6 +63,84 @@ namespace RemappingLogicTests
|
||||
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x42), false);
|
||||
}
|
||||
|
||||
// When injecting the remapped key fails (e.g. SendInput is blocked by UIPI or
|
||||
// another hook), the handler must let the ORIGINAL key through instead of
|
||||
// silently swallowing it, so the user is never left with a dead key. This
|
||||
// exercises the stuck-key hardening that checks SendVirtualInput's return value.
|
||||
TEST_METHOD (RemappedKey_ShouldPassOriginalKeyThrough_WhenInjectionFails)
|
||||
{
|
||||
// Remap A to B
|
||||
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
|
||||
|
||||
// Fail only KBM-injected events (tagged with a non-zero dwExtraInfo),
|
||||
// leaving the test's own driving input (dwExtraInfo == 0) untouched.
|
||||
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
|
||||
for (const auto& input : inputs)
|
||||
{
|
||||
if (input.ki.dwExtraInfo != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
|
||||
};
|
||||
|
||||
// Send A keydown - injection of B fails, so A must pass through
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// The original A is let through (state true); B was never injected (false)
|
||||
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
|
||||
}
|
||||
|
||||
// When the remapped key-DOWN injection is blocked but the later key-UP injection
|
||||
// would succeed, the handler must still let the ORIGINAL key-up through. The
|
||||
// key-down was passed through to the app (key is physically DOWN), so swallowing
|
||||
// the key-up would strand the physical key DOWN. This guards the asymmetric
|
||||
// injection-failure stuck-key edge case, where key-down and key-up arrive as
|
||||
// separate hook events.
|
||||
TEST_METHOD (RemappedKey_ShouldReleaseOriginalKey_WhenKeyDownInjectionFailedButKeyUpSucceeds)
|
||||
{
|
||||
// Remap A to B
|
||||
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
|
||||
|
||||
// Fail only KBM-injected key-DOWN events; allow injected key-ups (and the
|
||||
// test's own driving input, which has dwExtraInfo == 0) through.
|
||||
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
|
||||
for (const auto& input : inputs)
|
||||
{
|
||||
if (input.ki.dwExtraInfo != 0 && (input.ki.dwFlags & KEYEVENTF_KEYUP) == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
std::vector<INPUT> keyDown{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
|
||||
};
|
||||
|
||||
// Send A keydown - injection of B fails, so A passes through and is now DOWN
|
||||
mockedInputHandler.SendVirtualInput(keyDown);
|
||||
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
|
||||
|
||||
std::vector<INPUT> keyUp{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A', .dwFlags = KEYEVENTF_KEYUP } },
|
||||
};
|
||||
|
||||
// Send A keyup - even though injecting B's key-up would succeed, the original A
|
||||
// key-up must pass through so the physical A key is released, not stranded down
|
||||
mockedInputHandler.SendVirtualInput(keyUp);
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x41));
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
|
||||
}
|
||||
|
||||
// Test if key is suppressed if a key is disabled by single key remap
|
||||
TEST_METHOD (RemappedKeyDisabled_ShouldNotChangeKeyState_OnKeyEvent)
|
||||
{
|
||||
@@ -350,4 +428,148 @@ namespace RemappingLogicTests
|
||||
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x56), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests for single key to text remap modifier release logic
|
||||
TEST_CLASS (SingleKeyToTextRemapModifierTests)
|
||||
{
|
||||
private:
|
||||
KeyboardManagerInput::MockedInput mockedInputHandler;
|
||||
State testState;
|
||||
|
||||
public:
|
||||
TEST_METHOD_INITIALIZE(InitializeTestEnv)
|
||||
{
|
||||
TestHelpers::ResetTestEnv(mockedInputHandler, testState);
|
||||
|
||||
// Set HandleSingleKeyToTextRemapEvent as the hook procedure
|
||||
std::function<intptr_t(LowlevelKeyboardEvent*)> currentHookProc = std::bind(&KeyboardEventHandlers::HandleSingleKeyToTextRemapEvent, std::ref(mockedInputHandler), std::placeholders::_1, std::ref(testState));
|
||||
mockedInputHandler.SetHookProc(currentHookProc);
|
||||
}
|
||||
|
||||
// A held Win key must be released before the text is injected and then left
|
||||
// released — never re-pressed — so it can never be left stuck down.
|
||||
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseWinKeyAndNotRestore_WhenWinKeyIsHeld)
|
||||
{
|
||||
// Remap X to text "hello"
|
||||
testState.AddSingleKeyToTextRemap(0x58, L"hello");
|
||||
|
||||
// Simulate LWin being held down
|
||||
mockedInputHandler.SetKeyboardState(VK_LWIN, true);
|
||||
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
|
||||
};
|
||||
|
||||
// Send X keydown — handler releases LWin before the text and does not restore it
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// LWin must be left released so it can never be stuck down
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
|
||||
}
|
||||
|
||||
// A held Ctrl must be released before the text and left released afterwards.
|
||||
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseCtrlAndNotRestore_WhenCtrlIsHeld)
|
||||
{
|
||||
// Remap X to text "hello"
|
||||
testState.AddSingleKeyToTextRemap(0x58, L"hello");
|
||||
|
||||
// Simulate LCtrl being held down
|
||||
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
|
||||
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
|
||||
};
|
||||
|
||||
// Send X keydown
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// LCtrl must be left released so it can never be stuck down
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
|
||||
}
|
||||
|
||||
// Every modifier that was held should be released, and none re-pressed.
|
||||
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseAllHeldModifiers_AndNotRestore)
|
||||
{
|
||||
// Remap X to text "hello"
|
||||
testState.AddSingleKeyToTextRemap(0x58, L"hello");
|
||||
|
||||
// Simulate LCtrl and LShift being held down together
|
||||
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
|
||||
mockedInputHandler.SetKeyboardState(VK_LSHIFT, true);
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
|
||||
};
|
||||
|
||||
// Send X keydown
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// Both modifiers must be left released
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LSHIFT));
|
||||
}
|
||||
|
||||
// The handler must never inject a modifier key-down (re-press) event. Doing
|
||||
// so could leave a modifier stuck down if the user released it during text
|
||||
// injection, since GetAsyncKeyState cannot distinguish a still-held key from
|
||||
// one we just released ourselves.
|
||||
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldNeverRePressModifier_WhenModifierIsHeld)
|
||||
{
|
||||
// Remap X to text "hello"
|
||||
testState.AddSingleKeyToTextRemap(0x58, L"hello");
|
||||
|
||||
// Simulate LCtrl being held down
|
||||
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
|
||||
|
||||
// Count any modifier key-down events the handler injects (i.e. a re-press)
|
||||
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* keyEvent) {
|
||||
const DWORD vk = keyEvent->lParam->vkCode;
|
||||
const bool isModifier = (vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LMENU || vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN);
|
||||
return isModifier && keyEvent->wParam == WM_KEYDOWN;
|
||||
});
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
|
||||
};
|
||||
|
||||
// Send X keydown
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// No modifier re-press should ever be injected
|
||||
Assert::AreEqual(0, mockedInputHandler.GetSendVirtualInputCallCount());
|
||||
}
|
||||
|
||||
// A key-to-text remap must still fire while Alt is held. Windows delivers a
|
||||
// key pressed with Alt down as WM_SYSKEYDOWN rather than WM_KEYDOWN, so a
|
||||
// handler that only accepted WM_KEYDOWN would silently drop the remap. Alt
|
||||
// being held also drives the modifier-release path, so the proof that the
|
||||
// WM_SYSKEYDOWN event was accepted and processed is that the held Alt ends
|
||||
// up released. If WM_SYSKEYDOWN were rejected the handler would return
|
||||
// before the release loop and Alt would remain down.
|
||||
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldFireAndReleaseAlt_WhenAltIsHeld)
|
||||
{
|
||||
// Remap X to text "hello"
|
||||
testState.AddSingleKeyToTextRemap(0x58, L"hello");
|
||||
|
||||
// Simulate Left Alt being held. VK_MENU makes the mock deliver the key
|
||||
// as WM_SYSKEYDOWN (as the OS does while Alt is down); VK_LMENU is the
|
||||
// physical key the handler sees as held and must release.
|
||||
mockedInputHandler.SetKeyboardState(VK_MENU, true);
|
||||
mockedInputHandler.SetKeyboardState(VK_LMENU, true);
|
||||
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
|
||||
|
||||
std::vector<INPUT> inputs{
|
||||
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
|
||||
};
|
||||
|
||||
// Send X keydown — arrives as WM_SYSKEYDOWN because Alt is held
|
||||
mockedInputHandler.SendVirtualInput(inputs);
|
||||
|
||||
// The remap fired: the held Alt was released and never re-pressed, so it
|
||||
// can never be left stuck down.
|
||||
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ namespace TestHelpers
|
||||
input.ResetKeyboardState();
|
||||
input.SetHookProc(nullptr);
|
||||
input.SetSendVirtualInputTestHandler(nullptr);
|
||||
input.SetSendVirtualInputShouldFail(nullptr);
|
||||
input.SetForegroundProcess(L"");
|
||||
state.ClearSingleKeyRemaps();
|
||||
state.ClearOSLevelShortcuts();
|
||||
state.ClearAppSpecificShortcuts();
|
||||
state.ClearSingleKeyToTextRemaps();
|
||||
|
||||
// Allocate memory for the keyboardManagerState activatedApp member to avoid CRT assert errors
|
||||
std::wstring maxLengthString;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <common/utils/process_path.h>
|
||||
|
||||
#include "KeyboardManagerConstants.h"
|
||||
#include "InputInterface.h"
|
||||
|
||||
namespace Helpers
|
||||
{
|
||||
@@ -313,7 +314,7 @@ namespace Helpers
|
||||
// Shift+Enter. Each character is sent individually to avoid a synchronization
|
||||
// error across key-down and key-up events that causes repeated or dropped characters
|
||||
// when large batches of KEYEVENTF_UNICODE events are sent at once.
|
||||
void SendTextInput(const std::wstring& text)
|
||||
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii)
|
||||
{
|
||||
for (size_t i = 0; i < text.size(); ++i)
|
||||
{
|
||||
@@ -359,7 +360,7 @@ namespace Helpers
|
||||
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
|
||||
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
SendInput(ARRAYSIZE(returnInputs), returnInputs, sizeof(INPUT));
|
||||
ii.SendVirtualInput(std::vector<INPUT>(returnInputs, returnInputs + ARRAYSIZE(returnInputs)));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -374,7 +375,7 @@ namespace Helpers
|
||||
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
charInputs[1].ki.wScan = c;
|
||||
|
||||
SendInput(ARRAYSIZE(charInputs), charInputs, sizeof(INPUT));
|
||||
ii.SendVirtualInput(std::vector<INPUT>(charInputs, charInputs + ARRAYSIZE(charInputs)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
class LayoutMap;
|
||||
|
||||
namespace KeyboardManagerInput
|
||||
{
|
||||
class InputInterface;
|
||||
}
|
||||
|
||||
namespace Helpers
|
||||
{
|
||||
// Type to distinguish between keys
|
||||
@@ -41,7 +46,7 @@ namespace Helpers
|
||||
// Function to send text input directly, with multiline support.
|
||||
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
|
||||
// as separate SendInput calls to avoid mixing event types.
|
||||
void SendTextInput(const std::wstring& text);
|
||||
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii);
|
||||
|
||||
// Function to return window handle for a full screen UWP app
|
||||
HWND GetFullscreenUWPWindowHandle();
|
||||
|
||||
@@ -11,17 +11,41 @@ namespace KeyboardManagerInput
|
||||
class Input : public InputInterface
|
||||
{
|
||||
public:
|
||||
// Function to simulate input
|
||||
void SendVirtualInput(const std::vector<INPUT>& inputs)
|
||||
// Function to simulate input. Returns false only when nothing could be injected
|
||||
// (the call was fully blocked); returns true on full or partial success. A partial
|
||||
// injection means some remap events already reached the system, so passing the
|
||||
// original key through on top of them would corrupt the input stream (e.g. leave a
|
||||
// modifier stuck). In that rare case we suppress the original and log a warning.
|
||||
bool SendVirtualInput(const std::vector<INPUT>& inputs)
|
||||
{
|
||||
if (inputs.empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<INPUT> copy = inputs;
|
||||
UINT eventCount = SendInput(static_cast<UINT>(copy.size()), copy.data(), sizeof(INPUT));
|
||||
if (eventCount != copy.size())
|
||||
if (eventCount == 0)
|
||||
{
|
||||
// Nothing was injected (e.g. blocked by UIPI). The caller passes the
|
||||
// original key through so the user is never left with a dead key.
|
||||
Logger::error(
|
||||
L"Failed to send input events. {}",
|
||||
get_last_error_or_default(GetLastError()));
|
||||
return false;
|
||||
}
|
||||
if (eventCount != copy.size())
|
||||
{
|
||||
// Partial injection: SendInput stopped after some events. Report success so
|
||||
// the caller suppresses the original event rather than layering it on top of
|
||||
// a half-applied remap, which could strand a key or modifier down.
|
||||
Logger::warn(
|
||||
L"Partially sent input events ({} of {}). {}",
|
||||
eventCount,
|
||||
static_cast<UINT>(copy.size()),
|
||||
get_last_error_or_default(GetLastError()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function to get the state of a particular key
|
||||
|
||||
@@ -10,8 +10,9 @@ namespace KeyboardManagerInput
|
||||
class InputInterface
|
||||
{
|
||||
public:
|
||||
// Function to simulate input
|
||||
virtual void SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
|
||||
// Function to simulate input. Returns false only when nothing could be injected
|
||||
// (the call was fully blocked); returns true on full or partial success.
|
||||
virtual bool SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
|
||||
|
||||
// Function to get the state of a particular key
|
||||
virtual bool GetVirtualKeyState(int key) = 0;
|
||||
|
||||
@@ -33,16 +33,40 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
|
||||
return null;
|
||||
}
|
||||
|
||||
var (workspaceEnv, machineName) = ParseVSCodeAuthority.GetWorkspaceEnvironment(authority ?? rfc3986Uri.Authority);
|
||||
var isFileUri = string.Equals(
|
||||
rfc3986Uri.Scheme,
|
||||
Uri.UriSchemeFile,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var isUncFileUri =
|
||||
isFileUri &&
|
||||
string.IsNullOrEmpty(authority) &&
|
||||
!string.IsNullOrEmpty(rfc3986Uri.Authority) &&
|
||||
!string.Equals(
|
||||
rfc3986Uri.Authority,
|
||||
"localhost",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// file://server/share is a local Windows UNC path, not a VS Code remote URI.
|
||||
var effectiveAuthority =
|
||||
isFileUri && string.IsNullOrEmpty(authority)
|
||||
? string.Empty
|
||||
: authority ?? rfc3986Uri.Authority;
|
||||
|
||||
var (workspaceEnv, machineName) =
|
||||
ParseVSCodeAuthority.GetWorkspaceEnvironment(effectiveAuthority);
|
||||
|
||||
if (workspaceEnv is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = rfc3986Uri.Path;
|
||||
var path = isUncFileUri
|
||||
? $@"\\{rfc3986Uri.Authority}{rfc3986Uri.Path.Replace('/', '\\')}"
|
||||
: rfc3986Uri.Path;
|
||||
|
||||
// Remove preceding '/' from local (Windows) path
|
||||
if (workspaceEnv == WorkspaceEnvironment.Local)
|
||||
// file:///C:/... becomes C:/...
|
||||
if (workspaceEnv == WorkspaceEnvironment.Local && !isUncFileUri)
|
||||
{
|
||||
path = path[1..];
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public static class CharacterMappings
|
||||
[LetterKey.VK_M] = ["ṁ", "ᵐ", "ₘ"],
|
||||
[LetterKey.VK_N] = ["ņ", "ṅ", "ⁿ", "ℕ", "№", "ₙ"],
|
||||
[LetterKey.VK_O] = ["ȯ", "∅", "⌀", "ᵒ", "ₒ"],
|
||||
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ"],
|
||||
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ", "‰", "‱"],
|
||||
[LetterKey.VK_Q] = ["ℚ", "𐞥"],
|
||||
[LetterKey.VK_R] = ["ṙ", "®", "ℝ", "ʳ", "ᵣ"],
|
||||
[LetterKey.VK_S] = ["ṡ", "§", "∑", "∫", "ˢ", "ₛ"],
|
||||
@@ -73,10 +73,10 @@ public static class CharacterMappings
|
||||
[LetterKey.VK_X] = ["ẋ", "×", "ˣ", "ₓ"],
|
||||
[LetterKey.VK_Y] = ["ẏ", "ꝡ", "ʸ"],
|
||||
[LetterKey.VK_Z] = ["ʒ", "ǯ", "ℤ", "ᶻ"],
|
||||
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "–", "√", "‟", "《", "》", "‛", "〈", "〉", "″", "‴", "⁗"], // – is in VK_MINUS for other languages, but not VK_COMMA, so we add it here.
|
||||
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "–", "√", "‟", "⟪", "⟫", "‛", "⟨", "⟩", "″", "‴", "⁗"],
|
||||
[LetterKey.VK_PERIOD] = ["…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C"],
|
||||
[LetterKey.VK_MINUS] = ["~", "‐", "‑", "‒", "–", "—", "―", "⁓", "−", "⸺", "⸻", "∓", "₋", "⁻"],
|
||||
[LetterKey.VK_SLASH_] = ["÷", "√"],
|
||||
[LetterKey.VK_SLASH_] = ["÷", "√", "‽", "⸘"],
|
||||
[LetterKey.VK_DIVIDE_] = ["÷", "√"],
|
||||
[LetterKey.VK_MULTIPLY_] = ["×", "⋅", "ˣ", "ₓ"],
|
||||
[LetterKey.VK_PLUS] = ["≤", "≥", "≠", "≈", "≙", "⊕", "⊗", "±", "≅", "≡", "₊", "⁺", "₌", "⁼"],
|
||||
@@ -368,25 +368,32 @@ public static class CharacterMappings
|
||||
// a spoken language, but rather a set of symbols used across languages.
|
||||
new(Language.IPA, "IPA", LanguageGroup.Special, new Dictionary<LetterKey, string[]>
|
||||
{
|
||||
[LetterKey.VK_A] = ["ɐ", "ɑ", "ɒ", "ǎ"],
|
||||
[LetterKey.VK_B] = ["ʙ"],
|
||||
[LetterKey.VK_E] = ["ɘ", "ɵ", "ə", "ɛ", "ɜ", "ɞ"],
|
||||
[LetterKey.VK_F] = ["ɟ", "ɸ"],
|
||||
[LetterKey.VK_G] = ["ɢ", "ɣ"],
|
||||
[LetterKey.VK_H] = ["ɦ", "ʜ"],
|
||||
[LetterKey.VK_I] = ["ɨ", "ɪ"],
|
||||
[LetterKey.VK_J] = ["ʝ"],
|
||||
[LetterKey.VK_L] = ["ɬ", "ɮ", "ꞎ", "ɭ", "ʎ", "ʟ", "ɺ"],
|
||||
[LetterKey.VK_N] = ["ɳ", "ɲ", "ŋ", "ɴ"],
|
||||
[LetterKey.VK_O] = ["ɤ", "ɔ", "ɶ", "ǒ"],
|
||||
[LetterKey.VK_R] = ["ʁ", "ɹ", "ɻ", "ɾ", "ɽ", "ʀ"],
|
||||
[LetterKey.VK_S] = ["ʃ", "ʂ", "ɕ"],
|
||||
[LetterKey.VK_U] = ["ʉ", "ʊ", "ǔ"],
|
||||
[LetterKey.VK_V] = ["ʋ", "ⱱ", "ʌ"],
|
||||
[LetterKey.VK_W] = ["ɰ", "ɯ"],
|
||||
[LetterKey.VK_Y] = ["ʏ"],
|
||||
[LetterKey.VK_A] = ["ɑ", "æ", "ɒ", "ɐ"],
|
||||
[LetterKey.VK_B] = ["β", "ʙ", "ɓ", "ʘ"],
|
||||
[LetterKey.VK_C] = ["ç", "χ", "ǂ"],
|
||||
[LetterKey.VK_D] = ["ð", "ɗ", "ɖ", "ǀ"],
|
||||
[LetterKey.VK_E] = ["ə", "ɛ", "ɚ", "ɘ", "ɜ", "ɵ", "ɞ", "æ", "œ"],
|
||||
[LetterKey.VK_F] = ["ɸ"],
|
||||
[LetterKey.VK_G] = ["ɡ", "ɣ", "ɢ", "ɠ", "ʛ"],
|
||||
[LetterKey.VK_H] = ["ɦ", "ħ", "ɥ", "ʜ", "ɧ", "ʱ"],
|
||||
[LetterKey.VK_I] = ["ɪ", "ɨ"],
|
||||
[LetterKey.VK_J] = ["ɟ", "ʝ", "ʄ"],
|
||||
[LetterKey.VK_L] = ["ɫ", "ʎ", "ɬ", "ɮ", "ɭ", "ʟ", "ɺ", "ꞎ", "ǁ"],
|
||||
[LetterKey.VK_M] = ["ɱ"],
|
||||
[LetterKey.VK_N] = ["ŋ", "ɲ", "ɳ", "ɴ"],
|
||||
[LetterKey.VK_O] = ["ɔ", "ø", "œ", "ɤ", "ɶ", "ʘ"],
|
||||
[LetterKey.VK_R] = ["ɹ", "ɾ", "ʁ", "ʀ", "ɻ", "ɽ"],
|
||||
[LetterKey.VK_S] = ["ʃ", "ɕ", "ʂ"],
|
||||
[LetterKey.VK_T] = ["θ", "ʈ", "ǃ"],
|
||||
[LetterKey.VK_U] = ["ʊ", "ʉ"],
|
||||
[LetterKey.VK_V] = ["ʌ", "ʋ", "ⱱ"],
|
||||
[LetterKey.VK_W] = ["ʍ", "ɯ", "ɰ"],
|
||||
[LetterKey.VK_X] = ["χ"],
|
||||
[LetterKey.VK_Y] = ["ʎ", "ʏ"],
|
||||
[LetterKey.VK_Z] = ["ʒ", "ʐ", "ʑ"],
|
||||
[LetterKey.VK_COMMA] = ["ʡ", "ʔ", "ʕ", "ʢ"],
|
||||
[LetterKey.VK_COMMA] = ["ʔ", "ʕ", "ʡ", "ʢ"],
|
||||
[LetterKey.VK_PERIOD] = ["ˈ", "ˌ", "ː", "ʼ", "\u031D", "\u0325", "\u031A", "\u0361", "\u035C"],
|
||||
[LetterKey.VK_SLASH_] = ["ʔ"],
|
||||
}),
|
||||
|
||||
new(Language.IT, "Italian", LanguageGroup.Language, new Dictionary<LetterKey, string[]>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the persisted shape of the mouse-wheel-increment setting on
|
||||
/// <see cref="PowerDisplayProperties"/>: its default of 5 (the historical hardcoded step),
|
||||
/// its snake_case JSON key, round-trip fidelity, and the forward-compatibility promise that
|
||||
/// settings.json written before the feature existed deserializes to the default of 5 with no
|
||||
/// migration.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MouseWheelIncrementSettingsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Default_IsFive()
|
||||
{
|
||||
var properties = new PowerDisplayProperties();
|
||||
|
||||
Assert.AreEqual(5, properties.MouseWheelIncrement, "Default must preserve the historical hardcoded step of 5.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_LegacyJsonMissingField_DefaultsToFive()
|
||||
{
|
||||
// A settings.json captured before this feature shipped has no mouse_wheel_increment key.
|
||||
// Deserializing must fall back to the constructor default of 5, not 0. System.Text.Json
|
||||
// calls the parameterless constructor (which sets MouseWheelIncrement = 5) and then fills
|
||||
// only the fields present in JSON. If PowerDisplayProperties ever gains a
|
||||
// [JsonConstructor]-annotated constructor, re-verify this "defaults to 5" behavior.
|
||||
const string legacyJson = """
|
||||
{
|
||||
"monitor_refresh_delay": 5,
|
||||
"restore_settings_on_startup": false,
|
||||
"show_system_tray_icon": true
|
||||
}
|
||||
""";
|
||||
|
||||
var properties = JsonSerializer.Deserialize<PowerDisplayProperties>(legacyJson);
|
||||
|
||||
Assert.IsNotNull(properties);
|
||||
Assert.AreEqual(5, properties.MouseWheelIncrement);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = new PowerDisplayProperties { MouseWheelIncrement = 15 };
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var restored = JsonSerializer.Deserialize<PowerDisplayProperties>(json);
|
||||
|
||||
Assert.IsNotNull(restored);
|
||||
Assert.AreEqual(15, restored.MouseWheelIncrement);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_UsesSnakeCaseJsonKey()
|
||||
{
|
||||
var properties = new PowerDisplayProperties { MouseWheelIncrement = 10 };
|
||||
|
||||
var json = JsonSerializer.Serialize(properties);
|
||||
|
||||
StringAssert.Contains(json, "\"mouse_wheel_increment\":10");
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace PowerDisplay.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
|
||||
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
|
||||
/// <c>Microsoft.PowerToys.Common.UI.Controls.Window.FlyoutWindowHelper</c> (Common.UI.Controls).
|
||||
/// </summary>
|
||||
internal static partial class WindowHelper
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using PowerDisplay.Configuration;
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind ViewModel.MouseWheelIncrement, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -525,7 +525,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -556,7 +556,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -586,7 +586,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.UI.Windowing;
|
||||
|
||||
@@ -122,6 +122,7 @@ public partial class MainViewModel
|
||||
foreach (var monitor in Monitors)
|
||||
{
|
||||
monitor.RefreshCustomVcpNames();
|
||||
monitor.RefreshMouseWheelIncrement();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -93,6 +93,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
IsScanning = true;
|
||||
ShowProfileSwitcher = true;
|
||||
ShowIdentifyMonitorsButton = true;
|
||||
MouseWheelIncrement = 5;
|
||||
|
||||
// Initialize settings utils
|
||||
_settingsUtils = SettingsUtils.Default;
|
||||
@@ -129,6 +130,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-mouse-wheel-notch step applied to every flyout slider. Loaded from
|
||||
/// PowerDisplaySettings; defaults to 5 (the historical hardcoded step).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial int MouseWheelIncrement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
|
||||
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> so
|
||||
@@ -479,6 +487,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
|
||||
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
|
||||
MouseWheelIncrement = settings.Properties.MouseWheelIncrement;
|
||||
|
||||
// Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this
|
||||
// method runs after monitors are already discovered, the toggle hook can seed the master
|
||||
|
||||
@@ -210,6 +210,12 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
// Property to access IsInteractionEnabled from parent ViewModel
|
||||
public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared per-mouse-wheel-notch step for this monitor's sliders, proxied from the
|
||||
/// owning <see cref="MainViewModel"/>. Falls back to 5 if the owner is unavailable.
|
||||
/// </summary>
|
||||
public int MouseWheelIncrement => _mainViewModel?.MouseWheelIncrement ?? 5;
|
||||
|
||||
public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel)
|
||||
{
|
||||
_monitor = monitor;
|
||||
@@ -669,6 +675,16 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(AvailableInputSources));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raise <see cref="PropertyChanged"/> for <see cref="MouseWheelIncrement"/> so per-monitor
|
||||
/// sliders pick up a new value after the user changes it in Settings. Called from
|
||||
/// <c>MainViewModel.ApplySettingsFromUI</c>.
|
||||
/// </summary>
|
||||
public void RefreshMouseWheelIncrement()
|
||||
{
|
||||
OnPropertyChanged(nameof(MouseWheelIncrement));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source for this monitor
|
||||
/// </summary>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 328 B After Width: | Height: | Size: 2.0 KiB |
@@ -7,7 +7,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
using Microsoft.PowerToys.QuickAccess.Services;
|
||||
using Microsoft.PowerToys.QuickAccess.ViewModels;
|
||||
using Microsoft.UI.Dispatching;
|
||||
|
||||