settings_re8

This commit is contained in:
lincube
2026-03-14 22:45:09 +08:00
parent 91f9f3d6fb
commit 689be7b585
54 changed files with 5356 additions and 30 deletions

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.PluginSdk;
public interface IComponentEditorHostContext
{
void RequestRefresh();
void CloseEditor();
void RequestRestart(string? reason = null);
}

View File

@@ -0,0 +1,71 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorContext
{
public PluginDesktopComponentEditorContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
IPluginSettingsService? pluginSettings,
IComponentEditorHostContext hostContext)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(hostContext);
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
PluginSettings = pluginSettings;
HostContext = hostContext;
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public string ComponentId { get; }
public string? PlacementId { get; }
public IPluginSettingsService? PluginSettings { get; }
public IComponentEditorHostContext HostContext { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorRegistration
{
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(editorFactory);
if (preferredWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
}
if (preferredHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
}
if (minScale <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minScale));
}
if (maxScale < minScale)
{
throw new ArgumentOutOfRangeException(nameof(maxScale));
}
ComponentId = componentId.Trim();
EditorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = preferredWidth / preferredHeight;
}
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
: this(
componentId,
(_, context) => editorFactory(context),
preferredWidth,
preferredHeight,
minScale,
maxScale)
{
}
public string ComponentId { get; }
public Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> EditorFactory { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
}

View File

@@ -61,6 +61,27 @@ public static class PluginServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
this IServiceCollection services,
string componentId,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
componentId,
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
preferredWidth,
preferredHeight,
minScale,
maxScale));
return services;
}
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services) public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
where TContract : class where TContract : class
where TImplementation : class, TContract where TImplementation : class, TContract

View File

@@ -2,6 +2,7 @@ using System;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
@@ -45,8 +46,10 @@ public partial class App : Application
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private ISettingsPageRegistry? _settingsPageRegistry; private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService; private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService;
private bool _exitCleanupCompleted; private bool _exitCleanupCompleted;
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop; private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent; private ShutdownIntent _shutdownIntent;
@@ -92,6 +95,7 @@ public partial class App : Application
ApplyThemeFromSettings(); ApplyThemeFromSettings();
ApplyCurrentCultureFromSettings(); ApplyCurrentCultureFromSettings();
EnsureSettingsWindowService(); EnsureSettingsWindowService();
EnsureWeatherLocationRefreshService();
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
@@ -119,6 +123,8 @@ public partial class App : Application
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow); CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
} }
StartWeatherLocationRefreshIfNeeded();
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
@@ -300,6 +306,35 @@ public partial class App : Application
_settingsFacade); _settingsFacade);
} }
private void EnsureWeatherLocationRefreshService()
{
_weatherLocationRefreshService ??= new WeatherLocationRefreshService(
_settingsFacade,
_locationService,
_localizationService);
}
private void StartWeatherLocationRefreshIfNeeded()
{
EnsureWeatherLocationRefreshService();
if (_weatherLocationRefreshService is null)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await _weatherLocationRefreshService.TryRefreshOnStartupAsync();
}
catch (Exception ex)
{
AppLogger.Warn("Weather.Location", "Failed to refresh weather location during startup.", ex);
}
});
}
private void ApplyThemeFromSettings() private void ApplyThemeFromSettings()
{ {
var themeState = _settingsFacade.Theme.Get(); var themeState = _settingsFacade.Theme.Get();

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentEditorContext(
DesktopComponentDefinition Definition,
string ComponentId,
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore,
IComponentEditorHostContext HostContext);
public sealed class DesktopComponentEditorRegistration
{
public DesktopComponentEditorRegistration(
string componentId,
Func<DesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(editorFactory);
if (preferredWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
}
if (preferredHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
}
if (minScale <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minScale));
}
if (maxScale < minScale)
{
throw new ArgumentOutOfRangeException(nameof(maxScale));
}
ComponentId = componentId.Trim();
EditorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = preferredWidth / preferredHeight;
}
public string ComponentId { get; }
public Func<DesktopComponentEditorContext, Control> EditorFactory { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
}
public sealed class DesktopComponentEditorDescriptor
{
internal DesktopComponentEditorDescriptor(
DesktopComponentDefinition definition,
Func<DesktopComponentEditorContext, Control> editorFactory,
double preferredWidth,
double preferredHeight,
double minScale,
double maxScale,
double aspectRatio)
{
Definition = definition;
_editorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = aspectRatio;
}
private readonly Func<DesktopComponentEditorContext, Control> _editorFactory;
public DesktopComponentDefinition Definition { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
public Control CreateEditor(DesktopComponentEditorContext context)
{
return _editorFactory(context);
}
}
public sealed class DesktopComponentEditorRegistry
{
private readonly Dictionary<string, DesktopComponentEditorDescriptor> _descriptors;
public DesktopComponentEditorRegistry(
ComponentRegistry componentRegistry,
IEnumerable<DesktopComponentEditorRegistration> registrations)
{
ArgumentNullException.ThrowIfNull(componentRegistry);
ArgumentNullException.ThrowIfNull(registrations);
_descriptors = new Dictionary<string, DesktopComponentEditorDescriptor>(StringComparer.OrdinalIgnoreCase);
foreach (var registration in registrations)
{
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition))
{
continue;
}
_descriptors[registration.ComponentId] = new DesktopComponentEditorDescriptor(
definition,
registration.EditorFactory,
registration.PreferredWidth,
registration.PreferredHeight,
registration.MinScale,
registration.MaxScale,
registration.AspectRatio);
}
}
public bool TryGetDescriptor(string componentId, out DesktopComponentEditorDescriptor descriptor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
return _descriptors.TryGetValue(componentId.Trim(), out descriptor!);
}
public IReadOnlyList<DesktopComponentEditorDescriptor> GetAll()
{
return _descriptors.Values.ToList();
}
}

View File

@@ -46,6 +46,7 @@
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" /> <PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" /> <PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />

View File

@@ -93,6 +93,7 @@
"settings.status_bar.spacing_custom_label": "Custom spacing (%)", "settings.status_bar.spacing_custom_label": "Custom spacing (%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px", "settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "Weather", "settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source", "settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.", "settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search", "settings.weather.mode_city_search": "City Search",
@@ -119,6 +120,14 @@
"settings.weather.apply_coordinates_button": "Apply Coordinates", "settings.weather.apply_coordinates_button": "Apply Coordinates",
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}", "settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}", "settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
"settings.weather.location_services_header": "Location Service",
"settings.weather.location_services_desc": "Use the current Windows location and decide whether it refreshes automatically on startup.",
"settings.weather.use_current_location": "Use Current Location",
"settings.weather.location_unsupported": "Current platform does not support retrieving the current location.",
"settings.weather.location_ready": "You can use the current Windows location.",
"settings.weather.location_refreshing": "Requesting current location...",
"settings.weather.location_refresh_success_format": "Current location applied: {0}",
"settings.weather.location_refresh_failed_format": "Failed to get current location: {0}",
"settings.weather.preview_header": "Connection Test", "settings.weather.preview_header": "Connection Test",
"settings.weather.preview_desc": "Send one test request to verify current settings.", "settings.weather.preview_desc": "Send one test request to verify current settings.",
"settings.weather.preview_button": "Test Fetch", "settings.weather.preview_button": "Test Fetch",
@@ -127,6 +136,7 @@
"settings.weather.preview_panel_header": "Weather Preview", "settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.", "settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
"settings.weather.refresh_button": "Refresh", "settings.weather.refresh_button": "Refresh",
"settings.weather.preview_updated_format": "Updated {0}",
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.", "settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
"settings.weather.preview_missing_location": "Please apply one weather location before testing.", "settings.weather.preview_missing_location": "Please apply one weather location before testing.",
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}", "settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
@@ -335,12 +345,14 @@
"launcher.context.hide_icon": "Hide Icon", "launcher.context.hide_icon": "Hide Icon",
"launcher.action.hide": "Hide", "launcher.action.hide": "Hide",
"settings.launcher.title": "App Launcher", "settings.launcher.title": "App Launcher",
"settings.launcher.description": "Manage hidden apps and folders in the App Launcher.",
"settings.launcher.hidden_header": "Hidden Items", "settings.launcher.hidden_header": "Hidden Items",
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.", "settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.", "settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
"settings.launcher.hidden_empty": "No hidden items.", "settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_summary_format": "{0} hidden items",
"settings.launcher.hidden_type_folder": "Folder", "settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut", "settings.launcher.hidden_type_shortcut": "App",
"settings.launcher.restore_button": "Unhide", "settings.launcher.restore_button": "Unhide",
"settings.plugins.title": "Plugins", "settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime", "settings.plugins.runtime_header": "Plugin Runtime",
@@ -459,6 +471,12 @@
"component_library.drag_hint": "Drag to place", "component_library.drag_hint": "Drag to place",
"component.delete": "Delete", "component.delete": "Delete",
"component.edit": "Edit", "component.edit": "Edit",
"component.editor.instance_scope": "Changes apply to this component instance only.",
"component.editor.info_header": "Component Info",
"component.editor.id_label": "Component ID",
"component.editor.placement_label": "Placement ID",
"component.editor.scope_label": "Scope",
"component.editor.scope_instance": "Instance-scoped editor",
"component_category.clock": "Clock", "component_category.clock": "Clock",
"component_category.date": "Calendar", "component_category.date": "Calendar",
"component_category.weather": "Weather", "component_category.weather": "Weather",
@@ -801,4 +819,3 @@
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.", "single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK" "single_instance.notice.button": "OK"
} }

View File

@@ -98,6 +98,7 @@
"settings.status_bar.spacing_custom_label": "自定义间距(%", "settings.status_bar.spacing_custom_label": "自定义间距(%",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px", "settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "天气", "settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源", "settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。", "settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索", "settings.weather.mode_city_search": "城市搜索",
@@ -124,6 +125,14 @@
"settings.weather.apply_coordinates_button": "应用坐标", "settings.weather.apply_coordinates_button": "应用坐标",
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}", "settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}", "settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
"settings.weather.location_services_header": "定位服务",
"settings.weather.location_services_desc": "使用当前 Windows 定位,并决定是否在启动时自动刷新天气位置。",
"settings.weather.use_current_location": "使用当前位置",
"settings.weather.location_unsupported": "当前平台不支持获取当前位置。",
"settings.weather.location_ready": "可以使用当前 Windows 定位。",
"settings.weather.location_refreshing": "正在获取当前位置……",
"settings.weather.location_refresh_success_format": "已应用当前位置:{0}",
"settings.weather.location_refresh_failed_format": "获取当前位置失败:{0}",
"settings.weather.preview_header": "连接测试", "settings.weather.preview_header": "连接测试",
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。", "settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
"settings.weather.preview_button": "测试获取", "settings.weather.preview_button": "测试获取",
@@ -132,6 +141,7 @@
"settings.weather.preview_panel_header": "天气预览", "settings.weather.preview_panel_header": "天气预览",
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。", "settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
"settings.weather.refresh_button": "刷新", "settings.weather.refresh_button": "刷新",
"settings.weather.preview_updated_format": "更新于 {0}",
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。", "settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。", "settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}", "settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
@@ -340,12 +350,14 @@
"launcher.context.hide_icon": "隐藏图标", "launcher.context.hide_icon": "隐藏图标",
"launcher.action.hide": "隐藏", "launcher.action.hide": "隐藏",
"settings.launcher.title": "应用启动台", "settings.launcher.title": "应用启动台",
"settings.launcher.description": "管理应用启动台中已隐藏的应用与文件夹。",
"settings.launcher.hidden_header": "已隐藏项目", "settings.launcher.hidden_header": "已隐藏项目",
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。", "settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。", "settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
"settings.launcher.hidden_empty": "暂无隐藏项目。", "settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_summary_format": "共 {0} 个隐藏项目",
"settings.launcher.hidden_type_folder": "文件夹", "settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式", "settings.launcher.hidden_type_shortcut": "应用",
"settings.launcher.restore_button": "取消隐藏", "settings.launcher.restore_button": "取消隐藏",
"settings.plugins.title": "插件", "settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时", "settings.plugins.runtime_header": "插件运行时",
@@ -464,6 +476,12 @@
"component_library.drag_hint": "拖动放置", "component_library.drag_hint": "拖动放置",
"component.delete": "删除", "component.delete": "删除",
"component.edit": "编辑", "component.edit": "编辑",
"component.editor.instance_scope": "设置仅对当前组件实例生效。",
"component.editor.info_header": "组件信息",
"component.editor.id_label": "组件 ID",
"component.editor.placement_label": "实例 ID",
"component.editor.scope_label": "作用域",
"component.editor.scope_instance": "实例级编辑器",
"component_category.clock": "时钟", "component_category.clock": "时钟",
"component_category.date": "日历", "component_category.date": "日历",
"component_category.weather": "天气", "component_category.weather": "天气",
@@ -806,4 +824,3 @@
"single_instance.notice.description": "应用已经运行,无需多次点击打开。", "single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定" "single_instance.notice.button": "确定"
} }

View File

@@ -48,7 +48,7 @@ public sealed class AppSettingsSnapshot
public string WeatherExcludedAlerts { get; set; } = string.Empty; public string WeatherExcludedAlerts { get; set; } = string.Empty;
public string WeatherIconPackId { get; set; } = "FluentRegular"; public string WeatherIconPackId { get; set; } = "HyperOS3";
public bool WeatherNoTlsRequests { get; set; } public bool WeatherNoTlsRequests { get; set; }

View File

@@ -5,6 +5,7 @@ public enum TaskbarActionId
MinimizeToWindows, MinimizeToWindows,
AddDesktopPage, AddDesktopPage,
DeleteDesktopPage, DeleteDesktopPage,
EditComponent,
DeleteComponent, DeleteComponent,
HideLauncherEntry HideLauncherEntry
} }

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Services;
internal sealed record ComponentEditorThemePalette(
bool IsNightMode,
Color PrimaryColor,
Color SecondaryColor,
Color TertiaryColor,
Color WindowBackgroundColor,
Color SurfaceColor,
Color SurfaceContainerColor,
Color SurfaceContainerHighColor,
Color TopAppBarColor,
Color HeaderIconBackgroundColor,
Color TitleBarButtonHoverColor,
Color OutlineColor,
Color DividerColor,
Color OnSurfaceColor,
Color OnSurfaceVariantColor,
Color OnPrimaryColor);
internal static class ComponentEditorMaterialThemeAdapter
{
private static readonly Color DefaultPrimary = Color.Parse("#FF6750A4");
private static readonly Color DarkBackgroundBase = Color.Parse("#FF0B0F14");
private static readonly Color DarkSurfaceBase = Color.Parse("#FF10161D");
private static readonly Color DarkSurfaceContainerBase = Color.Parse("#FF151C24");
private static readonly Color DarkSurfaceContainerHighBase = Color.Parse("#FF1A232D");
private static readonly Color LightBackgroundBase = Color.Parse("#FFFCFCFF");
private static readonly Color LightSurfaceBase = Color.Parse("#FFFFFFFF");
private static readonly Color LightSurfaceContainerBase = Color.Parse("#FFF6F8FD");
private static readonly Color LightSurfaceContainerHighBase = Color.Parse("#FFF0F4FA");
private static readonly Color LightOnSurfaceBase = Color.Parse("#FF101316");
private static readonly Color DarkOnSurfaceBase = Color.Parse("#FFF6F8FC");
public static ComponentEditorThemePalette Build(
ThemeAppearanceSettingsState themeState,
WallpaperSettingsState wallpaperState,
MonetPalette monetPalette,
WallpaperMediaType wallpaperMediaType)
{
ArgumentNullException.ThrowIfNull(monetPalette);
var isNightMode = themeState.IsNightMode;
var monetColors = monetPalette.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetColors.Length > 0;
var primary = useWallpaperPalette
? monetColors[0]
: fallbackThemeColor ?? monetColors.FirstOrDefault(DefaultPrimary);
var secondary = ResolveSecondaryColor(primary, monetColors, isNightMode);
var tertiary = ResolveTertiaryColor(primary, secondary, monetColors, isNightMode);
var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
var surfaceContainerBase = isNightMode ? DarkSurfaceContainerBase : LightSurfaceContainerBase;
var surfaceContainerHighBase = isNightMode ? DarkSurfaceContainerHighBase : LightSurfaceContainerHighBase;
var background = ColorMath.Blend(backgroundBase, primary, isNightMode ? 0.10 : 0.025);
var surface = ColorMath.Blend(surfaceBase, primary, isNightMode ? 0.12 : 0.035);
var surfaceContainer = ColorMath.Blend(surfaceContainerBase, primary, isNightMode ? 0.18 : 0.065);
var surfaceContainerHigh = ColorMath.Blend(surfaceContainerHighBase, primary, isNightMode ? 0.24 : 0.09);
var topAppBar = ColorMath.Blend(surfaceContainerHigh, primary, isNightMode ? 0.10 : 0.06);
var onSurfaceBase = isNightMode ? DarkOnSurfaceBase : LightOnSurfaceBase;
var onSurface = ColorMath.EnsureContrast(onSurfaceBase, background, 7.0);
var onSurfaceVariantBase = ColorMath.Blend(
onSurface,
surfaceContainer,
isNightMode ? 0.30 : 0.42);
var onSurfaceVariant = ColorMath.EnsureContrast(onSurfaceVariantBase, surfaceContainer, 4.5);
var outlineBase = ColorMath.Blend(onSurface, surfaceContainer, isNightMode ? 0.74 : 0.82);
var outline = Color.FromArgb(
isNightMode ? (byte)0x66 : (byte)0x42,
outlineBase.R,
outlineBase.G,
outlineBase.B);
var divider = Color.FromArgb(
isNightMode ? (byte)0x52 : (byte)0x26,
outlineBase.R,
outlineBase.G,
outlineBase.B);
var headerIconBackground = Color.FromArgb(
isNightMode ? (byte)0x36 : (byte)0x1F,
primary.R,
primary.G,
primary.B);
var titleBarButtonHover = Color.FromArgb(
isNightMode ? (byte)0x24 : (byte)0x12,
onSurface.R,
onSurface.G,
onSurface.B);
var onPrimaryBase = isNightMode ? Color.Parse("#FF111318") : Color.Parse("#FFFFFFFF");
var onPrimary = ColorMath.EnsureContrast(onPrimaryBase, primary, 4.5);
return new ComponentEditorThemePalette(
isNightMode,
primary,
secondary,
tertiary,
background,
surface,
surfaceContainer,
surfaceContainerHigh,
topAppBar,
headerIconBackground,
titleBarButtonHover,
outline,
divider,
onSurface,
onSurfaceVariant,
onPrimary);
}
private static Color ResolveSecondaryColor(Color primary, IReadOnlyList<Color> monetColors, bool isNightMode)
{
if (monetColors.Count > 1)
{
return monetColors[1];
}
return ColorMath.Blend(
primary,
isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF1F1B24"),
isNightMode ? 0.18 : 0.16);
}
private static Color ResolveTertiaryColor(
Color primary,
Color secondary,
IReadOnlyList<Color> monetColors,
bool isNightMode)
{
if (monetColors.Count > 2)
{
return monetColors[2];
}
var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
return ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), blendTarget, isNightMode ? 0.12 : 0.14);
}
private static Color? TryParseColor(string? value)
{
return !string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)
? parsed
: null;
}
}

