feat.融合桌面组件展示优化

This commit is contained in:
lincube
2026-06-07 00:40:48 +08:00
parent 8df0271032
commit 11b8216e5b
20 changed files with 733 additions and 129 deletions

View File

@@ -186,3 +186,7 @@ Fusion desktop placement must reuse the existing Lan Mountain desktop grid setti
### Requirement: Snap individual windows to the grid
Fusion desktop no longer displays or depends on a full-screen grid window. Each component window uses the grid only as an individual placement constraint. Dragging remains free while the pointer is moving; on release, the window snaps to the nearest cell that can contain its saved cell span, clamps inside the current screen grid, and persists `X`, `Y`, `GridRow`, `GridColumn`, `GridWidthCells`, and `GridHeightCells`.
### Requirement: Preview area preserves widget proportions
The fused desktop component library preview area must size the selected widget from its component cell span instead of compressing every widget into a fixed preview box. The preview stage should stretch with the resizable library window, calculate the largest usable widget preview that fits the available stage, preserve the `MinWidthCells` / `MinHeightCells` ratio, and assign explicit preview control width and height before displaying the widget.

View File

@@ -18,27 +18,37 @@ internal static class NativeDependencyBootstrapper
"libSkiaSharp.dll"
];
public static void Prepare()
public static bool TryPrepare()
{
if (!OperatingSystem.IsWindows())
{
return;
return true;
}
var nativeDirectory = GetNativeDirectory();
Directory.CreateDirectory(nativeDirectory);
var extractedLibraries = new List<string>(NativeLibraryNames.Length);
foreach (var libraryName in NativeLibraryNames)
try
{
extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName));
var nativeDirectory = GetNativeDirectory();
Directory.CreateDirectory(nativeDirectory);
var extractedLibraries = new List<string>(NativeLibraryNames.Length);
foreach (var libraryName in NativeLibraryNames)
{
extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName));
}
AddToProcessDllSearchPath(nativeDirectory);
foreach (var libraryPath in extractedLibraries)
{
NativeLibrary.Load(libraryPath);
}
return true;
}
AddToProcessDllSearchPath(nativeDirectory);
foreach (var libraryPath in extractedLibraries)
catch (Exception ex)
{
NativeLibrary.Load(libraryPath);
System.Diagnostics.Debug.WriteLine($"[NativeDependencyBootstrapper] Failed to prepare native dependencies: {ex}");
return false;
}
}

View File

@@ -7,8 +7,18 @@ public static class Program
[STAThread]
public static void Main(string[] args)
{
NativeDependencyBootstrapper.Prepare();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
try
{
if (!NativeDependencyBootstrapper.TryPrepare())
{
System.Diagnostics.Debug.WriteLine("[Program] Failed to prepare native dependencies, but continuing...");
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Program] Unhandled exception: {ex}");
}
}
public static AppBuilder BuildAvaloniaApp()

View File

@@ -22,6 +22,10 @@ public sealed class PluginDesktopComponentOptions
public string? DisplayNameLocalizationKey { get; init; }
public string? Description { get; init; }
public string? DescriptionLocalizationKey { get; init; }
public PluginCornerRadiusPreset CornerRadiusPreset { get; init; } = PluginCornerRadiusPreset.Default;
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; init; }

View File

@@ -20,6 +20,12 @@ public sealed class PluginDesktopComponentRegistration
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
? null
: options.DisplayNameLocalizationKey.Trim();
Description = string.IsNullOrWhiteSpace(options.Description)
? null
: options.Description.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(options.DescriptionLocalizationKey)
? null
: options.DescriptionLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = options.IconKey.Trim();
Category = options.Category.Trim();
@@ -45,6 +51,10 @@ public sealed class PluginDesktopComponentRegistration
public string? DisplayNameLocalizationKey { get; }
public string? Description { get; }
public string? DescriptionLocalizationKey { get; }
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }

View File

