mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-20 13:51:28 +08:00
feat: implement plugin market installation service and associated infrastructure for desktop widgets and window management
This commit is contained in:
211
LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs
Normal file
211
LanMountainDesktop.Tests/DesktopWidgetWindowChromeTests.cs
Normal file
@@ -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<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
var editBorder = Assert.IsType<Border>(window.FindControl<Border>("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<Border>(window.FindControl<Border>("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<Border>(window.FindControl<Border>("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<Grid>(window.FindControl<Grid>("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<Border>(window.FindControl<Border>("ComponentContainer"));
|
||||
var root = Assert.IsType<Grid>(window.FindControl<Grid>("RootGrid"));
|
||||
var adorner = Assert.Single(root.Children.OfType<DesktopWidgetResizeAdorner>());
|
||||
|
||||
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<DesktopWidgetResizeHandle>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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, "<Window ");
|
||||
|
||||
Assert.Contains("WindowDecorations=\"None\"", window);
|
||||
Assert.Contains("Background=\"Transparent\"", window);
|
||||
Assert.Contains("TransparencyLevelHint=\"Transparent\"", window);
|
||||
Assert.Contains("ExtendClientAreaToDecorationsHint=\"True\"", window);
|
||||
Assert.Contains("ExtendClientAreaTitleBarHeightHint=\"-1\"", window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PanelShell_FillsClientAreaWithoutOuterShadowGutter()
|
||||
{
|
||||
var xaml = ReadRepositoryFile(
|
||||
"LanMountainDesktop",
|
||||
"Views",
|
||||
"FusedDesktopComponentLibraryWindow.axaml");
|
||||
var panelShell = ExtractElementStart(xaml, "<Border x:Name=\"PanelShell\"");
|
||||
|
||||
Assert.Contains("Classes=\"surface-translucent-strong\"", panelShell);
|
||||
Assert.Contains("HorizontalAlignment=\"Stretch\"", panelShell);
|
||||
Assert.Contains("VerticalAlignment=\"Stretch\"", panelShell);
|
||||
Assert.Contains("Margin=\"0\"", panelShell);
|
||||
Assert.Contains("BoxShadow=\"none\"", panelShell);
|
||||
Assert.Contains("CornerRadius=\"{DynamicResource DesignCornerRadiusLg}\"", panelShell);
|
||||
Assert.Contains("ClipToBounds=\"True\"", panelShell);
|
||||
Assert.DoesNotContain("Margin=\"10\"", panelShell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_DisablesNativeWindowsCornerAndBorderTreatment()
|
||||
{
|
||||
var codeBehind = ReadRepositoryFile(
|
||||
"LanMountainDesktop",
|
||||
"Views",
|
||||
"FusedDesktopComponentLibraryWindow.axaml.cs");
|
||||
|
||||
Assert.Contains("Win32Properties.SetWindowCornerPreference(", codeBehind);
|
||||
Assert.Contains("Win32Properties.WindowCornerPreference.DoNotRound", codeBehind);
|
||||
Assert.Contains("DwmWindowAttributeBorderColor = 34", codeBehind);
|
||||
Assert.Contains("DwmColorNone = 0xFFFFFFFE", codeBehind);
|
||||
}
|
||||
|
||||
private static string ExtractElementStart(string source, string startToken)
|
||||
{
|
||||
var start = source.IndexOf(startToken, StringComparison.Ordinal);
|
||||
Assert.True(start >= 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)}'.");
|
||||
}
|
||||
}
|
||||
57
LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs
Normal file
57
LanMountainDesktop.Tests/FusedDesktopScreenTopologyTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs
Normal file
79
LanMountainDesktop.Tests/PluginMarketCompatibilityTests.cs
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() =>
|
||||
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 $$"""
|
||||
{
|
||||
|
||||
264
LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs
Normal file
264
LanMountainDesktop.Tests/WindowPassthroughServiceTests.cs
Normal file
@@ -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<Point>(
|
||||
"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<bool>("OriginalWindowUsesParentClientCoordinates", WsChild));
|
||||
Assert.False(Invoke<bool>("OriginalWindowUsesParentClientCoordinates", WsPopup));
|
||||
Assert.False(Invoke<bool>("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<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
false,
|
||||
now,
|
||||
now.AddSeconds(30)));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
false,
|
||||
now,
|
||||
now.AddSeconds(-1)));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptNativeRepair",
|
||||
true,
|
||||
now,
|
||||
now.AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonitorPolicy_DoesNotRemountSafeFallbackUntilHostActuallyChanges()
|
||||
{
|
||||
var currentHost = new IntPtr(42);
|
||||
|
||||
Assert.False(Invoke<bool>(
|
||||
"ShouldAttemptDesktopAttachment",
|
||||
false,
|
||||
IntPtr.Zero,
|
||||
currentHost,
|
||||
false));
|
||||
Assert.True(Invoke<bool>(
|
||||
"ShouldAttemptDesktopAttachment",
|
||||
true,
|
||||
IntPtr.Zero,
|
||||
currentHost,
|
||||
false));
|
||||
Assert.True(Invoke<bool>(
|
||||
"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<bool>("IsPointInsideRegion", region, point);
|
||||
}
|
||||
|
||||
private static T Invoke<T>(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)}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||
private readonly HashSet<string> _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<string, FusedDesktopComponentPlacementSnapshot>(
|
||||
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<Screen> 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<FusedDesktopScreenWorkArea> 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<string, FusedDesktopComponentPlacementSnapshot>(
|
||||
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<string>(_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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
CanResize="False">
|
||||
|
||||
<Grid x:Name="RootGrid">
|
||||
<Grid x:Name="RootGrid"
|
||||
ClipToBounds="True">
|
||||
<Border x:Name="ComponentContainer"
|
||||
Background="Transparent"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
CornerRadius="0"
|
||||
ClipToBounds="False">
|
||||
<!-- Component control will be injected here -->
|
||||
</Border>
|
||||
|
||||
@@ -26,17 +27,10 @@
|
||||
<Border x:Name="EditModeBorder"
|
||||
BorderThickness="2"
|
||||
BorderBrush="#0078D4"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect Color="#0078D4"
|
||||
BlurRadius="8"
|
||||
OffsetX="0"
|
||||
OffsetY="0"
|
||||
Opacity="0.5"/>
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<!-- Resize adorner will be added programmatically -->
|
||||
</Grid>
|
||||
|
||||
@@ -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<Rect>
|
||||
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<WindowInteractiveRegion>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
WindowStartupLocation="CenterScreen"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Transparent"
|
||||
Title="Add Component">
|
||||
|
||||
<Grid x:Name="RootGrid"
|
||||
@@ -21,9 +23,10 @@
|
||||
Classes="surface-translucent-strong"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Margin="10"
|
||||
Margin="0"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
BoxShadow="none"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Height="64"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -13,14 +14,21 @@ namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
private const int DwmWindowAttributeBorderColor = 34;
|
||||
private const uint DwmColorNone = 0xFFFFFFFE;
|
||||
|
||||
private static readonly LocalizationService LocalizationService = new();
|
||||
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Win32Properties.SetWindowCornerPreference(
|
||||
this,
|
||||
Win32Properties.WindowCornerPreference.DoNotRound);
|
||||
ApplyFluentCornerRadius();
|
||||
ApplyLocalization();
|
||||
|
||||
Opened += OnWindowOpened;
|
||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||
KeyDown += OnWindowKeyDown;
|
||||
|
||||
@@ -108,16 +116,58 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Opened -= OnWindowOpened;
|
||||
TryDisableNativeWindowBorder();
|
||||
}
|
||||
|
||||
private void TryDisableNativeWindowBorder()
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var handle = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var borderColor = DwmColorNone;
|
||||
_ = DwmSetWindowAttribute(
|
||||
handle,
|
||||
DwmWindowAttributeBorderColor,
|
||||
ref borderColor,
|
||||
sizeof(uint));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DWM attributes are best-effort and unavailable on older/unsupported Windows builds.
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
AppLogger.Info("FusedDesktopLibrary", "Exited edit mode via library window close.");
|
||||
|
||||
LibraryControl.AddComponentRequested -= OnAddComponentRequested;
|
||||
Opened -= OnWindowOpened;
|
||||
KeyDown -= OnWindowKeyDown;
|
||||
base.OnClosed(e);
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(
|
||||
IntPtr windowHandle,
|
||||
int attribute,
|
||||
ref uint attributeValue,
|
||||
int attributeSize);
|
||||
}
|
||||
|
||||
@@ -149,52 +149,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private string? ValidateCompatibility(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
if (_hostVersion is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!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))
|
||||
{
|
||||
if (!AirAppMarketIndexDocument.TryParseVersion(plugin.ApiVersion, out var pluginApiVersion) ||
|
||||
pluginApiVersion is null)
|
||||
{
|
||||
return $"Plugin '{plugin.Id}' declares invalid API version '{plugin.ApiVersion}'.";
|
||||
}
|
||||
|
||||
var hostApiVersion = PluginSdkInfo.ApiVersion;
|
||||
if (hostApiVersion is not null)
|
||||
{
|
||||
if (!AirAppMarketIndexDocument.TryParseVersion(hostApiVersion, out var hostApiVersionParsed) ||
|
||||
hostApiVersionParsed is null)
|
||||
{
|
||||
AppLogger.Warn("PluginMarket", $"Host API version '{hostApiVersion}' could not be parsed. Skipping API version check.");
|
||||
}
|
||||
else if (pluginApiVersion.Major != hostApiVersionParsed.Major)
|
||||
{
|
||||
return $"Plugin '{plugin.Id}' uses incompatible API version {plugin.ApiVersion}. Host API version is {hostApiVersion}. Major version must match.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
private string? ValidateCompatibility(AirAppMarketPluginEntry plugin) =>
|
||||
AirAppMarketCompatibility.Validate(plugin, _hostVersion, PluginSdkInfo.ApiVersion);
|
||||
|
||||
private async Task<AirAppMarketAcquisitionResult> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user