View File

@@ -0,0 +1,169 @@
using System;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Services;
public readonly record struct ComponentEditorOpenRequest(
Window Owner,
DesktopComponentEditorDescriptor Descriptor,
string ComponentId,
string PlacementId,
Action RefreshAction,
Action<string?>? RestartAction = null);
public interface IComponentEditorWindowService
{
bool IsOpen { get; }
string? CurrentPlacementId { get; }
void Open(ComponentEditorOpenRequest request);
void Close();
}
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
{
private readonly ISettingsFacadeService _settingsFacade;
private ComponentEditorWindow? _window;
private string? _currentPlacementId;
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_settingsFacade.Settings.Changed += OnSettingsChanged;
}
public bool IsOpen => _window is { IsVisible: true };
public string? CurrentPlacementId => _currentPlacementId;
public void Open(ComponentEditorOpenRequest request)
{
ArgumentNullException.ThrowIfNull(request.Owner);
ArgumentNullException.ThrowIfNull(request.RefreshAction);
_window ??= CreateWindow();
var settingsService = _settingsFacade.Settings;
var accessor = settingsService.GetComponentAccessor(request.ComponentId, request.PlacementId);
var scopedStore = new ComponentSettingsService(settingsService);
scopedStore.SetScopedComponentContext(request.ComponentId, request.PlacementId);
var hostContext = new HostContext(this, request.RefreshAction, request.RestartAction);
var context = new DesktopComponentEditorContext(
request.Descriptor.Definition,
request.ComponentId,
request.PlacementId,
_settingsFacade,
settingsService,
accessor,
scopedStore,
hostContext);
_currentPlacementId = request.PlacementId;
_window.ApplyDescriptor(request.Descriptor, context);
if (!_window.IsVisible)
{
_window.Show(request.Owner);
return;
}
_window.Activate();
}
public void Close()
{
_window?.Close();
}
private ComponentEditorWindow CreateWindow()
{
var window = new ComponentEditorWindow();
ApplyTheme(window);
window.ShowInTaskbar = false;
window.Closed += (_, _) =>
{
_window = null;
_currentPlacementId = null;
};
return window;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
if (_window is null || e.Scope != SettingsScope.App)
{
return;
}
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
if (changedKeys.Length > 0 &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
{
return;
}
ApplyTheme(_window);
}
private void ApplyTheme(ComponentEditorWindow window)
{
var themeState = _settingsFacade.Theme.Get();
var wallpaperState = _settingsFacade.Wallpaper.Get();
var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath);
var monetPalette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, wallpaperState.WallpaperPath);
var palette = ComponentEditorMaterialThemeAdapter.Build(
themeState,
wallpaperState,
monetPalette,
wallpaperMediaType);
window.ApplyTheme(palette);
window.ApplyChromeMode(themeState.UseSystemChrome);
}
private sealed class HostContext : IComponentEditorHostContext
{
private readonly ComponentEditorWindowService _owner;
private readonly Action _refreshAction;
private readonly Action<string?>? _restartAction;
public HostContext(
ComponentEditorWindowService owner,
Action refreshAction,
Action<string?>? restartAction)
{
_owner = owner;
_refreshAction = refreshAction;
_restartAction = restartAction;
}
public void RequestRefresh()
{
_refreshAction();
}
public void CloseEditor()
{
_owner.Close();
}
public void RequestRestart(string? reason = null)
{
_restartAction?.Invoke(reason);
}
}
}

View File

@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Views.ComponentEditors;
namespace LanMountainDesktop.Services;
public static class DesktopComponentEditorRegistryFactory
{
public static DesktopComponentEditorRegistry Create(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService)
{
ArgumentNullException.ThrowIfNull(componentRegistry);
var registrations = GetBuiltInRegistrations(componentRegistry).ToList();
var registeredIds = new HashSet<string>(
registrations.Select(registration => registration.ComponentId),
StringComparer.OrdinalIgnoreCase);
if (pluginRuntimeService is not null)
{
foreach (var contribution in pluginRuntimeService.DesktopComponentEditors)
{
var registration = contribution.Registration;
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition) ||
!definition.AllowDesktopPlacement ||
!registeredIds.Add(registration.ComponentId))
{
continue;
}
registrations.Add(new DesktopComponentEditorRegistration(
registration.ComponentId,
context => CreatePluginEditor(contribution, context),
registration.PreferredWidth,
registration.PreferredHeight,
registration.MinScale,
registration.MaxScale));
}
}
return new DesktopComponentEditorRegistry(componentRegistry, registrations);
}
private static IEnumerable<DesktopComponentEditorRegistration> GetBuiltInRegistrations(ComponentRegistry componentRegistry)
{
var registrations = new Dictionary<string, DesktopComponentEditorRegistration>(StringComparer.OrdinalIgnoreCase)
{
[BuiltInComponentIds.DesktopClock] = new(
BuiltInComponentIds.DesktopClock,
context => new ClockComponentEditor(context)),
[BuiltInComponentIds.DesktopWorldClock] = new(
BuiltInComponentIds.DesktopWorldClock,
context => new WorldClockComponentEditor(context),
preferredWidth: 820d,
preferredHeight: 620d),
[BuiltInComponentIds.DesktopClassSchedule] = new(
BuiltInComponentIds.DesktopClassSchedule,
context => new ClassScheduleComponentEditor(context),
preferredWidth: 860d,
preferredHeight: 640d),
[BuiltInComponentIds.DesktopDailyArtwork] = new(
BuiltInComponentIds.DesktopDailyArtwork,
context => new DailyArtworkComponentEditor(context)),
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
[BuiltInComponentIds.DesktopMultiDayWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopMultiDayWeather),
[BuiltInComponentIds.DesktopExtendedWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopExtendedWeather),
[BuiltInComponentIds.DesktopCnrDailyNews] = new(
BuiltInComponentIds.DesktopCnrDailyNews,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "cnr.settings.desc",
DescriptionFallback = "Configure auto rotation for this CNR news widget.",
ToggleLabelKey = "cnr.settings.auto_rotate",
ToggleLabelFallback = "Auto rotate",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "cnr.settings.rotate_interval",
IntervalLabelFallback = "Rotate interval",
DefaultInterval = 60,
GetEnabled = snapshot => snapshot.CnrDailyNewsAutoRotateEnabled,
SetEnabled = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateEnabled = value,
GetInterval = snapshot => snapshot.CnrDailyNewsAutoRotateIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateEnabled),
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateIntervalMinutes)
]
})),
[BuiltInComponentIds.DesktopIfengNews] = new(
BuiltInComponentIds.DesktopIfengNews,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "ifeng.settings.desc",
DescriptionFallback = "Configure auto refresh and source channel for this iFeng widget.",
ToggleLabelKey = "ifeng.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "ifeng.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 20,
GetEnabled = snapshot => snapshot.IfengNewsAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.IfengNewsAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.IfengNewsAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.IfengNewsAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "ifeng.settings.channel",
ExtraSelectorLabelFallback = "Channel",
ExtraOptions =
[
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Comprehensive,
"ifeng.settings.channel.comprehensive",
"Comprehensive"),
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Mainland,
"ifeng.settings.channel.mainland",
"Mainland"),
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Taiwan,
"ifeng.settings.channel.taiwan",
"Taiwan")
],
GetExtraValue = snapshot => IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType),
SetExtraValue = (snapshot, value) => snapshot.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.IfengNewsChannelType)
]
})),
[BuiltInComponentIds.DesktopDailyWord] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord),
[BuiltInComponentIds.DesktopDailyWord2x2] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord2x2),
[BuiltInComponentIds.DesktopBilibiliHotSearch] = new(
BuiltInComponentIds.DesktopBilibiliHotSearch,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "bilibili.settings.desc",
DescriptionFallback = "Configure auto refresh for this Bilibili hot search widget.",
ToggleLabelKey = "bilibili.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "bilibili.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = snapshot => snapshot.BilibiliHotSearchAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes)
]
})),
[BuiltInComponentIds.DesktopBaiduHotSearch] = new(
BuiltInComponentIds.DesktopBaiduHotSearch,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "baidu.settings.desc",
DescriptionFallback = "Configure auto refresh and source for this Baidu hot search widget.",
ToggleLabelKey = "baidu.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "baidu.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = snapshot => snapshot.BaiduHotSearchAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "baidu.settings.source",
ExtraSelectorLabelFallback = "Source",
ExtraOptions =
[
new ComponentEditorSelectionOption(
BaiduHotSearchSourceTypes.Official,
"baidu.settings.source.official",
"Official"),
new ComponentEditorSelectionOption(
BaiduHotSearchSourceTypes.ThirdPartyRss,
"baidu.settings.source.third_party",
"Third-party RSS")
],
GetExtraValue = snapshot => BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType),
SetExtraValue = (snapshot, value) => snapshot.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.BaiduHotSearchSourceType)
]
})),
[BuiltInComponentIds.DesktopStcn24Forum] = new(
BuiltInComponentIds.DesktopStcn24Forum,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "stcn.settings.desc",
DescriptionFallback = "Configure auto refresh and sort mode for this STCN forum widget.",
ToggleLabelKey = "stcn.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "stcn.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 20,
GetEnabled = snapshot => snapshot.Stcn24ForumAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.Stcn24ForumAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "stcn.settings.sort_mode",
ExtraSelectorLabelFallback = "Sort mode",
ExtraOptions = Stcn24ForumSourceTypes.SupportedValues
.Select(value => new ComponentEditorSelectionOption(
value,
$"stcn.settings.source.{value}",
value))
.ToArray(),
GetExtraValue = snapshot => Stcn24ForumSourceTypes.Normalize(snapshot.Stcn24ForumSourceType),
SetExtraValue = (snapshot, value) => snapshot.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
]
}))
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
{
if (registrations.ContainsKey(componentId))
{
continue;
}
registrations[componentId] = new DesktopComponentEditorRegistration(
componentId,
context => new InformationalComponentEditor(
context,
$"This {context.Definition.DisplayName} component currently exposes instance-scoped editor metadata only."));
}
return registrations.Values;
}
private static IEnumerable<string> GetBuiltInDesktopComponentIds(ComponentRegistry componentRegistry)
{
return typeof(BuiltInComponentIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(field => field.FieldType == typeof(string))
.Select(field => field.GetRawConstantValue() as string)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!)
.Where(id => componentRegistry.TryGetDefinition(id, out var definition) && definition.AllowDesktopPlacement)
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static DesktopComponentEditorRegistration CreateWeatherRegistration(string componentId)
{
return new DesktopComponentEditorRegistration(
componentId,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "weather.settings.desc",
DescriptionFallback = "Configure weather auto refresh for this component instance.",
ToggleLabelKey = "weather.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "weather.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 12,
GetEnabled = snapshot => snapshot.WeatherAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.WeatherAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.WeatherAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.WeatherAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshIntervalMinutes)
]
}));
}
private static DesktopComponentEditorRegistration CreateDailyWordRegistration(string componentId)
{
return new DesktopComponentEditorRegistration(
componentId,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "dailyword.settings.desc",
DescriptionFallback = "Configure auto refresh for this Daily Word component.",
ToggleLabelKey = "dailyword.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "dailyword.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 360,
GetEnabled = snapshot => snapshot.DailyWordAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.DailyWordAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.DailyWordAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.DailyWordAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshIntervalMinutes)
]
}));
}
private static Control CreatePluginEditor(
PluginDesktopComponentEditorContribution contribution,
DesktopComponentEditorContext context)
{
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
?? context.SettingsService;
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
var pluginContext = new PluginDesktopComponentEditorContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
contribution.Plugin.Context.DataDirectory,
contribution.Plugin.Services,
contribution.Plugin.Context.Properties,
context.ComponentId,
context.PlacementId,
pluginSettings,
context.HostContext);
return contribution.Registration.EditorFactory(contribution.Plugin.Services, pluginContext);
}
}

View File

@@ -34,6 +34,12 @@ public sealed record WeatherQueryResult<T>(
public interface IWeatherInfoService public interface IWeatherInfoService
{ {
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default); Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
} }
public interface IWeatherDataService : IWeatherInfoService public interface IWeatherDataService : IWeatherInfoService

View File