@@ -0,0 +1,216 @@
using System.Text.Json;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ComponentSystem.Extensions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class FusedDesktopLibraryMetadataTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(FusedDesktopLibraryMetadataTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void PluginDesktopComponentDescriptionMetadata_ReachesRuntimeDescriptor()
{
const string componentId = "plugin.metadata.widget";
var registration = new PluginDesktopComponentRegistration(
_ => new Border(),
new PluginDesktopComponentOptions
{
ComponentId = componentId,
DisplayName = "Metadata Widget",
IconKey = "Apps",
Category = "Plugins",
Description = "Plugin supplied description.",
DescriptionLocalizationKey = "plugin.metadata.description"
});
Assert.Equal("Plugin supplied description.", registration.Description);
Assert.Equal("plugin.metadata.description", registration.DescriptionLocalizationKey);
var registry = ComponentRegistry.CreateDefault().RegisterComponents(
[
new DesktopComponentDefinition(
registration.ComponentId,
registration.DisplayName,
registration.IconKey,
registration.Category,
registration.MinWidthCells,
registration.MinHeightCells,
registration.AllowStatusBarPlacement,
registration.AllowDesktopPlacement,
Description: registration.Description,
DescriptionLocalizationKey: registration.DescriptionLocalizationKey)
]);
var runtimeRegistry = new DesktopComponentRuntimeRegistry(
registry,
[
new DesktopComponentRuntimeRegistration(
componentId,
displayNameLocalizationKey: registration.DisplayNameLocalizationKey,
_ => new Border(),
cornerRadiusResolver: (Func<double, double>?)null)
]);
Assert.True(runtimeRegistry.TryGetDescriptor(componentId, out var descriptor));
Assert.Equal("Plugin supplied description.", descriptor.Description);
Assert.Equal("plugin.metadata.description", descriptor.DescriptionLocalizationKey);
}
[Fact]
public void JsonComponentExtensionProvider_LoadsOptionalDescriptionMetadata()
{
var extensionDirectory = Path.Combine(_tempRoot, "extensions");
Directory.CreateDirectory(extensionDirectory);
File.WriteAllText(
Path.Combine(extensionDirectory, "components.json"),
"""
[
{
"id": "json.description.widget",
"displayName": "JSON Description Widget",
"iconKey": "Apps",
"category": "Extensions",
"description": "Description from JSON.",
"descriptionLocalizationKey": "json.description.widget.description"
},
{
"id": "json.default.widget",
"displayName": "JSON Default Widget",
"description": " ",
"descriptionLocalizationKey": " "
}
]
""");
var definitions = JsonComponentExtensionProvider
.LoadProvidersFromDirectory(extensionDirectory)
.SelectMany(provider => provider.GetComponents())
.ToDictionary(definition => definition.Id, StringComparer.OrdinalIgnoreCase);
var described = definitions["json.description.widget"];
Assert.Equal("Description from JSON.", described.Description);
Assert.Equal("json.description.widget.description", described.DescriptionLocalizationKey);
var defaults = definitions["json.default.widget"];
Assert.Null(defaults.Description);
Assert.Null(defaults.DescriptionLocalizationKey);
}
[Fact]
public void FusedDesktopLibraryLocalizationFiles_ContainRequiredKeys()
{
var requiredKeys = new[]
{
"fused_desktop.library.title",
"fused_desktop.library.add_button",
"fused_desktop.library.find_more",
"fused_desktop.library.empty_selection",
"fused_desktop.library.component_summary_format"
};
foreach (var language in new[] { "zh-CN", "en-US", "ja-JP", "ko-KR" })
{
var json = ReadRepositoryFile("LanMountainDesktop", "Localization", $"{language}.json");
var table = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(table);
foreach (var key in requiredKeys)
{
Assert.True(table!.ContainsKey(key), $"{language} is missing {key}.");
Assert.False(string.IsNullOrWhiteSpace(table[key]), $"{language} has an empty {key}.");
}
}
}
[Fact]
public void FusedDesktopLibraryLifecycle_KeepsAddOpenAndUsesEditModeBoundary()
{
var appSource = ReadRepositoryFile("LanMountainDesktop", "App.axaml.cs");
var openSource = ExtractMethodSource(appSource, "OpenFusedDesktopComponentLibraryFromUi");
var closedSource = ExtractMethodSource(appSource, "OnFusedComponentLibraryWindowClosed");
var librarySource = ReadRepositoryFile("LanMountainDesktop", "Views", "FusedDesktopComponentLibraryWindow.axaml.cs");
var addSource = ExtractMethodSource(librarySource, "OnAddComponentRequested");
Assert.Contains("EnterEditMode()", openSource);
Assert.Contains("ExitEditMode()", closedSource);
Assert.Contains("AddComponent(componentId, this)", addSource);
Assert.DoesNotContain("Close()", addSource);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
}
}
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)}'.");
}
private static string ExtractMethodSource(string source, string methodName)
{
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
if (methodIndex < 0)
{
methodIndex = source.IndexOf($"private bool {methodName}(", StringComparison.Ordinal);
}
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
var braceIndex = source.IndexOf('{', methodIndex);
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
var depth = 0;
for (var i = braceIndex; i < source.Length; i++)
{
if (source[i] == '{')
{
depth++;
}
else if (source[i] == '}')
{
depth--;
if (depth == 0)
{
return source.Substring(methodIndex, i - methodIndex + 1);
}
}
}
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
}
}

