diff --git a/LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs b/LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs new file mode 100644 index 0000000..553ee4e --- /dev/null +++ b/LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs @@ -0,0 +1,211 @@ +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using LanMountainDesktop.Views; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class DesktopWidgetWindowChromeTests +{ + [AvaloniaFact] + public void DirectRootBorderOwnsTheOnlyRoundedContourAndHasNoOuterShadow() + { + var componentRoot = new Border + { + BoxShadow = BoxShadows.Parse("0 8 24 #66000000") + }; + var component = new UserControl + { + Content = componentRoot + }; + + var window = new DesktopWidgetWindow(component, "placement", 18d); + window.UpdateComponentLayout(200d, 120d); + + var host = Assert.IsType(window.FindControl("ComponentContainer")); + var editBorder = Assert.IsType(window.FindControl("EditModeBorder")); + + Assert.Equal(new CornerRadius(18d), componentRoot.CornerRadius); + Assert.True(componentRoot.ClipToBounds); + Assert.Equal(default(BoxShadows), componentRoot.BoxShadow); + Assert.Equal(default, host.CornerRadius); + Assert.False(host.ClipToBounds); + Assert.Equal(new CornerRadius(18d), editBorder.CornerRadius); + Assert.Null(editBorder.Effect); + + componentRoot.BoxShadow = BoxShadows.Parse("0 10 30 #88000000"); + componentRoot.CornerRadius = new CornerRadius(2d); + componentRoot.ClipToBounds = false; + + Assert.Equal(default(BoxShadows), componentRoot.BoxShadow); + Assert.Equal(new CornerRadius(18d), componentRoot.CornerRadius); + Assert.True(componentRoot.ClipToBounds); + } + + [AvaloniaFact] + public void NonBorderRootUsesHostRoundedClip() + { + var component = new Grid(); + var window = new DesktopWidgetWindow(component, "placement", 14d); + window.UpdateComponentLayout(180d, 100d); + + var host = Assert.IsType(window.FindControl("ComponentContainer")); + + Assert.Equal(new CornerRadius(14d), host.CornerRadius); + Assert.True(host.ClipToBounds); + } + + [AvaloniaFact] + public void TemplatedContentControlDoesNotTransferContourOwnershipToItsContent() + { + var contentBorder = new Border + { + BoxShadow = BoxShadows.Parse("0 4 12 #44000000") + }; + var component = new ContentControl + { + Content = contentBorder + }; + var window = new DesktopWidgetWindow(component, "placement", 16d); + window.UpdateComponentLayout(180d, 100d); + + var host = Assert.IsType(window.FindControl("ComponentContainer")); + + Assert.Equal(new CornerRadius(16d), host.CornerRadius); + Assert.True(host.ClipToBounds); + Assert.NotEqual(default, contentBorder.BoxShadow); + } + + [AvaloniaFact] + public void ReplacingUserControlRootTransfersContourOwnershipToTheNewBorder() + { + var firstRoot = new Border(); + var component = new UserControl { Content = firstRoot }; + var window = new DesktopWidgetWindow(component, "placement", 20d); + + var replacementRoot = new Border + { + BoxShadow = BoxShadows.Parse("0 8 20 #66000000") + }; + component.Content = replacementRoot; + + Assert.Equal(new CornerRadius(20d), replacementRoot.CornerRadius); + Assert.True(replacementRoot.ClipToBounds); + Assert.Equal(default(BoxShadows), replacementRoot.BoxShadow); + } + + [AvaloniaFact] + public void HiddenComponentRootDoesNotFallBackToAFullWindowInteractiveRegion() + { + var componentRoot = new Border { IsVisible = false }; + var component = new UserControl { Content = componentRoot }; + var window = new DesktopWidgetWindow(component, "placement", 18d); + var method = typeof(DesktopWidgetWindow).GetMethod( + "ResolveLiveInteractiveRegion", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + var result = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) }); + + Assert.Null(result); + } + + [AvaloniaFact] + public void HiddenNonBorderComponentDoesNotUseTheVisibleHostAsItsInteractiveRegion() + { + var component = new Grid(); + var window = new DesktopWidgetWindow(component, "placement", 18d); + window.UpdateComponentLayout(200d, 120d); + var root = Assert.IsType(window.FindControl("RootGrid")); + root.Measure(new Size(200d, 120d)); + root.Arrange(new Rect(0, 0, 200d, 120d)); + var method = typeof(DesktopWidgetWindow).GetMethod( + "ResolveLiveInteractiveRegion", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + var visibleResult = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) }); + component.IsVisible = false; + var hiddenResult = method.Invoke(window, new object[] { new Rect(0, 0, 200, 120) }); + + Assert.NotNull(visibleResult); + Assert.Null(hiddenResult); + } + + [AvaloniaFact] + public void ResizeAdornerMatchesComponentBoundsAndKeepsEveryHandleInside() + { + var component = new Grid(); + var window = new DesktopWidgetWindow(component, "placement", 12d); + window.UpdateComponentLayout(200d, 120d); + + var host = Assert.IsType(window.FindControl("ComponentContainer")); + var root = Assert.IsType(window.FindControl("RootGrid")); + var adorner = Assert.Single(root.Children.OfType()); + + adorner.Show(); + host.Measure(new Size(200d, 120d)); + host.Arrange(new Rect(0d, 0d, 200d, 120d)); + adorner.Measure(new Size(200d, 120d)); + adorner.Arrange(new Rect(0d, 0d, 200d, 120d)); + + Assert.Equal(host.Bounds.Size, adorner.Bounds.Size); + Assert.Equal(new Size(200d, 120d), adorner.Bounds.Size); + + foreach (var handle in adorner.Children.OfType()) + { + var left = Canvas.GetLeft(handle); + var top = Canvas.GetTop(handle); + + Assert.InRange(left, 0d, adorner.Bounds.Width - handle.Width); + Assert.InRange(top, 0d, adorner.Bounds.Height - handle.Height); + Assert.True(left + handle.Width <= adorner.Bounds.Width); + Assert.True(top + handle.Height <= adorner.Bounds.Height); + } + } + + [Theory] + [InlineData(1d)] + [InlineData(1.25d)] + [InlineData(1.5d)] + [InlineData(2d)] + public void ResizeMathKeepsPhysicalRightEdgeUnderPointerAcrossDpi(double currentScaling) + { + var result = DesktopWidgetWindow.CalculateResizedBounds( + ResizeHandlePosition.Right, + new Point(20d, 0d), + new Size(200d, 120d), + new PixelPoint(-500, 100), + currentScaling); + + Assert.Equal(220d, result.width * currentScaling, 6); + Assert.Equal(120d, result.height * currentScaling, 6); + Assert.Equal(-500d, result.x); + Assert.Equal(100d, result.y); + } + + [Theory] + [InlineData(1d)] + [InlineData(1.25d)] + [InlineData(1.5d)] + [InlineData(2d)] + public void ResizeMathKeepsOppositeEdgeFixedForLeftResizeAcrossDpi(double currentScaling) + { + var result = DesktopWidgetWindow.CalculateResizedBounds( + ResizeHandlePosition.Left, + new Point(20d, 0d), + new Size(200d, 120d), + new PixelPoint(-500, 100), + currentScaling); + + var physicalWidth = result.width * currentScaling; + Assert.Equal(180d, physicalWidth, 6); + Assert.Equal(-480d, result.x); + Assert.Equal(-300d, result.x + physicalWidth, 6); + Assert.Equal(120d, result.height * currentScaling, 6); + } +} diff --git a/LanMountainDesktop.Tests/FusedDesktopComponentLibraryWindowShellTests.cs b/LanMountainDesktop.Tests/FusedDesktopComponentLibraryWindowShellTests.cs new file mode 100644 index 0000000..f03ecf7 --- /dev/null +++ b/LanMountainDesktop.Tests/FusedDesktopComponentLibraryWindowShellTests.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class FusedDesktopComponentLibraryWindowShellTests +{ + [Fact] + public void Window_UsesTransparentFullClientArea() + { + var xaml = ReadRepositoryFile( + "LanMountainDesktop", + "Views", + "FusedDesktopComponentLibraryWindow.axaml"); + var window = ExtractElementStart(xaml, "= 0, $"Could not find '{startToken}'."); + + var end = source.IndexOf('>', start); + Assert.True(end > start, $"Could not find end of '{startToken}'."); + + return source.Substring(start, end - start + 1); + } + + private static string ReadRepositoryFile(params string[] segments) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray()); + if (File.Exists(candidate)) + { + return File.ReadAllText(candidate); + } + + if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + break; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'."); + } +} diff --git a/LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs b/LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs new file mode 100644 index 0000000..a88287b --- /dev/null +++ b/LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs @@ -0,0 +1,57 @@ +using Avalonia; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class FusedDesktopScreenTopologyTests +{ + [Fact] + public void RemovedMonitorPositionMovesToNearestRemainingWorkingArea() + { + var screens = new[] + { + new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 1920, 1040), 1d) + }; + + var result = FusedDesktopManagerService.CoerceToValidWorkingArea( + new PixelPoint(-2600, 200), + new Size(300, 180), + screens); + + Assert.Equal(new PixelPoint(0, 200), result); + } + + [Fact] + public void HighDpiWorkingAreaKeepsEntireWidgetVisible() + { + var screens = new[] + { + new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 1920, 1040), 2d) + }; + + var result = FusedDesktopManagerService.CoerceToValidWorkingArea( + new PixelPoint(1800, 980), + new Size(200, 120), + screens); + + Assert.Equal(new PixelPoint(1520, 800), result); + } + + [Fact] + public void NegativeCoordinateMonitorUsesItsOwnWorkAreaAndScaling() + { + var screens = new[] + { + new FusedDesktopScreenWorkArea(new PixelRect(-1920, 0, 1920, 1080), 1d), + new FusedDesktopScreenWorkArea(new PixelRect(0, 0, 2560, 1400), 1.5d) + }; + + var result = FusedDesktopManagerService.CoerceToValidWorkingArea( + new PixelPoint(-100, 1000), + new Size(200, 200), + screens); + + Assert.Equal(new PixelPoint(-200, 880), result); + } +} diff --git a/LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs b/LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs new file mode 100644 index 0000000..8ca6894 --- /dev/null +++ b/LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs @@ -0,0 +1,79 @@ +using LanMountainDesktop.Services.PluginMarket; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class PluginMarketCompatibilityTests +{ + [Fact] + public void Validate_RejectsPluginFromPreviousApiMajor() + { + var error = AirAppMarketCompatibility.Validate( + CreatePlugin(apiVersion: "4.0.0"), + new Version(0, 8, 8), + "5.0.0"); + + Assert.NotNull(error); + Assert.Contains("incompatible API version 4.0.0", error, StringComparison.Ordinal); + } + + [Fact] + public void Validate_ChecksApiMajorEvenWhenHostProductVersionIsUnavailable() + { + var error = AirAppMarketCompatibility.Validate( + CreatePlugin(apiVersion: "4.0.0"), + hostVersion: null, + hostApiVersion: "5.0.0"); + + Assert.NotNull(error); + Assert.Contains("Host API version is 5.0.0", error, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RejectsPluginThatRequiresNewerHost() + { + var error = AirAppMarketCompatibility.Validate( + CreatePlugin(apiVersion: "5.0.0", minHostVersion: "0.9.0"), + new Version(0, 8, 8), + "5.0.0"); + + Assert.NotNull(error); + Assert.Contains("requires host version 0.9.0 or newer", error, StringComparison.Ordinal); + } + + [Fact] + public void Validate_AcceptsCompatiblePlugin() + { + var error = AirAppMarketCompatibility.Validate( + CreatePlugin(apiVersion: "5.2.0", minHostVersion: "0.8.0"), + new Version(0, 8, 8), + "5.0.0"); + + Assert.Null(error); + } + + private static AirAppMarketPluginEntry CreatePlugin( + string apiVersion, + string minHostVersion = "0.0.1") => + new() + { + PluginId = "Example.Plugin", + Id = "Example.Plugin", + Name = "Example Plugin", + Description = "Compatibility test plugin.", + Author = "LanMountainDesktop", + Version = "1.0.0", + ApiVersion = apiVersion, + MinHostVersion = minHostVersion, + RepositoryUrl = "https://github.com/example/example-plugin", + PackageSources = + [ + new AirAppMarketPluginPackageSourceEntry + { + Kind = "workspaceLocal", + Url = "workspace://Example.Plugin/Example.Plugin.1.0.0.laapp", + SourceKind = PluginPackageSourceKind.WorkspaceLocal + } + ] + }; +} diff --git a/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs b/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs index 4578973..33e6b7e 100644 --- a/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs +++ b/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs @@ -6,9 +6,9 @@ namespace LanMountainDesktop.Tests; public sealed class PluginMarketIndexDocumentTests { [Fact] - public void Load_WithNestedV2Entry_MapsDisplayFieldsAndWorkspacePath() + public void Load_WithFlatV3Entry_MapsDisplayFieldsAndWorkspacePath() { - var document = AirAppMarketIndexDocument.Load(CreateNestedIndexJson(), "test-index.json"); + var document = AirAppMarketIndexDocument.Load(CreateFlatIndexJson(), "test-index.json"); var plugin = Assert.Single(document.Plugins); var source = Assert.Single(plugin.PackageSources); @@ -25,7 +25,66 @@ public sealed class PluginMarketIndexDocumentTests Assert.Equal(PluginPackageSourceKind.WorkspaceLocal, source.SourceKind); } - private static string CreateNestedIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin") + [Fact] + public void Load_WithLegacyNestedV2Entry_RejectsUnsupportedSchema() + { + var exception = Assert.Throws(() => + AirAppMarketIndexDocument.Load(CreateNestedV2IndexJson(), "legacy-index.json")); + + Assert.Contains("schemaVersion '2.0.0'", exception.Message, StringComparison.Ordinal); + Assert.Contains("only supports '3.0.0'", exception.Message, StringComparison.Ordinal); + } + + private static string CreateFlatIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin") + { + return $$""" + { + "schemaVersion": "3.0.0", + "sourceId": "official", + "sourceName": "LanAirApp", + "generatedAt": "2026-04-29T00:00:00Z", + "contracts": [], + "plugins": [ + { + "pluginId": "LanMountainDesktop.SamplePlugin", + "name": "LanMountain Sample Plugin", + "description": "SDK v5 sample plugin.", + "author": "LanMountainDesktop", + "version": "0.4.0", + "apiVersion": "5.0.0", + "minHostVersion": "0.0.1", + "entranceAssembly": "LanMountainDesktop.SamplePlugin.dll", + "iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg", + "readmeUrl": "https://raw.githubusercontent.com/wwiinnddyy/{{repositoryName}}/main/README.md", + "projectUrl": "https://github.com/wwiinnddyy/{{repositoryName}}", + "homepageUrl": "https://github.com/wwiinnddyy/{{repositoryName}}", + "repositoryUrl": "https://github.com/wwiinnddyy/{{repositoryName}}", + "releaseTag": "v0.4.0", + "releaseAssetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "packageSizeBytes": 1024, + "publishedAt": "2026-04-29T00:00:00Z", + "updatedAt": "2026-04-29T00:00:00Z", + "releaseNotes": "Reference plugin for SDK v5 validation.", + "tags": [ "official", "sdk" ], + "sharedContracts": [], + "desktopComponents": [ "LanMountainDesktop.SamplePlugin.StatusClock" ], + "settingsSections": [ "status" ], + "exports": [], + "messageTypes": [], + "packageSources": [ + { + "kind": "workspaceLocal", + "url": "workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp" + } + ] + } + ] + } + """; + } + + private static string CreateNestedV2IndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin") { return $$""" { diff --git a/LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs b/LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs new file mode 100644 index 0000000..be91727 --- /dev/null +++ b/LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs @@ -0,0 +1,264 @@ +using System.Reflection; +using Avalonia; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class WindowPassthroughServiceTests +{ + private const uint WsChild = 0x40000000U; + private const uint WsPopup = 0x80000000U; + private const uint WsCaption = 0x00C00000U; + private const uint WsThickFrame = 0x00040000U; + private const uint WsMinimizeBox = 0x00020000U; + private const uint WsMaximizeBox = 0x00010000U; + private const uint WsSysMenu = 0x00080000U; + private const uint WsVisible = 0x10000000U; + private const uint WsExToolWindow = 0x00000080U; + private const uint WsExAppWindow = 0x00040000U; + private const uint WsExNoActivate = 0x08000000U; + private const uint WsExNoRedirectionBitmap = 0x00200000U; + private const uint WsExTopmost = 0x00000008U; + + [Fact] + public void DesktopChildStylePolicy_PreservesAvaloniaCompositionBits() + { + var style = WsPopup | WsCaption | WsThickFrame | WsMinimizeBox | + WsMaximizeBox | WsSysMenu | WsVisible; + var exStyle = WsExNoRedirectionBitmap | WsExTopmost | WsExAppWindow; + + var result = Invoke<(uint Style, uint ExStyle)>( + "CreateDesktopChildStyles", + style, + exStyle); + + Assert.NotEqual(0U, result.Style & WsChild); + Assert.NotEqual(0U, result.Style & WsVisible); + Assert.Equal(0U, result.Style & WsPopup); + Assert.Equal(0U, result.Style & WsCaption); + Assert.Equal(0U, result.Style & WsThickFrame); + Assert.Equal(0U, result.Style & WsMinimizeBox); + Assert.Equal(0U, result.Style & WsMaximizeBox); + Assert.Equal(0U, result.Style & WsSysMenu); + + Assert.NotEqual(0U, result.ExStyle & WsExNoRedirectionBitmap); + Assert.NotEqual(0U, result.ExStyle & WsExTopmost); + Assert.NotEqual(0U, result.ExStyle & WsExToolWindow); + Assert.NotEqual(0U, result.ExStyle & WsExNoActivate); + Assert.Equal(0U, result.ExStyle & WsExAppWindow); + } + + [Fact] + public void NativeIntegration_UsesAvaloniaCallbacksWithoutManualWndProcSubclassing() + { + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs"); + + Assert.Contains("Win32Properties.AddWindowStylesCallback", source); + Assert.Contains("Win32Properties.AddWndProcHookCallback", source); + Assert.Contains("Win32Properties.RemoveWindowStylesCallback", source); + Assert.Contains("Win32Properties.RemoveWndProcHookCallback", source); + Assert.DoesNotContain("WS_EX_LAYERED", source); + Assert.DoesNotContain("GWLP_WNDPROC", source); + Assert.DoesNotContain("_originalWndProcs", source); + Assert.Contains("SWP_FRAMECHANGED", source); + Assert.Contains("DwmSetWindowAttribute", source); + Assert.Contains("WindowCornerPreference.DoNotRound", source); + Assert.Contains("NeedsNativeRepair", source); + Assert.Contains("retrying incomplete native rollback", source); + Assert.Contains("if (!restored)", source); + Assert.Contains("SWP_HIDEWINDOW", source); + } + + [Theory] + [InlineData(96d, 200d, 120d, 200d, 120d)] + [InlineData(120d, 250d, 150d, 200d, 120d)] + [InlineData(144d, 300d, 180d, 200d, 120d)] + [InlineData(192d, 400d, 240d, 200d, 120d)] + public void HitTestCoordinates_ScalePhysicalPixelsAcrossSupportedDpiRanges( + double dpi, + double physicalX, + double physicalY, + double expectedX, + double expectedY) + { + var result = Invoke( + "ConvertPhysicalClientPointToDip", + new Point(physicalX, physicalY), + dpi / 96d); + + Assert.Equal(new Point(expectedX, expectedY), result); + } + + [Fact] + public void HitTesting_ExcludesRoundedTransparentCorners() + { + var region = new WindowInteractiveRegion(new Rect(0, 0, 100, 80), 20); + + Assert.False(IsInside(region, new Point(1, 1))); + Assert.False(IsInside(region, new Point(99, 1))); + Assert.False(IsInside(region, new Point(1, 79))); + Assert.False(IsInside(region, new Point(99, 79))); + Assert.True(IsInside(region, new Point(50, 1))); + Assert.True(IsInside(region, new Point(1, 40))); + Assert.True(IsInside(region, new Point(50, 40))); + } + + [Fact] + public void HitTesting_SupportsNegativeDesktopCoordinatesAndRectangularEditRegions() + { + var rounded = new WindowInteractiveRegion(new Rect(-100, -50, 100, 80), 20); + var editRegion = new WindowInteractiveRegion(new Rect(-100, -50, 100, 80), 0); + + Assert.False(IsInside(rounded, new Point(-99, -49))); + Assert.True(IsInside(rounded, new Point(-50, -49))); + Assert.True(IsInside(rounded, new Point(-50, -10))); + Assert.True(IsInside(editRegion, new Point(-99, -49))); + Assert.False(IsInside(editRegion, new Point(1, -10))); + } + + [Fact] + public void HitTesting_UsesInverseRegionTransformForScaledAndTranslatedRoots() + { + var clientToLocal = new Matrix( + 0.5, 0, + 0, 0.5, + -5, -10); + var region = new WindowInteractiveRegion( + new Rect(0, 0, 100, 80), + 20, + clientToLocal); + + Assert.False(IsInside(region, new Point(12, 22))); + Assert.True(IsInside(region, new Point(110, 100))); + Assert.False(IsInside(region, new Point(212, 100))); + } + + [Fact] + public void HitTesting_UsesLocalRoundedGeometryForRotatedRoots() + { + var clientToLocal = new Matrix( + 0, -1, + 1, 0, + -50, 200); + var region = new WindowInteractiveRegion( + new Rect(0, 0, 100, 80), + 20, + clientToLocal); + + Assert.True(IsInside(region, new Point(160, 100))); + Assert.False(IsInside(region, new Point(121, 51))); + Assert.False(IsInside(region, new Point(210, 100))); + } + + [Fact] + public void RestoreCoordinatePolicy_UsesParentClientCoordinatesOnlyForOriginalChildWindows() + { + Assert.True(Invoke("OriginalWindowUsesParentClientCoordinates", WsChild)); + Assert.False(Invoke("OriginalWindowUsesParentClientCoordinates", WsPopup)); + Assert.False(Invoke("OriginalWindowUsesParentClientCoordinates", 0U)); + + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs"); + Assert.Contains("GWLP_HWNDPARENT", source); + Assert.Contains("restore the owner through GWLP_HWNDPARENT", source); + } + + [Fact] + public void MonitorPolicy_HonorsPerWindowRepairBackoffAndHostChanges() + { + var now = new DateTime(2026, 7, 14, 12, 0, 0, DateTimeKind.Utc); + + Assert.False(Invoke( + "ShouldAttemptNativeRepair", + false, + now, + now.AddSeconds(30))); + Assert.True(Invoke( + "ShouldAttemptNativeRepair", + false, + now, + now.AddSeconds(-1))); + Assert.True(Invoke( + "ShouldAttemptNativeRepair", + true, + now, + now.AddMinutes(1))); + } + + [Fact] + public void MonitorPolicy_DoesNotRemountSafeFallbackUntilHostActuallyChanges() + { + var currentHost = new IntPtr(42); + + Assert.False(Invoke( + "ShouldAttemptDesktopAttachment", + false, + IntPtr.Zero, + currentHost, + false)); + Assert.True(Invoke( + "ShouldAttemptDesktopAttachment", + true, + IntPtr.Zero, + currentHost, + false)); + Assert.True(Invoke( + "ShouldAttemptDesktopAttachment", + false, + currentHost, + currentHost, + true)); + } + + [Fact] + public void HitTestCoordinates_AreResolvedFromTheLiveWindowState() + { + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "WindowPassthroughService.cs"); + + Assert.Contains("ScreenToClient(hWnd, ref screenPoint)", source); + Assert.Contains("GetDpiForWindow(handle)", source); + Assert.DoesNotContain("_windowScreenOrigins", source); + Assert.DoesNotContain("_windowDpiScales", source); + Assert.DoesNotContain("GetDpiForMonitor", source); + } + + private static bool IsInside(WindowInteractiveRegion region, Point point) + { + return Invoke("IsPointInsideRegion", region, point); + } + + private static T Invoke(string methodName, params object[] arguments) + { + var serviceType = typeof(IWindowBottomMostService).Assembly.GetType( + "LanMountainDesktop.Services.WindowsWindowBottomMostService", + throwOnError: true)!; + var method = serviceType.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + + Assert.NotNull(method); + return (T)method.Invoke(null, arguments)!; + } + + private static string ReadRepositoryFile(params string[] segments) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray()); + if (File.Exists(candidate)) + { + return File.ReadAllText(candidate); + } + + if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + break; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'."); + } +} diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index d6d554c..3598253 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Threading; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform; +using Avalonia.Threading; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.DesktopEditing; +using LanMountainDesktop.Host.Abstractions; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; @@ -26,11 +29,16 @@ public interface IFusedDesktopManagerService bool IsEditMode { get; } } +internal readonly record struct FusedDesktopScreenWorkArea(PixelRect WorkingArea, double Scaling); + internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { private readonly IFusedDesktopLayoutService _layoutService; private readonly ISettingsFacadeService _settingsFacade; + private readonly IWindowBottomMostService _bottomMostService; + private readonly IAppearanceThemeService _appearanceThemeService; private readonly Dictionary _widgetWindows = []; + private readonly HashSet _positioningFailures = new(StringComparer.OrdinalIgnoreCase); private readonly IWeatherInfoService _weatherDataService; private readonly TimeZoneService _timeZoneService; @@ -39,7 +47,10 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService private ComponentRegistry? _componentRegistry; private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; + private Screens? _screens; private bool _isEditMode; + private bool _isAppearanceSubscribed; + private int _screenTopologyUpdatePending; private const double DefaultCellSize = 100; @@ -51,6 +62,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { _layoutService = layoutService; _settingsFacade = settingsFacade; + _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); + _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); _weatherDataService = _settingsFacade.Weather.GetWeatherInfoService(); _timeZoneService = _settingsFacade.Region.GetTimeZoneService(); @@ -67,10 +80,22 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService return; } + EnsureAppearanceSubscription(); EnsureRegistries(); ReloadWidgets(); } + private void EnsureAppearanceSubscription() + { + if (_isAppearanceSubscribed) + { + return; + } + + _appearanceThemeService.Changed += OnAppearanceThemeChanged; + _isAppearanceSubscribed = true; + } + private void EnsureRegistries() { if (_componentRuntimeRegistry is not null) return; @@ -83,6 +108,340 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService _settingsFacade); } + private void EnsureScreenTopologySubscription(DesktopWidgetWindow window) + { + var screens = window.Screens; + if (ReferenceEquals(_screens, screens)) + { + return; + } + + if (_screens is not null) + { + _screens.Changed -= OnScreensChanged; + } + + _screens = screens; + _screens.Changed += OnScreensChanged; + } + + private void OnScreensChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + if (Interlocked.Exchange(ref _screenTopologyUpdatePending, 1) != 0) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + try + { + RevalidateWidgetsForScreenTopology(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktopMgr", "Failed to revalidate widgets after screen topology changed.", ex); + } + finally + { + Interlocked.Exchange(ref _screenTopologyUpdatePending, 0); + } + }, DispatcherPriority.Background); + } + + private void RevalidateWidgetsForScreenTopology() + { + if (_screens is null || _widgetWindows.Count == 0) + { + return; + } + + var layout = _layoutService.Load(); + var placements = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var placement in layout.ComponentPlacements) + { + placements[placement.PlacementId] = placement; + } + + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + var layoutChanged = false; + foreach (var (placementId, window) in _widgetWindows) + { + if (!placements.TryGetValue(placementId, out var placement)) + { + continue; + } + + var logicalSize = new Size( + placement.Width > 0 ? placement.Width : Math.Max(1d, window.Bounds.Width), + placement.Height > 0 ? placement.Height : Math.Max(1d, window.Bounds.Height)); + var currentPosition = _bottomMostService.GetScreenPosition(window); + if (_positioningFailures.Contains(placementId)) + { + var persistedPosition = new PixelPoint((int)placement.X, (int)placement.Y); + var retryPosition = CoerceToValidWorkingArea( + persistedPosition, + logicalSize, + _screens.All); + if (!_bottomMostService.SetScreenPosition( + window, + retryPosition, + queueOnFailure: true)) + { + UpdateWidgetChrome(window, placement, appearanceSnapshot); + continue; + } + + _positioningFailures.Remove(placementId); + currentPosition = _bottomMostService.GetScreenPosition(window); + } + + var validPosition = CoerceToValidWorkingArea( + currentPosition, + logicalSize, + _screens.All); + + var finalPosition = currentPosition; + if (validPosition != currentPosition) + { + if (_bottomMostService.SetScreenPosition( + window, + validPosition, + queueOnFailure: true)) + { + _positioningFailures.Remove(placementId); + finalPosition = _bottomMostService.GetScreenPosition(window); + } + else + { + _positioningFailures.Add(placementId); + } + } + + var finalValidPosition = CoerceToValidWorkingArea( + finalPosition, + logicalSize, + _screens.All); + if (finalPosition == finalValidPosition && + (placement.X != finalPosition.X || placement.Y != finalPosition.Y)) + { + placement.X = finalPosition.X; + placement.Y = finalPosition.Y; + layoutChanged = true; + } + + UpdateWidgetChrome(window, placement, appearanceSnapshot); + } + + if (layoutChanged) + { + _layoutService.Save(layout); + } + } + + private static PixelPoint CoerceToValidWorkingArea( + PixelPoint position, + Size logicalSize, + IReadOnlyList screens) + { + if (screens.Count == 0) + { + return position; + } + + var workAreas = new FusedDesktopScreenWorkArea[screens.Count]; + for (var i = 0; i < screens.Count; i++) + { + workAreas[i] = new FusedDesktopScreenWorkArea( + screens[i].WorkingArea, + screens[i].Scaling); + } + + return CoerceToValidWorkingArea(position, logicalSize, workAreas); + } + + internal static PixelPoint CoerceToValidWorkingArea( + PixelPoint position, + Size logicalSize, + IReadOnlyList screens) + { + if (screens.Count == 0) + { + return position; + } + + var targetIndex = -1; + for (var i = 0; i < screens.Count; i++) + { + if (screens[i].WorkingArea.Contains(position)) + { + targetIndex = i; + break; + } + } + + if (targetIndex < 0) + { + long nearestDistance = long.MaxValue; + for (var i = 0; i < screens.Count; i++) + { + var distance = SquaredDistanceToRect(position, screens[i].WorkingArea); + if (distance < nearestDistance) + { + nearestDistance = distance; + targetIndex = i; + } + } + } + + var targetScreen = screens[Math.Max(0, targetIndex)]; + var workArea = targetScreen.WorkingArea; + var scaling = double.IsFinite(targetScreen.Scaling) + ? Math.Max(0.1, targetScreen.Scaling) + : 1d; + var widthPixels = ScaleLogicalSizeToPixels(logicalSize.Width, scaling); + var heightPixels = ScaleLogicalSizeToPixels(logicalSize.Height, scaling); + var maxX = Math.Max(workArea.X, workArea.Right - Math.Min(widthPixels, workArea.Width)); + var maxY = Math.Max(workArea.Y, workArea.Bottom - Math.Min(heightPixels, workArea.Height)); + + return new PixelPoint( + Math.Clamp(position.X, workArea.X, maxX), + Math.Clamp(position.Y, workArea.Y, maxY)); + } + + private static int ScaleLogicalSizeToPixels(double logicalSize, double scaling) + { + if (!double.IsFinite(logicalSize) || logicalSize <= 0d) + { + return 1; + } + + var pixels = Math.Ceiling(logicalSize * scaling); + return pixels >= int.MaxValue ? int.MaxValue : Math.Max(1, (int)pixels); + } + + private static long SquaredDistanceToRect(PixelPoint point, PixelRect rect) + { + var deltaX = point.X < rect.X + ? (long)rect.X - point.X + : point.X >= rect.Right + ? (long)point.X - rect.Right + 1 + : 0L; + var deltaY = point.Y < rect.Y + ? (long)rect.Y - point.Y + : point.Y >= rect.Bottom + ? (long)point.Y - rect.Bottom + 1 + : 0L; + return deltaX * deltaX + deltaY * deltaY; + } + + private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot snapshot) + { + _ = sender; + + // Components receive the same appearance event themselves. Scheduling the host + // contour refresh after those handlers ensures a component cannot restore an outer + // shadow or a stale radius during its own theme update. + Dispatcher.UIThread.Post( + () => + { + try + { + RefreshWidgetChrome(snapshot); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktopMgr", "Failed to refresh widget chrome after appearance change.", ex); + } + }, + DispatcherPriority.Render); + } + + private void RefreshWidgetChrome(AppearanceThemeSnapshot snapshot) + { + if (_widgetWindows.Count == 0) + { + return; + } + + EnsureRegistries(); + var layout = _layoutService.Load(); + var placements = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var placement in layout.ComponentPlacements) + { + placements[placement.PlacementId] = placement; + } + + foreach (var (placementId, window) in _widgetWindows) + { + if (placements.TryGetValue(placementId, out var placement)) + { + UpdateWidgetChrome(window, placement, snapshot); + } + } + } + + private void UpdateWidgetChrome( + DesktopWidgetWindow window, + FusedDesktopComponentPlacementSnapshot placement, + AppearanceThemeSnapshot snapshot) + { + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + { + return; + } + + var cellSize = ResolveCellSize(placement); + window.UpdateComponentChrome(ResolveCornerRadiusSafely( + descriptor, + placement, + cellSize, + snapshot)); + } + + private static double ResolveCornerRadiusSafely( + DesktopComponentRuntimeDescriptor descriptor, + FusedDesktopComponentPlacementSnapshot placement, + double cellSize, + AppearanceThemeSnapshot snapshot) + { + var cornerRadius = Math.Max(0d, snapshot.CornerRadiusTokens.Component.TopLeft); + try + { + cornerRadius = ResolveCornerRadius(descriptor, placement, cellSize, snapshot); + } + catch (Exception ex) + { + // A third-party descriptor must not prevent the remaining fused components from + // receiving an appearance update. Keep this placement on the current global token. + AppLogger.Warn( + "FusedDesktopMgr", + $"Failed to resolve component chrome. ComponentId='{placement.ComponentId}'; " + + $"PlacementId='{placement.PlacementId}'.", + ex); + } + + return cornerRadius; + } + + private static double ResolveCornerRadius( + DesktopComponentRuntimeDescriptor descriptor, + FusedDesktopComponentPlacementSnapshot placement, + double cellSize, + AppearanceThemeSnapshot snapshot) + { + return descriptor.ResolveCornerRadius(new ComponentChromeContext( + placement.ComponentId, + placement.PlacementId, + cellSize, + snapshot.CornerRadiusTokens)); + } + public void EnterEditMode() { if (_isEditMode) return; @@ -141,13 +500,25 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService } window.Show(); - window.Position = new PixelPoint((int)placement.X, (int)placement.Y); + EnsureScreenTopologySubscription(window); + if (_bottomMostService.SetScreenPosition( + window, + new PixelPoint((int)placement.X, (int)placement.Y), + queueOnFailure: true)) + { + _positioningFailures.Remove(placement.PlacementId); + } + else + { + _positioningFailures.Add(placement.PlacementId); + } window.RefreshDesktopLayer(); } } catch (Exception ex) { AppLogger.Warn("FusedDesktopMgr", $"Failed to create widget window for {componentId}", ex); + _positioningFailures.Remove(placement.PlacementId); _layoutService.RemoveComponentPlacement(placement.PlacementId); } @@ -158,6 +529,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService public void RemoveComponent(string placementId) { + _positioningFailures.Remove(placementId); if (_widgetWindows.Remove(placementId, out var windowToRemove)) { windowToRemove.Close(); @@ -169,7 +541,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService public void ReloadWidgets() { + EnsureAppearanceSubscription(); var layout = _layoutService.Load(); + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); var existingIds = new HashSet(_widgetWindows.Keys); foreach (var placement in layout.ComponentPlacements) @@ -178,13 +552,25 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow)) { - existingWindow.Position = new PixelPoint((int)placement.X, (int)placement.Y); existingWindow.UpdateComponentLayout(placement.Width, placement.Height); + UpdateWidgetChrome(existingWindow, placement, appearanceSnapshot); if (existingWindow.IsVisible == false) { existingWindow.Show(); } + EnsureScreenTopologySubscription(existingWindow); + if (_bottomMostService.SetScreenPosition( + existingWindow, + new PixelPoint((int)placement.X, (int)placement.Y), + queueOnFailure: true)) + { + _positioningFailures.Remove(placement.PlacementId); + } + else + { + _positioningFailures.Add(placement.PlacementId); + } existingWindow.RefreshDesktopLayer(); } else @@ -201,7 +587,18 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService } window.Show(); - window.Position = new PixelPoint((int)placement.X, (int)placement.Y); + EnsureScreenTopologySubscription(window); + if (_bottomMostService.SetScreenPosition( + window, + new PixelPoint((int)placement.X, (int)placement.Y), + queueOnFailure: true)) + { + _positioningFailures.Remove(placement.PlacementId); + } + else + { + _positioningFailures.Add(placement.PlacementId); + } window.RefreshDesktopLayer(); } } @@ -214,22 +611,41 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService foreach (var id in existingIds) { + _positioningFailures.Remove(id); if (_widgetWindows.Remove(id, out var windowToRemove)) { windowToRemove.Close(); } } + + // A monitor may have been removed while the app was not running, in which case no + // Screens.Changed event will arrive for the stale persisted coordinates. + RevalidateWidgetsForScreenTopology(); } public void Shutdown() { _isEditMode = false; + if (_screens is not null) + { + _screens.Changed -= OnScreensChanged; + _screens = null; + } + + Interlocked.Exchange(ref _screenTopologyUpdatePending, 0); + if (_isAppearanceSubscribed) + { + _appearanceThemeService.Changed -= OnAppearanceThemeChanged; + _isAppearanceSubscribed = false; + } + foreach (var window in _widgetWindows.Values) { window.Close(); } _widgetWindows.Clear(); + _positioningFailures.Clear(); AppLogger.Info("FusedDesktop", "Fused desktop manager shut down."); } @@ -255,7 +671,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService control.Width = placement.Width; control.Height = placement.Height; - var window = new DesktopWidgetWindow(control, placement.PlacementId); + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + var cornerRadius = ResolveCornerRadiusSafely( + descriptor, + placement, + cellSize, + appearanceSnapshot); + var window = new DesktopWidgetWindow(control, placement.PlacementId, cornerRadius); + window.UpdateComponentLayout(placement.Width, placement.Height); return window; } diff --git a/LanMountainDesktop/Services/WindowPassthroughService.cs b/LanMountainDesktop/Services/WindowPassthroughService.cs index 44c2be4..745b20f 100644 --- a/LanMountainDesktop/Services/WindowPassthroughService.cs +++ b/LanMountainDesktop/Services/WindowPassthroughService.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Threading; using Avalonia; using Avalonia.Controls; +using Avalonia.Threading; namespace LanMountainDesktop.Services; @@ -10,12 +12,21 @@ public interface IWindowBottomMostService { void SetupBottomMost(Window window); void SendToBottom(Window window); + PixelPoint GetScreenPosition(Window window); + bool SetScreenPosition(Window window, PixelPoint position, bool queueOnFailure = false); bool IsBottomMostSupported { get; } } +public readonly record struct WindowInteractiveRegion( + Rect Bounds, + double CornerRadius, + Matrix? ClientToRegionTransform = null, + Rect? ClientClipBounds = null, + double ClientClipCornerRadius = 0d); + public interface IRegionPassthroughService { - void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions); + void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions); void ClearInteractiveRegions(Window window); bool IsRegionPassthroughSupported { get; } } @@ -56,175 +67,831 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService { private const int GWL_STYLE = -16; private const int GWL_EXSTYLE = -20; - private const int GWLP_WNDPROC = -4; + private const int GWLP_HWNDPARENT = -8; - private const long WS_CHILD = 0x40000000L; - private const long WS_POPUP = 0x80000000L; - private const long WS_CAPTION = 0x00C00000L; - private const long WS_THICKFRAME = 0x00040000L; - private const long WS_MINIMIZEBOX = 0x00020000L; - private const long WS_MAXIMIZEBOX = 0x00010000L; - private const long WS_SYSMENU = 0x00080000L; + private const uint WS_CHILD = 0x40000000U; + private const uint WS_POPUP = 0x80000000U; + private const uint WS_CAPTION = 0x00C00000U; + private const uint WS_THICKFRAME = 0x00040000U; + private const uint WS_MINIMIZEBOX = 0x00020000U; + private const uint WS_MAXIMIZEBOX = 0x00010000U; + private const uint WS_SYSMENU = 0x00080000U; - private const long WS_EX_TOOLWINDOW = 0x00000080L; - private const long WS_EX_APPWINDOW = 0x00040000L; - private const long WS_EX_NOACTIVATE = 0x08000000L; - private const long WS_EX_LAYERED = 0x00080000L; + private const uint WS_EX_TOOLWINDOW = 0x00000080U; + private const uint WS_EX_APPWINDOW = 0x00040000U; + private const uint WS_EX_NOACTIVATE = 0x08000000U; + private const uint WS_EX_NOREDIRECTIONBITMAP = 0x00200000U; + private const uint AVALONIA_COMPOSITION_EXSTYLE_MASK = WS_EX_NOREDIRECTIONBITMAP; private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOZORDER = 0x0004; private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_FRAMECHANGED = 0x0020; private const uint SWP_SHOWWINDOW = 0x0040; + private const uint SWP_HIDEWINDOW = 0x0080; - private const int WM_NCHITTEST = 0x0084; + private const uint WM_NCHITTEST = 0x0084; private const int HTTRANSPARENT = -1; private const int HTCLIENT = 1; - private const int MONITOR_DEFAULTTONEAREST = 2; - private const int MDT_EFFECTIVE_DPI = 0; + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWA_BORDER_COLOR = 34; + private const uint DWMWCP_DONOTROUND = 1; + private const uint DWMWA_COLOR_NONE = 0xFFFFFFFEU; private static readonly IntPtr HWND_TOP = IntPtr.Zero; private static readonly IntPtr HWND_BOTTOM = new(1); - private static readonly object _staticLock = new(); - private static readonly object _timerLock = new(); + private static readonly object StaticLock = new(); + private static readonly object TimerLock = new(); - private static readonly Dictionary _desktopWindows = new(); - private static readonly Dictionary _originalWndProcs = new(); - private static readonly Dictionary> _interactiveRegions = new(); - private static readonly Dictionary _windowScreenOrigins = new(); - private static readonly Dictionary _windowDpiScales = new(); + private static readonly Dictionary WindowStates = new(); - private static WndProcDelegate? _wndProcDelegate; private static System.Timers.Timer? _desktopHostMonitorTimer; + private static IntPtr _lastResolvedDesktopHost; + private static int _monitorDispatchPending; public bool IsBottomMostSupported => true; public void SetupBottomMost(Window window) { + ArgumentNullException.ThrowIfNull(window); if (!OperatingSystem.IsWindows()) { return; } - var handle = GetWindowHandle(window); - if (handle != IntPtr.Zero) + DesktopWindowState state; + lock (StaticLock) { - ApplyDesktopAttachment(handle, logSuccess: true); - } - else - { - window.Opened += (_, _) => + if (WindowStates.TryGetValue(window, out state!)) { - var openedHandle = GetWindowHandle(window); - if (openedHandle != IntPtr.Zero) - { - ApplyDesktopAttachment(openedHandle, logSuccess: true); - } - }; + return; + } + + state = new DesktopWindowState(window); + WindowStates[window] = state; } - window.Closed += (_, _) => + Win32Properties.SetWindowCornerPreference(window, Win32Properties.WindowCornerPreference.DoNotRound); + Win32Properties.AddWindowStylesCallback(window, state.WindowStylesCallback); + Win32Properties.AddWndProcHookCallback(window, state.WndProcHookCallback); + + window.Closed += OnWindowClosed; + + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) { - var closedHandle = GetWindowHandle(window); - if (closedHandle != IntPtr.Zero) - { - CleanupWindow(closedHandle); - } - }; + window.Opened += OnWindowOpened; + return; + } + + RunOnUiThread(() => InitializeAndAttach(state, handle, logSuccess: true)); } public void SendToBottom(Window window) { + ArgumentNullException.ThrowIfNull(window); + if (!TryGetWindowState(window, out var state)) + { + SetupBottomMost(window); + return; + } + + RunOnUiThread(() => + { + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero || !IsWindow(handle)) + { + return; + } + + RegisterHandle(state, handle); + if (state.NeedsNativeRepair && + !ShouldAttemptNativeRepair( + false, + DateTime.UtcNow, + state.NextNativeRepairAttemptUtc)) + { + return; + } + + var desktopHost = ResolveDesktopIconHost(); + if (!state.NeedsNativeRepair && + state.IsDesktopAttached && + state.HasStableDesktopAttachment && + desktopHost != IntPtr.Zero && + state.DesktopHost == desktopHost && + GetParent(handle) == desktopHost && + state.OriginalState is { } originalState && + HasExpectedDesktopRoleStyles(handle, originalState)) + { + _ = SetWindowPos( + handle, + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW); + return; + } + + ApplyDesktopAttachment( + state, + desktopHost, + logSuccess: false, + "explicit refresh", + allowRetryFailedHost: true); + }); + } + + public PixelPoint GetScreenPosition(Window window) + { + ArgumentNullException.ThrowIfNull(window); + var handle = GetWindowHandle(window); + return handle != IntPtr.Zero && GetWindowRect(handle, out var rect) + ? new PixelPoint(rect.Left, rect.Top) + : window.Position; + } + + public bool SetScreenPosition( + Window window, + PixelPoint position, + bool queueOnFailure = false) + { + ArgumentNullException.ThrowIfNull(window); + TryGetWindowState(window, out var state); + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero || !IsWindow(handle)) + { + window.Position = position; + if (state is not null) + { + state.PendingScreenPosition = null; + state.HasLoggedPositionFailure = false; + } + + return true; + } + + var nativePosition = new POINT(position.X, position.Y); + var style = ReadWindowStyle(handle, GWL_STYLE); + var nativeParent = GetParent(handle); + if (state is not null) + { + if (state.NeedsNativeRepair) + { + if (queueOnFailure) + { + state.PendingScreenPosition = position; + } + + return false; + } + + if (state.IsDesktopAttached) + { + if (state.OriginalState is not { } originalState || + nativeParent != state.DesktopHost || + !HasExpectedDesktopRoleStyles(handle, originalState)) + { + LogPositionFailureOnce( + state, + $"Refusing to move a desktop window with invalid native attachment state. " + + $"Window={handle}; Parent={nativeParent}; ExpectedHost={state.DesktopHost}."); + if (queueOnFailure) + { + state.PendingScreenPosition = position; + } + + return false; + } + + nativeParent = state.DesktopHost; + } + } + + if (OriginalWindowUsesParentClientCoordinates(style) && + (nativeParent == IntPtr.Zero || !ScreenToClient(nativeParent, ref nativePosition))) + { + if (state is not null) + { + LogPositionFailureOnce( + state, + $"Could not translate screen position to child-window coordinates. " + + $"Window={handle}; Parent={nativeParent}."); + if (queueOnFailure) + { + state.PendingScreenPosition = position; + } + } + return false; + } + + if (!SetWindowPos( + handle, + IntPtr.Zero, + nativePosition.X, + nativePosition.Y, + 0, + 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE)) + { + if (state is not null) + { + LogPositionFailureOnce( + state, + $"Could not set screen position. Window={handle}; Position={position}; " + + $"Error={Marshal.GetLastWin32Error()}."); + if (queueOnFailure) + { + state.PendingScreenPosition = position; + } + } + return false; + } + + if (state is not null) + { + state.PendingScreenPosition = null; + state.HasLoggedPositionFailure = false; + } + + return true; + } + + private static void LogPositionFailureOnce(DesktopWindowState state, string message) + { + if (state.HasLoggedPositionFailure) + { + return; + } + + AppLogger.Warn("WindowBottomMost", message); + state.HasLoggedPositionFailure = true; + } + + private static void TryApplyPendingScreenPosition(DesktopWindowState state) + { + if (state.NeedsNativeRepair || state.PendingScreenPosition is not { } pendingPosition) + { + return; + } + + _ = new WindowsWindowBottomMostService().SetScreenPosition( + state.Window, + pendingPosition, + queueOnFailure: true); + } + + internal static void SetInteractiveRegionsInternal( + Window window, + IReadOnlyList regions) + { + if (!TryGetWindowState(window, out var state)) + { + return; + } + + var snapshot = new WindowInteractiveRegion[regions.Count]; + for (var i = 0; i < regions.Count; i++) + { + snapshot[i] = regions[i]; + } + + state.InteractiveRegions = snapshot; + } + + internal static (uint Style, uint ExStyle) CreateDesktopChildStyles(uint style, uint exStyle) + { + style |= WS_CHILD; + style &= ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); + return (style, ApplyDesktopRoleExtendedStyles(exStyle)); + } + + internal static bool IsPointInsideRegion(WindowInteractiveRegion region, Point point) + { + if (region.ClientClipBounds is { } clientClipBounds && + !IsPointInsideRoundedBounds(clientClipBounds, region.ClientClipCornerRadius, point)) + { + return false; + } + + if (region.ClientToRegionTransform is { } clientToRegionTransform) + { + point = clientToRegionTransform.Transform(point); + } + + return IsPointInsideRoundedBounds(region.Bounds, region.CornerRadius, point); + } + + private static bool IsPointInsideRoundedBounds(Rect bounds, double cornerRadius, Point point) + { + if (bounds.Width <= 0 || bounds.Height <= 0 || !bounds.Contains(point)) + { + return false; + } + + var radius = Math.Clamp(cornerRadius, 0, Math.Min(bounds.Width, bounds.Height) / 2); + if (radius <= 0) + { + return true; + } + + var localX = point.X - bounds.X; + var localY = point.Y - bounds.Y; + if (localX >= radius && localX <= bounds.Width - radius || + localY >= radius && localY <= bounds.Height - radius) + { + return true; + } + + var centerX = localX < radius ? radius : bounds.Width - radius; + var centerY = localY < radius ? radius : bounds.Height - radius; + var deltaX = localX - centerX; + var deltaY = localY - centerY; + return deltaX * deltaX + deltaY * deltaY <= radius * radius; + } + + internal static bool OriginalWindowUsesParentClientCoordinates(uint style) + { + return (style & WS_CHILD) != 0; + } + + internal static bool ShouldAttemptNativeRepair( + bool hostChanged, + DateTime utcNow, + DateTime nextRepairAttemptUtc) + { + return hostChanged || utcNow >= nextRepairAttemptUtc; + } + + internal static bool ShouldAttemptDesktopAttachment( + bool hostChanged, + IntPtr attachedHost, + IntPtr currentHost, + bool parentMismatch) + { + return parentMismatch || (hostChanged && attachedHost != currentHost); + } + + private static uint ApplyDesktopRoleExtendedStyles(uint exStyle) + { + // Preserve every compositor-managed bit from Avalonia. In particular, this method must + // never opt the window into a second, legacy alpha-composition path. + return (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE) & ~WS_EX_APPWINDOW; + } + + private static void OnWindowOpened(object? sender, EventArgs e) + { + if (sender is not Window window || !TryGetWindowState(window, out var state)) + { + return; + } + + window.Opened -= OnWindowOpened; var handle = GetWindowHandle(window); if (handle != IntPtr.Zero) { - ApplyDesktopAttachment(handle, logSuccess: false); + RunOnUiThread(() => InitializeAndAttach(state, handle, logSuccess: true)); } } - internal static void SetInteractiveRegionsInternal(IntPtr handle, List regions) + private static void OnWindowClosed(object? sender, EventArgs e) { - lock (_staticLock) + if (sender is Window window && TryGetWindowState(window, out var state)) { - _interactiveRegions[handle] = regions; - UpdateWindowScreenOrigin(handle); - UpdateWindowDpiScale(handle); + CleanupWindow(state, restoreNativeState: true); } } - private static void ApplyDesktopAttachment(IntPtr handle, bool logSuccess) + private static void InitializeAndAttach(DesktopWindowState state, IntPtr handle, bool logSuccess) { if (handle == IntPtr.Zero || !IsWindow(handle)) { return; } - SetDesktopChildStyles(handle); - InstallMessageHook(handle); - - var attached = TryAttachToDesktopIconHost(handle, out var desktopHost); - lock (_staticLock) - { - _desktopWindows[handle] = new DesktopWindowState(desktopHost, attached); - if (!_interactiveRegions.ContainsKey(handle)) - { - _interactiveRegions[handle] = []; - } - - UpdateWindowScreenOrigin(handle); - UpdateWindowDpiScale(handle); - } - - if (attached) - { - SetWindowPos(handle, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW); - if (logSuccess) - { - AppLogger.Info("WindowBottomMost", $"Mounted window to desktop icon host. Window={handle}; Host={desktopHost}"); - } - } - else - { - SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE); - if (logSuccess) - { - AppLogger.Warn("WindowBottomMost", $"Desktop icon host not found. Falling back to HWND_BOTTOM. Window={handle}"); - } - } - - StartDesktopHostMonitorTimer(); + RegisterHandle(state, handle); + ConfigureDwmAppearance(handle); + ApplyDesktopAttachment(state, ResolveDesktopIconHost(), logSuccess, "initial setup"); } - private static void SetDesktopChildStyles(IntPtr handle) + private static void RegisterHandle(DesktopWindowState state, IntPtr handle) { - var style = GetWindowLongPtr(handle, GWL_STYLE).ToInt64(); - style |= WS_CHILD; - style &= ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); - SetWindowLongPtr(handle, GWL_STYLE, new IntPtr(style)); + lock (StaticLock) + { + if (state.Handle != IntPtr.Zero && state.Handle != handle) + { + state.OriginalState = null; + state.DesktopHost = IntPtr.Zero; + state.IsDesktopAttached = false; + state.HasStableDesktopAttachment = false; + ResetNativeRepairState(state); + state.AttachToCurrentHostAfterRepair = false; + state.HasLoggedFallback = false; + state.HasLoggedPositionFailure = false; + } - var exStyle = GetWindowLongPtr(handle, GWL_EXSTYLE).ToInt64(); - exStyle = (exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED) & ~WS_EX_APPWINDOW; - SetWindowLongPtr(handle, GWL_EXSTYLE, new IntPtr(exStyle)); + state.Handle = handle; + state.OriginalState ??= new NativeWindowState( + GetParent(handle), + ReadWindowStyle(handle, GWL_STYLE), + ReadWindowStyle(handle, GWL_EXSTYLE)); + } } - private static bool TryAttachToDesktopIconHost(IntPtr handle, out IntPtr desktopHost) + private static void ApplyDesktopAttachment( + DesktopWindowState state, + IntPtr desktopHost, + bool logSuccess, + string reason, + bool allowRetryFailedHost = false) { - desktopHost = ResolveDesktopIconHost(); + var handle = state.Handle; + if (handle == IntPtr.Zero || !IsWindow(handle) || state.OriginalState is not { } originalState) + { + return; + } + + var screenPosition = GetNativeScreenPosition(handle, state.Window.Position); + if (state.NeedsNativeRepair) + { + var failedHost = state.FailedDesktopHost; + var attachAfterRepair = state.AttachToCurrentHostAfterRepair; + FallBackToBottom( + state, + screenPosition, + "repairing an incomplete native rollback before attachment", + logSuccess: false, + failedHost); + if (state.NeedsNativeRepair) + { + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + state.AttachToCurrentHostAfterRepair = false; + if (!attachAfterRepair && desktopHost == failedHost && !allowRetryFailedHost) + { + // The failed host has not changed. Stay in the safe top-level fallback until + // Explorer changes or the caller explicitly requests another attachment. + StartDesktopHostMonitorTimer(desktopHost); + return; + } + } + + var beforeStyle = ReadWindowStyle(handle, GWL_STYLE); + var beforeExStyle = ReadWindowStyle(handle, GWL_EXSTYLE); + if (desktopHost == IntPtr.Zero || !IsWindow(desktopHost)) + { + FallBackToBottom( + state, + screenPosition, + "desktop icon host is unavailable", + logSuccess, + desktopHost); + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + if (state.IsDesktopAttached) + { + if (state.DesktopHost == desktopHost && GetParent(handle) == desktopHost) + { + if (HasExpectedDesktopRoleStyles(handle, originalState)) + { + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + FallBackToBottom( + state, + screenPosition, + "desktop child style validation failed", + logSuccess, + desktopHost); + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + var hostBeingDetached = state.DesktopHost; + if (!TryRestoreNativeState(state, screenPosition, showWindow: true)) + { + FallBackToBottom( + state, + screenPosition, + "failed to restore before remount", + logSuccess, + hostBeingDetached); + if (state.NeedsNativeRepair && desktopHost != hostBeingDetached) + { + state.AttachToCurrentHostAfterRepair = true; + } + + if (state.NeedsNativeRepair || + (desktopHost == hostBeingDetached && !allowRetryFailedHost)) + { + StartDesktopHostMonitorTimer(desktopHost); + return; + } + } + + beforeStyle = ReadWindowStyle(handle, GWL_STYLE); + beforeExStyle = ReadWindowStyle(handle, GWL_EXSTYLE); + } + + beforeExStyle |= originalState.ExStyle & AVALONIA_COMPOSITION_EXSTYLE_MASK; + var (expectedStyle, expectedExStyle) = CreateDesktopChildStyles(beforeStyle, beforeExStyle); + WriteWindowStyle(handle, GWL_STYLE, expectedStyle); + WriteWindowStyle(handle, GWL_EXSTYLE, expectedExStyle); + + _ = SetParent(handle, desktopHost); + var setParentError = Marshal.GetLastWin32Error(); + if (GetParent(handle) != desktopHost) + { + FallBackToBottom( + state, + screenPosition, + $"SetParent failed with error {setParentError}", + logSuccess, + desktopHost); + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + state.DesktopHost = desktopHost; + state.IsDesktopAttached = true; + state.HasStableDesktopAttachment = false; + + var childPosition = new POINT(screenPosition.X, screenPosition.Y); + if (!ScreenToClient(desktopHost, ref childPosition) || + !SetWindowPos( + handle, + HWND_TOP, + childPosition.X, + childPosition.Y, + 0, + 0, + SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED | SWP_SHOWWINDOW) || + !HasExpectedDesktopStyles(handle, expectedStyle, expectedExStyle) || + GetParent(handle) != desktopHost) + { + var error = Marshal.GetLastWin32Error(); + FallBackToBottom( + state, + screenPosition, + $"post-attachment validation failed with error {error}", + logSuccess, + desktopHost); + StartDesktopHostMonitorTimer(desktopHost); + return; + } + + ResetNativeRepairState(state); + state.AttachToCurrentHostAfterRepair = false; + state.HasLoggedFallback = false; + state.HasStableDesktopAttachment = true; + ConfigureDwmAppearance(handle); + if (logSuccess) + { + var afterStyle = ReadWindowStyle(handle, GWL_STYLE); + var afterExStyle = ReadWindowStyle(handle, GWL_EXSTYLE); + AppLogger.Info( + "WindowBottomMost", + $"Mounted window to desktop icon host. Window={handle}; Host={desktopHost}; Reason={reason}; " + + $"Style=0x{beforeStyle:X8}->0x{afterStyle:X8}; ExStyle=0x{beforeExStyle:X8}->0x{afterExStyle:X8}; " + + $"NoRedirectionBitmap={((afterExStyle & WS_EX_NOREDIRECTIONBITMAP) != 0)}."); + } + + StartDesktopHostMonitorTimer(desktopHost); + } + + private static void FallBackToBottom( + DesktopWindowState state, + PixelPoint screenPosition, + string reason, + bool logSuccess, + IntPtr failedDesktopHost) + { + var handle = state.Handle; + var wasAttached = state.IsDesktopAttached; + var wasStablyAttached = state.HasStableDesktopAttachment; + var restored = true; + if (state.OriginalState is { } originalState && + (state.NeedsNativeRepair || + wasAttached || + GetParent(handle) != originalState.Parent || + ReadWindowStyle(handle, GWL_STYLE) != originalState.Style || + ReadWindowStyle(handle, GWL_EXSTYLE) != originalState.ExStyle)) + { + restored = TryRestoreNativeState(state, screenPosition, showWindow: true); + } + + if (!restored) + { + MarkNativeRepairPending(state, failedDesktopHost); + if (logSuccess || !state.HasLoggedRepairFailure) + { + AppLogger.Warn( + "WindowBottomMost", + $"Native rollback is incomplete; keeping the window in repair state. " + + $"Window={handle}; FailedHost={failedDesktopHost}; Reason={reason}."); + state.HasLoggedRepairFailure = true; + } + + return; + } + + state.DesktopHost = IntPtr.Zero; + state.IsDesktopAttached = false; + state.HasStableDesktopAttachment = false; + + if (IsWindow(handle) && + !SetWindowPos( + handle, + HWND_BOTTOM, + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW)) + { + MarkNativeRepairPending(state, failedDesktopHost); + if (logSuccess || !state.HasLoggedRepairFailure) + { + AppLogger.Warn( + "WindowBottomMost", + $"Native state was restored, but HWND_BOTTOM fallback positioning failed. " + + $"Window={handle}; FailedHost={failedDesktopHost}; Reason={reason}; " + + $"Error={Marshal.GetLastWin32Error()}."); + state.HasLoggedRepairFailure = true; + } + + return; + } + + ResetNativeRepairState(state); + + if (logSuccess || wasStablyAttached || !state.HasLoggedFallback) + { + AppLogger.Warn( + "WindowBottomMost", + $"Using HWND_BOTTOM fallback. Window={handle}; Reason={reason}; NativeStateRestored={restored}."); + state.HasLoggedFallback = true; + } + } + + private static void MarkNativeRepairPending(DesktopWindowState state, IntPtr failedDesktopHost) + { + state.FailedDesktopHost = failedDesktopHost; + state.NativeRepairAttemptCount = Math.Min(state.NativeRepairAttemptCount + 1, 30); + var exponent = Math.Min(state.NativeRepairAttemptCount - 1, 5); + var delaySeconds = Math.Min(60, 2 * (1 << exponent)); + state.NextNativeRepairAttemptUtc = DateTime.UtcNow.AddSeconds(delaySeconds); + state.NeedsNativeRepair = true; + + if (state.Handle != IntPtr.Zero && IsWindow(state.Handle)) + { + _ = SetWindowPos( + state.Handle, + IntPtr.Zero, + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_HIDEWINDOW); + } + } + + private static void ResetNativeRepairState(DesktopWindowState state) + { + state.NeedsNativeRepair = false; + state.FailedDesktopHost = IntPtr.Zero; + state.HasLoggedRepairFailure = false; + state.NativeRepairAttemptCount = 0; + state.NextNativeRepairAttemptUtc = DateTime.MinValue; + } + + private static bool TryRestoreNativeState( + DesktopWindowState state, + PixelPoint screenPosition, + bool showWindow) + { + var handle = state.Handle; + if (handle == IntPtr.Zero || !IsWindow(handle) || state.OriginalState is not { } originalState) { return false; } - if (GetParent(handle) != desktopHost) + var previousDesktopHost = state.DesktopHost; + var wasDesktopAttached = state.IsDesktopAttached; + var wasStablyDesktopAttached = state.HasStableDesktopAttachment; + state.IsDesktopAttached = false; + state.HasStableDesktopAttachment = false; + state.DesktopHost = IntPtr.Zero; + + var restoreParentOrOwner = originalState.Parent != IntPtr.Zero && !IsWindow(originalState.Parent) + ? IntPtr.Zero + : originalState.Parent; + var restorePosition = new POINT(screenPosition.X, screenPosition.Y); + + if (OriginalWindowUsesParentClientCoordinates(originalState.Style)) { - _ = SetParent(handle, desktopHost); - if (GetParent(handle) != desktopHost) + _ = SetParent(handle, restoreParentOrOwner); + WriteWindowStyle(handle, GWL_STYLE, originalState.Style); + WriteWindowStyle(handle, GWL_EXSTYLE, originalState.ExStyle); + if (restoreParentOrOwner != IntPtr.Zero && + !ScreenToClient(restoreParentOrOwner, ref restorePosition)) { - return false; + restorePosition = new POINT(screenPosition.X, screenPosition.Y); } } + else + { + // GetParent returns an owner for a top-level popup. It is not a child-coordinate + // parent: first leave the desktop child hierarchy, restore the popup styles, then + // restore the owner through GWLP_HWNDPARENT and keep SetWindowPos in screen pixels. + _ = SetParent(handle, IntPtr.Zero); + WriteWindowStyle(handle, GWL_STYLE, originalState.Style); + WriteWindowStyle(handle, GWL_EXSTYLE, originalState.ExStyle); + _ = SetWindowLongPtr(handle, GWLP_HWNDPARENT, restoreParentOrOwner); + } - return true; + var flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED; + if (showWindow) + { + flags |= SWP_SHOWWINDOW; + } + + var positioned = SetWindowPos( + handle, + IntPtr.Zero, + restorePosition.X, + restorePosition.Y, + 0, + 0, + flags); + + var hierarchyAndStylesRestored = + GetParent(handle) == restoreParentOrOwner && + ReadWindowStyle(handle, GWL_STYLE) == originalState.Style && + ReadWindowStyle(handle, GWL_EXSTYLE) == originalState.ExStyle; + if (!hierarchyAndStylesRestored) + { + // Preserve the logical attachment state until a later repair attempt succeeds. + // This prevents the monitor from treating a half-converted child HWND as a valid + // top-level fallback. + state.DesktopHost = previousDesktopHost; + state.IsDesktopAttached = wasDesktopAttached; + state.HasStableDesktopAttachment = wasStablyDesktopAttached; + } + + return hierarchyAndStylesRestored && positioned; + } + + private static bool HasExpectedDesktopStyles(IntPtr handle, uint style, uint exStyle) + { + return ReadWindowStyle(handle, GWL_STYLE) == style && + ReadWindowStyle(handle, GWL_EXSTYLE) == exStyle; + } + + private static bool HasExpectedDesktopRoleStyles(IntPtr handle, NativeWindowState originalState) + { + var style = ReadWindowStyle(handle, GWL_STYLE); + var exStyle = ReadWindowStyle(handle, GWL_EXSTYLE); + var expected = CreateDesktopChildStyles(style, exStyle); + var requiredAvaloniaBits = originalState.ExStyle & AVALONIA_COMPOSITION_EXSTYLE_MASK; + return style == expected.Style && + exStyle == expected.ExStyle && + (exStyle & requiredAvaloniaBits) == requiredAvaloniaBits; + } + + private static void ConfigureDwmAppearance(IntPtr handle) + { + if (handle == IntPtr.Zero || !IsWindow(handle)) + { + return; + } + + try + { + var cornerPreference = DWMWCP_DONOTROUND; + _ = DwmSetWindowAttribute( + handle, + DWMWA_WINDOW_CORNER_PREFERENCE, + ref cornerPreference, + sizeof(uint)); + + var borderColor = DWMWA_COLOR_NONE; + _ = DwmSetWindowAttribute(handle, DWMWA_BORDER_COLOR, ref borderColor, sizeof(uint)); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + // These attributes are best-effort and unavailable on older Windows versions. + } } private static IntPtr ResolveDesktopIconHost() @@ -236,6 +903,21 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService return true; }, IntPtr.Zero); + foreach (var topLevelWindow in topLevelWindows) + { + var worker = FindWindowEx(topLevelWindow, IntPtr.Zero, "WorkerW", null); + if (worker == IntPtr.Zero) + { + continue; + } + + var defView = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null); + if (defView != IntPtr.Zero) + { + return defView; + } + } + foreach (var topLevelWindow in topLevelWindows) { var defView = FindWindowEx(topLevelWindow, IntPtr.Zero, "SHELLDLL_DefView", null); @@ -248,16 +930,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService return IntPtr.Zero; } - private static void StartDesktopHostMonitorTimer() + private static void StartDesktopHostMonitorTimer(IntPtr currentHost) { - lock (_timerLock) + lock (TimerLock) { if (_desktopHostMonitorTimer != null) { return; } - _desktopHostMonitorTimer = new System.Timers.Timer(TimeSpan.FromSeconds(2)); + _lastResolvedDesktopHost = currentHost; + _desktopHostMonitorTimer = new System.Timers.Timer(TimeSpan.FromSeconds(2)) + { + AutoReset = true + }; _desktopHostMonitorTimer.Elapsed += (_, _) => MonitorDesktopHostAttachments(); _desktopHostMonitorTimer.Start(); } @@ -265,159 +951,279 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService private static void MonitorDesktopHostAttachments() { - List handles; - lock (_staticLock) + var desktopHost = ResolveDesktopIconHost(); + var hostChanged = false; + lock (TimerLock) { - handles = [.. _desktopWindows.Keys]; - } - - foreach (var handle in handles) - { - if (!IsWindow(handle)) + if (desktopHost != _lastResolvedDesktopHost) { - CleanupWindow(handle); - continue; + _lastResolvedDesktopHost = desktopHost; + hostChanged = true; } - - ApplyDesktopAttachment(handle, logSuccess: false); } - } - private static void StopDesktopHostMonitorTimerIfIdle() - { - lock (_timerLock) + List states; + lock (StaticLock) { - lock (_staticLock) + states = [.. WindowStates.Values]; + } + + var requiresCleanupOrRepair = false; + var now = DateTime.UtcNow; + foreach (var state in states) + { + if ((state.NeedsNativeRepair && now >= state.NextNativeRepairAttemptUtc) || + (!state.NeedsNativeRepair && state.PendingScreenPosition.HasValue) || + state.Handle != IntPtr.Zero && !IsWindow(state.Handle) || + state.IsDesktopAttached && + (GetParent(state.Handle) != state.DesktopHost || + state.OriginalState is not { } originalState || + !HasExpectedDesktopRoleStyles(state.Handle, originalState))) { - if (_desktopWindows.Count > 0) + requiresCleanupOrRepair = true; + break; + } + } + + if (!hostChanged && !requiresCleanupOrRepair || + Interlocked.Exchange(ref _monitorDispatchPending, 1) != 0) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + try + { + var currentHost = ResolveDesktopIconHost(); + var effectiveHostChanged = hostChanged || currentHost != desktopHost; + List currentStates; + lock (StaticLock) { - return; + currentStates = [.. WindowStates.Values]; + } + + foreach (var state in currentStates) + { + if (state.Handle == IntPtr.Zero) + { + // A window can be registered before Avalonia creates its native handle. + continue; + } + + if (!IsWindow(state.Handle)) + { + CleanupWindow(state, restoreNativeState: false); + continue; + } + + if (state.NeedsNativeRepair) + { + if (!ShouldAttemptNativeRepair( + effectiveHostChanged, + DateTime.UtcNow, + state.NextNativeRepairAttemptUtc)) + { + continue; + } + + var failedHost = state.FailedDesktopHost; + var attachAfterRepair = state.AttachToCurrentHostAfterRepair; + var screenPosition = GetNativeScreenPosition(state.Handle, state.Window.Position); + FallBackToBottom( + state, + screenPosition, + "retrying incomplete native rollback", + logSuccess: false, + failedHost); + if (state.NeedsNativeRepair) + { + continue; + } + + state.AttachToCurrentHostAfterRepair = false; + if (currentHost != IntPtr.Zero && + (attachAfterRepair || + effectiveHostChanged && currentHost != failedHost)) + { + ApplyDesktopAttachment( + state, + currentHost, + logSuccess: true, + "desktop host changed while native state was being repaired"); + } + + TryApplyPendingScreenPosition(state); + continue; + } + + var attachmentDrift = state.IsDesktopAttached && + (GetParent(state.Handle) != state.DesktopHost || + state.OriginalState is not { } originalState || + !HasExpectedDesktopRoleStyles(state.Handle, originalState)); + if (ShouldAttemptDesktopAttachment( + effectiveHostChanged, + state.DesktopHost, + currentHost, + attachmentDrift)) + { + ApplyDesktopAttachment(state, currentHost, logSuccess: true, "desktop host changed"); + } + + TryApplyPendingScreenPosition(state); + } + + lock (TimerLock) + { + _lastResolvedDesktopHost = currentHost; } } - - _desktopHostMonitorTimer?.Stop(); - _desktopHostMonitorTimer?.Dispose(); - _desktopHostMonitorTimer = null; - } + finally + { + Interlocked.Exchange(ref _monitorDispatchPending, 0); + } + }, DispatcherPriority.Background); } - private static void CleanupWindow(IntPtr handle) + private static void CleanupWindow(DesktopWindowState state, bool restoreNativeState) { - IntPtr originalWndProc; - lock (_staticLock) - { - if (_originalWndProcs.TryGetValue(handle, out originalWndProc) && - originalWndProc != IntPtr.Zero && - IsWindow(handle)) - { - SetWindowLongPtr(handle, GWLP_WNDPROC, originalWndProc); - } + state.Window.Opened -= OnWindowOpened; + state.Window.Closed -= OnWindowClosed; + Win32Properties.RemoveWindowStylesCallback(state.Window, state.WindowStylesCallback); + Win32Properties.RemoveWndProcHookCallback(state.Window, state.WndProcHookCallback); - _desktopWindows.Remove(handle); - _originalWndProcs.Remove(handle); - _interactiveRegions.Remove(handle); - _windowScreenOrigins.Remove(handle); - _windowDpiScales.Remove(handle); + if (restoreNativeState && state.Handle != IntPtr.Zero && IsWindow(state.Handle)) + { + var screenPosition = GetNativeScreenPosition(state.Handle, state.Window.Position); + _ = TryRestoreNativeState(state, screenPosition, showWindow: false); + } + + lock (StaticLock) + { + WindowStates.Remove(state.Window); } StopDesktopHostMonitorTimerIfIdle(); } - private static void InstallMessageHook(IntPtr handle) + private static void StopDesktopHostMonitorTimerIfIdle() { - lock (_staticLock) + lock (StaticLock) { - if (_originalWndProcs.ContainsKey(handle)) + if (WindowStates.Count > 0) { return; } } - var originalWndProc = GetWindowLongPtr(handle, GWLP_WNDPROC); - if (originalWndProc == IntPtr.Zero) + lock (TimerLock) { - return; + _desktopHostMonitorTimer?.Stop(); + _desktopHostMonitorTimer?.Dispose(); + _desktopHostMonitorTimer = null; + _lastResolvedDesktopHost = IntPtr.Zero; } - - lock (_staticLock) - { - _originalWndProcs[handle] = originalWndProc; - _wndProcDelegate ??= SubclassWndProc; - } - - SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate)); } - private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam) + private static IntPtr HandleWindowMessage( + DesktopWindowState state, + IntPtr hWnd, + uint message, + IntPtr wParam, + IntPtr lParam, + ref bool handled) { - if (msg == WM_NCHITTEST) + if (message != WM_NCHITTEST) { - var screenX = (short)(lParam.ToInt64() & 0xFFFF); - var screenY = (short)((lParam.ToInt64() >> 16) & 0xFFFF); - - lock (_staticLock) - { - if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0) - { - _windowScreenOrigins.TryGetValue(hWnd, out var origin); - _windowDpiScales.TryGetValue(hWnd, out var dpiScale); - if (dpiScale <= 0) - { - dpiScale = 1.0; - } - - var point = new Point((screenX - origin.X) / dpiScale, (screenY - origin.Y) / dpiScale); - foreach (var region in regions) - { - if (region.Contains(point)) - { - return (IntPtr)HTCLIENT; - } - } - } - } + return IntPtr.Zero; + } + if (state.NeedsNativeRepair) + { + handled = true; return (IntPtr)HTTRANSPARENT; } - IntPtr originalWndProc; - lock (_staticLock) + var screenPoint = new POINT( + unchecked((short)(lParam.ToInt64() & 0xFFFF)), + unchecked((short)((lParam.ToInt64() >> 16) & 0xFFFF))); + if (!ScreenToClient(hWnd, ref screenPoint)) { - if (!_originalWndProcs.TryGetValue(hWnd, out originalWndProc)) + handled = true; + return (IntPtr)HTTRANSPARENT; + } + + var point = ConvertPhysicalClientPointToDip( + new Point(screenPoint.X, screenPoint.Y), + GetWindowDpiScale(hWnd)); + var regions = state.InteractiveRegions; + foreach (var region in regions) + { + if (IsPointInsideRegion(region, point)) { - return DefWindowProc(hWnd, msg, wParam, lParam); + handled = true; + return (IntPtr)HTCLIENT; } } - return CallWindowProc(originalWndProc, hWnd, msg, wParam, lParam); + handled = true; + return (IntPtr)HTTRANSPARENT; } - private static void UpdateWindowScreenOrigin(IntPtr handle) + internal static Point ConvertPhysicalClientPointToDip(Point physicalPoint, double dpiScale) { - if (GetWindowRect(handle, out var rect)) - { - _windowScreenOrigins[handle] = new Point(rect.Left, rect.Top); - } + var scale = double.IsFinite(dpiScale) ? Math.Max(0.1, dpiScale) : 1d; + return new Point(physicalPoint.X / scale, physicalPoint.Y / scale); } - private static void UpdateWindowDpiScale(IntPtr handle) + private static double GetWindowDpiScale(IntPtr handle) { try { - var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST); - if (monitor != IntPtr.Zero && - GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out _) == 0) - { - _windowDpiScales[handle] = dpiX / 96.0; - return; - } + var dpi = GetDpiForWindow(handle); + return dpi > 0 ? dpi / 96.0 : 1.0; } - catch + catch (EntryPointNotFoundException) { - // Use the default below. + return 1.0; } + } - _windowDpiScales[handle] = 1.0; + private static PixelPoint GetNativeScreenPosition(IntPtr handle, PixelPoint fallback) + { + return GetWindowRect(handle, out var rect) + ? new PixelPoint(rect.Left, rect.Top) + : fallback; + } + + private static bool TryGetWindowState(Window window, out DesktopWindowState state) + { + lock (StaticLock) + { + return WindowStates.TryGetValue(window, out state!); + } + } + + private static void RunOnUiThread(Action action) + { + if (Dispatcher.UIThread.CheckAccess()) + { + action(); + } + else + { + Dispatcher.UIThread.Post(action); + } + } + + private static uint ReadWindowStyle(IntPtr handle, int index) + { + return unchecked((uint)GetWindowLongPtr(handle, index).ToInt64()); + } + + private static void WriteWindowStyle(IntPtr handle, int index, uint value) + { + _ = SetWindowLongPtr(handle, index, new IntPtr(unchecked((int)value))); } private static IntPtr GetWindowHandle(Window window) @@ -441,22 +1247,83 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService public int Bottom; } - private sealed record DesktopWindowState(IntPtr DesktopHost, bool IsDesktopAttached); + [StructLayout(LayoutKind.Sequential)] + private struct POINT(int x, int y) + { + public int X = x; + public int Y = y; + } + + private sealed class DesktopWindowState + { + public DesktopWindowState(Window window) + { + Window = window; + WindowStylesCallback = ApplyWindowStyles; + WndProcHookCallback = ProcessWindowMessage; + } + + public Window Window { get; } + public IntPtr Handle { get; set; } + public NativeWindowState? OriginalState { get; set; } + public IntPtr DesktopHost { get; set; } + public volatile bool IsDesktopAttached; + public volatile bool HasStableDesktopAttachment; + public volatile bool NeedsNativeRepair; + public IntPtr FailedDesktopHost { get; set; } + public bool AttachToCurrentHostAfterRepair { get; set; } + public int NativeRepairAttemptCount { get; set; } + public DateTime NextNativeRepairAttemptUtc { get; set; } + public PixelPoint? PendingScreenPosition { get; set; } + public bool HasLoggedPositionFailure { get; set; } + public bool HasLoggedFallback { get; set; } + public bool HasLoggedRepairFailure { get; set; } + public volatile WindowInteractiveRegion[] InteractiveRegions = []; + public Win32Properties.CustomWindowStylesCallback WindowStylesCallback { get; } + public Win32Properties.CustomWndProcHookCallback WndProcHookCallback { get; } + + private (uint style, uint exStyle) ApplyWindowStyles(uint style, uint exStyle) + { + return IsDesktopAttached + ? CreateDesktopChildStyles(style, exStyle) + : (style, ApplyDesktopRoleExtendedStyles(exStyle)); + } + + private IntPtr ProcessWindowMessage( + IntPtr hWnd, + uint message, + IntPtr wParam, + IntPtr lParam, + ref bool handled) + { + return HandleWindowMessage(this, hWnd, message, wParam, lParam, ref handled); + } + } + + private readonly record struct NativeWindowState(IntPtr Parent, uint Style, uint ExStyle); - private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private delegate bool EnumWindowsProc(IntPtr handle, IntPtr lParam); [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); - [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr", SetLastError = true)] private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); - [DllImport("user32.dll")] - private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint flags); [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); @@ -465,59 +1332,52 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService private static extern IntPtr GetParent(IntPtr hWnd); [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsWindow(IntPtr hWnd); [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow); + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ScreenToClient(IntPtr hWnd, ref POINT lpPoint); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr FindWindowEx( + IntPtr hParent, + IntPtr hChildAfter, + string? lpszClass, + string? lpszWindow); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] - private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); + private static extern uint GetDpiForWindow(IntPtr hWnd); - [DllImport("user32.dll")] - private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll")] - private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags); - - [DllImport("shcore.dll")] - private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute( + IntPtr hWnd, + int dwAttribute, + ref uint pvAttribute, + int cbAttribute); } internal sealed class WindowsRegionPassthroughService : IRegionPassthroughService { public bool IsRegionPassthroughSupported => true; - public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) + public void SetInteractiveRegions( + Window window, + IReadOnlyList interactiveRegions) { - var handle = GetWindowHandle(window); - if (handle == IntPtr.Zero) return; - - WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, new List(interactiveRegions)); - AppLogger.Info("RegionPassthrough", $"Set {interactiveRegions.Count} interactive regions."); + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(interactiveRegions); + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(window, interactiveRegions); } public void ClearInteractiveRegions(Window window) { - var handle = GetWindowHandle(window); - if (handle == IntPtr.Zero) return; - - WindowsWindowBottomMostService.SetInteractiveRegionsInternal(handle, []); - } - - private static IntPtr GetWindowHandle(Window window) - { - try - { - return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; - } - catch - { - return IntPtr.Zero; - } + ArgumentNullException.ThrowIfNull(window); + WindowsWindowBottomMostService.SetInteractiveRegionsInternal(window, []); } } @@ -526,11 +1386,20 @@ internal sealed class NullWindowBottomMostService : IWindowBottomMostService public bool IsBottomMostSupported => false; public void SetupBottomMost(Window window) { } public void SendToBottom(Window window) { } + public PixelPoint GetScreenPosition(Window window) => window.Position; + public bool SetScreenPosition( + Window window, + PixelPoint position, + bool queueOnFailure = false) + { + window.Position = position; + return true; + } } internal sealed class NullRegionPassthroughService : IRegionPassthroughService { public bool IsRegionPassthroughSupported => false; - public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) { } + public void SetInteractiveRegions(Window window, IReadOnlyList interactiveRegions) { } public void ClearInteractiveRegions(Window window) { } } diff --git a/LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs b/LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs index e7bab8a..7ee38a3 100644 --- a/LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs +++ b/LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs @@ -118,7 +118,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas private ResizeHandlePosition _activeHandle; private bool _isResizing; - private Point _resizeStartPoint; + private PixelPoint _resizeStartScreenPoint; private Rect _resizeStartBounds; public DesktopWidgetResizeAdorner() @@ -143,7 +143,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas } } - public new void Show() + public void Show() { if (_isVisible) return; _isVisible = true; @@ -155,7 +155,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas UpdateHandlePositions(); } - public new void Hide() + public void Hide() { if (!_isVisible) return; _isVisible = false; @@ -181,31 +181,32 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas var width = Bounds.Width; var height = Bounds.Height; - const double offset = -6d; - SetLeft(_handles[0], offset); - SetTop(_handles[0], offset); + foreach (var handle in _handles) + { + var size = handle.GetHandleSize(handle.Position); + handle.Width = size.Width; + handle.Height = size.Height; - SetLeft(_handles[1], width / 2 - 6); - SetTop(_handles[1], offset); + var (left, top) = handle.Position switch + { + ResizeHandlePosition.TopLeft => (0d, 0d), + ResizeHandlePosition.Top => ((width - size.Width) / 2d, 0d), + ResizeHandlePosition.TopRight => (width - size.Width, 0d), + ResizeHandlePosition.Right => (width - size.Width, (height - size.Height) / 2d), + ResizeHandlePosition.BottomRight => (width - size.Width, height - size.Height), + ResizeHandlePosition.Bottom => ((width - size.Width) / 2d, height - size.Height), + ResizeHandlePosition.BottomLeft => (0d, height - size.Height), + ResizeHandlePosition.Left => (0d, (height - size.Height) / 2d), + _ => (0d, 0d) + }; - SetLeft(_handles[2], width - 10); - SetTop(_handles[2], offset); - - SetLeft(_handles[3], width - 10); - SetTop(_handles[3], height / 2 - 6); - - SetLeft(_handles[4], width - 10); - SetTop(_handles[4], height - 10); - - SetLeft(_handles[5], width / 2 - 6); - SetTop(_handles[5], height - 10); - - SetLeft(_handles[6], offset); - SetTop(_handles[6], height - 10); - - SetLeft(_handles[7], offset); - SetTop(_handles[7], height / 2 - 6); + // A resize affordance must never extend beyond the native window. Transparent + // pixels outside an HWND cannot be composed or hit-tested reliably after the + // window is attached to Explorer's desktop host. + SetLeft(handle, Math.Clamp(left, 0d, Math.Max(0d, width - size.Width))); + SetTop(handle, Math.Clamp(top, 0d, Math.Max(0d, height - size.Height))); + } } private void OnHandlePointerPressed(object? sender, PointerPressedEventArgs e) @@ -215,7 +216,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas _isResizing = true; _activeHandle = handle.Position; - _resizeStartPoint = e.GetPosition(Parent as Visual); + _resizeStartScreenPoint = this.PointToScreen(e.GetPosition(this)); _resizeStartBounds = Bounds; e.Pointer.Capture(handle); @@ -227,8 +228,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas { if (!_isResizing) return; - var currentPoint = e.GetPosition(Parent as Visual); - var delta = currentPoint - _resizeStartPoint; + var delta = GetScreenDelta(e); Resizing?.Invoke(this, new ResizeEventArgs(_activeHandle, delta, _resizeStartBounds)); e.Handled = true; @@ -238,8 +238,7 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas { if (!_isResizing) return; - var currentPoint = e.GetPosition(Parent as Visual); - var delta = currentPoint - _resizeStartPoint; + var delta = GetScreenDelta(e); ResizeCompleted?.Invoke(this, new ResizeCompletedEventArgs(_activeHandle, delta, _resizeStartBounds)); @@ -247,6 +246,14 @@ internal sealed class DesktopWidgetResizeAdorner : Canvas e.Pointer.Capture(null); e.Handled = true; } + + private Point GetScreenDelta(PointerEventArgs e) + { + var currentScreenPoint = this.PointToScreen(e.GetPosition(this)); + return new Point( + currentScreenPoint.X - _resizeStartScreenPoint.X, + currentScreenPoint.Y - _resizeStartScreenPoint.Y); + } } internal sealed class ResizeStartedEventArgs : EventArgs diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml index 847fbf2..35b5387 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml @@ -14,11 +14,12 @@ RenderOptions.BitmapInterpolationMode="HighQuality" CanResize="False"> - + + CornerRadius="0" + ClipToBounds="False"> @@ -26,17 +27,10 @@ - - - - + IsHitTestVisible="False" /> diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs index 48da3ad..fb8f70f 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.DesktopEditing; using LanMountainDesktop.Models; @@ -20,14 +21,24 @@ public partial class DesktopWidgetWindow : Window private bool _isEditMode; private bool _isDragging; private PixelPoint _dragStartWindowPosition; - private Point _dragStartPointerPosition; + private PixelPoint _dragStartPointerScreenPosition; private DesktopWidgetResizeAdorner? _resizeAdorner; private bool _isResizing; - private Size _resizeStartSize; + private Size _resizeStartPhysicalSize; private PixelPoint _resizeStartPosition; private int _resizeStartWidthCells; private int _resizeStartHeightCells; + private double _componentCornerRadius; + private Border? _componentRootBorder; + private Control? _interactiveRegionTarget; + private Transform? _interactiveRegionTransform; + private Transform? _componentContentTransform; + private bool _interactiveRegionUpdatePending; + private bool _isApplyingComponentChrome; + private bool _componentChromeApplyPending; + private bool _componentChromeDeferredApplyPending; + private bool _isClosing; public string? PlacementId { get; } @@ -42,21 +53,26 @@ public partial class DesktopWidgetWindow : Window } } - public DesktopWidgetWindow(Control componentContent, string? placementId = null) : this() + public DesktopWidgetWindow( + Control componentContent, + string? placementId = null, + double cornerRadius = 0d) : this() { PlacementId = placementId; ComponentContainer.Child = componentContent; + componentContent.Loaded += OnComponentContentLoaded; + componentContent.PropertyChanged += OnComponentContentPropertyChanged; + SetComponentContentTransform(componentContent.RenderTransform as Transform); SetupResizeAdorner(); + UpdateComponentChrome(cornerRadius); } private void SetupResizeAdorner() { _resizeAdorner = new DesktopWidgetResizeAdorner { - Width = ComponentContainer.Width, - Height = ComponentContainer.Height, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Stretch, IsVisible = false }; @@ -100,6 +116,165 @@ public partial class DesktopWidgetWindow : Window } AppLogger.Info("DesktopWidgetWindow", $"Edit mode set to {editMode}. PlacementId='{PlacementId}'."); + + if (OperatingSystem.IsWindows() && IsVisible) + { + ScheduleInteractiveRegionUpdate(); + } + } + + public void UpdateComponentChrome(double cornerRadius) + { + _componentCornerRadius = double.IsFinite(cornerRadius) + ? Math.Max(0d, cornerRadius) + : 0d; + + ApplyComponentChrome(); + + if (OperatingSystem.IsWindows() && IsVisible) + { + ScheduleInteractiveRegionUpdate(); + } + } + + private void OnComponentContentLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _ = sender; + _ = e; + ApplyComponentChrome(); + } + + private void OnComponentContentPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (sender is UserControl && e.Property == ContentControl.ContentProperty) + { + ApplyComponentChrome(); + ScheduleInteractiveRegionUpdate(); + return; + } + + if (e.Property == Visual.RenderTransformProperty && sender is Control componentContent) + { + SetComponentContentTransform(componentContent.RenderTransform as Transform); + } + + if (e.Property == Visual.IsVisibleProperty || + e.Property == Visual.BoundsProperty || + e.Property == Visual.RenderTransformProperty || + e.Property == Visual.RenderTransformOriginProperty) + { + ScheduleInteractiveRegionUpdate(); + } + } + + private void SetComponentContentTransform(Transform? transform) + { + if (ReferenceEquals(_componentContentTransform, transform)) + { + return; + } + + if (_componentContentTransform is not null) + { + _componentContentTransform.Changed -= OnComponentContentTransformChanged; + } + + _componentContentTransform = transform; + if (_componentContentTransform is not null) + { + _componentContentTransform.Changed += OnComponentContentTransformChanged; + } + } + + private void OnComponentContentTransformChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + ScheduleInteractiveRegionUpdate(); + } + + private void ApplyComponentChrome() + { + if (_isClosing) + { + return; + } + + if (_isApplyingComponentChrome) + { + _componentChromeApplyPending = true; + return; + } + + var passCount = 0; + do + { + passCount++; + _componentChromeApplyPending = false; + _isApplyingComponentChrome = true; + try + { + var cornerRadius = new CornerRadius(_componentCornerRadius); + _componentRootBorder = TryGetDirectRootBorder(ComponentContainer.Child as Control); + + if (_componentRootBorder is not null) + { + // The direct component surface owns the single outer contour. A local empty + // BoxShadow value is intentional: ClearValue would fall back to the global + // component-surface style and recreate an out-of-window shadow. + _componentRootBorder.CornerRadius = cornerRadius; + _componentRootBorder.ClipToBounds = true; + _componentRootBorder.BoxShadow = default; + + ComponentContainer.CornerRadius = default; + ComponentContainer.ClipToBounds = false; + } + else + { + ComponentContainer.CornerRadius = cornerRadius; + ComponentContainer.ClipToBounds = true; + } + + SetInteractiveRegionTarget( + _componentRootBorder ?? + ComponentContainer.Child as Control ?? + ComponentContainer); + + if (EditModeBorder is not null) + { + EditModeBorder.CornerRadius = cornerRadius; + } + } + finally + { + _isApplyingComponentChrome = false; + } + } while (_componentChromeApplyPending && passCount < 3); + + if (_componentChromeApplyPending && !_componentChromeDeferredApplyPending) + { + _componentChromeDeferredApplyPending = true; + Dispatcher.UIThread.Post(() => + { + _componentChromeDeferredApplyPending = false; + if (!_isClosing) + { + ApplyComponentChrome(); + } + }, DispatcherPriority.Render); + } + } + + private static Border? TryGetDirectRootBorder(Control? componentContent) + { + if (componentContent is Border border) + { + return border; + } + + return componentContent is UserControl { Content: Border contentBorder } + ? contentBorder + : null; } public void UpdateComponentLayout(double width, double height) @@ -121,7 +296,7 @@ public partial class DesktopWidgetWindow : Window if (OperatingSystem.IsWindows() && IsVisible) { - Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); + ScheduleInteractiveRegionUpdate(); } } @@ -133,7 +308,7 @@ public partial class DesktopWidgetWindow : Window } _bottomMostService.SendToBottom(this); - Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render); + ScheduleInteractiveRegionUpdate(); AppLogger.Info("DesktopWidgetWindow", "Refreshed desktop layer. WindowRole=DesktopSurface."); } @@ -182,12 +357,13 @@ public partial class DesktopWidgetWindow : Window { if (_isDragging) { - var currentPointer = e.GetPosition(this); - var delta = currentPointer - _dragStartPointerPosition; + var currentPointerScreenPosition = this.PointToScreen(e.GetPosition(this)); + var deltaX = currentPointerScreenPosition.X - _dragStartPointerScreenPosition.X; + var deltaY = currentPointerScreenPosition.Y - _dragStartPointerScreenPosition.Y; - Position = new PixelPoint( - _dragStartWindowPosition.X + (int)delta.X, - _dragStartWindowPosition.Y + (int)delta.Y); + SetScreenPosition(new PixelPoint( + _dragStartWindowPosition.X + deltaX, + _dragStartWindowPosition.Y + deltaY)); e.Handled = true; return; @@ -211,8 +387,8 @@ public partial class DesktopWidgetWindow : Window private void BeginDrag(PointerPressedEventArgs e) { _isDragging = true; - _dragStartWindowPosition = Position; - _dragStartPointerPosition = e.GetPosition(this); + _dragStartWindowPosition = GetScreenPosition(); + _dragStartPointerScreenPosition = this.PointToScreen(e.GetPosition(this)); e.Pointer.Capture(this); } @@ -238,17 +414,31 @@ public partial class DesktopWidgetWindow : Window private void ApplySnappedDragPlacement(FusedDesktopComponentPlacementSnapshot placement) { - if (!TrySnapToCurrentScreenGrid(placement, Position, out var snappedPosition) || + var originalPlacement = placement.Clone(); + var currentPosition = GetScreenPosition(); + if (!TrySnapToCurrentScreenGrid(placement, currentPosition, out var snappedPosition) || !snappedPosition.HasValue) { - placement.X = Position.X; - placement.Y = Position.Y; + placement.X = currentPosition.X; + placement.Y = currentPosition.Y; return; } placement.X = snappedPosition.Value.X; placement.Y = snappedPosition.Value.Y; - Position = snappedPosition.Value; + if (SetScreenPosition(snappedPosition.Value)) + { + var actualPosition = GetScreenPosition(); + placement.X = actualPosition.X; + placement.Y = actualPosition.Y; + } + else + { + RestorePlacementLayout(placement, originalPlacement); + placement.X = currentPosition.X; + placement.Y = currentPosition.Y; + UpdateComponentLayout(placement.Width, placement.Height); + } } private bool TrySnapToCurrentScreenGrid( @@ -327,14 +517,201 @@ public partial class DesktopWidgetWindow : Window private void UpdateInteractiveRegion() { - _regionPassthroughService.SetInteractiveRegions(this, new List + var width = Math.Max(0d, RootGrid.Bounds.Width > 0 ? RootGrid.Bounds.Width : Bounds.Width); + var height = Math.Max(0d, RootGrid.Bounds.Height > 0 ? RootGrid.Bounds.Height : Bounds.Height); + if (width <= 0d || height <= 0d) { - new(0, 0, Bounds.Width, Bounds.Height) + _regionPassthroughService.ClearInteractiveRegions(this); + return; + } + + var interactiveRegion = _isEditMode + ? new WindowInteractiveRegion(new Rect(0, 0, width, height), 0d) + : ResolveLiveInteractiveRegion(new Rect(0, 0, width, height)); + if (!interactiveRegion.HasValue) + { + _regionPassthroughService.ClearInteractiveRegions(this); + return; + } + + _regionPassthroughService.SetInteractiveRegions(this, new List + { + interactiveRegion.Value }); } + private void SetInteractiveRegionTarget(Control target) + { + if (ReferenceEquals(_interactiveRegionTarget, target)) + { + return; + } + + if (_interactiveRegionTarget is not null) + { + _interactiveRegionTarget.LayoutUpdated -= OnInteractiveRegionTargetLayoutUpdated; + _interactiveRegionTarget.PropertyChanged -= OnInteractiveRegionTargetPropertyChanged; + } + + SetInteractiveRegionTransform(null); + + _interactiveRegionTarget = target; + _interactiveRegionTarget.LayoutUpdated += OnInteractiveRegionTargetLayoutUpdated; + _interactiveRegionTarget.PropertyChanged += OnInteractiveRegionTargetPropertyChanged; + SetInteractiveRegionTransform(_interactiveRegionTarget.RenderTransform as Transform); + } + + private void OnInteractiveRegionTargetLayoutUpdated(object? sender, EventArgs e) + { + _ = sender; + _ = e; + ScheduleInteractiveRegionUpdate(); + } + + private void OnInteractiveRegionTargetPropertyChanged( + object? sender, + AvaloniaPropertyChangedEventArgs e) + { + if (!_isApplyingComponentChrome && + ReferenceEquals(sender, _componentRootBorder) && + (e.Property == Border.BoxShadowProperty || + e.Property == Border.CornerRadiusProperty || + e.Property == Visual.ClipToBoundsProperty)) + { + ApplyComponentChrome(); + } + + if (e.Property == Visual.RenderTransformProperty && sender is Control target) + { + SetInteractiveRegionTransform(target.RenderTransform as Transform); + } + + if (e.Property == Visual.BoundsProperty || + e.Property == Visual.RenderTransformProperty || + e.Property == Visual.RenderTransformOriginProperty || + e.Property == Visual.IsVisibleProperty) + { + ScheduleInteractiveRegionUpdate(); + } + } + + private void SetInteractiveRegionTransform(Transform? transform) + { + if (ReferenceEquals(_interactiveRegionTransform, transform)) + { + return; + } + + if (_interactiveRegionTransform is not null) + { + _interactiveRegionTransform.Changed -= OnInteractiveRegionTransformChanged; + } + + _interactiveRegionTransform = transform; + if (_interactiveRegionTransform is not null) + { + _interactiveRegionTransform.Changed += OnInteractiveRegionTransformChanged; + } + } + + private void OnInteractiveRegionTransformChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + ScheduleInteractiveRegionUpdate(); + } + + private void ScheduleInteractiveRegionUpdate() + { + if (!OperatingSystem.IsWindows() || !IsVisible || _interactiveRegionUpdatePending) + { + return; + } + + _interactiveRegionUpdatePending = true; + Dispatcher.UIThread.Post(() => + { + _interactiveRegionUpdatePending = false; + if (IsVisible) + { + UpdateInteractiveRegion(); + } + }, DispatcherPriority.Render); + } + + private WindowInteractiveRegion? ResolveLiveInteractiveRegion(Rect fallback) + { + if (ComponentContainer.Child is Control componentContent && + !componentContent.IsEffectivelyVisible) + { + return null; + } + + Visual target = _interactiveRegionTarget ?? ComponentContainer; + if (!target.IsEffectivelyVisible || target.Bounds.Width <= 0 || target.Bounds.Height <= 0) + { + return null; + } + + var transform = target.TransformToVisual(RootGrid); + if (transform is null) + { + return null; + } + + if (!transform.Value.TryInvert(out var clientToTarget)) + { + return null; + } + + var topLeft = transform.Value.Transform(new Point(0, 0)); + var topRight = transform.Value.Transform(new Point(target.Bounds.Width, 0)); + var bottomLeft = transform.Value.Transform(new Point(0, target.Bounds.Height)); + var bottomRight = transform.Value.Transform(new Point(target.Bounds.Width, target.Bounds.Height)); + var left = Math.Min(Math.Min(topLeft.X, topRight.X), Math.Min(bottomLeft.X, bottomRight.X)); + var top = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomLeft.Y, bottomRight.Y)); + var right = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomLeft.X, bottomRight.X)); + var bottom = Math.Max(Math.Max(topLeft.Y, topRight.Y), Math.Max(bottomLeft.Y, bottomRight.Y)); + var visibleBounds = new Rect(left, top, right - left, bottom - top).Intersect(fallback); + if (visibleBounds.Width <= 0 || visibleBounds.Height <= 0) + { + return null; + } + + var rootBorderOwnsContour = ReferenceEquals(target, _componentRootBorder); + var hostOwnsContour = !rootBorderOwnsContour && !ReferenceEquals(target, ComponentContainer); + return new WindowInteractiveRegion( + new Rect(0, 0, target.Bounds.Width, target.Bounds.Height), + rootBorderOwnsContour || ReferenceEquals(target, ComponentContainer) + ? _componentCornerRadius + : 0d, + clientToTarget, + hostOwnsContour ? fallback : null, + hostOwnsContour ? _componentCornerRadius : 0d); + } + protected override void OnClosing(WindowClosingEventArgs e) { + base.OnClosing(e); + if (e.Cancel) + { + return; + } + + _isClosing = true; + if (_interactiveRegionTarget is not null) + { + _interactiveRegionTarget.LayoutUpdated -= OnInteractiveRegionTargetLayoutUpdated; + _interactiveRegionTarget.PropertyChanged -= OnInteractiveRegionTargetPropertyChanged; + _interactiveRegionTarget = null; + } + + SetInteractiveRegionTransform(null); + SetComponentContentTransform(null); + + _interactiveRegionUpdatePending = false; + _componentChromeApplyPending = false; + _componentChromeDeferredApplyPending = false; if (_resizeAdorner is not null) { _resizeAdorner.ResizeStarted -= OnResizeStarted; @@ -342,12 +719,18 @@ public partial class DesktopWidgetWindow : Window _resizeAdorner.ResizeCompleted -= OnResizeCompleted; } + if (ComponentContainer.Child is Control componentContent) + { + componentContent.Loaded -= OnComponentContentLoaded; + componentContent.PropertyChanged -= OnComponentContentPropertyChanged; + } + if (ComponentContainer.Child is IDisposable disposable) { disposable.Dispose(); } + _regionPassthroughService.ClearInteractiveRegions(this); ComponentContainer.Child = null; - base.OnClosing(e); } private void OnResizeStarted(object? sender, ResizeStartedEventArgs e) @@ -355,8 +738,11 @@ public partial class DesktopWidgetWindow : Window if (PlacementId is null) return; _isResizing = true; - _resizeStartSize = new Size(ComponentContainer.Width, ComponentContainer.Height); - _resizeStartPosition = Position; + var startScaling = Math.Max(0.1, RenderScaling); + _resizeStartPhysicalSize = new Size( + ComponentContainer.Width * startScaling, + ComponentContainer.Height * startScaling); + _resizeStartPosition = GetScreenPosition(); var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); var layout = layoutService.Load(); @@ -378,28 +764,17 @@ public partial class DesktopWidgetWindow : Window var (newWidth, newHeight, newX, newY) = CalculateResizedBounds( e.Handle, e.Delta, - _resizeStartSize, - _resizeStartPosition); + _resizeStartPhysicalSize, + _resizeStartPosition, + RenderScaling); - ComponentContainer.Width = newWidth; - ComponentContainer.Height = newHeight; - - if (ComponentContainer.Child is Control child) - { - child.Width = newWidth; - child.Height = newHeight; - } - - if (_resizeAdorner is not null) - { - _resizeAdorner.Width = newWidth; - _resizeAdorner.Height = newHeight; - } + UpdateComponentLayout(newWidth, newHeight); if (e.Handle is ResizeHandlePosition.TopLeft or ResizeHandlePosition.Top or - ResizeHandlePosition.TopRight or ResizeHandlePosition.Left) + ResizeHandlePosition.TopRight or ResizeHandlePosition.BottomLeft or + ResizeHandlePosition.Left) { - Position = new PixelPoint((int)newX, (int)newY); + SetScreenPosition(new PixelPoint((int)Math.Round(newX), (int)Math.Round(newY))); } } @@ -426,65 +801,74 @@ public partial class DesktopWidgetWindow : Window AppLogger.Info("DesktopWidget", $"Resize completed. PlacementId='{PlacementId}'"); } - private (double width, double height, double x, double y) CalculateResizedBounds( + internal static (double width, double height, double x, double y) CalculateResizedBounds( ResizeHandlePosition handle, - Point delta, - Size startSize, - PixelPoint startPosition) + Point physicalDelta, + Size startPhysicalSize, + PixelPoint startPosition, + double currentScaling) { - var newWidth = startSize.Width; - var newHeight = startSize.Height; + var scaling = double.IsFinite(currentScaling) ? Math.Max(0.1, currentScaling) : 1d; + var minimumPhysicalSize = 50d * scaling; + var newPhysicalWidth = startPhysicalSize.Width; + var newPhysicalHeight = startPhysicalSize.Height; var newX = (double)startPosition.X; var newY = (double)startPosition.Y; switch (handle) { case ResizeHandlePosition.TopLeft: - newWidth = Math.Max(50, startSize.Width - delta.X); - newHeight = Math.Max(50, startSize.Height - delta.Y); - newX = startPosition.X + (startSize.Width - newWidth); - newY = startPosition.Y + (startSize.Height - newHeight); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width - physicalDelta.X); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height - physicalDelta.Y); + newX = startPosition.X + startPhysicalSize.Width - newPhysicalWidth; + newY = startPosition.Y + startPhysicalSize.Height - newPhysicalHeight; break; case ResizeHandlePosition.Top: - newHeight = Math.Max(50, startSize.Height - delta.Y); - newY = startPosition.Y + (startSize.Height - newHeight); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height - physicalDelta.Y); + newY = startPosition.Y + startPhysicalSize.Height - newPhysicalHeight; break; case ResizeHandlePosition.TopRight: - newWidth = Math.Max(50, startSize.Width + delta.X); - newHeight = Math.Max(50, startSize.Height - delta.Y); - newY = startPosition.Y + (startSize.Height - newHeight); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width + physicalDelta.X); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height - physicalDelta.Y); + newY = startPosition.Y + startPhysicalSize.Height - newPhysicalHeight; break; case ResizeHandlePosition.Right: - newWidth = Math.Max(50, startSize.Width + delta.X); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width + physicalDelta.X); break; case ResizeHandlePosition.BottomRight: - newWidth = Math.Max(50, startSize.Width + delta.X); - newHeight = Math.Max(50, startSize.Height + delta.Y); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width + physicalDelta.X); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height + physicalDelta.Y); break; case ResizeHandlePosition.Bottom: - newHeight = Math.Max(50, startSize.Height + delta.Y); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height + physicalDelta.Y); break; case ResizeHandlePosition.BottomLeft: - newWidth = Math.Max(50, startSize.Width - delta.X); - newHeight = Math.Max(50, startSize.Height + delta.Y); - newX = startPosition.X + (startSize.Width - newWidth); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width - physicalDelta.X); + newPhysicalHeight = Math.Max(minimumPhysicalSize, startPhysicalSize.Height + physicalDelta.Y); + newX = startPosition.X + startPhysicalSize.Width - newPhysicalWidth; break; case ResizeHandlePosition.Left: - newWidth = Math.Max(50, startSize.Width - delta.X); - newX = startPosition.X + (startSize.Width - newWidth); + newPhysicalWidth = Math.Max(minimumPhysicalSize, startPhysicalSize.Width - physicalDelta.X); + newX = startPosition.X + startPhysicalSize.Width - newPhysicalWidth; break; } - return (newWidth, newHeight, newX, newY); + return ( + newPhysicalWidth / scaling, + newPhysicalHeight / scaling, + newX, + newY); } private void ApplySnappedResizePlacement(FusedDesktopComponentPlacementSnapshot placement) { + var originalPlacement = placement.Clone(); + var currentPosition = GetScreenPosition(); var screen = Screens.ScreenFromWindow(this) ?? Screens.Primary; if (screen is null) { - placement.X = Position.X; - placement.Y = Position.Y; + placement.X = currentPosition.X; + placement.Y = currentPosition.Y; placement.Width = ComponentContainer.Width; placement.Height = ComponentContainer.Height; return; @@ -496,16 +880,16 @@ public partial class DesktopWidgetWindow : Window var adapter = new FusedDesktopEditGridAdapter(_settingsFacade); if (!adapter.TryCreate(viewportSize, out var context)) { - placement.X = Position.X; - placement.Y = Position.Y; + placement.X = currentPosition.X; + placement.Y = currentPosition.Y; placement.Width = ComponentContainer.Width; placement.Height = ComponentContainer.Height; return; } var requestedLocalOrigin = new Point( - (Position.X - workArea.X) / scaling, - (Position.Y - workArea.Y) / scaling); + (currentPosition.X - workArea.X) / scaling, + (currentPosition.Y - workArea.Y) / scaling); var requestedLocalWidth = ComponentContainer.Width; var requestedLocalHeight = ComponentContainer.Height; @@ -539,10 +923,54 @@ public partial class DesktopWidgetWindow : Window placement.X = snappedPosition.X; placement.Y = snappedPosition.Y; - Position = snappedPosition; + if (SetScreenPosition(snappedPosition)) + { + var actualPosition = GetScreenPosition(); + placement.X = actualPosition.X; + placement.Y = actualPosition.Y; + } + else + { + RestorePlacementLayout(placement, originalPlacement); + placement.X = currentPosition.X; + placement.Y = currentPosition.Y; + } + UpdateComponentLayout(placement.Width, placement.Height); } + private static void RestorePlacementLayout( + FusedDesktopComponentPlacementSnapshot target, + FusedDesktopComponentPlacementSnapshot source) + { + target.X = source.X; + target.Y = source.Y; + target.Width = source.Width; + target.Height = source.Height; + target.GridRow = source.GridRow; + target.GridColumn = source.GridColumn; + target.GridWidthCells = source.GridWidthCells; + target.GridHeightCells = source.GridHeightCells; + } + + private PixelPoint GetScreenPosition() + { + return OperatingSystem.IsWindows() + ? _bottomMostService.GetScreenPosition(this) + : Position; + } + + private bool SetScreenPosition(PixelPoint position) + { + if (OperatingSystem.IsWindows()) + { + return _bottomMostService.SetScreenPosition(this, position); + } + + Position = position; + return true; + } + private static int EstimateCellSpan(double pixelSize, DesktopGridGeometry grid) { if (!grid.IsValid || grid.CellSize <= 0) diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml index 1f2fa27..de88a35 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml @@ -11,7 +11,9 @@ WindowStartupLocation="CenterScreen" WindowDecorations="None" ExtendClientAreaToDecorationsHint="True" + ExtendClientAreaTitleBarHeightHint="-1" Background="Transparent" + TransparencyLevelHint="Transparent" Title="Add Component"> + AirAppMarketCompatibility.Validate(plugin, _hostVersion, PluginSdkInfo.ApiVersion); private async Task AcquirePackageAsync( AirAppMarketPluginEntry plugin, @@ -401,3 +357,53 @@ internal sealed class AirAppMarketInstallService : IDisposable string? PackagePath, string? ErrorMessage); } + +internal static class AirAppMarketCompatibility +{ + public static string? Validate( + AirAppMarketPluginEntry plugin, + Version? hostVersion, + string? hostApiVersion) + { + ArgumentNullException.ThrowIfNull(plugin); + + if (hostVersion is not null && !string.IsNullOrWhiteSpace(plugin.MinHostVersion)) + { + if (!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) || + minHostVersion is null) + { + return $"Plugin '{plugin.Id}' declares invalid minimum host version '{plugin.MinHostVersion}'."; + } + + if (hostVersion < minHostVersion) + { + return $"Plugin '{plugin.Id}' requires host version {plugin.MinHostVersion} or newer. Current host version is {hostVersion}."; + } + } + + if (string.IsNullOrWhiteSpace(plugin.ApiVersion)) + { + return null; + } + + if (!AirAppMarketIndexDocument.TryParseVersion(plugin.ApiVersion, out var pluginApiVersion) || + pluginApiVersion is null) + { + return $"Plugin '{plugin.Id}' declares invalid API version '{plugin.ApiVersion}'."; + } + + if (string.IsNullOrWhiteSpace(hostApiVersion) || + !AirAppMarketIndexDocument.TryParseVersion(hostApiVersion, out var hostApiVersionParsed) || + hostApiVersionParsed is null) + { + AppLogger.Warn( + "PluginMarket", + $"Host API version '{hostApiVersion ?? string.Empty}' could not be parsed. Skipping API version check."); + return null; + } + + return pluginApiVersion.Major != hostApiVersionParsed.Major + ? $"Plugin '{plugin.Id}' uses incompatible API version {plugin.ApiVersion}. Host API version is {hostApiVersion}. Major version must match." + : null; + } +}