@@ -0,0 +1,351 @@
using System;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public enum LocationFailureReason
{
None = 0,
Unsupported = 1,
PermissionDenied = 2,
Disabled = 3,
Timeout = 4,
Cancelled = 5,
Unavailable = 6,
Unknown = 7
}
public readonly record struct LocationCoordinate(
double Latitude,
double Longitude,
double? AccuracyMeters = null);
public sealed record LocationRequestResult(
bool Success,
bool IsSupported,
LocationCoordinate? Coordinate = null,
LocationFailureReason FailureReason = LocationFailureReason.None,
string? ErrorMessage = null)
{
public static LocationRequestResult Unsupported(string? errorMessage = null)
=> new(false, false, null, LocationFailureReason.Unsupported, errorMessage);
public static LocationRequestResult Ok(LocationCoordinate coordinate)
=> new(true, true, coordinate, LocationFailureReason.None, null);
public static LocationRequestResult Fail(LocationFailureReason reason, string? errorMessage = null)
=> new(false, true, null, reason, errorMessage);
}
public interface ILocationService
{
bool IsSupported { get; }
Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default);
}
public sealed class UnsupportedLocationService : ILocationService
{
public bool IsSupported => false;
public Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult(LocationRequestResult.Unsupported("Location service is not supported on this platform."));
}
}
public sealed class WindowsLocationService : ILocationService
{
private static readonly Type? GeolocatorType = ResolveWinRtType("Windows.Devices.Geolocation.Geolocator");
private static readonly MethodInfo? RequestAccessAsyncMethod =
GeolocatorType?.GetMethod("RequestAccessAsync", BindingFlags.Public | BindingFlags.Static);
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
public bool IsSupported =>
OperatingSystem.IsWindows() &&
GeolocatorType is not null &&
RequestAccessAsyncMethod is not null &&
AsTaskGenericMethodDefinition is not null;
public async Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
{
if (!IsSupported)
{
return LocationRequestResult.Unsupported();
}
try
{
var access = await AwaitWinRtOperationAsync(RequestAccessAsyncMethod!.Invoke(null, null), cancellationToken);
var accessText = access?.ToString();
if (string.Equals(accessText, "Denied", StringComparison.OrdinalIgnoreCase))
{
return LocationRequestResult.Fail(
LocationFailureReason.PermissionDenied,
"Location permission was denied by the system.");
}
if (string.Equals(accessText, "Unspecified", StringComparison.OrdinalIgnoreCase))
{
return LocationRequestResult.Fail(
LocationFailureReason.Disabled,
"Location access is unavailable on this device.");
}
var geolocator = Activator.CreateInstance(GeolocatorType!);
if (geolocator is null)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Failed to create a Windows geolocator instance.");
}
SetPropertyValue(geolocator, "DesiredAccuracyInMeters", (uint)50);
SetPropertyValue(geolocator, "MovementThreshold", 0d);
SetPropertyValue(geolocator, "ReportInterval", (uint)0);
var geoposition = await AwaitWinRtOperationAsync(
InvokeMethod(geolocator, "GetGeopositionAsync"),
cancellationToken);
if (geoposition is null)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location request returned no position.");
}
var coordinate = GetPropertyValue(geoposition, "Coordinate");
var point = GetPropertyValue(coordinate, "Point");
var position = GetPropertyValue(point, "Position");
var latitude = ReadDoubleProperty(position, "Latitude");
var longitude = ReadDoubleProperty(position, "Longitude");
if (!latitude.HasValue || !longitude.HasValue)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location coordinates are not available.");
}
var accuracy = ReadDoubleProperty(coordinate, "Accuracy");
return LocationRequestResult.Ok(new LocationCoordinate(latitude.Value, longitude.Value, accuracy));
}
catch (OperationCanceledException)
{
return LocationRequestResult.Fail(
cancellationToken.IsCancellationRequested ? LocationFailureReason.Cancelled : LocationFailureReason.Timeout,
"Location request was cancelled.");
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
return MapException(ex.InnerException);
}
catch (Exception ex)
{
return MapException(ex);
}
}
private static LocationRequestResult MapException(Exception ex)
{
if (ex is UnauthorizedAccessException)
{
return LocationRequestResult.Fail(LocationFailureReason.PermissionDenied, ex.Message);
}
if (ex is TimeoutException)
{
return LocationRequestResult.Fail(LocationFailureReason.Timeout, ex.Message);
}
var hr = ex.HResult;
if (hr == unchecked((int)0x80070422))
{
return LocationRequestResult.Fail(LocationFailureReason.Disabled, ex.Message);
}
return LocationRequestResult.Fail(LocationFailureReason.Unknown, ex.Message);
}
private static async Task<object?> AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
{
if (operation is null || AsTaskGenericMethodDefinition is null)
{
return null;
}
var resultType = ResolveWinRtOperationResultType(operation.GetType());
if (resultType is null)
{
return null;
}
var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType);
var taskObject = asTaskMethod.Invoke(null, [operation]) as Task;
if (taskObject is null)
{
return null;
}
await taskObject.WaitAsync(cancellationToken);
return taskObject
.GetType()
.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?
.GetValue(taskObject);
}
private static Type? ResolveWinRtOperationResultType(Type operationType)
{
if (operationType.IsGenericType)
{
var genericArguments = operationType.GetGenericArguments();
if (genericArguments.Length == 1)
{
return genericArguments[0];
}
}
foreach (var iface in operationType.GetInterfaces())
{
if (!iface.IsGenericType)
{
continue;
}
var genericTypeDef = iface.GetGenericTypeDefinition();
if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
{
return iface.GetGenericArguments()[0];
}
}
return null;
}
private static MethodInfo? ResolveAsTaskGenericMethod()
{
try
{
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
if (type is null)
{
return null;
}
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
try
{
if (!string.Equals(method.Name, "AsTask", StringComparison.Ordinal) ||
!method.IsGenericMethodDefinition)
{
continue;
}
var parameters = method.GetParameters();
if (parameters.Length == 1)
{
return method;
}
}
catch (PlatformNotSupportedException)
{
// Some WinRT bridge overloads throw during metadata inspection on unsupported runtimes.
}
catch
{
// Ignore unusable overloads and keep probing for a compatible AsTask<T>.
}
}
}
catch
{
// If the WinRT bridge is unavailable, the location service will gracefully report unsupported.
}
return null;
}
private static Type? ResolveWinRtType(string typeName)
{
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
}
private static object? InvokeMethod(object? target, string methodName)
{
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null);
}
private static object? GetPropertyValue(object? target, string propertyName)
{
return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target);
}
private static void SetPropertyValue(object target, string propertyName, object value)
{
var property = target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (property is null || !property.CanWrite)
{
return;
}
try
{
property.SetValue(target, value);
}
catch
{
}
}
private static double? ReadDoubleProperty(object? target, string propertyName)
{
var value = GetPropertyValue(target, propertyName);
if (value is null)
{
return null;
}
try
{
return Convert.ToDouble(value);
}
catch
{
return null;
}
}
}
internal static class HostLocationServiceProvider
{
private static readonly object Gate = new();
private static ILocationService? _instance;
public static ILocationService GetOrCreate()
{
lock (Gate)
{
if (_instance is not null)
{
return _instance;
}
if (!OperatingSystem.IsWindows())
{
_instance = new UnsupportedLocationService();
return _instance;
}
try
{
_instance = new WindowsLocationService();
}
catch (Exception ex)
{
AppLogger.Warn("Location", "Failed to initialize Windows location service. Falling back to unsupported mode.", ex);
_instance = new UnsupportedLocationService();
}
return _instance;
}
}
}

View File

@@ -120,12 +120,27 @@ public interface IWeatherProvider
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync( Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
WeatherQuery query, WeatherQuery query,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
} }
public interface IWeatherSettingsService public interface IWeatherSettingsService
{ {
WeatherSettingsState Get(); WeatherSettingsState Get();
void Save(WeatherSettingsState state); void Save(WeatherSettingsState state);
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
IWeatherInfoService GetWeatherInfoService(); IWeatherInfoService GetWeatherInfoService();
} }

View File

@@ -350,6 +350,15 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoSer
return _weatherDataService.GetWeatherAsync(query, cancellationToken); return _weatherDataService.GetWeatherAsync(query, cancellationToken);
} }
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default)
{
return _weatherDataService.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
}
public void Dispose() public void Dispose()
{ {
if (_weatherDataService is IDisposable disposable) if (_weatherDataService is IDisposable disposable)
@@ -380,7 +389,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
snapshot.WeatherLongitude, snapshot.WeatherLongitude,
snapshot.WeatherAutoRefreshLocation, snapshot.WeatherAutoRefreshLocation,
snapshot.WeatherExcludedAlerts, snapshot.WeatherExcludedAlerts,
snapshot.WeatherIconPackId, NormalizeIconPackId(snapshot.WeatherIconPackId),
snapshot.WeatherNoTlsRequests, snapshot.WeatherNoTlsRequests,
snapshot.WeatherLocationQuery); snapshot.WeatherLocationQuery);
} }
@@ -395,7 +404,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
snapshot.WeatherLongitude = state.Longitude; snapshot.WeatherLongitude = state.Longitude;
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation; snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts; snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
snapshot.WeatherIconPackId = state.IconPackId; snapshot.WeatherIconPackId = NormalizeIconPackId(state.IconPackId);
snapshot.WeatherNoTlsRequests = state.NoTlsRequests; snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
snapshot.WeatherLocationQuery = state.LocationQuery; snapshot.WeatherLocationQuery = state.LocationQuery;
_settingsService.SaveSnapshot( _settingsService.SaveSnapshot(
@@ -416,6 +425,23 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
]); ]);
} }
public Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default)
{
return _weatherProvider.SearchLocationsAsync(keyword, locale, cancellationToken);
}
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default)
{
return _weatherProvider.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
}
public IWeatherInfoService GetWeatherInfoService() public IWeatherInfoService GetWeatherInfoService()
{ {
return _weatherProvider; return _weatherProvider;
@@ -425,6 +451,13 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
{ {
_weatherProvider.Dispose(); _weatherProvider.Dispose();
} }
private static string NormalizeIconPackId(string? iconPackId)
{
return string.IsNullOrWhiteSpace(iconPackId)
? "HyperOS3"
: "HyperOS3";
}
} }
internal sealed class RegionSettingsService : IRegionSettingsService internal sealed class RegionSettingsService : IRegionSettingsService

View File

@@ -5,6 +5,7 @@ using System.Reflection;
using Avalonia.Controls; using Avalonia.Controls;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Plugins; using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views.SettingsPages; using LanMountainDesktop.Views.SettingsPages;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -177,6 +178,8 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
services.AddSingleton(_settingsFacade.Catalog); services.AddSingleton(_settingsFacade.Catalog);
services.AddSingleton(_hostApplicationLifecycle); services.AddSingleton(_hostApplicationLifecycle);
services.AddSingleton(_localizationService); services.AddSingleton(_localizationService);
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
services.AddSingleton<WeatherLocationRefreshService>();
var pluginRuntime = _pluginRuntimeAccessor(); var pluginRuntime = _pluginRuntimeAccessor();
if (pluginRuntime is not null) if (pluginRuntime is not null)

View File

@@ -0,0 +1,158 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed record WeatherLocationRefreshResult(
bool Success,
bool IsSupported,
WeatherSettingsState? AppliedState = null,
WeatherLocation? ResolvedLocation = null,
LocationRequestResult? LocationResult = null,
string? ErrorMessage = null)
{
public static WeatherLocationRefreshResult Unsupported(LocationRequestResult result)
=> new(false, false, null, null, result, result.ErrorMessage);
public static WeatherLocationRefreshResult Fail(LocationRequestResult? locationResult, string? errorMessage)
=> new(false, locationResult?.IsSupported ?? true, null, null, locationResult, errorMessage);
public static WeatherLocationRefreshResult Ok(
WeatherSettingsState state,
WeatherLocation? resolvedLocation,
LocationRequestResult locationResult)
=> new(true, true, state, resolvedLocation, locationResult, null);
}
public sealed class WeatherLocationRefreshService
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly ILocationService _locationService;
private readonly LocalizationService _localizationService;
public WeatherLocationRefreshService(
ISettingsFacadeService settingsFacade,
ILocationService locationService,
LocalizationService localizationService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
}
public bool IsSupported => _locationService.IsSupported;
public async Task<WeatherLocationRefreshResult> RefreshCurrentLocationAsync(CancellationToken cancellationToken = default)
{
var locationResult = await _locationService.TryGetCurrentLocationAsync(cancellationToken);
if (!locationResult.IsSupported)
{
return WeatherLocationRefreshResult.Unsupported(locationResult);
}
if (!locationResult.Success || locationResult.Coordinate is null)
{
return WeatherLocationRefreshResult.Fail(locationResult, locationResult.ErrorMessage);
}
var coordinate = locationResult.Coordinate.Value;
var settingsState = _settingsFacade.Weather.Get();
var languageCode = _settingsFacade.Region.Get().LanguageCode;
var locale = NormalizeWeatherLocale(languageCode);
WeatherLocation? resolvedLocation = null;
var weatherService = _settingsFacade.Weather.GetWeatherInfoService();
var resolvedResult = await weatherService.ResolveLocationAsync(
coordinate.Latitude,
coordinate.Longitude,
locale,
cancellationToken);
if (resolvedResult.Success && resolvedResult.Data is not null)
{
resolvedLocation = resolvedResult.Data;
}
var locationKey = resolvedLocation?.LocationKey?.Trim();
if (string.IsNullOrWhiteSpace(locationKey))
{
locationKey = BuildCoordinateKey(coordinate.Latitude, coordinate.Longitude);
}
var locationName = resolvedLocation?.Name?.Trim();
if (string.IsNullOrWhiteSpace(locationName))
{
locationName = BuildCoordinateDisplayName(languageCode, coordinate.Latitude, coordinate.Longitude);
}
var nextState = settingsState with
{
LocationMode = "Coordinates",
LocationKey = locationKey,
LocationName = locationName,
Latitude = Math.Round(coordinate.Latitude, 6),
Longitude = Math.Round(coordinate.Longitude, 6),
LocationQuery = resolvedLocation?.Name?.Trim() ?? settingsState.LocationQuery,
IconPackId = NormalizeIconPackId(settingsState.IconPackId)
};
_settingsFacade.Weather.Save(nextState);
return WeatherLocationRefreshResult.Ok(nextState, resolvedLocation, locationResult);
}
public async Task<bool> TryRefreshOnStartupAsync(CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Weather.Get();
var isCoordinatesMode = string.Equals(state.LocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase);
if (!isCoordinatesMode || !state.AutoRefreshLocation)
{
return false;
}
var result = await RefreshCurrentLocationAsync(cancellationToken);
if (!result.Success)
{
AppLogger.Warn(
"Weather.Location",
$"Automatic weather location refresh failed. Reason='{result.LocationResult?.FailureReason}'. Message='{result.ErrorMessage ?? "<none>"}'.");
}
return result.Success;
}
private static string NormalizeIconPackId(string? iconPackId)
{
return string.IsNullOrWhiteSpace(iconPackId)
? "HyperOS3"
: "HyperOS3";
}
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
{
var normalizedLanguage = _localizationService.NormalizeLanguageCode(languageCode);
var format = _localizationService.GetString(
normalizedLanguage,
"settings.weather.coordinates_default_name_format",
"Coordinate {0:F4}, {1:F4}");
return string.Format(
CultureInfo.InvariantCulture,
format,
latitude,
longitude);
}
private static string BuildCoordinateKey(double latitude, double longitude)
{
return FormattableString.Invariant($"coord:{latitude:F4},{longitude:F4}");
}
private static string NormalizeWeatherLocale(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en_us"
: "zh_cn";
}
}

View File