View File

@@ -0,0 +1,66 @@
using LanMountainDesktop.Views;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class FusedDesktopLibraryPreviewLayoutTests
{
[Fact]
public void Calculate_PreservesLandscapeComponentRatio()
{
var metrics = FusedDesktopLibraryPreviewLayout.Calculate(
widthCells: 4,
heightCells: 2,
stageWidth: 520,
stageHeight: 320);
Assert.Equal(4, metrics.WidthCells);
Assert.Equal(2, metrics.HeightCells);
Assert.True(metrics.Width > metrics.Height);
Assert.Equal(2d, metrics.Width / metrics.Height, precision: 3);
}
[Fact]
public void Calculate_PreservesPortraitComponentRatio()
{
var metrics = FusedDesktopLibraryPreviewLayout.Calculate(
widthCells: 2,
heightCells: 4,
stageWidth: 520,
stageHeight: 320);
Assert.Equal(2, metrics.WidthCells);
Assert.Equal(4, metrics.HeightCells);
Assert.True(metrics.Height > metrics.Width);
Assert.Equal(0.5d, metrics.Width / metrics.Height, precision: 3);
}
[Fact]
public void Calculate_FitsPreviewInsideStageInsets()
{
var metrics = FusedDesktopLibraryPreviewLayout.Calculate(
widthCells: 4,
heightCells: 4,
stageWidth: 420,
stageHeight: 260);
Assert.Equal(metrics.Width, metrics.Height, precision: 3);
Assert.True(metrics.Width <= 420);
Assert.True(metrics.Height <= 260);
Assert.True(metrics.CellSize > 0);
}
[Fact]
public void Calculate_UsesFallbackStageWhenBoundsAreNotMeasured()
{
var metrics = FusedDesktopLibraryPreviewLayout.Calculate(
widthCells: 4,
heightCells: 2,
stageWidth: 0,
stageHeight: 0);
Assert.True(metrics.Width > 0);
Assert.True(metrics.Height > 0);
Assert.Equal(2d, metrics.Width / metrics.Height, precision: 3);
}
}

View File

@@ -9,4 +9,6 @@ public sealed record DesktopComponentDefinition(
int MinHeightCells,
bool AllowStatusBarPlacement,
bool AllowDesktopPlacement,
DesktopComponentResizeMode ResizeMode = DesktopComponentResizeMode.Proportional);
DesktopComponentResizeMode ResizeMode = DesktopComponentResizeMode.Proportional,
string? Description = null,
string? DescriptionLocalizationKey = null);

View File

