feat: implement plugin market installation service and associated infrastructure for desktop widgets and window management

This commit is contained in:
lincube
2026-07-14 11:40:12 +09:00
parent 611d03b828
commit 5f684ce8b6
14 changed files with 2952 additions and 414 deletions

View 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);
}
}

View File

@@ -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)}'.");
}
}

View 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);
}
}

View 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
}
]
};
}

View File

@@ -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 $$"""
{

View 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)}'.");
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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)

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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;
}
}