@@ -18,6 +18,8 @@ public sealed record XiaomiWeatherApiOptions
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search"; public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
public string CityGeoPath { get; init; } = "/wtr-v3/location/city/geo";
public string AppKey { get; init; } = "weather20151024"; public string AppKey { get; init; } = "weather20151024";
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07"; public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
@@ -173,6 +175,63 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
} }
} }
public async Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default)
{
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim();
var parameters = new Dictionary<string, string>
{
["longitude"] = longitude.ToString("F6", CultureInfo.InvariantCulture),
["latitude"] = latitude.ToString("F6", CultureInfo.InvariantCulture),
["locale"] = normalizedLocale
};
var requestUri = BuildUri(_options.CityGeoPath, parameters);
string responseText;
try
{
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return WeatherQueryResult<WeatherLocation>.Fail(
"http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return WeatherQueryResult<WeatherLocation>.Fail("network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (TryGetProperty(root, out var dataNode, "data"))
{
root = dataNode;
}
var location = ParseSingleLocation(root, latitude, longitude);
return location is null
? WeatherQueryResult<WeatherLocation>.Fail("not_found", "No weather location could be resolved from the provided coordinates.")
: WeatherQueryResult<WeatherLocation>.Ok(location);
}
catch (Exception ex)
{
return WeatherQueryResult<WeatherLocation>.Fail("parse_error", ex.Message);
}
}
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync( public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
WeatherQuery query, WeatherQuery query,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -285,6 +344,44 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
return results; return results;
} }
private static WeatherLocation? ParseSingleLocation(JsonElement root, double latitude, double longitude)
{
if (TryResolveLocationArray(root, out var locationArray))
{
foreach (var item in locationArray.EnumerateArray())
{
var location = ParseLocationItem(item);
if (location is not null)
{
return location;
}
}
return null;
}
return ParseLocationItem(root, latitude, longitude);
}
private static WeatherLocation? ParseLocationItem(JsonElement item, double? fallbackLatitude = null, double? fallbackLongitude = null)
{
var locationKey = ReadString(item, "locationKey") ??
ReadString(item, "key") ??
ReadString(item, "id");
if (string.IsNullOrWhiteSpace(locationKey))
{
return null;
}
var name = ReadString(item, "name") ??
ReadString(item, "city") ??
locationKey;
var affiliation = ReadString(item, "affiliation") ?? ReadString(item, "province");
var latitude = ReadDouble(item, "latitude") ?? fallbackLatitude ?? 0;
var longitude = ReadDouble(item, "longitude") ?? fallbackLongitude ?? 0;
return new WeatherLocation(name, locationKey, latitude, longitude, affiliation);
}
private WeatherSnapshot ParseWeatherSnapshot( private WeatherSnapshot ParseWeatherSnapshot(
JsonElement root, JsonElement root,
string locationKey, string locationKey,

View File

@@ -0,0 +1,175 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Window.component-editor-window">
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
</Style>
<Style Selector="Window.component-editor-window TextBlock">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="Window.component-editor-window PathIcon">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="ComboBox.component-editor-select">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="FontSize" Value="14" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="ComboBox.component-editor-select:pointerover">
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldHoverBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="ComboBox.component-editor-select:focus">
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldFocusBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="ComboBox.component-editor-select /template/ ToggleButton">
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="ComboBox.component-editor-select:pointerover /template/ ToggleButton">
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldHoverBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="ComboBox.component-editor-select:focus /template/ ToggleButton">
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldFocusBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="ComboBoxItem.component-editor-select-item">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="16,12" />
<Setter Property="Margin" Value="6,4" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="MinHeight" Value="44" />
</Style>
<Style Selector="ComboBoxItem.component-editor-select-item:pointerover">
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemHoverBrush}" />
</Style>
<Style Selector="ComboBoxItem.component-editor-select-item:selected">
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemSelectedBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="Window.component-editor-window ToggleSwitch">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="Border.component-editor-segmented-host">
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Padding" Value="4" />
</Style>
<Style Selector="ToggleButton.component-editor-segmented-choice">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="MinHeight" Value="48" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="ToggleButton.component-editor-segmented-choice:pointerover">
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemHoverBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="ToggleButton.component-editor-segmented-choice:checked">
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="ToggleButton.component-editor-segmented-choice:checked:pointerover">
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
</Style>
<Style Selector="Border.component-editor-hero-card">
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="28" />
</Style>
<Style Selector="Border.component-editor-card">
<Setter Property="Background" Value="{DynamicResource ComponentEditorCardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="24" />
</Style>
<Style Selector="TextBlock.component-editor-headline">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="TextBlock.component-editor-section-title">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.component-editor-secondary-text">
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style Selector="Button.component-editor-titlebar-button">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
</Style>
<Style Selector="Button.component-editor-titlebar-button:pointerover">
<Setter Property="Background" Value="{DynamicResource EditorTitleBarButtonHoverBrush}" />
</Style>
<Style Selector="Button.component-editor-footer-button">
<Setter Property="MinWidth" Value="96" />
<Setter Property="Padding" Value="16,10" />
</Style>
<Style Selector="ScrollViewer.component-editor-scroll-host">
<Setter Property="Background" Value="Transparent" />
</Style>
</Styles>

View File

@@ -1,6 +1,7 @@
<Styles xmlns="https://github.com/avaloniaui" <Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"> xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
<Style Selector="StackPanel.settings-page-container"> <Style Selector="StackPanel.settings-page-container">
<Setter Property="Spacing" Value="0" /> <Setter Property="Spacing" Value="0" />
@@ -189,6 +190,41 @@
<Setter Property="Padding" Value="14,8" /> <Setter Property="Padding" Value="14,8" />
</Style> </Style>
<Style Selector="ListBox.weather-settings-search-results">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,2,0,0" />
</Style>
<Style Selector="ListBox.weather-settings-search-results ListBoxItem">
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
</Style>
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
</Style>
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected TextBlock.settings-item-label, ListBox.weather-settings-search-results ListBoxItem:selected TextBlock.settings-item-description">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
</Style>
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
</Style>
<Style Selector="Button.settings-accent-button"> <Style Selector="Button.settings-accent-button">
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />

View File

@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentIcons.Common;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ViewModels;
public enum LauncherHiddenItemKind
{
Folder,
Shortcut
}
public sealed partial class LauncherHiddenItemViewModel : ObservableObject
{
private readonly Action<LauncherHiddenItemViewModel> _restoreAction;
public LauncherHiddenItemViewModel(
LauncherHiddenItemKind kind,
string key,
string displayName,
string typeLabel,
Symbol iconSymbol,
string restoreButtonText,
Action<LauncherHiddenItemViewModel> restoreAction)
{
Kind = kind;
Key = key;
DisplayName = displayName;
TypeLabel = typeLabel;
IconSymbol = iconSymbol;
RestoreButtonText = restoreButtonText;
_restoreAction = restoreAction ?? throw new ArgumentNullException(nameof(restoreAction));
}
public LauncherHiddenItemKind Kind { get; }
public string Key { get; }
public string DisplayName { get; }
public string TypeLabel { get; }
public Symbol IconSymbol { get; }
public string RestoreButtonText { get; }
[RelayCommand]
private void Restore()
{
_restoreAction(this);
}
}
public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisposable
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
private bool _disposed;
public LauncherSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
ReloadData();
_settingsFacade.Settings.Changed += OnSettingsChanged;
}
public ObservableCollection<LauncherHiddenItemViewModel> HiddenItems { get; } = [];
[ObservableProperty]
private string _pageTitle = string.Empty;
[ObservableProperty]
private string _pageDescription = string.Empty;
[ObservableProperty]
private string _launcherHeader = string.Empty;
[ObservableProperty]
private string _launcherSubtitle = string.Empty;
[ObservableProperty]
private string _hiddenHeader = string.Empty;
[ObservableProperty]
private string _hiddenDescription = string.Empty;
[ObservableProperty]
private string _hiddenHint = string.Empty;
[ObservableProperty]
private string _hiddenEmptyText = string.Empty;
[ObservableProperty]
private string _hiddenSummary = string.Empty;
[ObservableProperty]
private string _hiddenCountText = "0";
[ObservableProperty]
private bool _hasHiddenItems;
[ObservableProperty]
private bool _isHiddenItemsEmpty = true;
public void Dispose()
{
if (_disposed)
{
return;
}
_settingsFacade.Settings.Changed -= OnSettingsChanged;
_disposed = true;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
if (e.Scope != SettingsScope.Launcher)
{
return;
}
Dispatcher.UIThread.Post(ReloadData, DispatcherPriority.Background);
}
private void ReloadData()
{
var root = LoadCatalogSafe();
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
var hiddenItems = BuildHiddenItems(root, snapshot);
HiddenItems.Clear();
foreach (var hiddenItem in hiddenItems)
{
HiddenItems.Add(hiddenItem);
}
HasHiddenItems = HiddenItems.Count > 0;
IsHiddenItemsEmpty = !HasHiddenItems;
HiddenCountText = HiddenItems.Count.ToString(CultureInfo.CurrentCulture);
HiddenSummary = string.Format(
ResolveCulture(),
L("settings.launcher.hidden_summary_format", "{0} hidden items"),
HiddenItems.Count);
}
private StartMenuFolderNode LoadCatalogSafe()
{
try
{
return _settingsFacade.LauncherCatalog.LoadCatalog() ?? new StartMenuFolderNode(L("launcher.title", "App Launcher"), string.Empty);
}
catch (Exception ex)
{
AppLogger.Warn("Launcher.Settings", "Failed to load launcher catalog for settings page.", ex);
return new StartMenuFolderNode(L("launcher.title", "App Launcher"), string.Empty);
}
}
private IReadOnlyList<LauncherHiddenItemViewModel> BuildHiddenItems(StartMenuFolderNode root, LauncherSettingsSnapshot snapshot)
{
var items = new List<LauncherHiddenItemViewModel>();
var seenFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenApps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectHiddenItems(root, snapshot, items, seenFolders, seenApps);
foreach (var key in snapshot.HiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
var normalizedKey = NormalizeLauncherHiddenKey(key);
if (string.IsNullOrWhiteSpace(normalizedKey) || !seenFolders.Add(normalizedKey))
{
continue;
}
items.Add(CreateHiddenItem(
LauncherHiddenItemKind.Folder,
normalizedKey,
BuildLauncherHiddenFallbackDisplayName(normalizedKey)));
}
foreach (var key in snapshot.HiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
var normalizedKey = NormalizeLauncherHiddenKey(key);
if (string.IsNullOrWhiteSpace(normalizedKey) || !seenApps.Add(normalizedKey))
{
continue;
}
items.Add(CreateHiddenItem(
LauncherHiddenItemKind.Shortcut,
normalizedKey,
BuildLauncherHiddenFallbackDisplayName(normalizedKey)));
}
return items
.OrderBy(item => item.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void CollectHiddenItems(
StartMenuFolderNode folder,
LauncherSettingsSnapshot snapshot,
List<LauncherHiddenItemViewModel> items,
HashSet<string> seenFolders,
HashSet<string> seenApps)
{
foreach (var subFolder in folder.Folders)
{
var folderKey = NormalizeLauncherHiddenKey(subFolder.RelativePath);
if (!string.IsNullOrWhiteSpace(folderKey) &&
snapshot.HiddenLauncherFolderPaths.Contains(folderKey, StringComparer.OrdinalIgnoreCase) &&
seenFolders.Add(folderKey))
{
items.Add(CreateHiddenItem(
LauncherHiddenItemKind.Folder,
folderKey,
subFolder.Name));
}
CollectHiddenItems(subFolder, snapshot, items, seenFolders, seenApps);
}
foreach (var app in folder.Apps)
{
var appKey = NormalizeLauncherHiddenKey(app.RelativePath);
if (string.IsNullOrWhiteSpace(appKey) ||
!snapshot.HiddenLauncherAppPaths.Contains(appKey, StringComparer.OrdinalIgnoreCase) ||
!seenApps.Add(appKey))
{
continue;
}
items.Add(CreateHiddenItem(
LauncherHiddenItemKind.Shortcut,
appKey,
app.DisplayName));
}
}
private LauncherHiddenItemViewModel CreateHiddenItem(
LauncherHiddenItemKind kind,
string key,
string displayName)
{
var typeLabel = kind == LauncherHiddenItemKind.Folder
? L("settings.launcher.hidden_type_folder", "Folder")
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
var iconSymbol = kind == LauncherHiddenItemKind.Folder
? Symbol.Folder
: Symbol.Apps;
return new LauncherHiddenItemViewModel(
kind,
key,
displayName,
typeLabel,
iconSymbol,
L("settings.launcher.restore_button", "Unhide"),
RestoreHiddenItem);
}
private void RestoreHiddenItem(LauncherHiddenItemViewModel item)
{
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
var normalizedKey = NormalizeLauncherHiddenKey(item.Key);
if (string.IsNullOrWhiteSpace(normalizedKey))
{
return;
}
IReadOnlyCollection<string>? changedKeys = item.Kind switch
{
LauncherHiddenItemKind.Folder => RemoveKey(snapshot.HiddenLauncherFolderPaths, normalizedKey)
? [nameof(LauncherSettingsSnapshot.HiddenLauncherFolderPaths)]
: null,
LauncherHiddenItemKind.Shortcut => RemoveKey(snapshot.HiddenLauncherAppPaths, normalizedKey)
? [nameof(LauncherSettingsSnapshot.HiddenLauncherAppPaths)]
: null,
_ => null
};
if (changedKeys is null)
{
return;
}
_settingsFacade.Settings.SaveSnapshot(SettingsScope.Launcher, snapshot, changedKeys: changedKeys);
ReloadData();
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.launcher.title", "App Launcher");
PageDescription = L("settings.launcher.description", "Manage hidden apps and folders in the App Launcher.");
LauncherHeader = L("launcher.title", "App Launcher");
LauncherSubtitle = OperatingSystem.IsLinux()
? L("launcher.subtitle_linux", "Displays installed apps discovered from Linux desktop entries.")
: L("launcher.subtitle", "Displays all apps and folders based on the Windows Start menu structure.");
HiddenHeader = L("settings.launcher.hidden_header", "Hidden Items");
HiddenDescription = L("settings.launcher.hidden_desc", "Review hidden launcher entries and show them again.");
HiddenHint = L("settings.launcher.hidden_hint", "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.");
HiddenEmptyText = L("settings.launcher.hidden_empty", "No hidden items.");
}
private CultureInfo ResolveCulture()
{
try
{
return CultureInfo.GetCultureInfo(_languageCode);
}
catch (CultureNotFoundException)
{
return CultureInfo.InvariantCulture;
}
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
private static string NormalizeLauncherHiddenKey(string? key)
=> string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim();
private static string BuildLauncherHiddenFallbackDisplayName(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return "Unknown";
}
var normalized = key.Replace('\\', '/');
var fileName = Path.GetFileNameWithoutExtension(normalized);
return string.IsNullOrWhiteSpace(fileName)
? key
: fileName;
}
private static bool RemoveKey(ICollection<string> values, string key)
{
var existing = values.FirstOrDefault(value => string.Equals(value, key, StringComparison.OrdinalIgnoreCase));
if (existing is null)
{
return false;
}
values.Remove(existing);
return true;
}
}

View File

@@ -0,0 +1,678 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.ViewModels;
public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly LocalizationService _localizationService;
private readonly ILocationService _locationService;
private readonly WeatherLocationRefreshService _weatherLocationRefreshService;
private string _languageCode;
private bool _isInitializing;
public WeatherSettingsPageViewModel(
ISettingsFacadeService settingsFacade,
LocalizationService localizationService,
ILocationService locationService,
WeatherLocationRefreshService weatherLocationRefreshService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
_weatherLocationRefreshService = weatherLocationRefreshService ?? throw new ArgumentNullException(nameof(weatherLocationRefreshService));
var regionState = _settingsFacade.Region.Get();
_languageCode = _localizationService.NormalizeLanguageCode(regionState.LanguageCode);
RefreshLocalizedText();
LocationModes = CreateLocationModes();
var weatherState = _settingsFacade.Weather.Get();
SearchKeyword = weatherState.LocationQuery;
SelectedLocationMode = LocationModes.FirstOrDefault(option =>
string.Equals(option.Value, weatherState.LocationMode, StringComparison.OrdinalIgnoreCase))
?? LocationModes[0];
_isInitializing = true;
Latitude = weatherState.Latitude;
Longitude = weatherState.Longitude;
LocationKey = weatherState.LocationKey;
LocationName = weatherState.LocationName;
AutoRefreshLocation = weatherState.AutoRefreshLocation;
ExcludedAlerts = weatherState.ExcludedAlerts;
NoTlsRequests = weatherState.NoTlsRequests;
_isInitializing = false;
IsLocationSupported = _locationService.IsSupported;
UpdateModeVisibility();
UpdateCurrentLocationSummary();
LocationActionStatus = IsLocationSupported
? LocationReadyText
: LocationUnsupportedText;
_ = RefreshPreviewAsync();
}
public IReadOnlyList<SelectionOption> LocationModes { get; }
public ObservableCollection<WeatherLocation> SearchResults { get; } = [];
[ObservableProperty]
private string _pageTitle = string.Empty;
[ObservableProperty]
private string _pageDescription = string.Empty;
[ObservableProperty]
private string _previewHeader = string.Empty;
[ObservableProperty]
private string _previewDescription = string.Empty;
[ObservableProperty]
private string _locationSourceHeader = string.Empty;
[ObservableProperty]
private string _locationSourceDescription = string.Empty;
[ObservableProperty]
private string _citySearchHeader = string.Empty;
[ObservableProperty]
private string _citySearchDescription = string.Empty;
[ObservableProperty]
private string _coordinatesHeader = string.Empty;
[ObservableProperty]
private string _coordinatesDescription = string.Empty;
[ObservableProperty]
private string _locationServicesHeader = string.Empty;
[ObservableProperty]
private string _locationServicesDescription = string.Empty;
[ObservableProperty]
private string _alertFilterHeader = string.Empty;
[ObservableProperty]
private string _alertFilterDescription = string.Empty;
[ObservableProperty]
private string _requestHeader = string.Empty;
[ObservableProperty]
private string _requestDescription = string.Empty;
[ObservableProperty]
private string _searchPlaceholder = string.Empty;
[ObservableProperty]
private string _searchButtonText = string.Empty;
[ObservableProperty]
private string _applyCityButtonText = string.Empty;
[ObservableProperty]
private string _refreshButtonText = string.Empty;
[ObservableProperty]
private string _applyCoordinatesButtonText = string.Empty;
[ObservableProperty]
private string _useCurrentLocationButtonText = string.Empty;
[ObservableProperty]
private string _autoRefreshLabel = string.Empty;
[ObservableProperty]
private string _latitudeLabel = string.Empty;
[ObservableProperty]
private string _longitudeLabel = string.Empty;
[ObservableProperty]
private string _locationKeyPlaceholder = string.Empty;
[ObservableProperty]
private string _locationNamePlaceholder = string.Empty;
[ObservableProperty]
private string _noTlsToggleText = string.Empty;
[ObservableProperty]
private string _locationUnsupportedText = string.Empty;
[ObservableProperty]
private string _locationReadyText = string.Empty;
[ObservableProperty]
private string _locationRefreshingText = string.Empty;
[ObservableProperty]
private string _footerHint = string.Empty;
[ObservableProperty]
private SelectionOption _selectedLocationMode = new("CitySearch", "City Search");
[ObservableProperty]
private bool _isCitySearchMode = true;
[ObservableProperty]
private bool _isCoordinatesMode;
[ObservableProperty]
private bool _isLocationSupported;
[ObservableProperty]
private string _searchKeyword = string.Empty;
[ObservableProperty]
private WeatherLocation? _selectedSearchResult;
[ObservableProperty]
private string _searchStatus = string.Empty;
[ObservableProperty]
private bool _isSearching;
[ObservableProperty]
private double _latitude;
[ObservableProperty]
private double _longitude;
[ObservableProperty]
private string _locationKey = string.Empty;
[ObservableProperty]
private string _locationName = string.Empty;
[ObservableProperty]
private bool _autoRefreshLocation;
[ObservableProperty]
private string _excludedAlerts = string.Empty;
[ObservableProperty]
private bool _noTlsRequests;
[ObservableProperty]
private string _currentLocationSummary = string.Empty;
[ObservableProperty]
private string _locationActionStatus = string.Empty;
[ObservableProperty]
private bool _isRefreshingLocation;
[ObservableProperty]
private bool _isRefreshingPreview;
[ObservableProperty]
private IImage? _previewIcon;
[ObservableProperty]
private string _previewLocation = string.Empty;
[ObservableProperty]
private string _previewTemperature = string.Empty;
[ObservableProperty]
private string _previewCondition = string.Empty;
[ObservableProperty]
private string _previewUpdated = string.Empty;
[ObservableProperty]
private string _previewStatus = string.Empty;
partial void OnSelectedLocationModeChanged(SelectionOption value)
{
UpdateModeVisibility();
UpdateCurrentLocationSummary();
if (_isInitializing || value is null)
{
return;
}
_settingsFacade.Weather.Save(CreateEditableState(value.Value));
_ = RefreshPreviewAsync();
}
partial void OnAutoRefreshLocationChanged(bool value)
{
_ = value;
if (_isInitializing)
{
return;
}
_settingsFacade.Weather.Save(CreateEditableState());
}
partial void OnExcludedAlertsChanged(string value)
{
_ = value;
if (_isInitializing)
{
return;
}
_settingsFacade.Weather.Save(CreateEditableState());
}
partial void OnNoTlsRequestsChanged(bool value)
{
_ = value;
if (_isInitializing)
{
return;
}
_settingsFacade.Weather.Save(CreateEditableState());
}
[RelayCommand]
private async Task SearchAsync()
{
SearchStatus = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
if (string.IsNullOrWhiteSpace(SearchKeyword))
{
SearchStatus = L("settings.weather.search_required", "Please enter a city keyword first.");
return;
}
IsSearching = true;
try
{
var result = await _settingsFacade.Weather.SearchLocationsAsync(
SearchKeyword.Trim(),
NormalizeWeatherLocale(_languageCode));
if (!result.Success)
{
SearchStatus = string.Format(
ResolveCulture(),
L("settings.weather.search_failed_format", "Search failed: {0}"),
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
return;
}
foreach (var item in result.Data ?? [])
{
SearchResults.Add(item);
}
SearchStatus = SearchResults.Count == 0
? L("settings.weather.search_no_results", "No locations were found.")
: string.Format(
ResolveCulture(),
L("settings.weather.search_result_count_format", "Found {0} locations."),
SearchResults.Count);
SelectedSearchResult = SearchResults.FirstOrDefault();
}
finally
{
IsSearching = false;
}
}
[RelayCommand]
private async Task ApplyCitySelectionAsync()
{
if (SelectedSearchResult is null)
{
SearchStatus = L("settings.weather.search_select_required", "Please select one location from search results.");
return;
}
var selected = SelectedSearchResult;
var nextState = new WeatherSettingsState(
"CitySearch",
selected.LocationKey,
selected.Name,
selected.Latitude,
selected.Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
ApplySavedState(nextState);
SearchStatus = string.Format(
ResolveCulture(),
L("settings.weather.search_applied_format", "Location applied: {0}"),
selected.Name);
await RefreshPreviewAsync();
}
[RelayCommand]
private async Task ApplyCoordinatesAsync()
{
var nextState = CreateEditableState("Coordinates");
_settingsFacade.Weather.Save(nextState);
ApplySavedState(nextState, save: false);
SearchStatus = string.Format(
ResolveCulture(),
L("settings.weather.coordinates_saved_format", "Coordinates saved: {0:F4}, {1:F4}"),
nextState.Latitude,
nextState.Longitude);
await RefreshPreviewAsync();
}
[RelayCommand]
private async Task UseCurrentLocationAsync()
{
if (!IsLocationSupported)
{
LocationActionStatus = LocationUnsupportedText;
return;
}
IsRefreshingLocation = true;
LocationActionStatus = LocationRefreshingText;
try
{
var result = await _weatherLocationRefreshService.RefreshCurrentLocationAsync();
if (!result.Success || result.AppliedState is null)
{
LocationActionStatus = string.Format(
ResolveCulture(),
L("settings.weather.location_refresh_failed_format", "Failed to get current location: {0}"),
result.ErrorMessage ?? result.LocationResult?.FailureReason.ToString() ?? L("settings.weather.preview_unknown", "Unknown"));
return;
}
ApplySavedState(result.AppliedState, save: false);
LocationActionStatus = string.Format(
ResolveCulture(),
L("settings.weather.location_refresh_success_format", "Current location applied: {0}"),
result.AppliedState.LocationName);
await RefreshPreviewAsync();
}
finally
{
IsRefreshingLocation = false;
}
}
[RelayCommand]
private async Task RefreshPreviewAsync()
{
IsRefreshingPreview = true;
try
{
var state = ResolvePreviewState();
if (string.IsNullOrWhiteSpace(state.LocationKey))
{
PreviewStatus = L("settings.weather.preview_missing_location", "Please apply one weather location before testing.");
PreviewIcon = null;
PreviewLocation = CurrentLocationSummary;
PreviewTemperature = "--";
PreviewCondition = string.Empty;
PreviewUpdated = string.Empty;
return;
}
var result = await _settingsFacade.Weather.GetWeatherInfoService().GetWeatherAsync(
new WeatherQuery(
state.LocationKey,
state.Latitude,
state.Longitude,
ForecastDays: 3,
Locale: NormalizeWeatherLocale(_languageCode),
ForceRefresh: true));
if (!result.Success || result.Data is null)
{
PreviewStatus = string.Format(
ResolveCulture(),
L("settings.weather.preview_failed_format", "Test fetch failed: {0}"),
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
PreviewIcon = null;
return;
}
var snapshot = result.Data;
var isNight = snapshot.Current.IsDaylight.HasValue
? !snapshot.Current.IsDaylight.Value
: _settingsFacade.Theme.Get().IsNightMode;
var visualKind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight);
PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(visualKind));
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
? state.LocationName
: snapshot.LocationName!;
PreviewTemperature = snapshot.Current.TemperatureC.HasValue
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value)
: "--";
PreviewCondition = snapshot.Current.WeatherText ?? L("settings.weather.preview_unknown", "Unknown");
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
PreviewUpdated = string.Format(
ResolveCulture(),
L("settings.weather.preview_updated_format", "Updated {0}"),
updatedAt.ToString("g", ResolveCulture()));
PreviewStatus = string.Format(
ResolveCulture(),
L("settings.weather.preview_success_format", "Test success: {0} · {1} · {2}"),
PreviewLocation,
PreviewTemperature,
PreviewCondition);
}
finally
{
IsRefreshingPreview = false;
}
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.weather.title", "Weather");
PageDescription = L("settings.weather.description", "Configure weather location, automatic positioning, and Xiaomi weather preview.");
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
LocationSourceDescription = L("settings.weather.location_source_desc", "Choose how weather widgets resolve location.");
CitySearchHeader = L("settings.weather.city_search_header", "City Search");
CitySearchDescription = L("settings.weather.city_search_desc", "Search cities and apply one weather location.");
CoordinatesHeader = L("settings.weather.coordinates_header", "Coordinates");
CoordinatesDescription = L("settings.weather.coordinates_desc", "Set latitude/longitude and optional key/name.");
LocationServicesHeader = L("settings.weather.location_services_header", "Location Service");
LocationServicesDescription = L("settings.weather.location_services_desc", "Use the current Windows location and decide whether it refreshes automatically at startup.");
AlertFilterHeader = L("settings.weather.alert_filter_header", "Excluded Alerts");
AlertFilterDescription = L("settings.weather.alert_filter_desc", "Alerts containing these words will not be shown. One rule per line.");
RequestHeader = L("settings.weather.no_tls_header", "No TLS Weather Request");
RequestDescription = L("settings.weather.no_tls_desc", "Not recommended. Enable only for incompatible network environments.");
SearchPlaceholder = L("settings.weather.search_placeholder", "e.g. Beijing");
SearchButtonText = L("settings.weather.search_button", "Search");
ApplyCityButtonText = L("settings.weather.apply_city_button", "Apply City");
RefreshButtonText = L("settings.weather.refresh_button", "Refresh");
ApplyCoordinatesButtonText = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
UseCurrentLocationButtonText = L("settings.weather.use_current_location", "Use Current Location");
AutoRefreshLabel = L("settings.weather.auto_refresh", "Auto refresh location on startup");
LatitudeLabel = L("settings.weather.latitude_label", "Latitude");
LongitudeLabel = L("settings.weather.longitude_label", "Longitude");
LocationKeyPlaceholder = L("settings.weather.location_key_placeholder", "Location key (optional)");
LocationNamePlaceholder = L("settings.weather.location_name_placeholder", "Display name (optional)");
NoTlsToggleText = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
LocationUnsupportedText = L("settings.weather.location_unsupported", "Current platform does not support retrieving the current location.");
LocationReadyText = L("settings.weather.location_ready", "You can use the current Windows location.");
LocationRefreshingText = L("settings.weather.location_refreshing", "Requesting current location…");
FooterHint = L("settings.weather.footer_hint", "Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
}
private IReadOnlyList<SelectionOption> CreateLocationModes()
{
return
[
new SelectionOption("CitySearch", L("settings.weather.mode_city_search", "City Search")),
new SelectionOption("Coordinates", L("settings.weather.mode_coordinates", "Coordinates"))
];
}
private void UpdateModeVisibility()
{
var mode = SelectedLocationMode?.Value ?? "CitySearch";
IsCitySearchMode = string.Equals(mode, "CitySearch", StringComparison.OrdinalIgnoreCase);
IsCoordinatesMode = string.Equals(mode, "Coordinates", StringComparison.OrdinalIgnoreCase);
}
private void UpdateCurrentLocationSummary()
{
var state = CreateEditableState();
var modeLabel = SelectedLocationMode?.Label ?? state.LocationMode;
if (string.Equals(state.LocationMode, "CitySearch", StringComparison.OrdinalIgnoreCase))
{
CurrentLocationSummary = string.IsNullOrWhiteSpace(state.LocationKey)
? L("settings.weather.status_city_empty", "No city location is configured.")
: string.Format(
ResolveCulture(),
L("settings.weather.status_city_format", "Mode: {0} | {1} | Key: {2}"),
modeLabel,
string.IsNullOrWhiteSpace(state.LocationName) ? L("settings.weather.location_not_selected", "No location selected") : state.LocationName,
state.LocationKey);
return;
}
CurrentLocationSummary = string.Format(
ResolveCulture(),
L("settings.weather.status_coordinates_format", "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}"),
modeLabel,
state.Latitude,
state.Longitude,
state.LocationKey);
}
private WeatherSettingsState CreateEditableState(string? locationMode = null)
{
var mode = locationMode ?? SelectedLocationMode?.Value ?? "CitySearch";
var locationKey = LocationKey?.Trim() ?? string.Empty;
var locationName = LocationName?.Trim() ?? string.Empty;
if (string.Equals(mode, "Coordinates", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(locationKey))
{
locationKey = BuildCoordinateKey(Latitude, Longitude);
}
if (string.IsNullOrWhiteSpace(locationName))
{
locationName = BuildCoordinateDisplayName(Latitude, Longitude);
}
}
return new WeatherSettingsState(
mode,
locationKey,
locationName,
Latitude,
Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
}
private WeatherSettingsState ResolvePreviewState()
{
if (IsCitySearchMode && SelectedSearchResult is not null)
{
return new WeatherSettingsState(
"CitySearch",
SelectedSearchResult.LocationKey,
SelectedSearchResult.Name,
SelectedSearchResult.Latitude,
SelectedSearchResult.Longitude,
AutoRefreshLocation,
ExcludedAlerts ?? string.Empty,
"HyperOS3",
NoTlsRequests,
SearchKeyword?.Trim() ?? string.Empty);
}
return CreateEditableState();
}
private void ApplySavedState(WeatherSettingsState state, bool save = true)
{
if (save)
{
_settingsFacade.Weather.Save(state);
}
_isInitializing = true;
SelectedLocationMode = LocationModes.FirstOrDefault(option =>
string.Equals(option.Value, state.LocationMode, StringComparison.OrdinalIgnoreCase))
?? LocationModes[0];
Latitude = state.Latitude;
Longitude = state.Longitude;
LocationKey = state.LocationKey;
LocationName = state.LocationName;
AutoRefreshLocation = state.AutoRefreshLocation;
ExcludedAlerts = state.ExcludedAlerts;
NoTlsRequests = state.NoTlsRequests;
SearchKeyword = state.LocationQuery;
_isInitializing = false;
UpdateModeVisibility();
UpdateCurrentLocationSummary();
}
private string BuildCoordinateDisplayName(double latitude, double longitude)
{
return string.Format(
CultureInfo.InvariantCulture,
L("settings.weather.coordinates_default_name_format", "Coordinate {0:F4}, {1:F4}"),
latitude,
longitude);
}
private static string BuildCoordinateKey(double latitude, double longitude)
{
return FormattableString.Invariant($"coord:{latitude:F4},{longitude:F4}");
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private CultureInfo ResolveCulture()
{
try
{
return CultureInfo.GetCultureInfo(_languageCode);
}
catch (CultureNotFoundException)
{
return CultureInfo.InvariantCulture;
}
}
private static string NormalizeWeatherLocale(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en_us"
: "zh_cn";
}
}

View File

@@ -0,0 +1,135 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fa="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditorWindow"
x:Name="RootWindow"
Classes="component-editor-window"
Width="720"
Height="540"
MinWidth="420"
MinHeight="320"
CanResize="True"
SizeToContent="Manual"
ShowInTaskbar="False"
SystemDecorations="BorderOnly"
Background="{DynamicResource EditorWindowBackgroundBrush}"
Title="Component Editor">
<Window.Resources>
<!-- Material Design 3 Brushes -->
<SolidColorBrush x:Key="EditorWindowBackgroundBrush" Color="#FFFEF7FF" />
<SolidColorBrush x:Key="EditorSurfaceBrush" Color="#FFFEF7FF" />
<SolidColorBrush x:Key="EditorSurfaceContainerBrush" Color="#FFF3EDF7" />
<SolidColorBrush x:Key="EditorSurfaceContainerHighBrush" Color="#FFE6E0E9" />
<SolidColorBrush x:Key="EditorTopAppBarBackgroundBrush" Color="#FFF3EDF7" />
<SolidColorBrush x:Key="EditorHeaderIconBackgroundBrush" Color="#FFEADDFF" />
<SolidColorBrush x:Key="EditorTitleBarButtonHoverBrush" Color="#121D1B20" />
<SolidColorBrush x:Key="EditorPrimaryBrush" Color="#FF6750A4" />
<SolidColorBrush x:Key="EditorOnPrimaryBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="EditorSecondaryBrush" Color="#FF625B71" />
<SolidColorBrush x:Key="EditorTertiaryBrush" Color="#FF7D5260" />
<SolidColorBrush x:Key="EditorSelectFieldBackgroundBrush" Color="#FFE6E0E9" />
<SolidColorBrush x:Key="EditorSelectFieldHoverBrush" Color="#FFE0DAE4" />
<SolidColorBrush x:Key="EditorSelectFieldFocusBrush" Color="#FFDDD3E6" />
<SolidColorBrush x:Key="EditorSelectOutlineBrush" Color="#FF79747E" />
<SolidColorBrush x:Key="EditorSelectOutlineStrongBrush" Color="#FF6750A4" />
<SolidColorBrush x:Key="EditorSelectMenuItemHoverBrush" Color="#1F6750A4" />
<SolidColorBrush x:Key="EditorSelectMenuItemSelectedBrush" Color="#306750A4" />
<SolidColorBrush x:Key="ComponentEditorHeroBackgroundBrush" Color="#FFEADDFF" />
<SolidColorBrush x:Key="ComponentEditorCardBackgroundBrush" Color="#FFF3EDF7" />
<SolidColorBrush x:Key="ComponentEditorCardBorderBrush" Color="#FFCAC4D0" />
<SolidColorBrush x:Key="ComponentEditorPrimaryTextBrush" Color="#FF1D1B20" />
<SolidColorBrush x:Key="ComponentEditorSecondaryTextBrush" Color="#FF49454F" />
<SolidColorBrush x:Key="EditorDividerBrush" Color="#FFCAC4D0" />
</Window.Resources>
<Window.Styles>
<themes:CustomMaterialTheme BaseTheme="Light" PrimaryColor="#6750A4" SecondaryColor="#625B71" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml" />
<!-- MD3 Button Styles -->
<Style Selector="Button.component-editor-footer-button">
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
<Setter Property="Height" Value="40" />
</Style>
</Window.Styles>
<Grid Background="{DynamicResource EditorWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<Border x:Name="CustomTitleBarHost"
Padding="24,16"
Background="{DynamicResource EditorWindowBackgroundBrush}"
IsVisible="False"
PointerPressed="OnWindowTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="16">
<mi:MaterialIcon x:Name="HeaderIcon"
Width="28"
Height="28"
Foreground="{DynamicResource EditorPrimaryBrush}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1"
Spacing="0"
VerticalAlignment="Center">
<TextBlock x:Name="TitleTextBlock"
Classes="component-editor-headline"
FontSize="20"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Grid.Column="2"
Classes="component-editor-titlebar-button"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Width="40" Height="40"
Padding="8"
Click="OnCloseClick">
<mi:MaterialIcon Kind="Close"
Width="24" Height="24" />
</Button>
</Grid>
</Border>
<Panel Grid.Row="1">
<ScrollViewer Classes="component-editor-scroll-host"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ContentControl x:Name="EditorContentHost"
Margin="24,0,24,100"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ScrollViewer>
<!-- Floating Save Button (MD3 Style) -->
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64"
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="18"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
</Button.Styles>
<mi:MaterialIcon Kind="Check"
Width="32"
Height="32" />
</Button>
</Panel>
</Grid>
</Window>

View File

@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Styling;
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using Material.Styles.Themes;
using Material.Styles.Themes.Base;
using Material.Icons;
using Material.Icons.Avalonia;
namespace LanMountainDesktop.Views;
public partial class ComponentEditorWindow : Window
{
private readonly Dictionary<string, Size> _sizeCache = new(StringComparer.OrdinalIgnoreCase);
private readonly CustomMaterialTheme _materialTheme;
private DesktopComponentEditorDescriptor? _descriptor;
private string? _currentComponentId;
private bool _suppressAspectRatioCorrection;
private Size _lastStableSize;
public ComponentEditorWindow()
{
InitializeComponent();
_materialTheme = Styles.OfType<CustomMaterialTheme>().FirstOrDefault()
?? throw new InvalidOperationException("Component editor Material theme is missing.");
_lastStableSize = new Size(Width, Height);
ApplyChromeMode(useSystemChrome: false);
}
public void ApplyDescriptor(
DesktopComponentEditorDescriptor descriptor,
DesktopComponentEditorContext context)
{
_descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
_currentComponentId = context.ComponentId;
var editor = descriptor.CreateEditor(context);
EditorContentHost.Content = editor;
TitleTextBlock.Text = descriptor.Definition.DisplayName;
HeaderIcon.Kind = ResolveSymbol(descriptor.Definition.IconKey);
Title = descriptor.Definition.DisplayName;
ApplyPreferredSize(descriptor);
}
public void ApplyChromeMode(bool useSystemChrome)
{
var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
if (preferSystemChrome)
{
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
ExtendClientAreaTitleBarHeightHint = -1;
SystemDecorations = SystemDecorations.Full;
CustomTitleBarHost.IsVisible = false;
return;
}
SystemDecorations = SystemDecorations.BorderOnly;
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
ExtendClientAreaTitleBarHeightHint = 52;
CustomTitleBarHost.IsVisible = true;
}
internal void ApplyTheme(ComponentEditorThemePalette palette)
{
ArgumentNullException.ThrowIfNull(palette);
RequestedThemeVariant = palette.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light;
_materialTheme.BaseTheme = palette.IsNightMode ? BaseThemeMode.Dark : BaseThemeMode.Light;
_materialTheme.PrimaryColor = palette.PrimaryColor;
_materialTheme.SecondaryColor = palette.SecondaryColor;
SetBrushResource("EditorPrimaryBrush", palette.PrimaryColor);
SetBrushResource("EditorOnPrimaryBrush", palette.IsNightMode ? Colors.Black : Colors.White);
SetBrushResource("EditorSecondaryBrush", palette.SecondaryColor);
SetBrushResource("EditorTertiaryBrush", palette.TertiaryColor);
SetBrushResource("EditorWindowBackgroundBrush", palette.WindowBackgroundColor);
SetBrushResource("EditorSurfaceBrush", palette.SurfaceColor);
SetBrushResource("EditorSurfaceContainerBrush", palette.SurfaceContainerColor);
SetBrushResource("EditorSurfaceContainerHighBrush", palette.SurfaceContainerHighColor);
SetBrushResource("EditorSelectFieldBackgroundBrush", palette.SurfaceContainerHighColor);
SetBrushResource(
"EditorSelectFieldHoverBrush",
ColorMath.Blend(palette.SurfaceContainerHighColor, palette.PrimaryColor, palette.IsNightMode ? 0.18 : 0.08));
SetBrushResource(
"EditorSelectFieldFocusBrush",
ColorMath.Blend(palette.SurfaceContainerHighColor, palette.PrimaryColor, palette.IsNightMode ? 0.24 : 0.12));
SetBrushResource("EditorSelectOutlineBrush", palette.OutlineColor);
SetBrushResource(
"EditorSelectOutlineStrongBrush",
ColorMath.EnsureContrast(palette.PrimaryColor, palette.SurfaceContainerHighColor, 3.0));
SetBrushResource(
"EditorSelectMenuItemHoverBrush",
ColorMath.Blend(palette.SurfaceContainerColor, palette.PrimaryColor, palette.IsNightMode ? 0.20 : 0.10));
SetBrushResource(
"EditorSelectMenuItemSelectedBrush",
ColorMath.Blend(palette.SurfaceContainerColor, palette.PrimaryColor, palette.IsNightMode ? 0.30 : 0.16));
SetBrushResource("EditorTopAppBarBackgroundBrush", palette.TopAppBarColor);
SetBrushResource("EditorHeaderIconBackgroundBrush", palette.HeaderIconBackgroundColor);
SetBrushResource("EditorTitleBarButtonHoverBrush", palette.TitleBarButtonHoverColor);
SetBrushResource("ComponentEditorHeroBackgroundBrush", palette.SurfaceContainerHighColor);
SetBrushResource("ComponentEditorCardBackgroundBrush", palette.SurfaceContainerColor);
SetBrushResource("ComponentEditorCardBorderBrush", palette.OutlineColor);
SetBrushResource("ComponentEditorPrimaryTextBrush", palette.OnSurfaceColor);
SetBrushResource("ComponentEditorSecondaryTextBrush", palette.OnSurfaceVariantColor);
SetBrushResource("EditorDividerBrush", palette.DividerColor);
}
protected override void OnSizeChanged(SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (_descriptor is null || _suppressAspectRatioCorrection)
{
_lastStableSize = e.NewSize;
return;
}
var correctedSize = CoerceSize(e.NewSize, e.PreviousSize, _descriptor);
if (Math.Abs(correctedSize.Width - e.NewSize.Width) < 0.5 &&
Math.Abs(correctedSize.Height - e.NewSize.Height) < 0.5)
{
_lastStableSize = correctedSize;
CacheCurrentSize();
return;
}
_suppressAspectRatioCorrection = true;
Width = correctedSize.Width;
Height = correctedSize.Height;
_suppressAspectRatioCorrection = false;
_lastStableSize = correctedSize;
CacheCurrentSize();
}
private void SetBrushResource(string key, Color color)
{
Resources[key] = new SolidColorBrush(color);
}
private void ApplyPreferredSize(DesktopComponentEditorDescriptor descriptor)
{
var width = descriptor.PreferredWidth;
var height = descriptor.PreferredHeight;
if (!string.IsNullOrWhiteSpace(_currentComponentId) &&
_sizeCache.TryGetValue(_currentComponentId, out var cached))
{
width = cached.Width;
height = cached.Height;
}
_suppressAspectRatioCorrection = true;
MinWidth = descriptor.PreferredWidth * descriptor.MinScale;
MinHeight = descriptor.PreferredHeight * descriptor.MinScale;
MaxWidth = descriptor.PreferredWidth * descriptor.MaxScale;
MaxHeight = descriptor.PreferredHeight * descriptor.MaxScale;
Width = width;
Height = height;
_lastStableSize = new Size(width, height);
_suppressAspectRatioCorrection = false;
}
private void CacheCurrentSize()
{
if (_descriptor is null || string.IsNullOrWhiteSpace(_currentComponentId))
{
return;
}
_sizeCache[_currentComponentId] = _lastStableSize;
}
private static Size CoerceSize(Size currentSize, Size previousSize, DesktopComponentEditorDescriptor descriptor)
{
var preferredWidth = descriptor.PreferredWidth;
var preferredHeight = descriptor.PreferredHeight;
var aspectRatio = descriptor.AspectRatio;
var minWidth = preferredWidth * descriptor.MinScale;
var maxWidth = preferredWidth * descriptor.MaxScale;
var minHeight = preferredHeight * descriptor.MinScale;
var maxHeight = preferredHeight * descriptor.MaxScale;
var deltaWidth = Math.Abs(currentSize.Width - previousSize.Width);
var deltaHeight = Math.Abs(currentSize.Height - previousSize.Height);
double width;
double height;
if (deltaWidth >= deltaHeight)
{
width = Math.Clamp(currentSize.Width, minWidth, maxWidth);
height = Math.Clamp(width / aspectRatio, minHeight, maxHeight);
width = Math.Clamp(height * aspectRatio, minWidth, maxWidth);
}
else
{
height = Math.Clamp(currentSize.Height, minHeight, maxHeight);
width = Math.Clamp(height * aspectRatio, minWidth, maxWidth);
height = Math.Clamp(width / aspectRatio, minHeight, maxHeight);
}
return new Size(width, height);
}
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
private static MaterialIconKind ResolveSymbol(string iconKey)
{
return iconKey switch
{
"Clock" => MaterialIconKind.Clock,
"Timer" => MaterialIconKind.Timer,
"WeatherSunny" => MaterialIconKind.WeatherSunny,
"CalendarDate" => MaterialIconKind.CalendarRange,
"CalendarMonth" => MaterialIconKind.CalendarMonth,
"MicOn" => MaterialIconKind.Microphone,
"News" => MaterialIconKind.Newspaper,
"Image" => MaterialIconKind.Image,
"Book" => MaterialIconKind.BookOpenVariant,
"History" => MaterialIconKind.History,
"DataLine" => MaterialIconKind.ChartLine,
"Edit" => MaterialIconKind.Pencil,
"Calculator" => MaterialIconKind.Calculator,
"Globe" => MaterialIconKind.Web,
"Play" => MaterialIconKind.Play,
_ => MaterialIconKind.Settings
};
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
Close();
}
}

View File

@@ -0,0 +1,36 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.ClassScheduleComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<Button x:Name="AddScheduleButton"
HorizontalAlignment="Left"
Classes="accent"
Padding="16,10"
Click="OnAddScheduleClick" />
<TextBlock x:Name="EmptyStateTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<StackPanel x:Name="ScheduleItemsPanel"
Spacing="10" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
public ClassScheduleComponentEditor()
: this(null)
{
}
public ClassScheduleComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
LoadState();
ApplyState();
RenderImportedSchedules();
}
private void LoadState()
{
var snapshot = LoadSnapshot();
_importedSchedules.Clear();
foreach (var item in snapshot.ImportedClassSchedules)
{
if (string.IsNullOrWhiteSpace(item.Id) || string.IsNullOrWhiteSpace(item.FilePath))
{
continue;
}
_importedSchedules.Add(new ImportedClassScheduleSnapshot
{
Id = item.Id.Trim(),
DisplayName = item.DisplayName?.Trim() ?? string.Empty,
FilePath = item.FilePath.Trim()
});
}
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
if (_importedSchedules.Count > 0 &&
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
{
_activeScheduleId = _importedSchedules[0].Id;
}
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
}
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is not { } storageProvider)
{
return;
}
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
{
Patterns = ["*.cses", "*.yaml", "*.yml"]
}
]
});
if (files.Count == 0)
{
return;
}
var importedPath = await ImportScheduleFileAsync(files[0]);
if (string.IsNullOrWhiteSpace(importedPath))
{
return;
}
var existing = _importedSchedules.FirstOrDefault(item =>
string.Equals(item.FilePath, importedPath, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
_activeScheduleId = existing.Id;
}
else
{
_importedSchedules.Add(new ImportedClassScheduleSnapshot
{
Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"),
FilePath = importedPath
});
_activeScheduleId = _importedSchedules[^1].Id;
}
PersistState();
RenderImportedSchedules();
}
private async Task<string?> ImportScheduleFileAsync(IStorageFile file)
{
try
{
var extension = Path.GetExtension(file.Name);
if (string.IsNullOrWhiteSpace(extension))
{
extension = ".cses";
}
var importedDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Schedules");
Directory.CreateDirectory(importedDirectory);
var destinationPath = Path.Combine(
importedDirectory,
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
await using var sourceStream = await file.OpenReadAsync();
await using var destinationStream = File.Create(destinationPath);
await sourceStream.CopyToAsync(destinationStream);
return destinationPath;
}
catch
{
return null;
}
}
private void RenderImportedSchedules()
{
ScheduleItemsPanel.Children.Clear();
EmptyStateTextBlock.IsVisible = _importedSchedules.Count == 0;
if (_importedSchedules.Count == 0)
{
return;
}
foreach (var item in _importedSchedules)
{
var selector = new RadioButton
{
GroupName = "class_schedule_imports",
IsChecked = string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase),
VerticalAlignment = VerticalAlignment.Center,
Tag = item.Id
};
selector.IsCheckedChanged += OnScheduleSelectionChanged;
var title = new TextBlock
{
Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表")
: item.DisplayName,
FontWeight = FontWeight.SemiBold
};
var path = new TextBlock
{
Text = item.FilePath,
FontSize = 11,
Opacity = 0.7,
TextTrimming = TextTrimming.CharacterEllipsis
};
var deleteButton = new Button
{
Content = L("schedule.settings.delete", "删除"),
Tag = item.Id,
Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right
};
deleteButton.Click += OnDeleteScheduleClick;
var row = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 12
};
var details = new StackPanel
{
Spacing = 4,
Children = { title, path }
};
row.Children.Add(selector);
row.Children.Add(details);
row.Children.Add(deleteButton);
Grid.SetColumn(details, 1);
Grid.SetColumn(deleteButton, 2);
ScheduleItemsPanel.Children.Add(new Border
{
Padding = new Thickness(12, 10),
CornerRadius = new CornerRadius(16),
Background = Brushes.Transparent,
BorderBrush = Brushes.Gray,
BorderThickness = new Thickness(1),
Child = row
});
}
}
private void OnScheduleSelectionChanged(object? sender, RoutedEventArgs e)
{
_ = e;
if (sender is not RadioButton radioButton || radioButton.IsChecked != true || radioButton.Tag is not string scheduleId)
{
return;
}
_activeScheduleId = scheduleId;
PersistState();
}
private void OnDeleteScheduleClick(object? sender, RoutedEventArgs e)
{
_ = e;
if (sender is not Button button || button.Tag is not string scheduleId)
{
return;
}
var removed = _importedSchedules.RemoveAll(item =>
string.Equals(item.Id, scheduleId, StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return;
}
if (string.Equals(_activeScheduleId, scheduleId, StringComparison.OrdinalIgnoreCase))
{
_activeScheduleId = _importedSchedules.FirstOrDefault()?.Id ?? string.Empty;
}
PersistState();
RenderImportedSchedules();
}
private void PersistState()
{
var snapshot = LoadSnapshot();
snapshot.ImportedClassSchedules = _importedSchedules
.Select(item => new ImportedClassScheduleSnapshot
{
Id = item.Id,
DisplayName = item.DisplayName,
FilePath = item.FilePath
})
.ToList();
snapshot.ActiveImportedClassScheduleId = _activeScheduleId;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.ImportedClassSchedules),
nameof(ComponentSettingsSnapshot.ActiveImportedClassScheduleId));
}
}