@@ -44,7 +44,7 @@ public sealed class JsonComponentExtensionProvider : IComponentExtensionProvider
try
{
var json = File.ReadAllText(filePath);
var entries = JsonSerializer.Deserialize<List<ComponentExtensionEntry>>(json);
var entries = JsonSerializer.Deserialize<List<ComponentExtensionEntry>>(json, JsonOptions);
if (entries is null || entries.Count == 0)
{
return null;
@@ -67,7 +67,9 @@ public sealed class JsonComponentExtensionProvider : IComponentExtensionProvider
MinWidthCells: Math.Max(1, entry.MinWidthCells),
MinHeightCells: Math.Max(1, entry.MinHeightCells),
AllowStatusBarPlacement: entry.AllowStatusBarPlacement,
AllowDesktopPlacement: entry.AllowDesktopPlacement));
AllowDesktopPlacement: entry.AllowDesktopPlacement,
Description: NormalizeOptional(entry.Description),
DescriptionLocalizationKey: NormalizeOptional(entry.DescriptionLocalizationKey)));
}
return definitions.Count == 0 ? null : new JsonComponentExtensionProvider(definitions);
@@ -78,6 +80,16 @@ public sealed class JsonComponentExtensionProvider : IComponentExtensionProvider
}
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed class ComponentExtensionEntry
{
public string Id { get; set; } = string.Empty;
@@ -95,5 +107,9 @@ public sealed class JsonComponentExtensionProvider : IComponentExtensionProvider
public bool AllowStatusBarPlacement { get; set; }
public bool AllowDesktopPlacement { get; set; } = true;
public string? Description { get; set; }
public string? DescriptionLocalizationKey { get; set; }
}
}

View File

@@ -1031,6 +1031,11 @@
"component_library.components_none": "No components.",
"component_library.drag_hint": "Drag to place",
"component_library.preview_unavailable": "Preview unavailable",
"fused_desktop.library.title": "Add widgets",
"fused_desktop.library.add_button": "Add widget",
"fused_desktop.library.find_more": "Find more widgets",
"fused_desktop.library.empty_selection": "Choose a category to view widgets.",
"fused_desktop.library.component_summary_format": "{0} - {1} x {2}",
"component.delete": "Delete",
"component.edit": "Edit",
"component.move": "Move",

View File

@@ -752,6 +752,11 @@
"component_library.title": "ウィジェット",
"component_library.empty": "スワイプしてカテゴリを選択し、タップして開き、ウィジェットをデスクトップにドラッグします。",
"component_library.drag_hint": "ドラッグして配置",
"fused_desktop.library.title": "ウィジェットを追加",
"fused_desktop.library.add_button": "ウィジェットを追加",
"fused_desktop.library.find_more": "さらにウィジェットを探す",
"fused_desktop.library.empty_selection": "カテゴリを選択して追加できるウィジェットを表示します。",
"fused_desktop.library.component_summary_format": "{0} - {1} x {2}",
"component.delete": "削除",
"component.edit": "編集",
"component.editor.instance_scope": "変更はこのコンポーネントインスタンスにのみ適用されます。",

View File

@@ -799,6 +799,11 @@
"component_library.title": "바탕화면 편집",
"component_library.empty": "좌우로 스와이프하여 카테고리를 선택하고 클릭하여 진입한 후 컴포넌트를 바탕화면에 드래그하여 배치하세요.",
"component_library.drag_hint": "드래그하여 배치",
"fused_desktop.library.title": "위젯 추가",
"fused_desktop.library.add_button": "위젯 추가",
"fused_desktop.library.find_more": "더 많은 위젯 찾기",
"fused_desktop.library.empty_selection": "카테고리를 선택하여 추가 가능한 위젯을 확인하세요.",
"fused_desktop.library.component_summary_format": "{0} - {1} x {2}",
"component.delete": "삭제",
"component.edit": "편집",
"component.editor.instance_scope": "설정은 현재 컴포넌트 인스턴스에만 적용됩니다.",

View File

@@ -962,6 +962,11 @@
"component_library.components_none": "暂无组件",
"component_library.drag_hint": "拖动放置",
"component_library.preview_unavailable": "预览不可用",
"fused_desktop.library.title": "添加小组件",
"fused_desktop.library.add_button": "添加小组件",
"fused_desktop.library.find_more": "查找更多小组件",
"fused_desktop.library.empty_selection": "选择一个分类以查看可添加组件。",
"fused_desktop.library.component_summary_format": "{0} - {1} x {2}",
"component.delete": "删除",
"component.edit": "编辑",
"component.move": "移动",

