mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.融合桌面组件展示优化
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -18,13 +18,15 @@ internal static class NativeDependencyBootstrapper
|
||||
"libSkiaSharp.dll"
|
||||
];
|
||||
|
||||
public static void Prepare()
|
||||
public static bool TryPrepare()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var nativeDirectory = GetNativeDirectory();
|
||||
Directory.CreateDirectory(nativeDirectory);
|
||||
|
||||
@@ -40,6 +42,14 @@ internal static class NativeDependencyBootstrapper
|
||||
{
|
||||
NativeLibrary.Load(libraryPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[NativeDependencyBootstrapper] Failed to prepare native dependencies: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetNativeDirectory()
|
||||
|
||||
@@ -7,9 +7,19 @@ public static class Program
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
NativeDependencyBootstrapper.Prepare();
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
216
LanMountainDesktop.Tests/FusedDesktopLibraryMetadataTests.cs
Normal file
216
LanMountainDesktop.Tests/FusedDesktopLibraryMetadataTests.cs
Normal 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "変更はこのコンポーネントインスタンスにのみ適用されます。",
|
||||
|
||||
@@ -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": "설정은 현재 컴포넌트 인스턴스에만 적용됩니다.",
|
||||
|
||||
@@ -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": "移动",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,13 +123,11 @@
|
||||
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">
|
||||
<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}"
|
||||
@@ -152,17 +152,19 @@
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
Width="390"
|
||||
Height="230"
|
||||
MinWidth="360"
|
||||
MinHeight="220"
|
||||
Focusable="True"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
SizeChanged="OnPreviewInteractionHostSizeChanged"
|
||||
PointerPressed="OnPreviewPointerPressed"
|
||||
PointerReleased="OnPreviewPointerReleased"
|
||||
PointerCaptureLost="OnPreviewPointerCaptureLost"
|
||||
PointerWheelChanged="OnPreviewPointerWheelChanged"
|
||||
KeyDown="OnPreviewKeyDown">
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
<Border x:Name="SelectedComponentPreviewFrame"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
@@ -185,11 +187,12 @@
|
||||
Click="OnAddComponentClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
|
||||
<TextBlock Text="添加小组件" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="AddComponentButtonTextBlock"
|
||||
Text="添加小组件"
|
||||
FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -203,13 +206,13 @@
|
||||
FontSize="64"
|
||||
Opacity="0.3"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
<TextBlock x:Name="EmptySelectionTextBlock"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="选择一个分类以查看可添加组件。"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
79
LanMountainDesktop/Views/FusedDesktopLibraryPreviewLayout.cs
Normal file
79
LanMountainDesktop/Views/FusedDesktopLibraryPreviewLayout.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user