View File

@@ -0,0 +1,56 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.ClockComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="TimeZoneLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="TimeZoneComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SecondHandLabelTextBlock"
Classes="component-editor-section-title" />
<Border Classes="component-editor-segmented-host"
HorizontalAlignment="Left">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="4">
<ToggleButton x:Name="TickRadioButton"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
<ToggleButton x:Name="SweepRadioButton"
Grid.Column="1"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
</Grid>
</Border>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,139 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClockComponentEditor : ComponentEditorViewBase
{
private readonly TimeZoneService _timeZoneService = new();
private bool _suppressEvents;
public ClockComponentEditor()
: this(null)
{
}
public ClockComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
if (context is not null)
{
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
}
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Clock";
DescriptionTextBlock.Text = L("clock.settings.desc", "配置时区和秒针动画。");
TimeZoneLabelTextBlock.Text = L("clock.settings.timezone", "时区");
SecondHandLabelTextBlock.Text = L("clock.settings.second_mode_label", "秒针方式");
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
var snapshot = LoadSnapshot();
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
? TimeZoneInfo.Local.Id
: snapshot.DesktopClockTimeZoneId.Trim();
_suppressEvents = true;
TimeZoneComboBox.Items.Clear();
foreach (var timeZone in _timeZoneService.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase))
{
var item = new ComboBoxItem
{
Tag = timeZone.Id,
Content = FormatTimeZone(timeZone)
};
item.Classes.Add("component-editor-select-item");
TimeZoneComboBox.Items.Add(item);
}
TimeZoneComboBox.SelectedItem = TimeZoneComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, configuredTimeZoneId, StringComparison.OrdinalIgnoreCase))
?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
var secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
TickRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
SweepRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
_suppressEvents = true;
if (sender == TickRadioButton)
{
TickRadioButton.IsChecked = true;
SweepRadioButton.IsChecked = false;
}
else if (sender == SweepRadioButton)
{
SweepRadioButton.IsChecked = true;
TickRadioButton.IsChecked = false;
}
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
{
TickRadioButton.IsChecked = true;
}
_suppressEvents = false;
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
snapshot.DesktopClockTimeZoneId = TimeZoneComboBox.SelectedItem is ComboBoxItem item && item.Tag is string timeZoneId
? timeZoneId
: TimeZoneInfo.Local.Id;
snapshot.DesktopClockSecondHandMode = SweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.DesktopClockTimeZoneId),
nameof(ComponentSettingsSnapshot.DesktopClockSecondHandMode));
}
private static string FormatTimeZone(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
}
}