View File

@@ -112,7 +112,9 @@ public static class DesktopComponentRegistryFactory
registration.AllowDesktopPlacement,
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
? DesktopComponentResizeMode.Free
: DesktopComponentResizeMode.Proportional));
: DesktopComponentResizeMode.Proportional,
Description: registration.Description,
DescriptionLocalizationKey: registration.DescriptionLocalizationKey));
}
return definitions;

View File

@@ -109,6 +109,10 @@ public sealed class DesktopComponentRuntimeDescriptor
public string? DisplayNameLocalizationKey { get; }
public string? Description => Definition.Description;
public string? DescriptionLocalizationKey => Definition.DescriptionLocalizationKey;
public Control CreateControl(
double cellSize,
TimeZoneService timeZoneService,

View File

@@ -108,7 +108,9 @@
Click="OnFindMoreComponentsClick">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
<TextBlock Text="查找更多小组件" FontSize="12"/>
<TextBlock x:Name="FindMoreComponentsTextBlock"
Text="查找更多小组件"
FontSize="12"/>
</StackPanel>
</Button>
</StackPanel>
@@ -121,95 +123,96 @@
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.35"/>
<ScrollViewer Grid.Column="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="28,8,8,10">
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
<Grid RowDefinitions="Auto,Auto,*,Auto"
MinHeight="330">
<TextBlock FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Grid Grid.Column="1"
Margin="28,8,8,10">
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}"
RowDefinitions="Auto,Auto,*,Auto"
MinHeight="0">
<TextBlock FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Row="1"
Margin="0,6,0,14"
MaxHeight="44"
FontSize="13"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Opacity="0.82"
Text="{Binding SelectedComponent.Description}"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Row="1"
Margin="0,6,0,14"
MaxHeight="44"
FontSize="13"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Opacity="0.82"
Text="{Binding SelectedComponent.Description}"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/>
<Border x:Name="PreviewInteractionHost"
Grid.Row="2"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Width="390"
Height="230"
Focusable="True"
HorizontalAlignment="Center"
VerticalAlignment="Center"
PointerPressed="OnPreviewPointerPressed"
PointerReleased="OnPreviewPointerReleased"
PointerCaptureLost="OnPreviewPointerCaptureLost"
PointerWheelChanged="OnPreviewPointerWheelChanged"
KeyDown="OnPreviewKeyDown">
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Padding="12"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ContentControl x:Name="SelectedComponentPreviewHost"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False"/>
</Border>
</Border>
<Border x:Name="PreviewInteractionHost"
Grid.Row="2"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
MinWidth="360"
MinHeight="220"
Focusable="True"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
SizeChanged="OnPreviewInteractionHostSizeChanged"
PointerPressed="OnPreviewPointerPressed"
PointerReleased="OnPreviewPointerReleased"
PointerCaptureLost="OnPreviewPointerCaptureLost"
PointerWheelChanged="OnPreviewPointerWheelChanged"
KeyDown="OnPreviewKeyDown">
<Border x:Name="SelectedComponentPreviewFrame"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Padding="12"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ContentControl x:Name="SelectedComponentPreviewHost"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False"/>
</Border>
</Border>
<Button Grid.Row="3"
HorizontalAlignment="Center"
Margin="0,18,0,0"
Classes="fused-library-add-button"
Tag="{Binding SelectedComponent.ComponentId}"
Click="OnAddComponentClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="添加小组件" FontWeight="SemiBold"/>
</StackPanel>
</Button>
</Grid>
</Panel>
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="330">
<StackPanel Spacing="16"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<fi:FluentIcon Icon="Apps"
IconVariant="Regular"
FontSize="64"
Opacity="0.3"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
<TextBlock HorizontalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="选择一个分类以查看可添加组件。"/>
<Button Grid.Row="3"
HorizontalAlignment="Center"
Margin="0,18,0,0"
Classes="fused-library-add-button"
Tag="{Binding SelectedComponent.ComponentId}"
Click="OnAddComponentClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
<TextBlock x:Name="AddComponentButtonTextBlock"
Text="添加小组件"
FontWeight="SemiBold"/>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</Button>
</Grid>
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="330">
<StackPanel Spacing="16"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<fi:FluentIcon Icon="Apps"
IconVariant="Regular"
FontSize="64"
Opacity="0.3"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
<TextBlock x:Name="EmptySelectionTextBlock"
HorizontalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="选择一个分类以查看可添加组件。"/>
</StackPanel>
</Grid>
</Grid>
</Grid>
</UserControl>

