diff --git a/.trae/specs/fused-desktop-library-redesign/spec.md b/.trae/specs/fused-desktop-library-redesign/spec.md index 0c1ff69..7e1af19 100644 --- a/.trae/specs/fused-desktop-library-redesign/spec.md +++ b/.trae/specs/fused-desktop-library-redesign/spec.md @@ -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. diff --git a/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs b/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs index b43ca0e..4ba4890 100644 --- a/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs +++ b/LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs @@ -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(NativeLibraryNames.Length); - foreach (var libraryName in NativeLibraryNames) + try { - extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName)); + var nativeDirectory = GetNativeDirectory(); + Directory.CreateDirectory(nativeDirectory); + + var extractedLibraries = new List(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; } } diff --git a/LanDesktopPLONDS.installer/Program.cs b/LanDesktopPLONDS.installer/Program.cs index e252155..d68cd9a 100644 --- a/LanDesktopPLONDS.installer/Program.cs +++ b/LanDesktopPLONDS.installer/Program.cs @@ -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() diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs index d5172a4..7820be3 100644 --- a/LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs @@ -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? CornerRadiusResolver { get; init; } diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs index 6ed016b..501d165 100644 --- a/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs @@ -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 ControlFactory { get; } public string IconKey { get; } diff --git a/LanMountainDesktop.Tests/FusedDesktopLibraryMetadataTests.cs b/LanMountainDesktop.Tests/FusedDesktopLibraryMetadataTests.cs new file mode 100644 index 0000000..b1fed1f --- /dev/null +++ b/LanMountainDesktop.Tests/FusedDesktopLibraryMetadataTests.cs @@ -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?)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>(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}'."); + } +} diff --git a/LanMountainDesktop.Tests/FusedDesktopLibraryPreviewLayoutTests.cs b/LanMountainDesktop.Tests/FusedDesktopLibraryPreviewLayoutTests.cs new file mode 100644 index 0000000..ba80f43 --- /dev/null +++ b/LanMountainDesktop.Tests/FusedDesktopLibraryPreviewLayoutTests.cs @@ -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); + } +} diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentDefinition.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentDefinition.cs index d555be2..917e000 100644 --- a/LanMountainDesktop/ComponentSystem/DesktopComponentDefinition.cs +++ b/LanMountainDesktop/ComponentSystem/DesktopComponentDefinition.cs @@ -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); diff --git a/LanMountainDesktop/ComponentSystem/Extensions/JsonComponentExtensionProvider.cs b/LanMountainDesktop/ComponentSystem/Extensions/JsonComponentExtensionProvider.cs index c00001e..a9f8cac 100644 --- a/LanMountainDesktop/ComponentSystem/Extensions/JsonComponentExtensionProvider.cs +++ b/LanMountainDesktop/ComponentSystem/Extensions/JsonComponentExtensionProvider.cs @@ -44,7 +44,7 @@ public sealed class JsonComponentExtensionProvider : IComponentExtensionProvider try { var json = File.ReadAllText(filePath); - var entries = JsonSerializer.Deserialize>(json); + var entries = JsonSerializer.Deserialize>(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; } } } diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 2bd1207..efc1482 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index 903dcdd..94451d7 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -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": "変更はこのコンポーネントインスタンスにのみ適用されます。", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index 2efe5fc..0517406 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -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": "설정은 현재 컴포넌트 인스턴스에만 적용됩니다.", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 8aaf7ab..0e55a90 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -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": "移动", diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs index ad15fb6..170064f 100644 --- a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs @@ -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; diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 5d561a3..aca32c1 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -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, diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml index f75edf0..4ce2950 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml @@ -108,7 +108,9 @@ Click="OnFindMoreComponentsClick"> - + @@ -121,95 +123,96 @@ Background="{DynamicResource AdaptiveGlassPanelBorderBrush}" Opacity="0.35"/> - - - - - + + + - + - - - - - + + + + + - - - - - - - - + + + + + + + + + + diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs index c2556f2..03e0b31 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -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) diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml index 1fad1eb..1f2fa27 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml @@ -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"> @@ -32,7 +31,8 @@ Background="Transparent" PointerPressed="OnWindowTitleBarPointerPressed"> - = 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; + } +}