View File

@@ -0,0 +1,44 @@
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public abstract class ComponentEditorViewBase : UserControl
{
protected ComponentEditorViewBase(DesktopComponentEditorContext? context)
{
Context = context;
}
protected DesktopComponentEditorContext? Context { get; }
protected LocalizationService LocalizationService { get; } = new();
protected string LanguageCode =>
LocalizationService.NormalizeLanguageCode(Context?.SettingsFacade.Region.Get().LanguageCode);
protected string L(string key, string fallback)
{
return LocalizationService.GetString(LanguageCode, key, fallback);
}
protected ComponentSettingsSnapshot LoadSnapshot()
{
return Context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>() ?? new ComponentSettingsSnapshot();
}
protected void SaveSnapshot(ComponentSettingsSnapshot snapshot, params string[] changedKeys)
{
if (Context is null)
{
return;
}
Context.ComponentSettingsAccessor.SaveSnapshot(
snapshot,
changedKeys.Length == 0 ? null : changedKeys);
Context.HostContext.RequestRefresh();
}
}

View File

@@ -0,0 +1,30 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.DailyArtworkComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourceLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="SourceComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="DomesticItem"
Classes="component-editor-select-item"
Tag="Domestic" />
<ComboBoxItem x:Name="OverseasItem"
Classes="component-editor-select-item"
Tag="Overseas" />
</ComboBox>
<TextBlock x:Name="StatusTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,64 @@
using System;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class DailyArtworkComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public DailyArtworkComponentEditor()
: this(null)
{
}
public DailyArtworkComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
SourceLabelTextBlock.Text = L("artwork.settings.source_label", "Mirror Source");
DomesticItem.Content = L("artwork.settings.source_domestic", "Domestic Mirror");
OverseasItem.Content = L("artwork.settings.source_overseas", "Overseas Mirror");
_suppressEvents = true;
var source = DailyArtworkMirrorSources.Normalize(LoadSnapshot().DailyArtworkMirrorSource);
SourceComboBox.SelectedItem = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? DomesticItem
: OverseasItem;
UpdateStatus(source);
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? DailyArtworkMirrorSources.Normalize(tag)
: DailyArtworkMirrorSources.Overseas;
var snapshot = LoadSnapshot();
snapshot.DailyArtworkMirrorSource = source;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.DailyArtworkMirrorSource));
UpdateStatus(source);
}
private void UpdateStatus(string source)
{
StatusTextBlock.Text = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? L("artwork.settings.source_status_domestic", "当前源:国内镜像(优先中国网络)")
: L("artwork.settings.source_status_overseas", "当前源:国外镜像(艺术馆推荐)");
}
}

View File