View File

@@ -36,6 +36,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private Control? _selectedPreviewControl;
private DesktopComponentDefinition? _selectedPreviewDefinition;
private FusedDesktopLibraryPreviewMetrics? _selectedPreviewMetrics;
private bool _isPreviewSwipeActive;
private Point _previewSwipeStartPoint;
@@ -47,6 +49,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
ApplyLocalization();
LoadRegistry();
LoadCategories();
@@ -57,6 +60,24 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
}
}
private void ApplyLocalization()
{
var languageCode = _settingsFacade.Region.Get().LanguageCode;
_viewModel.Title = L(languageCode, "fused_desktop.library.title", "Add widgets");
FindMoreComponentsTextBlock.Text = L(
languageCode,
"fused_desktop.library.find_more",
"Find more widgets");
AddComponentButtonTextBlock.Text = L(
languageCode,
"fused_desktop.library.add_button",
"Add widget");
EmptySelectionTextBlock.Text = L(
languageCode,
"fused_desktop.library.empty_selection",
"Choose a category to view widgets.");
}
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
_ = sender;
@@ -136,10 +157,73 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
}
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition, string languageCode)
{
return new ComponentLibraryItemViewModel(
definition.Id,
ResolveComponentDisplayName(definition, languageCode),
ResolveComponentDescription(definition, languageCode));
}
private string ResolveComponentDisplayName(DesktopComponentDefinition definition, string languageCode)
{
if (_componentRuntimeRegistry is not null &&
_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor) &&
!string.IsNullOrWhiteSpace(descriptor.DisplayNameLocalizationKey))
{
return L(languageCode, descriptor.DisplayNameLocalizationKey, definition.DisplayName);
}
return definition.DisplayName;
}
private string ResolveComponentDescription(DesktopComponentDefinition definition, string languageCode)
{
if (_componentRuntimeRegistry is not null &&
_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor))
{
if (!string.IsNullOrWhiteSpace(descriptor.DescriptionLocalizationKey))
{
return L(
languageCode,
descriptor.DescriptionLocalizationKey,
descriptor.Description ?? CreateComponentFallbackDescription(definition, languageCode));
}
if (!string.IsNullOrWhiteSpace(descriptor.Description))
{
return descriptor.Description;
}
}
if (!string.IsNullOrWhiteSpace(definition.DescriptionLocalizationKey))
{
return L(
languageCode,
definition.DescriptionLocalizationKey,
definition.Description ?? CreateComponentFallbackDescription(definition, languageCode));
}
if (!string.IsNullOrWhiteSpace(definition.Description))
{
return definition.Description;
}
return CreateComponentFallbackDescription(definition, languageCode);
}
private string CreateComponentFallbackDescription(DesktopComponentDefinition definition, string languageCode)
{
var categoryTitle = GetLocalizedCategoryTitle(languageCode, definition.Category);
var description = $"{categoryTitle} - {Math.Max(1, definition.MinWidthCells)} x {Math.Max(1, definition.MinHeightCells)}";
return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName, description);
var fallbackFormat = L(
languageCode,
"fused_desktop.library.component_summary_format",
"{0} - {1} x {2}");
return string.Format(
System.Globalization.CultureInfo.CurrentCulture,
fallbackFormat,
categoryTitle,
Math.Max(1, definition.MinWidthCells),
Math.Max(1, definition.MinHeightCells));
}
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -154,6 +238,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
if (CategoryListBox.SelectedItem is not ComponentLibraryCategoryViewModel selectedCategory)
{
_viewModel.SelectedComponent = null;
_selectedPreviewDefinition = null;
_selectedPreviewMetrics = null;
SetSelectedPreviewControl(null);
return;
}
@@ -174,14 +260,18 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
if (_selectedCategoryDefinitions.Count == 0)
{
_viewModel.SelectedComponent = null;
_selectedPreviewDefinition = null;
_selectedPreviewMetrics = null;
SetSelectedPreviewControl(null);
return;
}
_selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex);
var selectedDefinition = _selectedCategoryDefinitions[_selectedComponentIndex];
_selectedPreviewDefinition = selectedDefinition;
_selectedPreviewMetrics = null;
_viewModel.SelectedComponent = CreateComponentItem(selectedDefinition, _settingsFacade.Region.Get().LanguageCode);
SetSelectedPreviewControl(CreateStaticPreviewControl(selectedDefinition));
RefreshSelectedPreviewControl(force: true);
}
private int NormalizeComponentIndex(int index)
@@ -274,7 +364,45 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
}
}
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)
private void OnPreviewInteractionHostSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
RefreshSelectedPreviewControl(force: false);
}
private void RefreshSelectedPreviewControl(bool force)
{
if (_selectedPreviewDefinition is null)
{
_selectedPreviewMetrics = null;
SetSelectedPreviewControl(null);
return;
}
var metrics = FusedDesktopLibraryPreviewLayout.Calculate(
_selectedPreviewDefinition,
PreviewInteractionHost.Bounds.Size);
if (!force &&
_selectedPreviewMetrics is { } previousMetrics &&
ArePreviewMetricsClose(previousMetrics, metrics))
{
return;
}
_selectedPreviewMetrics = metrics;
if (!force && _selectedPreviewControl is not null)
{
ApplyPreviewMetricsToControl(_selectedPreviewControl, metrics);
return;
}
SetSelectedPreviewControl(CreateStaticPreviewControl(_selectedPreviewDefinition, metrics));
}
private Control? CreateStaticPreviewControl(
DesktopComponentDefinition definition,
FusedDesktopLibraryPreviewMetrics metrics)
{
if (_componentRuntimeRegistry is null ||
!_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor))
@@ -285,7 +413,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
try
{
var control = descriptor.CreateControl(
ResolvePreviewCellSize(definition),
metrics.CellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
@@ -293,6 +421,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_settingsFacade,
placementId: null,
renderMode: DesktopComponentRenderMode.LibraryPreview);
ApplyPreviewMetricsToControl(control, metrics);
ComponentPreviewRuntimeQuiescer.Attach(control);
return control;
}
@@ -306,16 +435,30 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
}
}
private static double ResolvePreviewCellSize(DesktopComponentDefinition definition)
private static void ApplyPreviewMetricsToControl(
Control control,
FusedDesktopLibraryPreviewMetrics metrics)
{
const double maxWidth = 360d;
const double maxHeight = 240d;
return Math.Clamp(
Math.Min(
maxWidth / Math.Max(1, definition.MinWidthCells),
maxHeight / Math.Max(1, definition.MinHeightCells)),
32d,
96d);
control.Width = metrics.Width;
control.Height = metrics.Height;
control.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center;
control.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
if (control is IDesktopComponentWidget sizedComponent)
{
sizedComponent.ApplyCellSize(metrics.CellSize);
}
}
private static bool ArePreviewMetricsClose(
FusedDesktopLibraryPreviewMetrics first,
FusedDesktopLibraryPreviewMetrics second)
{
const double tolerance = 0.5d;
return first.WidthCells == second.WidthCells &&
first.HeightCells == second.HeightCells &&
Math.Abs(first.CellSize - second.CellSize) <= tolerance &&
Math.Abs(first.Width - second.Width) <= tolerance &&
Math.Abs(first.Height - second.Height) <= tolerance;
}
private void SetSelectedPreviewControl(Control? control)