@@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.InformationalComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="12"
RowSpacing="8">
<TextBlock x:Name="ComponentLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ComponentValueTextBlock"
Grid.Column="1" />
<TextBlock Grid.Row="1"
x:Name="PlacementLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="PlacementValueTextBlock"
Grid.Row="1"
Grid.Column="1" />
<TextBlock Grid.Row="2"
x:Name="ScopeLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ScopeValueTextBlock"
Grid.Row="2"
Grid.Column="1"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</Grid>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,31 @@
using LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class InformationalComponentEditor : ComponentEditorViewBase
{
private readonly string _description;
public InformationalComponentEditor()
: this(null, "This component currently exposes instance information only.")
{
}
public InformationalComponentEditor(DesktopComponentEditorContext? context, string description)
: base(context)
{
_description = description;
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
ComponentLabelTextBlock.Text = L("component.editor.id_label", "Component");
ComponentValueTextBlock.Text = Context?.ComponentId ?? "-";
PlacementLabelTextBlock.Text = L("component.editor.placement_label", "Placement");
PlacementValueTextBlock.Text = Context?.PlacementId ?? "-";
ScopeLabelTextBlock.Text = L("component.editor.scope_label", "Scope");
ScopeValueTextBlock.Text = L("component.editor.scope_instance", "Instance-scoped editor");
}
}

View File

@@ -0,0 +1,45 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="DisplayDbHeaderTextBlock"
Classes="component-editor-section-title" />
<ToggleSwitch x:Name="DisplayDbToggleSwitch"
IsCheckedChanged="OnToggleChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="DbfsHeaderTextBlock"
Classes="component-editor-section-title" />
<ToggleSwitch x:Name="DbfsToggleSwitch"
IsCheckedChanged="OnToggleChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,72 @@
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public StudyEnvironmentComponentEditor()
: this(null)
{
}
public StudyEnvironmentComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
if (!showDisplayDb && !showDbfs)
{
showDisplayDb = true;
}
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
_suppressEvents = true;
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false;
}
private void OnToggleChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var showDisplayDb = DisplayDbToggleSwitch.IsChecked == true;
var showDbfs = DbfsToggleSwitch.IsChecked == true;
if (!showDisplayDb && !showDbfs)
{
_suppressEvents = true;
DisplayDbToggleSwitch.IsChecked = true;
_suppressEvents = false;
showDisplayDb = true;
}
var snapshot = LoadSnapshot();
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
snapshot.StudyEnvironmentShowDbfs = showDbfs;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDisplayDb),
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDbfs));
}
}

View File

@@ -0,0 +1,56 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.ToggleIntervalComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-card"
Padding="20">
<Grid RowDefinitions="Auto,Auto"
ColumnDefinitions="*,Auto"
ColumnSpacing="16"
RowSpacing="6">
<StackPanel Spacing="4">
<TextBlock x:Name="ToggleLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="ToggleDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch x:Name="EnabledToggleSwitch"
Grid.Column="1"
VerticalAlignment="Center"
Checked="OnEnabledChanged"
Unchecked="OnEnabledChanged" />
</Grid>
</Border>
<Border x:Name="IntervalCard"
Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="IntervalLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="IntervalComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnIntervalSelectionChanged" />
</StackPanel>
</Border>
<Border x:Name="ExtraSelectorCard"
Classes="component-editor-card"
Padding="20"
IsVisible="False">
<StackPanel Spacing="12">
<TextBlock x:Name="ExtraSelectorLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ExtraSelectorComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnExtraSelectionChanged" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public sealed record ComponentEditorSelectionOption(
string Value,
string LabelKey,
string FallbackLabel);
public sealed class ToggleIntervalComponentEditorOptions
{
public required string DescriptionKey { get; init; }
public required string DescriptionFallback { get; init; }
public required string ToggleLabelKey { get; init; }
public required string ToggleLabelFallback { get; init; }
public string ToggleDescriptionKey { get; init; } = string.Empty;
public string ToggleDescriptionFallback { get; init; } = string.Empty;
public required string IntervalLabelKey { get; init; }
public required string IntervalLabelFallback { get; init; }
public required Func<ComponentSettingsSnapshot, bool> GetEnabled { get; init; }
public required Action<ComponentSettingsSnapshot, bool> SetEnabled { get; init; }
public required Func<ComponentSettingsSnapshot, int> GetInterval { get; init; }
public required Action<ComponentSettingsSnapshot, int> SetInterval { get; init; }
public required int DefaultInterval { get; init; }
public IReadOnlyList<string> ChangedKeys { get; init; } = Array.Empty<string>();
public string ExtraSelectorLabelKey { get; init; } = string.Empty;
public string ExtraSelectorLabelFallback { get; init; } = string.Empty;
public IReadOnlyList<ComponentEditorSelectionOption> ExtraOptions { get; init; } = Array.Empty<ComponentEditorSelectionOption>();
public Func<ComponentSettingsSnapshot, string>? GetExtraValue { get; init; }
public Action<ComponentSettingsSnapshot, string>? SetExtraValue { get; init; }
}
public partial class ToggleIntervalComponentEditor : ComponentEditorViewBase
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly ToggleIntervalComponentEditorOptions _options;
private bool _suppressEvents;
public ToggleIntervalComponentEditor()
: this(null, new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "component.editor.desc",
DescriptionFallback = "Configure this component.",
ToggleLabelKey = "component.editor.toggle",
ToggleLabelFallback = "Enabled",
IntervalLabelKey = "component.editor.interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = _ => true,
SetEnabled = (_, _) => { },
GetInterval = _ => 15,
SetInterval = (_, _) => { }
})
{
}
public ToggleIntervalComponentEditor(
DesktopComponentEditorContext? context,
ToggleIntervalComponentEditorOptions options)
: base(context)
{
_options = options;
InitializeComponent();
BuildOptions();
ApplyState();
}
private void BuildOptions()
{
IntervalComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
var item = new ComboBoxItem
{
Tag = minutes.ToString(),
Content = L("refresh.frequency." + RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes), RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes))
};
item.Classes.Add("component-editor-select-item");
IntervalComboBox.Items.Add(item);
}
ExtraSelectorComboBox.Items.Clear();
foreach (var option in _options.ExtraOptions)
{
var item = new ComboBoxItem
{
Tag = option.Value,
Content = L(option.LabelKey, option.FallbackLabel)
};
item.Classes.Add("component-editor-select-item");
ExtraSelectorComboBox.Items.Add(item);
}
}
private void ApplyState()
{
ToggleLabelTextBlock.Text = L(_options.ToggleLabelKey, _options.ToggleLabelFallback);
ToggleDescriptionTextBlock.Text = string.IsNullOrWhiteSpace(_options.ToggleDescriptionKey)
? L("component.editor.instance_scope", "Changes are stored per component instance.")
: L(_options.ToggleDescriptionKey, _options.ToggleDescriptionFallback);
IntervalLabelTextBlock.Text = L(_options.IntervalLabelKey, _options.IntervalLabelFallback);
ExtraSelectorLabelTextBlock.Text = L(_options.ExtraSelectorLabelKey, _options.ExtraSelectorLabelFallback);
ExtraSelectorCard.IsVisible = _options.ExtraOptions.Count > 0;
_suppressEvents = true;
try
{
var snapshot = LoadSnapshot();
var enabled = _options.GetEnabled(snapshot);
EnabledToggleSwitch.IsChecked = enabled;
IntervalCard.IsVisible = enabled;
var normalizedInterval = RefreshIntervalCatalog.Normalize(_options.GetInterval(snapshot), _options.DefaultInterval);
IntervalComboBox.SelectedItem = IntervalComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => item.Tag is string tag && int.TryParse(tag, out var minutes) && minutes == normalizedInterval);
if (_options.GetExtraValue is not null && _options.ExtraOptions.Count > 0)
{
var extraValue = _options.GetExtraValue(snapshot);
ExtraSelectorComboBox.SelectedItem = ExtraSelectorComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, extraValue, StringComparison.OrdinalIgnoreCase))
?? ExtraSelectorComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
}
finally
{
_suppressEvents = false;
}
}
private void OnEnabledChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
IntervalCard.IsVisible = EnabledToggleSwitch.IsChecked == true;
SaveState();
}
private void OnIntervalSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnExtraSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
_options.SetEnabled(snapshot, EnabledToggleSwitch.IsChecked == true);
_options.SetInterval(snapshot, GetSelectedInterval());
if (_options.SetExtraValue is not null &&
ExtraSelectorComboBox.SelectedItem is ComboBoxItem extraItem &&
extraItem.Tag is string extraValue)
{
_options.SetExtraValue(snapshot, extraValue);
}
SaveSnapshot(snapshot, _options.ChangedKeys.ToArray());
}
private int GetSelectedInterval()
{
if (IntervalComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tag &&
int.TryParse(tag, out var minutes))
{
return RefreshIntervalCatalog.Normalize(minutes, _options.DefaultInterval);
}
return _options.DefaultInterval;
}
}

View File

@@ -0,0 +1,92 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.WorldClockComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockOneLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockOneComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockTwoLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockTwoComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockThreeLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockThreeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ClockFourLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ClockFourComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SecondHandLabelTextBlock"
Classes="component-editor-section-title" />
<Border Classes="component-editor-segmented-host"
HorizontalAlignment="Left">
<Grid ColumnDefinitions="*,*"
ColumnSpacing="4">
<ToggleButton x:Name="TickRadioButton"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
<ToggleButton x:Name="SweepRadioButton"
Grid.Column="1"
Classes="component-editor-segmented-choice"
Checked="OnSecondHandChanged"
Unchecked="OnSecondHandChanged" />
</Grid>
</Border>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,152 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class WorldClockComponentEditor : ComponentEditorViewBase
{
private readonly TimeZoneService _timeZoneService = new();
private readonly ComboBox[] _timeZoneCombos;
private bool _suppressEvents;
public WorldClockComponentEditor()
: this(null)
{
}
public WorldClockComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
if (context is not null)
{
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
}
InitializeComponent();
_timeZoneCombos = [ClockOneComboBox, ClockTwoComboBox, ClockThreeComboBox, ClockFourComboBox];
ApplyState();
}
private void ApplyState()
{
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "World Clock";
DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。");
ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1");
ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2");
ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3");
ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4");
SecondHandLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式");
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
var allTimeZones = _timeZoneService.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
var selectedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(LoadSnapshot().WorldClockTimeZoneIds, allTimeZones);
_suppressEvents = true;
foreach (var combo in _timeZoneCombos)
{
combo.Items.Clear();
foreach (var zone in allTimeZones)
{
var item = new ComboBoxItem
{
Tag = zone.Id,
Content = FormatTimeZone(zone)
};
item.Classes.Add("component-editor-select-item");
combo.Items.Add(item);
}
}
for (var index = 0; index < _timeZoneCombos.Length; index++)
{
var combo = _timeZoneCombos[index];
var targetId = index < selectedIds.Count ? selectedIds[index] : TimeZoneInfo.Local.Id;
combo.SelectedItem = combo.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase))
?? combo.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
var secondMode = ClockSecondHandMode.Normalize(LoadSnapshot().WorldClockSecondHandMode);
TickRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
SweepRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
_suppressEvents = true;
if (sender == TickRadioButton)
{
TickRadioButton.IsChecked = true;
SweepRadioButton.IsChecked = false;
}
else if (sender == SweepRadioButton)
{
SweepRadioButton.IsChecked = true;
TickRadioButton.IsChecked = false;
}
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
{
TickRadioButton.IsChecked = true;
}
_suppressEvents = false;
SaveState();
}
private void SaveState()
{
var snapshot = LoadSnapshot();
snapshot.WorldClockTimeZoneIds = _timeZoneCombos
.Select(combo => combo.SelectedItem is ComboBoxItem item && item.Tag is string tag ? tag : TimeZoneInfo.Local.Id)
.ToList();
snapshot.WorldClockSecondHandMode = SweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
SaveSnapshot(
snapshot,
nameof(ComponentSettingsSnapshot.WorldClockTimeZoneIds),
nameof(ComponentSettingsSnapshot.WorldClockSecondHandMode));
}
private static string FormatTimeZone(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
}
}

View File

@@ -508,6 +508,17 @@ public partial class MainWindow
if (_selectedDesktopComponentHost is not null) if (_selectedDesktopComponentHost is not null)
{ {
if (TryGetSelectedDesktopPlacement(out var selectedPlacement) &&
_componentEditorRegistry.TryGetDescriptor(selectedPlacement.ComponentId, out _))
{
actions.Add(new TaskbarActionItem(
TaskbarActionId.EditComponent,
L("component.edit", "Edit"),
"Edit",
IsVisible: true,
CommandKey: "component.edit"));
}
actions.Add(new TaskbarActionItem( actions.Add(new TaskbarActionItem(
TaskbarActionId.DeleteComponent, TaskbarActionId.DeleteComponent,
L("component.delete", "Delete"), L("component.delete", "Delete"),
@@ -606,15 +617,12 @@ public partial class MainWindow
action.Id == TaskbarActionId.DeleteComponent; action.Id == TaskbarActionId.DeleteComponent;
var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry; var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry;
Symbol iconSymbol; var iconSymbol = action.Id switch
if (isDeleteAction || isHideAction)
{ {
iconSymbol = Symbol.Delete; TaskbarActionId.EditComponent => Symbol.Edit,
} _ when isDeleteAction || isHideAction => Symbol.Delete,
else _ => Symbol.Add
{ };
iconSymbol = Symbol.Add;
}
Control icon = new SymbolIcon Control icon = new SymbolIcon
{ {
@@ -675,6 +683,9 @@ public partial class MainWindow
case "component.delete": case "component.delete":
DeleteSelectedComponent(); DeleteSelectedComponent();
break; break;
case "component.edit":
OpenSelectedComponentEditor();
break;
case "launcher.hide": case "launcher.hide":
HideSelectedLauncherEntry(); HideSelectedLauncherEntry();
break; break;
@@ -695,6 +706,11 @@ public partial class MainWindow
return; return;
} }
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
{
_componentEditorWindowService.Close();
}
ClearTimeZoneServiceBindings(_selectedDesktopComponentHost); ClearTimeZoneServiceBindings(_selectedDesktopComponentHost);
DisposeComponentIfNeeded(_selectedDesktopComponentHost); DisposeComponentIfNeeded(_selectedDesktopComponentHost);
@@ -713,6 +729,90 @@ public partial class MainWindow
PersistSettings(); PersistSettings();
} }
private void OpenSelectedComponentEditor()
{
if (!TryGetSelectedDesktopPlacement(out var placement) ||
!_componentEditorRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
{
return;
}
_componentEditorWindowService.Open(new ComponentEditorOpenRequest(
Owner: this,
Descriptor: descriptor,
ComponentId: placement.ComponentId,
PlacementId: placement.PlacementId,
RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId)));
}
private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement)
{
placement = null!;
if (_selectedDesktopComponentHost?.Tag is not string placementId)
{
return false;
}
var matchedPlacement = _desktopComponentPlacements.FirstOrDefault(candidate =>
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
if (matchedPlacement is null)
{
return false;
}
placement = matchedPlacement;
return true;
}
private void RefreshDesktopComponentPlacement(string placementId)
{
if (string.IsNullOrWhiteSpace(placementId))
{
return;
}
var placement = _desktopComponentPlacements.FirstOrDefault(candidate =>
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
if (placement is null ||
!_desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid))
{
return;
}
var host = pageGrid.Children
.OfType<Border>()
.FirstOrDefault(candidate => string.Equals(candidate.Tag as string, placementId, StringComparison.OrdinalIgnoreCase));
if (host is null)
{
RestoreDesktopPageComponents(placement.PageIndex);
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
return;
}
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId, placement.PageIndex);
if (component is null)
{
return;
}
if (TryGetContentHost(host) is not Border contentHost)
{
RestoreDesktopPageComponents(placement.PageIndex);
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
return;
}
ClearTimeZoneServiceBindings(host);
DisposeComponentIfNeeded(host);
contentHost.Child = component;
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
UpdateDesktopPageAwareComponentContext();
if (_selectedDesktopComponentHost == host)
{
ApplySelectionStateToHost(host, true);
}
}
private static void DisposeComponentIfNeeded(Border host) private static void DisposeComponentIfNeeded(Border host)
{ {
if (TryGetContentHost(host) is Border contentHost && contentHost.Child is Control componentControl) if (TryGetContentHost(host) is Border contentHost && contentHost.Child is Control componentControl)
@@ -724,7 +824,7 @@ public partial class MainWindow
} }
} }
// Component settings popup UI is removed in API-only settings hard-cut mode. // Legacy in-window popup editor is removed; component editing now routes through the Material editor window service.
private void AddDesktopPage() private void AddDesktopPage()
{ {
@@ -1680,6 +1780,9 @@ public partial class MainWindow
ApplySelectionStateToHost(_selectedDesktopComponentHost, false); ApplySelectionStateToHost(_selectedDesktopComponentHost, false);
_selectedDesktopComponentHost = null; _selectedDesktopComponentHost = null;
} }
_componentEditorWindowService.Close();
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
} }
private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e)

View File