View File

@@ -3,10 +3,10 @@
xmlns:controls="using:LanMountainDesktop.Views"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
Width="740"
Height="500"
MinWidth="600"
MinHeight="440"
Width="860"
Height="560"
MinWidth="720"
MinHeight="500"
CanResize="True"
WindowStartupLocation="CenterScreen"
WindowDecorations="None"
@@ -19,10 +19,9 @@
Background="Transparent">
<Border x:Name="PanelShell"
Classes="surface-translucent-strong"
Width="720"
MaxWidth="720"
HorizontalAlignment="Center"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Margin="10"
Padding="0"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
ClipToBounds="True">
@@ -32,7 +31,8 @@
Background="Transparent"
PointerPressed="OnWindowTitleBarPointerPressed">
<Grid ColumnDefinitions="*,Auto">
<TextBlock VerticalAlignment="Center"
<TextBlock x:Name="WindowTitleTextBlock"
VerticalAlignment="Center"
FontSize="22"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"

View File

@@ -6,16 +6,20 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Views;
public partial class FusedDesktopComponentLibraryWindow : Window
{
private static readonly LocalizationService LocalizationService = new();
public FusedDesktopComponentLibraryWindow()
{
InitializeComponent();
ApplyFluentCornerRadius();
ApplyLocalization();
LibraryControl.AddComponentRequested += OnAddComponentRequested;
KeyDown += OnWindowKeyDown;
@@ -24,6 +28,17 @@ public partial class FusedDesktopComponentLibraryWindow : Window
mainWindow?.RegisterFusedLibraryWindow(this);
}
private void ApplyLocalization()
{
var languageCode = HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode;
var title = LocalizationService.GetString(
languageCode,
"fused_desktop.library.title",
"Add widgets");
Title = title;
WindowTitleTextBlock.Text = title;
}
private void ApplyFluentCornerRadius()
{
if (RootGrid is null)

View File

@@ -0,0 +1,79 @@
using System;
using Avalonia;
using LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.Views;
internal readonly record struct FusedDesktopLibraryPreviewMetrics(
int WidthCells,
int HeightCells,
double CellSize,
double Width,
double Height);
internal static class FusedDesktopLibraryPreviewLayout
{
internal const double DefaultStageWidth = 460d;
internal const double DefaultStageHeight = 300d;
private const double StageHorizontalInset = 48d;
private const double StageVerticalInset = 42d;
private const double MinCellSize = 32d;
private const double MaxCellSize = 128d;
public static FusedDesktopLibraryPreviewMetrics Calculate(
DesktopComponentDefinition definition,
Size stageSize)
{
ArgumentNullException.ThrowIfNull(definition);
return Calculate(
definition.MinWidthCells,
definition.MinHeightCells,
stageSize.Width,
stageSize.Height);
}
public static FusedDesktopLibraryPreviewMetrics Calculate(
int widthCells,
int heightCells,
double stageWidth,
double stageHeight)
{
var normalizedWidthCells = Math.Max(1, widthCells);
var normalizedHeightCells = Math.Max(1, heightCells);
var normalizedStageWidth = NormalizeStageLength(stageWidth, DefaultStageWidth);
var normalizedStageHeight = NormalizeStageLength(stageHeight, DefaultStageHeight);
var availableWidth = Math.Max(1d, normalizedStageWidth - StageHorizontalInset);
var availableHeight = Math.Max(1d, normalizedStageHeight - StageVerticalInset);
var fitCellSize = Math.Min(
availableWidth / normalizedWidthCells,
availableHeight / normalizedHeightCells);
if (!double.IsFinite(fitCellSize) || fitCellSize <= 0d)
{
fitCellSize = Math.Min(
(DefaultStageWidth - StageHorizontalInset) / normalizedWidthCells,
(DefaultStageHeight - StageVerticalInset) / normalizedHeightCells);
}
var cellSize = fitCellSize >= MinCellSize
? Math.Min(fitCellSize, MaxCellSize)
: Math.Max(1d, fitCellSize);
return new FusedDesktopLibraryPreviewMetrics(
normalizedWidthCells,
normalizedHeightCells,
cellSize,
normalizedWidthCells * cellSize,
normalizedHeightCells * cellSize);
}
private static double NormalizeStageLength(double value, double fallback)
{
return double.IsFinite(value) && value > 1d
? value
: fallback;
}
}