@@ -158,7 +158,7 @@ public partial class MainWindow
_weatherLongitude = snapshot.WeatherLongitude; _weatherLongitude = snapshot.WeatherLongitude;
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation; _weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty; _weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "FluentRegular" : snapshot.WeatherIconPackId; _weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests; _weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
} }
@@ -488,6 +488,7 @@ public partial class MainWindow
private AppSettingsSnapshot BuildAppSettingsSnapshot() private AppSettingsSnapshot BuildAppSettingsSnapshot()
{ {
var latestWeatherState = _weatherSettingsService.Get();
return new AppSettingsSnapshot return new AppSettingsSnapshot
{ {
GridShortSideCells = _targetShortSideCells, GridShortSideCells = _targetShortSideCells,
@@ -500,15 +501,16 @@ public partial class MainWindow
WallpaperColor = _wallpaperSolidColor?.ToString(), WallpaperColor = _wallpaperSolidColor?.ToString(),
LanguageCode = _languageCode, LanguageCode = _languageCode,
TimeZoneId = _timeZoneService.CurrentTimeZone.Id, TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
WeatherLocationMode = _weatherLocationMode.ToString(), WeatherLocationMode = latestWeatherState.LocationMode,
WeatherLocationKey = _weatherLocationKey, WeatherLocationKey = latestWeatherState.LocationKey,
WeatherLocationName = _weatherLocationName, WeatherLocationName = latestWeatherState.LocationName,
WeatherLatitude = _weatherLatitude, WeatherLatitude = latestWeatherState.Latitude,
WeatherLongitude = _weatherLongitude, WeatherLongitude = latestWeatherState.Longitude,
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation, WeatherAutoRefreshLocation = latestWeatherState.AutoRefreshLocation,
WeatherExcludedAlerts = _weatherExcludedAlertsRaw, WeatherLocationQuery = latestWeatherState.LocationQuery,
WeatherIconPackId = _weatherIconPackId, WeatherExcludedAlerts = latestWeatherState.ExcludedAlerts,
WeatherNoTlsRequests = _weatherNoTlsRequests, WeatherIconPackId = latestWeatherState.IconPackId,
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
AutoStartWithWindows = _autoStartWithWindows, AutoStartWithWindows = _autoStartWithWindows,
AppRenderMode = _selectedAppRenderMode, AppRenderMode = _selectedAppRenderMode,
TopStatusComponentIds = [.. _topStatusComponentIds], TopStatusComponentIds = [.. _topStatusComponentIds],

View File

@@ -95,7 +95,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private readonly ComponentRegistry _componentRegistry; private readonly ComponentRegistry _componentRegistry;
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry; private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
private readonly DesktopComponentEditorRegistry _componentEditorRegistry;
private readonly IComponentLibraryService _componentLibraryService; private readonly IComponentLibraryService _componentLibraryService;
private readonly IComponentEditorWindowService _componentEditorWindowService;
private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService(); private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService();
private ComponentLibraryWindow? _detachedComponentLibraryWindow; private ComponentLibraryWindow? _detachedComponentLibraryWindow;
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
@@ -164,7 +166,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private double _weatherLongitude = 116.4074; private double _weatherLongitude = 116.4074;
private bool _weatherAutoRefreshLocation; private bool _weatherAutoRefreshLocation;
private string _weatherExcludedAlertsRaw = string.Empty; private string _weatherExcludedAlertsRaw = string.Empty;
private string _weatherIconPackId = "FluentRegular"; private string _weatherIconPackId = "HyperOS3";
private bool _weatherNoTlsRequests; private bool _weatherNoTlsRequests;
private bool _autoStartWithWindows; private bool _autoStartWithWindows;
private bool _suppressAutoStartToggleEvents; private bool _suppressAutoStartToggleEvents;
@@ -197,7 +199,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
_componentRegistry, _componentRegistry,
pluginRuntimeService, pluginRuntimeService,
_settingsFacade); _settingsFacade);
_componentEditorRegistry = DesktopComponentEditorRegistryFactory.Create(
_componentRegistry,
pluginRuntimeService);
_componentLibraryService = new ComponentLibraryService(_componentRegistry, _componentRuntimeRegistry); _componentLibraryService = new ComponentLibraryService(_componentRegistry, _componentRuntimeRegistry);
_componentEditorWindowService = new ComponentEditorWindowService(_settingsFacade);
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault(); _fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
_settingsService.Changed += OnSettingsChanged; _settingsService.Changed += OnSettingsChanged;
PropertyChanged += OnWindowPropertyChanged; PropertyChanged += OnWindowPropertyChanged;
@@ -307,6 +313,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
PersistSettings(); PersistSettings();
_componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null) if (_detachedComponentLibraryWindow is not null)
{ {
_detachedComponentLibraryWindow.AddComponentRequested -= OnDetachedComponentLibraryAddComponentRequested; _detachedComponentLibraryWindow.AddComponentRequested -= OnDetachedComponentLibraryAddComponentRequested;

View File

@@ -0,0 +1,97 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
x:DataType="vm:LauncherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="18">
<Border Classes="settings-section-card-icon-host"
Width="72"
Height="72"
Padding="10">
<fi:SymbolIcon Symbol="Apps"
FontSize="34"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Text="{Binding LauncherHeader}" />
<TextBlock Classes="settings-card-description"
Text="{Binding LauncherSubtitle}" />
<TextBlock Classes="settings-item-description"
Margin="0,10,0,0"
Text="{Binding HiddenHint}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Grid.Column="2"
Spacing="4"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock Classes="settings-section-title"
FontSize="28"
HorizontalAlignment="Right"
Margin="0"
Text="{Binding HiddenCountText}" />
<TextBlock Classes="settings-item-description"
HorizontalAlignment="Right"
Text="{Binding HiddenSummary}" />
</StackPanel>
</Grid>
</Border>
<controls:IconText Icon="Apps"
Text="{Binding HiddenHeader}"
Margin="0,0,0,4" />
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding HiddenHeader}"
Description="{Binding HiddenDescription}"
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Apps" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsHiddenItemsEmpty}"
Margin="0,0,0,4"
Text="{Binding HiddenEmptyText}"
TextWrapping="Wrap" />
<ItemsControl ItemsSource="{Binding HiddenItems}"
IsVisible="{Binding HasHiddenItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LauncherHiddenItemViewModel">
<ui:SettingsExpanderItem Content="{Binding DisplayName}"
Description="{Binding TypeLabel}"
IsClickEnabled="False">
<ui:SettingsExpanderItem.IconSource>
<fi:SymbolIconSource Symbol="{Binding IconSymbol}" />
</ui:SettingsExpanderItem.IconSource>
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding RestoreCommand}"
Content="{Binding RestoreButtonText}"
VerticalAlignment="Center" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,31 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo(
"launcher",
"App Launcher",
SettingsPageCategory.Components,
IconKey = "Apps",
SortOrder = 10,
Scope = SettingsScope.Launcher,
TitleLocalizationKey = "settings.launcher.title",
DescriptionLocalizationKey = "settings.launcher.description")]
public partial class LauncherSettingsPage : SettingsPageBase
{
public LauncherSettingsPage()
: this(new LauncherSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
{
}
public LauncherSettingsPage(LauncherSettingsPageViewModel viewModel)
{
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
}
public LauncherSettingsPageViewModel ViewModel { get; }
}

View File

@@ -0,0 +1,263 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:models="using:LanMountainDesktop.Models"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
x:DataType="vm:WeatherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
<Border Classes="settings-section-card-icon-host"
Width="72"
Height="72"
Padding="10">
<Image Source="{Binding PreviewIcon}"
Stretch="Uniform" />
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Text="{Binding PreviewHeader}" />
<TextBlock Classes="settings-card-description"
Text="{Binding PreviewDescription}" />
<TextBlock Classes="settings-section-title"
FontSize="24"
Margin="0,10,0,0"
Text="{Binding PreviewTemperature}" />
<TextBlock Classes="settings-item-label"
Text="{Binding PreviewLocation}" />
<TextBlock Classes="settings-item-description"
Text="{Binding PreviewCondition}" />
<TextBlock Classes="settings-item-description"
Text="{Binding PreviewUpdated}" />
<TextBlock Classes="settings-item-description"
Text="{Binding PreviewStatus}" />
</StackPanel>
<StackPanel Grid.Column="2"
Spacing="12"
VerticalAlignment="Center">
<Button Classes="settings-accent-button"
Command="{Binding RefreshPreviewCommand}"
Content="{Binding RefreshButtonText}" />
<ui:ProgressRing IsIndeterminate="True"
IsVisible="{Binding IsRefreshingPreview}"
Width="28"
Height="28"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding LocationSourceHeader}"
Description="{Binding LocationSourceDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="WeatherMoon" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding LocationModes}"
SelectedItem="{Binding SelectedLocationMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<TextBlock Classes="settings-item-description"
Text="{Binding CurrentLocationSummary}"
TextWrapping="Wrap" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding CitySearchHeader}"
Description="{Binding CitySearchDescription}"
IsVisible="{Binding IsCitySearchMode}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Search" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<Button Classes="settings-accent-button"
Command="{Binding ApplyCitySelectionCommand}"
Content="{Binding ApplyCityButtonText}" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBox Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}" />
<Button Grid.Column="1"
Command="{Binding SearchCommand}"
Content="{Binding SearchButtonText}" />
</Grid>
<ui:ProgressRing IsIndeterminate="True"
IsVisible="{Binding IsSearching}"
Width="24"
Height="24"
HorizontalAlignment="Left" />
<TextBlock Classes="settings-item-description"
Text="{Binding SearchStatus}"
TextWrapping="Wrap" />
<ListBox Classes="weather-settings-search-results"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:WeatherLocation">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<fi:SymbolIcon Classes="icon-s"
Margin="0,2,0,0"
Symbol="City" />
<StackPanel Grid.Column="1"
Spacing="4">
<TextBlock Classes="settings-item-label"
Text="{Binding Name}" />
<TextBlock Classes="settings-item-description"
Text="{Binding Affiliation}" />
<TextBlock Classes="settings-item-description"
Text="{Binding LocationKey}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding CoordinatesHeader}"
Description="{Binding CoordinatesDescription}"
IsVisible="{Binding IsCoordinatesMode}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Location" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<Button Classes="settings-accent-button"
Command="{Binding ApplyCoordinatesCommand}"
Content="{Binding ApplyCoordinatesButtonText}" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,*"
Classes="settings-inline-pair">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding LatitudeLabel}" />
<NumericUpDown Minimum="-90"
Maximum="90"
Increment="0.0001"
FormatString="F4"
Value="{Binding Latitude}" />
</StackPanel>
<StackPanel Grid.Column="1"
Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding LongitudeLabel}" />
<NumericUpDown Minimum="-180"
Maximum="180"
Increment="0.0001"
FormatString="F4"
Value="{Binding Longitude}" />
</StackPanel>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding LocationServicesHeader}"
Description="{Binding LocationServicesDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Location" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<Button Classes="settings-accent-button"
Command="{Binding UseCurrentLocationCommand}"
Content="{Binding UseCurrentLocationButtonText}"
IsVisible="{Binding IsLocationSupported}" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding AutoRefreshLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding LocationActionStatus}"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding AutoRefreshLocation}"
IsEnabled="{Binding IsLocationSupported}" />
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem IsVisible="{Binding IsRefreshingLocation}">
<ui:ProgressRing IsIndeterminate="True"
IsVisible="{Binding IsRefreshingLocation}"
Width="28"
Height="28"
HorizontalAlignment="Left" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AlertFilterHeader}"
Description="{Binding AlertFilterDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Warning" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<TextBox Width="360"
MinHeight="120"
AcceptsReturn="True"
TextWrapping="Wrap"
Text="{Binding ExcludedAlerts}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding RequestHeader}"
Description="{Binding RequestDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ShieldDismiss" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding NoTlsRequests}" />
</ui:SettingsExpander.Footer>
<ui:SettingsExpanderItem>
<TextBlock Classes="settings-item-description"
Text="{Binding NoTlsToggleText}"
TextWrapping="Wrap" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<TextBlock Classes="settings-item-description"
Margin="0,8,0,0"
Text="{Binding FooterHint}"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,47 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo(
"weather",
"Weather",
SettingsPageCategory.Appearance,
IconKey = "WeatherMoon",
SortOrder = 18,
TitleLocalizationKey = "settings.weather.title",
DescriptionLocalizationKey = "settings.weather.description")]
public partial class WeatherSettingsPage : SettingsPageBase
{
public WeatherSettingsPage()
: this(CreateDefaultViewModel())
{
}
public WeatherSettingsPage(WeatherSettingsPageViewModel viewModel)
{
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
}
public WeatherSettingsPageViewModel ViewModel { get; }
private static WeatherSettingsPageViewModel CreateDefaultViewModel()
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService();
var locationService = HostLocationServiceProvider.GetOrCreate();
var weatherLocationRefreshService = new WeatherLocationRefreshService(
settingsFacade,
locationService,
localizationService);
return new WeatherSettingsPageViewModel(
settingsFacade,
localizationService,
locationService,
weatherLocationRefreshService);
}
}

View File

@@ -634,6 +634,8 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
{ {
"DesignIdeas" => Symbol.Color, "DesignIdeas" => Symbol.Color,
"Image" => Symbol.Image, "Image" => Symbol.Image,
"WeatherMoon" => Symbol.WeatherMoon,
"Apps" => Symbol.Apps,
"GridDots" => Symbol.GridDots, "GridDots" => Symbol.GridDots,
"PuzzlePiece" => Symbol.PuzzlePiece, "PuzzlePiece" => Symbol.PuzzlePiece,
"Info" => Symbol.Info, "Info" => Symbol.Info,

View File

@@ -23,6 +23,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
IServiceProvider services, IServiceProvider services,
IReadOnlyList<PluginSettingsSectionRegistration> settingsSections, IReadOnlyList<PluginSettingsSectionRegistration> settingsSections,
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents, IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
IReadOnlyList<PluginServiceExportDescriptor> exportedServices, IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
IReadOnlyList<IHostedService> hostedServices, IReadOnlyList<IHostedService> hostedServices,
PluginLoadContext loadContext) PluginLoadContext loadContext)
@@ -36,6 +37,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
Services = services; Services = services;
SettingsSections = settingsSections; SettingsSections = settingsSections;
DesktopComponents = desktopComponents; DesktopComponents = desktopComponents;
DesktopComponentEditors = desktopComponentEditors;
ExportedServices = exportedServices; ExportedServices = exportedServices;
HostedServices = hostedServices; HostedServices = hostedServices;
LoadContext = loadContext; LoadContext = loadContext;
@@ -61,6 +63,8 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; } public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
public IReadOnlyList<PluginDesktopComponentEditorRegistration> DesktopComponentEditors { get; }
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; } public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
public PluginLoadContext LoadContext { get; } public PluginLoadContext LoadContext { get; }

View File

@@ -10,3 +10,7 @@ public sealed record PluginSettingsSectionContribution(
public sealed record PluginDesktopComponentContribution( public sealed record PluginDesktopComponentContribution(
LoadedPlugin Plugin, LoadedPlugin Plugin,
PluginDesktopComponentRegistration Registration); PluginDesktopComponentRegistration Registration);
public sealed record PluginDesktopComponentEditorContribution(
LoadedPlugin Plugin,
PluginDesktopComponentEditorRegistration Registration);

View File

@@ -181,10 +181,14 @@ public sealed class PluginLoader
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase) .OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase) .ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
var desktopComponentEditors = pluginServices
.GetServices<PluginDesktopComponentEditorRegistration>()
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
.ToArray();
var exportedServices = ResolveExports(manifest, pluginServices); var exportedServices = ResolveExports(manifest, pluginServices);
AppLogger.Info( AppLogger.Info(
"PluginLoader", "PluginLoader",
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Exports={exportedServices.Count}."); $"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}.");
hostedServices = pluginServices.GetServices<IHostedService>().ToArray(); hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
StartHostedServices(hostedServices); StartHostedServices(hostedServices);
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}."); AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
@@ -199,6 +203,7 @@ public sealed class PluginLoader
pluginServices, pluginServices,
settingsSections, settingsSections,
desktopComponents, desktopComponents,
desktopComponentEditors,
exportedServices, exportedServices,
hostedServices, hostedServices,
loadContext); loadContext);

View File

@@ -36,6 +36,7 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List<PluginCatalogEntry> _catalog = []; private readonly List<PluginCatalogEntry> _catalog = [];
private readonly List<PluginSettingsSectionContribution> _settingsSections = []; private readonly List<PluginSettingsSectionContribution> _settingsSections = [];
private readonly List<PluginDesktopComponentContribution> _desktopComponents = []; private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
private readonly object _packageMutationGate = new(); private readonly object _packageMutationGate = new();
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null) public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
@@ -73,6 +74,7 @@ public sealed class PluginRuntimeService : IDisposable
public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections => _settingsSections; public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections => _settingsSections;
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents; public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
public IReadOnlyList<PluginDesktopComponentEditorContribution> DesktopComponentEditors => _desktopComponentEditors;
public IPluginExportRegistry ExportRegistry => _exportRegistry; public IPluginExportRegistry ExportRegistry => _exportRegistry;
@@ -193,7 +195,7 @@ public sealed class PluginRuntimeService : IDisposable
loadResult.LoadedPlugin.DesktopComponents.Count)); loadResult.LoadedPlugin.DesktopComponents.Count));
AppLogger.Info( AppLogger.Info(
"PluginRuntime", "PluginRuntime",
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}."); $"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'."); Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
continue; continue;
} }
@@ -622,6 +624,10 @@ public sealed class PluginRuntimeService : IDisposable
entry.Plugin.Manifest.Id, entry.Plugin.Manifest.Id,
loadedPlugin.Manifest.Id, loadedPlugin.Manifest.Id,
StringComparison.OrdinalIgnoreCase)); StringComparison.OrdinalIgnoreCase));
_desktopComponentEditors.RemoveAll(entry => string.Equals(
entry.Plugin.Manifest.Id,
loadedPlugin.Manifest.Id,
StringComparison.OrdinalIgnoreCase));
foreach (var settingsSection in loadedPlugin.SettingsSections) foreach (var settingsSection in loadedPlugin.SettingsSections)
{ {
@@ -632,6 +638,11 @@ public sealed class PluginRuntimeService : IDisposable
{ {
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent)); _desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
} }
foreach (var desktopComponentEditor in loadedPlugin.DesktopComponentEditors)
{
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
}
} }
private void RegisterSharedContractsForLoad(PluginManifest manifest) private void RegisterSharedContractsForLoad(PluginManifest manifest)