mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
915739ff7b | ||
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 | ||
|
|
081abeb688 | ||
|
|
594a62132f | ||
|
|
15e589aedd | ||
|
|
ac4617f5cf | ||
|
|
0645598753 |
16
.codex/environments/environment.toml
Normal file
16
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "LanMountainDesktop"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
|
||||
|
||||
[[actions]]
|
||||
name = "构建"
|
||||
icon = "tool"
|
||||
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
|
||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.0.0</Version>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.Appearance;
|
||||
|
||||
public static class AppearanceCornerRadiusTokenFactory
|
||||
{
|
||||
public static AppearanceCornerRadiusTokens Create(double scale)
|
||||
{
|
||||
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
|
||||
return new AppearanceCornerRadiusTokens(
|
||||
Radius(6, normalizedScale),
|
||||
Radius(10, normalizedScale),
|
||||
Radius(14, normalizedScale),
|
||||
Radius(18, normalizedScale),
|
||||
Radius(24, normalizedScale),
|
||||
Radius(30, normalizedScale),
|
||||
Radius(36, normalizedScale));
|
||||
}
|
||||
|
||||
private static CornerRadius Radius(double value, double scale)
|
||||
{
|
||||
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
|
||||
return new CornerRadius(scaled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
27
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
27
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public static class DesktopBootstrap
|
||||
{
|
||||
public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(initializeDeviceId);
|
||||
ArgumentNullException.ThrowIfNull(initializeCrashReporting);
|
||||
ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
|
||||
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
||||
|
||||
initializeDeviceId();
|
||||
initializeCrashReporting();
|
||||
initializeUserBehaviorAnalytics();
|
||||
scheduleStartupCleanup();
|
||||
}
|
||||
|
||||
public static void InitializeApplication(Application application, Action initializeShell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(application);
|
||||
ArgumentNullException.ThrowIfNull(initializeShell);
|
||||
initializeShell();
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class DesktopShellHost : IDesktopShellHost
|
||||
{
|
||||
private readonly Action _initializePluginRuntime;
|
||||
private readonly Action _initializeTrayIcon;
|
||||
private readonly Action<IClassicDesktopStyleApplicationLifetime> _createAndAssignMainWindow;
|
||||
private readonly Action _performExitCleanup;
|
||||
private readonly Action _startActivationListener;
|
||||
private readonly Action _startWeatherRefresh;
|
||||
|
||||
public DesktopShellHost(
|
||||
Action initializePluginRuntime,
|
||||
Action initializeTrayIcon,
|
||||
Action<IClassicDesktopStyleApplicationLifetime> createAndAssignMainWindow,
|
||||
Action performExitCleanup,
|
||||
Action startActivationListener,
|
||||
Action startWeatherRefresh)
|
||||
{
|
||||
_initializePluginRuntime = initializePluginRuntime;
|
||||
_initializeTrayIcon = initializeTrayIcon;
|
||||
_createAndAssignMainWindow = createAndAssignMainWindow;
|
||||
_performExitCleanup = performExitCleanup;
|
||||
_startActivationListener = startActivationListener;
|
||||
_startWeatherRefresh = startWeatherRefresh;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
throw new InvalidOperationException("An application instance is required to initialize the desktop shell.");
|
||||
}
|
||||
|
||||
public void Initialize(Application application)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(application);
|
||||
|
||||
_initializePluginRuntime();
|
||||
_initializeTrayIcon();
|
||||
|
||||
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Exit += (_, _) => _performExitCleanup();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
_startActivationListener();
|
||||
}
|
||||
|
||||
_startWeatherRefresh();
|
||||
}
|
||||
}
|
||||
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class DesktopStartupCoordinator
|
||||
{
|
||||
private readonly Action _restoreWorkspaceState;
|
||||
|
||||
public DesktopStartupCoordinator(Action restoreWorkspaceState)
|
||||
{
|
||||
_restoreWorkspaceState = restoreWorkspaceState ?? throw new ArgumentNullException(nameof(restoreWorkspaceState));
|
||||
}
|
||||
|
||||
public void Restore() => _restoreWorkspaceState();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class SettingsWindowHost
|
||||
{
|
||||
private readonly Action<string, string?> _openSettingsWindow;
|
||||
|
||||
public SettingsWindowHost(Action<string, string?> openSettingsWindow)
|
||||
{
|
||||
_openSettingsWindow = openSettingsWindow ?? throw new ArgumentNullException(nameof(openSettingsWindow));
|
||||
}
|
||||
|
||||
public void Open(string source, string? pageId = null)
|
||||
{
|
||||
_openSettingsWindow(source, pageId);
|
||||
}
|
||||
}
|
||||
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class ShutdownCoordinator
|
||||
{
|
||||
private readonly Action<bool, string> _prepareForShutdown;
|
||||
private readonly Action<string> _resetShutdownIntent;
|
||||
|
||||
public ShutdownCoordinator(Action<bool, string> prepareForShutdown, Action<string> resetShutdownIntent)
|
||||
{
|
||||
_prepareForShutdown = prepareForShutdown ?? throw new ArgumentNullException(nameof(prepareForShutdown));
|
||||
_resetShutdownIntent = resetShutdownIntent ?? throw new ArgumentNullException(nameof(resetShutdownIntent));
|
||||
}
|
||||
|
||||
public void Prepare(bool isRestart, string source) => _prepareForShutdown(isRestart, source);
|
||||
|
||||
public void Reset(string source) => _resetShutdownIntent(source);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
public sealed record ComponentChromeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
SettingsScope Scope = SettingsScope.App);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
public interface IDesktopShellHost
|
||||
{
|
||||
void Initialize();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
@@ -11,6 +13,8 @@ public sealed class PluginDesktopComponentContext
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize,
|
||||
double globalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens cornerRadiusTokens,
|
||||
IPluginSettingsService? pluginSettings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
@@ -19,6 +23,7 @@ public sealed class PluginDesktopComponentContext
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
ArgumentNullException.ThrowIfNull(cornerRadiusTokens);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
@@ -28,6 +33,8 @@ public sealed class PluginDesktopComponentContext
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
GlobalCornerRadiusScale = Math.Max(0.1d, globalCornerRadiusScale);
|
||||
CornerRadiusTokens = cornerRadiusTokens;
|
||||
PluginSettings = pluginSettings;
|
||||
}
|
||||
|
||||
@@ -47,8 +54,22 @@ public sealed class PluginDesktopComponentContext
|
||||
|
||||
public double CellSize { get; }
|
||||
|
||||
public double GlobalCornerRadiusScale { get; }
|
||||
|
||||
public AppearanceCornerRadiusTokens CornerRadiusTokens { get; }
|
||||
|
||||
public IPluginSettingsService? PluginSettings { get; }
|
||||
|
||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
var scaled = Math.Max(0d, baseRadius) * GlobalCornerRadiusScale;
|
||||
var scaledMin = minimum.HasValue ? minimum.Value * GlobalCornerRadiusScale : scaled;
|
||||
var scaledMax = maximum.HasValue ? maximum.Value * GlobalCornerRadiusScale : scaled;
|
||||
return minimum.HasValue || maximum.HasValue
|
||||
? Math.Clamp(scaled, scaledMin, scaledMax)
|
||||
: scaled;
|
||||
}
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
|
||||
20
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
20
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace LanMountainDesktop.Settings.Core;
|
||||
|
||||
public static class GlobalAppearanceSettings
|
||||
{
|
||||
public const double DefaultCornerRadiusScale = 1.0;
|
||||
public const double MinimumCornerRadiusScale = 0.70;
|
||||
public const double MaximumCornerRadiusScale = 1.40;
|
||||
public const double CornerRadiusScaleStep = 0.05;
|
||||
|
||||
public static double NormalizeCornerRadiusScale(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return DefaultCornerRadiusScale;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
|
||||
return Math.Round(clamped / CornerRadiusScaleStep, MidpointRounding.AwayFromZero) * CornerRadiusScaleStep;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
public sealed record AppearanceCornerRadiusTokens(
|
||||
CornerRadius Micro,
|
||||
CornerRadius Xs,
|
||||
CornerRadius Sm,
|
||||
CornerRadius Md,
|
||||
CornerRadius Lg,
|
||||
CornerRadius Xl,
|
||||
CornerRadius Island);
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardNotePersistenceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
var snapshot = CreateSampleSnapshot();
|
||||
|
||||
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
|
||||
|
||||
Assert.Single(loaded.Strokes);
|
||||
Assert.Equal(2, loaded.Strokes[0].Points.Count);
|
||||
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
|
||||
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
|
||||
|
||||
Assert.Empty(loaded.Strokes);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
|
||||
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
|
||||
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
|
||||
|
||||
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
|
||||
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
|
||||
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
|
||||
|
||||
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
|
||||
|
||||
Assert.Equal(2, deletedCount);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
|
||||
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
|
||||
}
|
||||
|
||||
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
|
||||
{
|
||||
return new WhiteboardNoteSnapshot
|
||||
{
|
||||
Strokes =
|
||||
[
|
||||
new WhiteboardStrokeSnapshot
|
||||
{
|
||||
Color = "#FF112233",
|
||||
InkThickness = 3.5d,
|
||||
IgnorePressure = true,
|
||||
Points =
|
||||
[
|
||||
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
|
||||
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
|
||||
{
|
||||
private readonly string _directoryPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.WhiteboardNoteTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly string _databasePath;
|
||||
|
||||
public WhiteboardNotePersistenceSandbox()
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
|
||||
}
|
||||
|
||||
public WhiteboardNotePersistenceService CreateService()
|
||||
{
|
||||
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
|
||||
}
|
||||
|
||||
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
|
||||
{
|
||||
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE whiteboard_notes
|
||||
SET saved_at_utc_ms = $savedAtUtcMs,
|
||||
expires_at_utc_ms = $expiresAtUtcMs,
|
||||
updated_at_utc_ms = $updatedAtUtcMs
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$componentId", componentId);
|
||||
command.Parameters.AddWithValue("$placementId", placementId);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public bool Exists(string componentId, string placementId)
|
||||
{
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT COUNT(1)
|
||||
FROM whiteboard_notes
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$componentId", componentId);
|
||||
command.Parameters.AddWithValue("$placementId", placementId);
|
||||
return Convert.ToInt32(command.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_directoryPath))
|
||||
{
|
||||
Directory.Delete(_directoryPath, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Temporary test directories are best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
<Solution>
|
||||
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
|
||||
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
|
||||
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
||||
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
|
||||
@@ -15,6 +15,7 @@ using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -61,6 +62,7 @@ public partial class App : Application
|
||||
private MainWindow? _mainWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||
@@ -116,28 +118,32 @@ public partial class App : Application
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
InitializeTrayIcon();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
desktop.Exit += (_, _) =>
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void InitializeDesktopShell()
|
||||
{
|
||||
_desktopShellHost ??= new DesktopShellHost(
|
||||
InitializePluginRuntime,
|
||||
InitializeTrayIcon,
|
||||
desktop =>
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
},
|
||||
() =>
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
};
|
||||
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
StartWeatherLocationRefreshIfNeeded();
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
},
|
||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||
StartWeatherLocationRefreshIfNeeded);
|
||||
_desktopShellHost.Initialize(this);
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
@@ -493,6 +499,7 @@ public partial class App : Application
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -510,6 +517,8 @@ public partial class App : Application
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
// 清除本地化缓存,强制重新加载语言文件
|
||||
_localizationService.ClearCache();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LanMountainDesktop 隐私政策
|
||||
|
||||
**最后更新日期:2024年**
|
||||
**最后更新日期:2026年3月17日**
|
||||
|
||||
---
|
||||
|
||||
@@ -321,6 +321,6 @@ a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
|
||||
---
|
||||
|
||||
**感谢您信任 LanMountainDesktop!**
|
||||
**感谢您信任阑山桌面LanMountainDesktop!**
|
||||
|
||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
||||
|
||||
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
}
|
||||
|
||||
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopRemovableStorage,
|
||||
"Removable Storage",
|
||||
"Storage",
|
||||
"File",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Date,
|
||||
"Calendar",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
@@ -10,5 +11,6 @@ public sealed record DesktopComponentRuntimeContext(
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
ISettingsService SettingsService,
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
ComponentChromeContext Chrome,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentChromeContextAware
|
||||
{
|
||||
void SetComponentChromeContext(ComponentChromeContext context);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -21,12 +21,20 @@
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<AvaloniaResource Include="Localization\**" />
|
||||
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
|
||||
<EmbeddedResource Include="Localization\*.json" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
@@ -53,6 +61,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
||||
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
"tray.menu.restart": "Restart App",
|
||||
"tray.menu.exit": "Exit App",
|
||||
"button.back_to_windows": "Back to Windows",
|
||||
"button.back_to_platform": "Back to {0}",
|
||||
"tooltip.back_to_windows": "Back to Windows",
|
||||
"tooltip.back_to_platform": "Back to {0}",
|
||||
"platform.windows": "Windows",
|
||||
"platform.linux": "Linux",
|
||||
"platform.macos": "macOS",
|
||||
"tooltip.open_settings": "Settings",
|
||||
"settings.title": "Settings",
|
||||
"settings.shell.title": "Settings",
|
||||
@@ -20,7 +25,7 @@
|
||||
"settings.nav.group_system": "System",
|
||||
"settings.nav.group_extensions": "Extensions",
|
||||
"settings.nav.wallpaper": "Wallpaper",
|
||||
"settings.nav.grid": "Grid",
|
||||
"settings.nav.grid": "Components",
|
||||
"settings.nav.color": "Color",
|
||||
"settings.nav.status_bar": "Status Bar",
|
||||
"settings.nav.weather": "Weather",
|
||||
@@ -86,6 +91,8 @@
|
||||
"settings.status_bar.description": "Choose which components appear on the top status bar.",
|
||||
"settings.status_bar.clock_header": "Clock Component",
|
||||
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
|
||||
"settings.status_bar.clock_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
|
||||
"settings.status_bar.spacing_header": "Component Spacing",
|
||||
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
|
||||
"settings.status_bar.spacing_mode_compact": "Compact",
|
||||
@@ -296,8 +303,17 @@
|
||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||
"settings.components.title": "Components",
|
||||
"settings.components.description": "Adjust desktop grid density and widget placement.",
|
||||
"settings.components.grid_header": "Grid Layout",
|
||||
"settings.components.description": "Adjust component layout and corner design.",
|
||||
"settings.components.grid_header": "Grid Settings",
|
||||
"settings.components.header": "Grid Settings",
|
||||
"settings.components.short_side_label": "Short Side Cells",
|
||||
"settings.components.edge_inset_label": "Screen Inset",
|
||||
"settings.components.spacing_label": "Component Spacing",
|
||||
"settings.components.spacing_compact": "Compact",
|
||||
"settings.components.spacing_relaxed": "Relaxed",
|
||||
"settings.components.corner_radius.header": "Corner Design",
|
||||
"settings.components.corner_radius.label": "Component Corner Radius",
|
||||
"settings.components.corner_radius.description": "Adjust the shared corner radius used by component containers, and expand the internal safe area with it.",
|
||||
"settings.update.title": "Update",
|
||||
"settings.update.current_version_label": "Current Version",
|
||||
"settings.update.latest_version_label": "Latest Release",
|
||||
@@ -403,6 +419,7 @@
|
||||
"common.monet": "Monet",
|
||||
"desktop.page_index_format": "Desktop {0}",
|
||||
"launcher.title": "App Launcher",
|
||||
"launcher.folder": "Folder",
|
||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
||||
"launcher.empty": "No Start Menu entries found.",
|
||||
@@ -559,6 +576,7 @@
|
||||
"component_category.info": "Info",
|
||||
"component_category.calculator": "Calculator",
|
||||
"component_category.study": "Study",
|
||||
"component_category.file": "File",
|
||||
"component.date": "Calendar",
|
||||
"component.month_calendar": "Month Calendar",
|
||||
"component.lunar_calendar": "Lunar Calendar",
|
||||
@@ -587,6 +605,19 @@
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
"component.office_recent_documents": "Recent Documents",
|
||||
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
|
||||
"whiteboard.settings.retention.title": "Note retention",
|
||||
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
|
||||
"whiteboard.settings.retention.option": "{0} days",
|
||||
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
|
||||
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
|
||||
"office_recent_documents.settings.sources_title": "Recent document sources",
|
||||
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
|
||||
"office_recent_documents.settings.source.registry": "Office registry MRU",
|
||||
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
|
||||
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
|
||||
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
|
||||
"component.removable_storage": "Removable Storage",
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
"component.study_environment": "Environment",
|
||||
"component.study_session_control": "Study Session Control",
|
||||
@@ -788,6 +819,20 @@
|
||||
"study.environment.settings.show_display_db": "Show display dB",
|
||||
"study.environment.settings.show_dbfs": "Show dBFS",
|
||||
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
||||
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
|
||||
"removable_storage.settings.behavior_title": "Behavior",
|
||||
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
|
||||
"removable_storage.action.open": "Open",
|
||||
"removable_storage.action.eject": "Eject",
|
||||
"removable_storage.widget.default_name": "Removable Drive",
|
||||
"removable_storage.widget.empty_title": "No device inserted",
|
||||
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
|
||||
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
|
||||
"removable_storage.widget.ready": "Ready to open or eject.",
|
||||
"removable_storage.widget.ejecting": "Ejecting drive...",
|
||||
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
|
||||
"removable_storage.widget.open_failed": "Failed to open this drive.",
|
||||
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
|
||||
"study.session_control.action.start": "Start Study Session",
|
||||
"study.session_control.action.stop": "Stop Study Session",
|
||||
"study.session_control.idle_hint": "Tap the right button to start",
|
||||
@@ -892,5 +937,7 @@
|
||||
"placement.tile": "Tile",
|
||||
"single_instance.notice.title": "App already running",
|
||||
"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",
|
||||
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
|
||||
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
"tray.menu.restart": "重启应用",
|
||||
"tray.menu.exit": "退出应用",
|
||||
"button.back_to_windows": "回到Windows",
|
||||
"button.back_to_platform": "回到{0}",
|
||||
"tooltip.back_to_windows": "回到Windows",
|
||||
"tooltip.back_to_platform": "回到{0}",
|
||||
"platform.windows": "Windows",
|
||||
"platform.linux": "Linux",
|
||||
"platform.macos": "macOS",
|
||||
"tooltip.open_settings": "设置",
|
||||
"settings.title": "设置",
|
||||
"settings.shell.title": "设置",
|
||||
@@ -20,7 +25,7 @@
|
||||
"settings.nav.group_system": "系统",
|
||||
"settings.nav.group_extensions": "扩展",
|
||||
"settings.nav.wallpaper": "壁纸",
|
||||
"settings.nav.grid": "网格",
|
||||
"settings.nav.grid": "组件",
|
||||
"settings.nav.color": "颜色",
|
||||
"settings.nav.status_bar": "状态栏",
|
||||
"settings.nav.weather": "天气",
|
||||
@@ -85,6 +90,8 @@
|
||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||
"settings.status_bar.clock_header": "时间组件",
|
||||
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
|
||||
"settings.status_bar.clock_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
|
||||
"settings.status_bar.spacing_header": "组件间距",
|
||||
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
|
||||
"settings.status_bar.spacing_mode_compact": "紧凑",
|
||||
@@ -294,9 +301,18 @@
|
||||
"settings.status_bar.clock_format_label": "时钟格式",
|
||||
"settings.status_bar.clock_format.hm": "时:分",
|
||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||
"settings.components.title": "网格",
|
||||
"settings.components.description": "调整桌面网格与布局。",
|
||||
"settings.components.grid_header": "网格布局",
|
||||
"settings.components.title": "组件",
|
||||
"settings.components.description": "调整组件布局与圆角设计。",
|
||||
"settings.components.grid_header": "网格设置",
|
||||
"settings.components.header": "网格设置",
|
||||
"settings.components.short_side_label": "短边格数",
|
||||
"settings.components.edge_inset_label": "屏幕边距",
|
||||
"settings.components.spacing_label": "组件间距",
|
||||
"settings.components.spacing_compact": "紧凑",
|
||||
"settings.components.spacing_relaxed": "宽松",
|
||||
"settings.components.corner_radius.header": "圆角设计",
|
||||
"settings.components.corner_radius.label": "组件圆角",
|
||||
"settings.components.corner_radius.description": "统一调整组件容器圆角,并随圆角增大同步扩展内部安全区。",
|
||||
"settings.update.title": "更新",
|
||||
"settings.update.current_version_label": "当前版本",
|
||||
"settings.update.latest_version_label": "最新发布",
|
||||
@@ -401,6 +417,7 @@
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
"launcher.title": "应用启动台",
|
||||
"launcher.folder": "文件夹",
|
||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
@@ -557,6 +574,7 @@
|
||||
"component_category.info": "信息推荐",
|
||||
"component_category.calculator": "计算器",
|
||||
"component_category.study": "自习",
|
||||
"component_category.file": "文件",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
"component.lunar_calendar": "农历",
|
||||
@@ -585,6 +603,18 @@
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
"component.office_recent_documents": "最近文档",
|
||||
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
|
||||
"whiteboard.settings.retention.title": "笔记保留时间",
|
||||
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
|
||||
"whiteboard.settings.retention.option": "{0} 天",
|
||||
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
|
||||
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
|
||||
"office_recent_documents.settings.sources_title": "最近文档来源",
|
||||
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
|
||||
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
|
||||
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
|
||||
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
|
||||
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
"component.study_environment": "环境",
|
||||
"component.study_session_control": "自习时段控制",
|
||||
@@ -781,6 +811,21 @@
|
||||
"study.environment.value.unavailable": "--",
|
||||
"study.environment.value.display_format": "{0:F1} dB",
|
||||
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||
"component.removable_storage": "可移动存储",
|
||||
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
|
||||
"removable_storage.settings.behavior_title": "行为",
|
||||
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
|
||||
"removable_storage.action.open": "打开",
|
||||
"removable_storage.action.eject": "弹出",
|
||||
"removable_storage.widget.default_name": "可移动磁盘",
|
||||
"removable_storage.widget.empty_title": "未插入设备",
|
||||
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
|
||||
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
|
||||
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
|
||||
"removable_storage.widget.ejecting": "正在弹出设备...",
|
||||
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
|
||||
"removable_storage.widget.open_failed": "打开该设备失败。",
|
||||
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
|
||||
"study.environment.settings.title": "环境组件设置",
|
||||
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
|
||||
"study.environment.settings.show_display_db": "显示 display dB",
|
||||
@@ -890,5 +935,7 @@
|
||||
"placement.tile": "平铺",
|
||||
"single_instance.notice.title": "应用已经运行",
|
||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||
"single_instance.notice.button": "确定"
|
||||
"single_instance.notice.button": "确定",
|
||||
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
|
||||
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
@@ -16,6 +17,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool UseSystemChrome { get; set; }
|
||||
|
||||
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
|
||||
public string ThemeColorMode { get; set; } = "default_neutral";
|
||||
|
||||
public string SystemMaterialMode { get; set; } = "none";
|
||||
@@ -101,6 +104,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
||||
|
||||
public bool StatusBarClockTransparentBackground { get; set; }
|
||||
|
||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
@@ -58,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public int WhiteboardNoteRetentionDays { get; set; } = 15;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
|
||||
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
@@ -91,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
: [];
|
||||
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
|
||||
? new List<string>(OfficeRecentDocumentsEnabledSources)
|
||||
: null;
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class OfficeRecentDocumentSourceTypes
|
||||
{
|
||||
public const string Registry = "registry";
|
||||
public const string RecentFolders = "recent_folders";
|
||||
public const string JumpLists = "jump_lists";
|
||||
|
||||
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||
[
|
||||
Registry,
|
||||
RecentFolders,
|
||||
JumpLists
|
||||
];
|
||||
|
||||
public static IReadOnlyList<string> DefaultValues => SupportedValues;
|
||||
|
||||
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = values
|
||||
.Select(NormalizeValue)
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0 && useDefaultWhenEmpty)
|
||||
{
|
||||
return DefaultValues;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeValue(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
Registry => Registry,
|
||||
RecentFolders => RecentFolders,
|
||||
JumpLists => JumpLists,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class WhiteboardNoteRetentionPolicy
|
||||
{
|
||||
public const int MinimumDays = 7;
|
||||
public const int MaximumDays = 15;
|
||||
public const int DefaultDays = MaximumDays;
|
||||
|
||||
public static int NormalizeDays(int days)
|
||||
{
|
||||
if (days < MinimumDays)
|
||||
{
|
||||
return MinimumDays;
|
||||
}
|
||||
|
||||
if (days > MaximumDays)
|
||||
{
|
||||
return MaximumDays;
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
}
|
||||
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class WhiteboardNoteSnapshot
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset SavedUtc { get; set; }
|
||||
|
||||
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
|
||||
|
||||
public WhiteboardNoteSnapshot Clone()
|
||||
{
|
||||
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
|
||||
clone.Strokes = Strokes is { Count: > 0 }
|
||||
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
|
||||
: [];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WhiteboardStrokeSnapshot
|
||||
{
|
||||
public string Color { get; set; } = "#FF000000";
|
||||
|
||||
public double InkThickness { get; set; } = 2.5d;
|
||||
|
||||
public bool IgnorePressure { get; set; } = true;
|
||||
|
||||
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
|
||||
|
||||
public WhiteboardStrokeSnapshot Clone()
|
||||
{
|
||||
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
|
||||
clone.Points = Points is { Count: > 0 }
|
||||
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
|
||||
: [];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WhiteboardStylusPointSnapshot
|
||||
{
|
||||
public double X { get; set; }
|
||||
|
||||
public double Y { get; set; }
|
||||
|
||||
public double Pressure { get; set; } = 0.5d;
|
||||
|
||||
public double Width { get; set; }
|
||||
|
||||
public double Height { get; set; }
|
||||
|
||||
public WhiteboardStylusPointSnapshot Clone()
|
||||
{
|
||||
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
@@ -20,9 +21,11 @@ sealed class Program
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
RegisterGlobalExceptionLogging();
|
||||
InitializeDeviceId();
|
||||
InitializeCrashReporting();
|
||||
InitializeUserBehaviorAnalytics();
|
||||
DesktopBootstrap.InitializeStartupServices(
|
||||
InitializeDeviceId,
|
||||
InitializeCrashReporting,
|
||||
InitializeUserBehaviorAnalytics,
|
||||
ScheduleWhiteboardNoteStartupCleanup);
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
@@ -88,6 +91,25 @@ sealed class Program
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ScheduleWhiteboardNoteStartupCleanup()
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
|
||||
if (deletedCount > 0)
|
||||
{
|
||||
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||
{
|
||||
var singleInstance = SingleInstanceService.CreateDefault();
|
||||
|
||||
@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
|
||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||
}
|
||||
|
||||
public AppDatabaseService(string databasePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
{
|
||||
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
|
||||
}
|
||||
|
||||
_databasePath = databasePath;
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_databasePath);
|
||||
|
||||
@@ -11,9 +11,12 @@ using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.Media.Imaging;
|
||||
using LanMountainDesktop.Appearance;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using LanMountainDesktop.Theme;
|
||||
using Microsoft.Win32;
|
||||
|
||||
@@ -41,6 +44,8 @@ public sealed record AppearanceThemeSnapshot(
|
||||
string ThemeColorMode,
|
||||
string? UserThemeColor,
|
||||
string? SelectedWallpaperSeed,
|
||||
double GlobalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
string ResolvedSeedSource,
|
||||
MonetPalette MonetPalette,
|
||||
Color AccentColor,
|
||||
@@ -464,6 +469,13 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
var context = CreateThemeContext(snapshot);
|
||||
ThemeColorSystemService.ApplyThemeResources(resources, context);
|
||||
GlassEffectService.ApplyGlassResources(resources, context);
|
||||
resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro;
|
||||
resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs;
|
||||
resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm;
|
||||
resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md;
|
||||
resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
|
||||
resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
|
||||
resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
|
||||
}
|
||||
|
||||
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
|
||||
@@ -538,6 +550,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
if (!refreshAll &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
|
||||
!(respondsToThemeColor &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||
!(respondsToWallpaper &&
|
||||
@@ -559,6 +572,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
bool queueWallpaperPaletteBuild)
|
||||
{
|
||||
var availableModes = _windowMaterialService.GetAvailableModes();
|
||||
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
|
||||
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
|
||||
MonetPalette palette;
|
||||
IReadOnlyList<Color> wallpaperSeedCandidates;
|
||||
Color effectiveSeedColor;
|
||||
@@ -598,6 +613,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
themeColorMode,
|
||||
themeState.ThemeColor,
|
||||
selectedWallpaperSeed,
|
||||
globalCornerRadiusScale,
|
||||
cornerRadiusTokens,
|
||||
resolvedSeedSource,
|
||||
palette,
|
||||
ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
|
||||
|
||||
@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
|
||||
public void DeleteForComponent(string componentId, string? placementId)
|
||||
{
|
||||
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
|
||||
|
||||
if (_settingsService is not null)
|
||||
{
|
||||
_settingsService.SaveSnapshot(
|
||||
|
||||
@@ -72,6 +72,18 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
||||
BuiltInComponentIds.DesktopStudyEnvironment,
|
||||
context => new StudyEnvironmentComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopRemovableStorage] = new(
|
||||
BuiltInComponentIds.DesktopRemovableStorage,
|
||||
context => new RemovableStorageComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopWhiteboard] = new(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
context => new WhiteboardComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
|
||||
BuiltInComponentIds.DesktopBlackboardLandscape,
|
||||
context => new WhiteboardComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
|
||||
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||
context => new OfficeRecentDocumentsComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||
|
||||
@@ -122,6 +122,7 @@ public static class DesktopComponentRegistryFactory
|
||||
var pluginSettings = new PluginScopedSettingsService(
|
||||
contribution.Plugin.Manifest.Id,
|
||||
settingsService);
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
@@ -131,6 +132,8 @@ public static class DesktopComponentRegistryFactory
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens,
|
||||
pluginSettings);
|
||||
|
||||
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record ComponentLibraryCategoryEntry(
|
||||
|
||||
public sealed record ComponentLibraryCreateContext(
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IWhiteboardNotePersistenceService
|
||||
{
|
||||
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
|
||||
|
||||
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||
|
||||
bool DeleteNote(string componentId, string? placementId);
|
||||
|
||||
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
|
||||
|
||||
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
|
||||
|
||||
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -16,6 +17,23 @@ public sealed class LocalizationService
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 清除指定语言代码的缓存,强制下次重新加载。
|
||||
/// 在语言切换时调用此方法以确保加载最新的语言文件。
|
||||
/// </summary>
|
||||
public void ClearCache(string? languageCode = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(languageCode))
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedCode = NormalizeLanguageCode(languageCode);
|
||||
_cache.Remove(normalizedCode);
|
||||
}
|
||||
}
|
||||
|
||||
public string NormalizeLanguageCode(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -42,14 +60,17 @@ public sealed class LocalizationService
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
|
||||
if (File.Exists(filePath))
|
||||
var json = TryLoadFromFileSystem(languageCode);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
json = TryLoadFromEmbeddedResource(languageCode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
|
||||
json = json.TrimStart('\uFEFF');
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
if (data is not null)
|
||||
if (data is not null && data.Count > 0)
|
||||
{
|
||||
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -60,7 +81,48 @@ public sealed class LocalizationService
|
||||
// Keep empty table for resilience.
|
||||
}
|
||||
|
||||
_cache[languageCode] = result;
|
||||
// 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
|
||||
if (result.Count > 0)
|
||||
{
|
||||
_cache[languageCode] = result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string? TryLoadFromFileSystem(string languageCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return File.ReadAllText(filePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to next method
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? TryLoadFromEmbeddedResource(string languageCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream != null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to next method
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using Microsoft.Win32;
|
||||
using MudTools.OfficeInterop;
|
||||
using MudTools.OfficeInterop.Excel;
|
||||
using MudTools.OfficeInterop.Word;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IOfficeRecentDocumentsService
|
||||
{
|
||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
|
||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
|
||||
void OpenDocument(string filePath);
|
||||
}
|
||||
|
||||
@@ -24,78 +31,67 @@ public sealed class OfficeRecentDocument
|
||||
public DateTime LastModifiedTime { get; set; }
|
||||
public long FileSizeBytes { get; set; }
|
||||
public string IconGlyph { get; set; } = string.Empty;
|
||||
internal DateTime? RecentAccessTime { get; set; }
|
||||
internal int SourcePriority { get; set; }
|
||||
internal int SourceOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
{
|
||||
private const string LogCategory = "OfficeRecentDocs";
|
||||
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
||||
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
||||
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
||||
private static readonly Regex OfficeFilePathRegex = new(
|
||||
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex OfficeMruTimestampRegex = new(
|
||||
@"\[T(?<filetime>[0-9A-F]+)\]",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
|
||||
{
|
||||
var documents = new List<OfficeRecentDocument>();
|
||||
var recentPaths = GetRecentFolders();
|
||||
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||
enabledSources,
|
||||
useDefaultWhenEmpty: enabledSources is null);
|
||||
|
||||
foreach (var recentPath in recentPaths)
|
||||
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
|
||||
{
|
||||
if (!Directory.Exists(recentPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
||||
foreach (var lnkPath in files)
|
||||
{
|
||||
var targetPath = GetShortcutTarget(lnkPath);
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
|
||||
if (!IsOfficeFile(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (useRegistry)
|
||||
{
|
||||
TryGetFromRegistry(documents);
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (useRecentFolders)
|
||||
{
|
||||
TryGetFromRecentFolders(documents);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(targetPath);
|
||||
var doc = new OfficeRecentDocument
|
||||
{
|
||||
FileName = Path.GetFileNameWithoutExtension(targetPath),
|
||||
FilePath = targetPath,
|
||||
Extension = extension,
|
||||
LastModifiedTime = fileInfo.LastWriteTime,
|
||||
FileSizeBytes = fileInfo.Length,
|
||||
IconGlyph = GetIconGlyph(extension)
|
||||
};
|
||||
if (useJumpLists)
|
||||
{
|
||||
TryGetFromJumpLists(documents);
|
||||
}
|
||||
|
||||
if (!documents.Any(d => d.FilePath == targetPath))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
if (useRegistry && documents.Count < maxCount)
|
||||
{
|
||||
TryGetFromMudToolsInterop(documents);
|
||||
}
|
||||
|
||||
return documents
|
||||
.OrderByDescending(d => d.LastModifiedTime)
|
||||
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(MergeDocuments)
|
||||
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
|
||||
.ThenBy(d => d.SourcePriority)
|
||||
.ThenBy(d => d.SourceOrder)
|
||||
.ThenByDescending(d => d.LastModifiedTime)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
}
|
||||
@@ -109,30 +105,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
|
||||
{
|
||||
var preferred = group
|
||||
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
|
||||
.ThenBy(d => d.SourcePriority)
|
||||
.ThenBy(d => d.SourceOrder)
|
||||
.ThenByDescending(d => d.LastModifiedTime)
|
||||
.First();
|
||||
|
||||
return new OfficeRecentDocument
|
||||
{
|
||||
FileName = preferred.FileName,
|
||||
FilePath = preferred.FilePath,
|
||||
Extension = preferred.Extension,
|
||||
LastModifiedTime = group.Max(d => d.LastModifiedTime),
|
||||
FileSizeBytes = preferred.FileSizeBytes,
|
||||
IconGlyph = preferred.IconGlyph,
|
||||
RecentAccessTime = group
|
||||
.Where(d => d.RecentAccessTime.HasValue)
|
||||
.Select(d => d.RecentAccessTime)
|
||||
.Max(),
|
||||
SourcePriority = preferred.SourcePriority,
|
||||
SourceOrder = preferred.SourceOrder
|
||||
};
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
|
||||
{
|
||||
try
|
||||
{
|
||||
RunOnStaThread(() =>
|
||||
{
|
||||
var sourceOrder = 0;
|
||||
TryGetFromWordInterop(documents, ref sourceOrder);
|
||||
TryGetFromExcelInterop(documents, ref sourceOrder);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||
{
|
||||
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object? application = null;
|
||||
|
||||
try
|
||||
{
|
||||
application = WordFactory.Connection(comObject!);
|
||||
|
||||
if (createdNew)
|
||||
{
|
||||
TrySetProperty(comObject, "Visible", false);
|
||||
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
|
||||
}
|
||||
|
||||
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupOfficeApplication(application, comObject, createdNew);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||
{
|
||||
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object? application = null;
|
||||
|
||||
try
|
||||
{
|
||||
application = ExcelFactory.Connection(comObject!);
|
||||
|
||||
if (createdNew)
|
||||
{
|
||||
TrySetProperty(comObject, "Visible", false);
|
||||
TrySetProperty(application, "DisplayAlerts", false);
|
||||
}
|
||||
|
||||
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupOfficeApplication(application, comObject, createdNew);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddInteropRecentFiles(
|
||||
List<OfficeRecentDocument> documents,
|
||||
object? recentFiles,
|
||||
int sourcePriority,
|
||||
ref int sourceOrder)
|
||||
{
|
||||
if (recentFiles == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = GetIntProperty(recentFiles, "Count");
|
||||
var itemProperty = recentFiles.GetType().GetProperty("Item");
|
||||
if (count <= 0 || itemProperty == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
|
||||
var filePath = GetStringProperty(recentFile, "Path");
|
||||
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore a single malformed MRU entry and keep processing the rest.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
|
||||
{
|
||||
comObject = null;
|
||||
createdNew = false;
|
||||
|
||||
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
|
||||
if (applicationType == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
comObject = Activator.CreateInstance(applicationType);
|
||||
createdNew = comObject != null;
|
||||
return comObject != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (createdNew && application != null)
|
||||
{
|
||||
InvokeParameterlessMethod(application, "Quit");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (application is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
ReleaseComObject(application);
|
||||
if (!ReferenceEquals(application, comObject))
|
||||
{
|
||||
ReleaseComObject(comObject);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static void ReleaseComObject(object? instance)
|
||||
{
|
||||
if (instance == null || !Marshal.IsComObject(instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.FinalReleaseComObject(instance);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetRecentFolders()
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static void RunOnStaThread(Action action)
|
||||
{
|
||||
var folders = new List<string>();
|
||||
Exception? exception = null;
|
||||
using var finished = new ManualResetEventSlim();
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exception = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished.Set();
|
||||
}
|
||||
});
|
||||
|
||||
thread.IsBackground = true;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
finished.Wait();
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
|
||||
if (officeRoot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var versions = officeRoot
|
||||
.GetSubKeyNames()
|
||||
.Where(IsOfficeVersionKey)
|
||||
.OrderByDescending(ParseVersionKey)
|
||||
.ToList();
|
||||
|
||||
var sourceOrder = 0;
|
||||
foreach (var version in versions)
|
||||
{
|
||||
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
|
||||
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
|
||||
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
|
||||
{
|
||||
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
|
||||
|
||||
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
|
||||
if (userMruRoot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var identityKey in userMruRoot.GetSubKeyNames())
|
||||
{
|
||||
TryGetFromRegistryMruKey(
|
||||
documents,
|
||||
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
|
||||
ref sourceOrder);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
|
||||
if (key == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = key
|
||||
.GetValueNames()
|
||||
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(name => new
|
||||
{
|
||||
Name = name,
|
||||
Order = ParseMruItemOrder(name),
|
||||
Value = key.GetValue(name) as string
|
||||
})
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
|
||||
.OrderBy(entry => entry.Order);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
|
||||
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
|
||||
{
|
||||
try
|
||||
{
|
||||
var linkFiles = GetRecentFolders()
|
||||
.Where(Directory.Exists)
|
||||
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
|
||||
.Select(path => new FileInfo(path))
|
||||
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||
.ToList();
|
||||
|
||||
var sourceOrder = 0;
|
||||
foreach (var linkFile in linkFiles)
|
||||
{
|
||||
var targetPath = GetShortcutTarget(linkFile.FullName);
|
||||
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jumpListFiles = GetJumpListFolders()
|
||||
.Where(Directory.Exists)
|
||||
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
|
||||
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
|
||||
.Select(path => new FileInfo(path))
|
||||
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||
.ToList();
|
||||
|
||||
var sourceOrder = 0;
|
||||
foreach (var jumpListFile in jumpListFiles)
|
||||
{
|
||||
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(jumpListFile.FullName);
|
||||
foreach (var filePath in ExtractPossiblePaths(bytes))
|
||||
{
|
||||
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore a single Jump List file and keep scanning the rest.
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
|
||||
{
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var text in new[]
|
||||
{
|
||||
Encoding.Unicode.GetString(bytes),
|
||||
Encoding.Latin1.GetString(bytes)
|
||||
})
|
||||
{
|
||||
foreach (Match match in OfficeFilePathRegex.Matches(text))
|
||||
{
|
||||
var normalizedPath = NormalizeFilePath(match.Value);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
paths.Add(normalizedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private void AddDocumentIfExists(
|
||||
List<OfficeRecentDocument> documents,
|
||||
string? filePath,
|
||||
int sourcePriority,
|
||||
int sourceOrder,
|
||||
DateTime? recentAccessTime)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalizedPath = NormalizeFilePath(filePath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
|
||||
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(normalizedPath);
|
||||
documents.Add(new OfficeRecentDocument
|
||||
{
|
||||
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
|
||||
FilePath = normalizedPath,
|
||||
Extension = extension,
|
||||
LastModifiedTime = fileInfo.LastWriteTime,
|
||||
FileSizeBytes = fileInfo.Length,
|
||||
IconGlyph = GetIconGlyph(extension),
|
||||
RecentAccessTime = recentAccessTime,
|
||||
SourcePriority = sourcePriority,
|
||||
SourceOrder = sourceOrder
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore a single file and keep processing the rest of the MRU list.
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetRecentFolders()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
|
||||
return folders;
|
||||
return new[]
|
||||
{
|
||||
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
|
||||
Path.Combine(appData, "Microsoft", "Word", "Recent"),
|
||||
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
|
||||
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
|
||||
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
|
||||
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
|
||||
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
|
||||
}.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetJumpListFolders()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
|
||||
return new[]
|
||||
{
|
||||
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
|
||||
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
|
||||
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
|
||||
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
|
||||
}.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsOfficeVersionKey(string keyName)
|
||||
{
|
||||
return Version.TryParse(keyName, out _);
|
||||
}
|
||||
|
||||
private static Version ParseVersionKey(string keyName)
|
||||
{
|
||||
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
|
||||
}
|
||||
|
||||
private static int ParseMruItemOrder(string valueName)
|
||||
{
|
||||
var numberText = valueName["Item ".Length..];
|
||||
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
|
||||
? number
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
|
||||
{
|
||||
var filePath = ExtractOfficeFilePath(rawValue);
|
||||
DateTime? recentAccessTime = null;
|
||||
|
||||
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
|
||||
if (timestampMatch.Success &&
|
||||
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
|
||||
fileTime > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
recentAccessTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (filePath, recentAccessTime);
|
||||
}
|
||||
|
||||
private static string? ExtractOfficeFilePath(string rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var markerIndex = rawValue.LastIndexOf('*');
|
||||
var candidate = markerIndex >= 0
|
||||
? rawValue[(markerIndex + 1)..]
|
||||
: rawValue;
|
||||
|
||||
var normalizedCandidate = NormalizeFilePath(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
|
||||
{
|
||||
return normalizedCandidate;
|
||||
}
|
||||
|
||||
var match = OfficeFilePathRegex.Match(rawValue);
|
||||
return match.Success ? NormalizeFilePath(match.Value) : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeFilePath(string? rawPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawPath.Trim('\0', ' ', '"');
|
||||
candidate = Environment.ExpandEnvironmentVariables(candidate);
|
||||
|
||||
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
|
||||
{
|
||||
candidate = uri.LocalPath;
|
||||
}
|
||||
|
||||
candidate = candidate.Replace('/', '\\');
|
||||
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
|
||||
}
|
||||
|
||||
private static bool IsOfficeFile(string extension)
|
||||
{
|
||||
return OfficeExtensions.Contains(extension) ||
|
||||
ExcelExtensions.Contains(extension) ||
|
||||
PowerPointExtensions.Contains(extension);
|
||||
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetIconGlyph(string extension)
|
||||
@@ -150,4 +703,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
{
|
||||
return ShortcutHelper.GetShortcutTarget(lnkPath);
|
||||
}
|
||||
|
||||
private static object? GetPropertyValue(object? instance, string propertyName)
|
||||
{
|
||||
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
|
||||
}
|
||||
|
||||
private static string? GetStringProperty(object? instance, string propertyName)
|
||||
{
|
||||
return GetPropertyValue(instance, propertyName) as string;
|
||||
}
|
||||
|
||||
private static int GetIntProperty(object instance, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(instance, propertyName);
|
||||
return value switch
|
||||
{
|
||||
int intValue => intValue,
|
||||
short shortValue => shortValue,
|
||||
long longValue => (int)longValue,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrySetProperty(object? instance, string propertyName, object value)
|
||||
{
|
||||
var property = instance?.GetType().GetProperty(propertyName);
|
||||
if (property?.CanWrite == true)
|
||||
{
|
||||
property.SetValue(instance, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InvokeParameterlessMethod(object instance, string methodName)
|
||||
{
|
||||
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
|
||||
}
|
||||
}
|
||||
|
||||
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal file
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record RemovableStorageDrive(
|
||||
string RootPath,
|
||||
string DriveLetter,
|
||||
string? VolumeLabel);
|
||||
|
||||
public interface IRemovableStorageService
|
||||
{
|
||||
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
|
||||
|
||||
bool OpenDrive(string rootPath);
|
||||
|
||||
bool EjectDrive(string rootPath);
|
||||
}
|
||||
|
||||
public sealed class RemovableStorageService : IRemovableStorageService
|
||||
{
|
||||
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
|
||||
{
|
||||
var drives = new List<RemovableStorageDrive>();
|
||||
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rootPath = NormalizeRootPath(drive.Name);
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
|
||||
? null
|
||||
: drive.VolumeLabel.Trim();
|
||||
|
||||
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return drives
|
||||
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool OpenDrive(string rootPath)
|
||||
{
|
||||
var normalizedRootPath = NormalizeRootPath(rootPath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedRootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = normalizedRootPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool EjectDrive(string rootPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedRootPath = NormalizeRootPath(rootPath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedRootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
object? shellApplication = null;
|
||||
object? computerFolder = null;
|
||||
object? driveItem = null;
|
||||
|
||||
try
|
||||
{
|
||||
var shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||
if (shellType is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shellApplication = Activator.CreateInstance(shellType);
|
||||
if (shellApplication is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
computerFolder = shellType.InvokeMember(
|
||||
"NameSpace",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: shellApplication,
|
||||
args: [17]);
|
||||
if (computerFolder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
driveItem = computerFolder.GetType().InvokeMember(
|
||||
"ParseName",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: computerFolder,
|
||||
args: [driveToken]);
|
||||
if (driveItem is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryInvokeVerb(driveItem, "Eject"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryInvokeLocalizedEjectVerb(driveItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(driveItem);
|
||||
ReleaseComObject(computerFolder);
|
||||
ReleaseComObject(shellApplication);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
|
||||
{
|
||||
object? verbs = null;
|
||||
|
||||
try
|
||||
{
|
||||
verbs = driveItem.GetType().InvokeMember(
|
||||
"Verbs",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: driveItem,
|
||||
args: null);
|
||||
if (verbs is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var verbsType = verbs.GetType();
|
||||
var countObject = verbsType.InvokeMember(
|
||||
"Count",
|
||||
BindingFlags.GetProperty,
|
||||
binder: null,
|
||||
target: verbs,
|
||||
args: null);
|
||||
var count = countObject is null
|
||||
? 0
|
||||
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
|
||||
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
object? verb = null;
|
||||
|
||||
try
|
||||
{
|
||||
verb = verbsType.InvokeMember(
|
||||
"Item",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: verbs,
|
||||
args: [index]);
|
||||
if (verb is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var verbNameObject = verb.GetType().InvokeMember(
|
||||
"Name",
|
||||
BindingFlags.GetProperty,
|
||||
binder: null,
|
||||
target: verb,
|
||||
args: null);
|
||||
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
|
||||
if (!IsEjectVerbName(verbName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
verb.GetType().InvokeMember(
|
||||
"DoIt",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: verb,
|
||||
args: null);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(verb);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(verbs);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInvokeVerb(object driveItem, string verbName)
|
||||
{
|
||||
try
|
||||
{
|
||||
driveItem.GetType().InvokeMember(
|
||||
"InvokeVerb",
|
||||
BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: driveItem,
|
||||
args: [verbName]);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEjectVerbName(string? verbName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(verbName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = string.Concat(
|
||||
verbName
|
||||
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
|
||||
.Trim();
|
||||
|
||||
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("弹出", StringComparison.Ordinal) ||
|
||||
normalized.Contains("安全删除", StringComparison.Ordinal) ||
|
||||
normalized.Contains("卸载", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeRootPath(string? rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = rootPath.Trim();
|
||||
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
|
||||
}
|
||||
|
||||
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
|
||||
{
|
||||
return trimmed + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
var resolvedRoot = Path.GetPathRoot(normalized);
|
||||
return string.IsNullOrWhiteSpace(resolvedRoot)
|
||||
? normalized
|
||||
: resolvedRoot;
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object? value)
|
||||
{
|
||||
if (value is not null && Marshal.IsComObject(value))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ internal sealed class SettingsCatalogService : ISettingsCatalog
|
||||
[
|
||||
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
|
||||
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
|
||||
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "GridDots", sortOrder: 20),
|
||||
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "Apps", sortOrder: 20),
|
||||
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
|
||||
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Services.Settings;
|
||||
|
||||
@@ -20,6 +21,7 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
bool IsNightMode,
|
||||
string? ThemeColor,
|
||||
bool UseSystemChrome,
|
||||
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null);
|
||||
@@ -29,6 +31,7 @@ public sealed record StatusBarSettingsState(
|
||||
bool EnableDynamicTaskbarActions,
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
public sealed record WeatherSettingsState(
|
||||
|
||||
@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
|
||||
namespace LanMountainDesktop.Services.Settings;
|
||||
@@ -242,6 +243,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
snapshot.IsNightMode ?? false,
|
||||
snapshot.ThemeColor,
|
||||
snapshot.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale),
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed);
|
||||
@@ -252,6 +254,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
var snapshot = _settingsService.Load();
|
||||
var changedKeys = new List<string>();
|
||||
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
|
||||
var normalizedCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(state.GlobalCornerRadiusScale);
|
||||
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
|
||||
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
|
||||
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
||||
@@ -276,6 +279,12 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
||||
}
|
||||
|
||||
if (Math.Abs(GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale) - normalizedCornerRadiusScale) > 0.0001d)
|
||||
{
|
||||
snapshot.GlobalCornerRadiusScale = normalizedCornerRadiusScale;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale));
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.ThemeColorMode = normalizedThemeColorMode;
|
||||
@@ -361,6 +370,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.EnableDynamicTaskbarActions,
|
||||
snapshot.TaskbarLayoutMode,
|
||||
snapshot.ClockDisplayFormat,
|
||||
snapshot.StatusBarClockTransparentBackground,
|
||||
snapshot.StatusBarSpacingMode,
|
||||
snapshot.StatusBarCustomSpacingPercent);
|
||||
}
|
||||
@@ -373,6 +383,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
|
||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||
_settingsService.SaveSnapshot(
|
||||
@@ -385,6 +396,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||
]);
|
||||
|
||||
@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
if (languageChanged)
|
||||
{
|
||||
var regionState = _settingsFacade.Region.Get();
|
||||
// 清除本地化缓存,强制重新加载语言文件
|
||||
_localizationService.ClearCache();
|
||||
_viewModel.RefreshLanguage(regionState.LanguageCode);
|
||||
_pageRegistry.Rebuild();
|
||||
_window.ReloadPages(_viewModel.CurrentPageId);
|
||||
|
||||
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
@@ -0,0 +1,338 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
|
||||
{
|
||||
private const int DefaultCleanupBatchSize = 256;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly object _schemaSyncRoot = new();
|
||||
private readonly AppDatabaseService _databaseService;
|
||||
private bool _schemaInitialized;
|
||||
|
||||
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
|
||||
{
|
||||
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||
}
|
||||
|
||||
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
|
||||
{
|
||||
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||
{
|
||||
return new WhiteboardNoteSnapshot();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
DeleteExpiredInternal(
|
||||
connection,
|
||||
normalizedComponentId,
|
||||
normalizedPlacementId,
|
||||
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT note_json, saved_at_utc_ms
|
||||
FROM whiteboard_notes
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read() || reader.IsDBNull(0))
|
||||
{
|
||||
return new WhiteboardNoteSnapshot();
|
||||
}
|
||||
|
||||
var json = reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new WhiteboardNoteSnapshot();
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
|
||||
if (!reader.IsDBNull(1))
|
||||
{
|
||||
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
|
||||
}
|
||||
|
||||
if (IsExpired(snapshot, retentionDays))
|
||||
{
|
||||
DeleteNote(normalizedComponentId, normalizedPlacementId);
|
||||
return new WhiteboardNoteSnapshot();
|
||||
}
|
||||
|
||||
return snapshot.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new WhiteboardNoteSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||
{
|
||||
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
|
||||
persistedSnapshot.SavedUtc = nowUtc;
|
||||
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
|
||||
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
|
||||
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO whiteboard_notes(
|
||||
component_id,
|
||||
placement_id,
|
||||
note_json,
|
||||
saved_at_utc_ms,
|
||||
expires_at_utc_ms,
|
||||
updated_at_utc_ms)
|
||||
VALUES(
|
||||
$componentId,
|
||||
$placementId,
|
||||
$noteJson,
|
||||
$savedAtUtcMs,
|
||||
$expiresAtUtcMs,
|
||||
$updatedAtUtcMs)
|
||||
ON CONFLICT(component_id, placement_id) DO UPDATE SET
|
||||
note_json = excluded.note_json,
|
||||
saved_at_utc_ms = excluded.saved_at_utc_ms,
|
||||
expires_at_utc_ms = excluded.expires_at_utc_ms,
|
||||
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||
command.Parameters.AddWithValue("$noteJson", json);
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep whiteboard usable even when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
public bool DeleteNote(string componentId, string? placementId)
|
||||
{
|
||||
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM whiteboard_notes
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||
return command.ExecuteNonQuery() > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
|
||||
{
|
||||
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
return DeleteExpiredInternal(
|
||||
connection,
|
||||
normalizedComponentId,
|
||||
normalizedPlacementId,
|
||||
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM whiteboard_notes
|
||||
WHERE rowid IN (
|
||||
SELECT rowid
|
||||
FROM whiteboard_notes
|
||||
WHERE expires_at_utc_ms <= $nowUtcMs
|
||||
ORDER BY expires_at_utc_ms ASC
|
||||
LIMIT $batchSize
|
||||
);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
|
||||
if (!expirationUtc.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||
{
|
||||
if (snapshot is null || snapshot.SavedUtc == default)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
}
|
||||
|
||||
private SqliteConnection OpenConnection()
|
||||
{
|
||||
var connection = _databaseService.OpenConnection();
|
||||
EnsureSchema(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private void EnsureSchema(SqliteConnection connection)
|
||||
{
|
||||
if (_schemaInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_schemaSyncRoot)
|
||||
{
|
||||
if (_schemaInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_notes (
|
||||
component_id TEXT NOT NULL,
|
||||
placement_id TEXT NOT NULL,
|
||||
note_json TEXT NOT NULL,
|
||||
saved_at_utc_ms INTEGER NOT NULL,
|
||||
expires_at_utc_ms INTEGER NOT NULL,
|
||||
updated_at_utc_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (component_id, placement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
|
||||
ON whiteboard_notes(expires_at_utc_ms);
|
||||
""";
|
||||
command.ExecuteNonQuery();
|
||||
_schemaInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DeleteExpiredInternal(
|
||||
SqliteConnection connection,
|
||||
string componentId,
|
||||
string placementId,
|
||||
int retentionDays,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
using var selectCommand = connection.CreateCommand();
|
||||
selectCommand.CommandText = """
|
||||
SELECT saved_at_utc_ms
|
||||
FROM whiteboard_notes
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId
|
||||
LIMIT 1;
|
||||
""";
|
||||
selectCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||
selectCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||
|
||||
var scalar = selectCommand.ExecuteScalar();
|
||||
if (scalar is not long savedAtUtcMs)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
|
||||
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
if (expiresUtc > nowUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var deleteCommand = connection.CreateCommand();
|
||||
deleteCommand.CommandText = """
|
||||
DELETE FROM whiteboard_notes
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
""";
|
||||
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||
return deleteCommand.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
private static bool TryNormalizeKeys(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
out string normalizedComponentId,
|
||||
out string normalizedPlacementId)
|
||||
{
|
||||
normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(normalizedComponentId);
|
||||
}
|
||||
|
||||
private static int NormalizeBatchSize(int batchSize)
|
||||
{
|
||||
return batchSize <= 0
|
||||
? DefaultCleanupBatchSize
|
||||
: Math.Clamp(batchSize, 1, 4096);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<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:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
|
||||
<Style Selector="Window.component-editor-window">
|
||||
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
||||
</Style>
|
||||
@@ -17,7 +18,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Padding" Value="16,14,12,14" />
|
||||
<Setter Property="MinHeight" Value="56" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
@@ -39,7 +40,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Padding" Value="16,14,12,14" />
|
||||
<Setter Property="MinHeight" Value="56" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||
@@ -60,7 +61,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
<Setter Property="Margin" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
</Style>
|
||||
|
||||
@@ -74,7 +75,21 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window.component-editor-window RadioButton">
|
||||
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||
<Setter Property="Margin" Value="0,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
|
||||
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
|
||||
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window.component-editor-window RadioButton:pointerover">
|
||||
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window.component-editor-window RadioButton:checked">
|
||||
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window.component-editor-window ToggleSwitch">
|
||||
@@ -85,7 +100,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
</Style>
|
||||
|
||||
@@ -93,7 +108,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="Padding" Value="18,12" />
|
||||
<Setter Property="MinHeight" Value="48" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
@@ -124,14 +139,14 @@
|
||||
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
|
||||
</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" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.component-editor-headline">
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
<Styles.Resources>
|
||||
<!-- Unified corner radius tokens used across settings and widget panels -->
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">6</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXl">32</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusLg">28</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMd">20</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusIsland">36</CornerRadius>
|
||||
</Styles.Resources>
|
||||
|
||||
<Style Selector="TextBlock">
|
||||
@@ -19,7 +21,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
@@ -155,7 +157,7 @@
|
||||
|
||||
<Style Selector="Button.swatch-button">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="Opacity" Value="0.88" />
|
||||
</Style>
|
||||
|
||||
@@ -165,29 +167,35 @@
|
||||
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.glass-panel">
|
||||
<!--
|
||||
半透明表面样式类
|
||||
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
|
||||
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
|
||||
-->
|
||||
|
||||
<Style Selector="Border.surface-translucent-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.2" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.glass-strong">
|
||||
<Style Selector="Border.surface-translucent-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="CornerRadius" Value="32" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.glass-island">
|
||||
<Style Selector="Border.surface-translucent-island">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="CornerRadius" Value="36" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
<Setter Property="Transitions">
|
||||
@@ -197,19 +205,26 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.mica-strong">
|
||||
<Style Selector="Border.surface-solid-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="36" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.glass-overlay">
|
||||
<Style Selector="Border.surface-translucent-overlay">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<Style Selector="Border.glass-panel" />
|
||||
<Style Selector="Border.glass-strong" />
|
||||
<Style Selector="Border.glass-island" />
|
||||
<Style Selector="Border.mica-strong" />
|
||||
<Style Selector="Border.glass-overlay" />
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -48,21 +48,21 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.settings-section-card">
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
<Setter Property="Margin" Value="0,0,0,14" />
|
||||
<Setter Property="BoxShadow" Value="0 2 8 #12000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.settings-option-card">
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.settings-list-item">
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Margin" Value="0,0,0,10" />
|
||||
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
|
||||
@@ -77,7 +77,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
</Style>
|
||||
@@ -201,7 +201,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="Padding" Value="14,12" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
@@ -229,7 +229,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
</Style>
|
||||
@@ -254,7 +254,7 @@
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
|
||||
@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
RefreshInstalledSnapshot();
|
||||
RefreshItemStates();
|
||||
|
||||
// 设置更明显的状态消息
|
||||
var pluginName = result.PluginName ?? item.Name;
|
||||
StatusMessage = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
|
||||
result.PluginName ?? item.Name);
|
||||
RestartRequested?.Invoke(RestartRequiredMessage);
|
||||
L("market.status.install_success_restart_format", "✓ Plugin '{0}' installed successfully! Please restart the application to activate it."),
|
||||
pluginName);
|
||||
|
||||
// 触发重启提醒
|
||||
RestartRequested?.Invoke(string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("market.dialog.restart_message_format", "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"),
|
||||
pluginName));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
@@ -268,12 +269,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnSelectedLanguageChanged(SelectionOption value)
|
||||
{
|
||||
RefreshPreview();
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新语言代码并刷新UI文本
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
|
||||
RefreshLocalizedText();
|
||||
RefreshPreview();
|
||||
|
||||
// 保存设置
|
||||
_settingsFacade.Region.Save(new RegionSettingsState(
|
||||
value.Value,
|
||||
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
|
||||
@@ -476,6 +482,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _useSystemChrome;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet");
|
||||
|
||||
@@ -542,6 +551,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _systemMaterialLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeHeader = string.Empty;
|
||||
|
||||
@@ -663,6 +678,32 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
partial void OnSelectedThemeColorModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
@@ -727,6 +768,8 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
||||
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
||||
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
|
||||
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius");
|
||||
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers.");
|
||||
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
||||
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
||||
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
||||
@@ -771,6 +814,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode = theme.IsNightMode;
|
||||
ThemeColor = theme.ThemeColor ?? string.Empty;
|
||||
UseSystemChrome = theme.UseSystemChrome;
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
||||
SyncCustomSeedPickerWithSavedThemeColor();
|
||||
|
||||
@@ -820,6 +864,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode,
|
||||
themeColor,
|
||||
UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
themeColorMode,
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||
_selectedWallpaperSeed);
|
||||
@@ -951,7 +996,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
private string _pageDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gridHeader = string.Empty;
|
||||
private string _componentsHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _shortSideCellsLabel = string.Empty;
|
||||
@@ -962,6 +1007,18 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _spacingPresetLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _componentRadiusHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var state = _settingsFacade.Grid.Get();
|
||||
@@ -971,6 +1028,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
SelectedSpacingPreset = SpacingPresets.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, spacingPreset, StringComparison.OrdinalIgnoreCase))
|
||||
?? SpacingPresets[1];
|
||||
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
partial void OnShortSideCellsChanged(int value)
|
||||
@@ -1003,6 +1063,32 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
SaveGrid();
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SaveComponentCornerRadius();
|
||||
}
|
||||
|
||||
private void SaveGrid()
|
||||
{
|
||||
_settingsFacade.Grid.Save(new GridSettingsState(
|
||||
@@ -1011,23 +1097,39 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
Math.Clamp(EdgeInsetPercent, 0, 30)));
|
||||
}
|
||||
|
||||
private void SaveComponentCornerRadius()
|
||||
{
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
_settingsFacade.Theme.Save(new ThemeAppearanceSettingsState(
|
||||
theme.IsNightMode,
|
||||
theme.ThemeColor,
|
||||
theme.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
theme.ThemeColorMode,
|
||||
theme.SystemMaterialMode,
|
||||
theme.SelectedWallpaperSeed));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateSpacingPresets()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Compact", L("settings.grid.spacing_compact", "Compact")),
|
||||
new SelectionOption("Relaxed", L("settings.grid.spacing_relaxed", "Relaxed"))
|
||||
new SelectionOption("Compact", L("settings.components.spacing_compact", "Compact")),
|
||||
new SelectionOption("Relaxed", L("settings.components.spacing_relaxed", "Relaxed"))
|
||||
];
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.components.title", "Components");
|
||||
PageDescription = L("settings.components.description", "Desktop grid and widget placement density.");
|
||||
GridHeader = L("settings.components.grid_header", "Grid Layout");
|
||||
ShortSideCellsLabel = L("settings.grid.short_side_label", "Short Side Cells");
|
||||
EdgeInsetPercentLabel = L("settings.grid.edge_inset_label", "Screen Inset");
|
||||
SpacingPresetLabel = L("settings.grid.spacing_label", "Grid Spacing");
|
||||
PageDescription = L("settings.components.description", "Adjust component layout and corner design.");
|
||||
ComponentsHeader = L("settings.components.header", "Grid Settings");
|
||||
ShortSideCellsLabel = L("settings.components.short_side_label", "Short Side Cells");
|
||||
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
|
||||
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
|
||||
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
|
||||
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius");
|
||||
GlobalCornerRadiusDescription = L("settings.components.corner_radius.description", "Adjust the shared corner radius used by component containers, and expand the internal safe area with it.");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _clockTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||
|
||||
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _clockFormatLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _spacingHeader = string.Empty;
|
||||
|
||||
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockFormats[1];
|
||||
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||
|
||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnClockTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||
{
|
||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
state.EnableDynamicTaskbarActions,
|
||||
state.TaskbarLayoutMode,
|
||||
SelectedClockFormat.Value,
|
||||
ClockTransparentBackground,
|
||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||
}
|
||||
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
|
||||
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
|
||||
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
||||
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
|
||||
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
|
||||
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
<!-- MD3 Button Styles -->
|
||||
<Style Selector="Button.component-editor-footer-button">
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
@@ -118,7 +118,7 @@
|
||||
Height="64"
|
||||
Background="{DynamicResource EditorPrimaryBrush}"
|
||||
Foreground="{DynamicResource EditorOnPrimaryBrush}"
|
||||
CornerRadius="18"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Classes="accent"
|
||||
Click="OnCloseClick">
|
||||
<Button.Styles>
|
||||
|
||||
@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
|
||||
"DataLine" => MaterialIconKind.ChartLine,
|
||||
"Edit" => MaterialIconKind.Pencil,
|
||||
"Calculator" => MaterialIconKind.Calculator,
|
||||
"Storage" => MaterialIconKind.UsbFlashDrive,
|
||||
"Globe" => MaterialIconKind.Web,
|
||||
"Play" => MaterialIconKind.Play,
|
||||
_ => MaterialIconKind.Settings
|
||||
|
||||
@@ -22,14 +22,17 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="ColorSchemeComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="follow_system" />
|
||||
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
||||
DescriptionTextBlock.Text = L(
|
||||
"schedule.settings.desc",
|
||||
"Import a ClassIsland CSES schedule file and choose which one to use.");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
|
||||
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
|
||||
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||
|
||||
AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
|
||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
|
||||
|
||||
_suppressEvents = true;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = true;
|
||||
}
|
||||
|
||||
ColorSchemeComboBox.SelectedItem =
|
||||
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||
? FollowSystemColorSchemeItem
|
||||
: UseNativeColorSchemeItem;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? tag
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
@@ -121,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
|
||||
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
|
||||
Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
|
||||
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
|
||||
{
|
||||
Patterns = ["*.cses", "*.yaml", "*.yml"]
|
||||
}
|
||||
@@ -155,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
||||
?? L("schedule.settings.unnamed", "未命名课表"),
|
||||
?? L("schedule.settings.unnamed", "Untitled Schedule"),
|
||||
FilePath = importedPath
|
||||
});
|
||||
_activeScheduleId = _importedSchedules[^1].Id;
|
||||
@@ -219,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
||||
? L("schedule.settings.unnamed", "未命名课表")
|
||||
? L("schedule.settings.unnamed", "Untitled Schedule")
|
||||
: item.DisplayName,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
};
|
||||
@@ -234,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
|
||||
var deleteButton = new Button
|
||||
{
|
||||
Content = L("schedule.settings.delete", "删除"),
|
||||
Content = L("schedule.settings.delete", "Delete"),
|
||||
Tag = item.Id,
|
||||
Padding = new Thickness(12, 8),
|
||||
HorizontalAlignment = HorizontalAlignment.Right
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<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.OfficeRecentDocumentsComponentEditor">
|
||||
<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="SourcesHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock x:Name="SourcesDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
<CheckBox x:Name="RegistryCheckBox"
|
||||
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||
<CheckBox x:Name="RecentFoldersCheckBox"
|
||||
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||
<CheckBox x:Name="JumpListsCheckBox"
|
||||
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="HintTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
Margin="12,0"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private bool _suppressEvents;
|
||||
|
||||
public OfficeRecentDocumentsComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyState();
|
||||
}
|
||||
|
||||
private void ApplyState()
|
||||
{
|
||||
var snapshot = LoadSnapshot();
|
||||
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
|
||||
"component.office_recent_documents",
|
||||
"Recent Documents");
|
||||
DescriptionTextBlock.Text = L(
|
||||
"office_recent_documents.settings.desc",
|
||||
"Choose which Windows and Office sources this widget should scan for recent documents.");
|
||||
SourcesHeaderTextBlock.Text = L(
|
||||
"office_recent_documents.settings.sources_title",
|
||||
"Recent document sources");
|
||||
SourcesDescriptionTextBlock.Text = L(
|
||||
"office_recent_documents.settings.sources_desc",
|
||||
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
|
||||
RegistryCheckBox.Content = L(
|
||||
"office_recent_documents.settings.source.registry",
|
||||
"Office registry MRU");
|
||||
RecentFoldersCheckBox.Content = L(
|
||||
"office_recent_documents.settings.source.recent_folders",
|
||||
"Windows Recent folders");
|
||||
JumpListsCheckBox.Content = L(
|
||||
"office_recent_documents.settings.source.jump_lists",
|
||||
"Windows Jump Lists");
|
||||
HintTextBlock.Text = L(
|
||||
"office_recent_documents.settings.hint",
|
||||
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
|
||||
|
||||
_suppressEvents = true;
|
||||
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedSources = new[]
|
||||
{
|
||||
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
|
||||
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
|
||||
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
|
||||
}
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<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.RemovableStorageComponentEditor">
|
||||
<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="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox x:Name="ColorSchemeComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="follow_system" />
|
||||
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="BehaviorHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock x:Name="BehaviorTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private bool _suppressEvents;
|
||||
|
||||
public RemovableStorageComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyState();
|
||||
}
|
||||
|
||||
private void ApplyState()
|
||||
{
|
||||
var snapshot = LoadSnapshot();
|
||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
|
||||
DescriptionTextBlock.Text = L(
|
||||
"removable_storage.settings.desc",
|
||||
"Show a connected USB drive with quick open and eject actions.");
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
|
||||
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
|
||||
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
|
||||
BehaviorTextBlock.Text = L(
|
||||
"removable_storage.settings.behavior_desc",
|
||||
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
|
||||
|
||||
_suppressEvents = true;
|
||||
ColorSchemeComboBox.SelectedItem =
|
||||
string.IsNullOrWhiteSpace(colorSchemeSource) ||
|
||||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||
? FollowSystemColorSchemeItem
|
||||
: UseNativeColorSchemeItem;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? tag
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||
<StackPanel Spacing="16">
|
||||
<Border Classes="component-editor-hero_card"
|
||||
<Border Classes="component-editor-hero-card"
|
||||
Padding="24">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="HeadlineTextBlock"
|
||||
@@ -23,14 +23,17 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="ColorSchemeComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="follow_system" />
|
||||
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -44,7 +47,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor_card"
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -34,43 +36,40 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
||||
}
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
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", "至少启用一种显示方式。");
|
||||
DescriptionTextBlock.Text = L(
|
||||
"study.environment.settings.desc",
|
||||
"Configure the realtime audio level information shown on the right side.");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
|
||||
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
|
||||
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||
|
||||
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "Show display dB");
|
||||
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "Show dBFS");
|
||||
HintTextBlock.Text = L("study.environment.settings.hint", "At least one display mode must stay enabled.");
|
||||
|
||||
_suppressEvents = true;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = true;
|
||||
}
|
||||
|
||||
ColorSchemeComboBox.SelectedItem =
|
||||
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||
? FollowSystemColorSchemeItem
|
||||
: UseNativeColorSchemeItem;
|
||||
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
||||
DbfsToggleSwitch.IsChecked = showDbfs;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? tag
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<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.WhiteboardComponentEditor">
|
||||
<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="RetentionHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock x:Name="RetentionDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
<ComboBox x:Name="RetentionComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnRetentionSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="InstanceHintTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
Margin="12,0"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class WhiteboardComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private bool _suppressEvents;
|
||||
|
||||
public WhiteboardComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public WhiteboardComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
BuildRetentionOptions();
|
||||
ApplyState();
|
||||
}
|
||||
|
||||
private void BuildRetentionOptions()
|
||||
{
|
||||
RetentionComboBox.Items.Clear();
|
||||
for (var days = WhiteboardNoteRetentionPolicy.MinimumDays; days <= WhiteboardNoteRetentionPolicy.MaximumDays; days++)
|
||||
{
|
||||
var item = new ComboBoxItem
|
||||
{
|
||||
Tag = days.ToString(),
|
||||
Content = L(
|
||||
"whiteboard.settings.retention.option",
|
||||
"{0} days").Replace("{0}", days.ToString())
|
||||
};
|
||||
item.Classes.Add("component-editor-select-item");
|
||||
RetentionComboBox.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyState()
|
||||
{
|
||||
var snapshot = LoadSnapshot();
|
||||
var retentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Blackboard";
|
||||
DescriptionTextBlock.Text = L(
|
||||
"whiteboard.settings.desc",
|
||||
"Each blackboard keeps its own note history and saves it independently.");
|
||||
RetentionHeaderTextBlock.Text = L(
|
||||
"whiteboard.settings.retention.title",
|
||||
"Note retention");
|
||||
RetentionDescriptionTextBlock.Text = L(
|
||||
"whiteboard.settings.retention.desc",
|
||||
"Choose how long this blackboard should keep saved notes before expired data is removed automatically.");
|
||||
InstanceHintTextBlock.Text = L(
|
||||
"whiteboard.settings.instance_scope",
|
||||
"This retention setting is stored per blackboard component instance.");
|
||||
|
||||
_suppressEvents = true;
|
||||
RetentionComboBox.SelectedItem = RetentionComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tag &&
|
||||
int.TryParse(tag, out var days) &&
|
||||
days == retentionDays);
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnRetentionSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.WhiteboardNoteRetentionDays = GetSelectedRetentionDays();
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.WhiteboardNoteRetentionDays));
|
||||
}
|
||||
|
||||
private int GetSelectedRetentionDays()
|
||||
{
|
||||
if (RetentionComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tag &&
|
||||
int.TryParse(tag, out var days))
|
||||
{
|
||||
return NormalizeRetentionDays(days);
|
||||
}
|
||||
|
||||
return WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
}
|
||||
|
||||
private static int NormalizeRetentionDays(int days)
|
||||
{
|
||||
return WhiteboardNoteRetentionPolicy.NormalizeDays(
|
||||
days <= 0
|
||||
? WhiteboardNoteRetentionPolicy.DefaultDays
|
||||
: days);
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="240,*"
|
||||
ColumnSpacing="12">
|
||||
<Border Classes="glass-panel"
|
||||
CornerRadius="24"
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Background="Transparent"
|
||||
@@ -50,7 +50,7 @@
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Border Padding="10"
|
||||
Margin="0,0,0,6"
|
||||
CornerRadius="14"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
@@ -70,8 +70,8 @@
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Classes="glass-strong"
|
||||
CornerRadius="24"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
@@ -87,14 +87,14 @@
|
||||
<Border Width="240"
|
||||
Height="220"
|
||||
Margin="6"
|
||||
CornerRadius="18"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="*,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<Border CornerRadius="12"
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusXs}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
|
||||
@@ -326,7 +326,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(42 * scale, 16, 56));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(42 * scale, 16, 56);
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 26));
|
||||
ApplyModeVisualIfNeeded();
|
||||
}
|
||||
|
||||
@@ -381,12 +381,12 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
|
||||
|
||||
@@ -386,12 +386,12 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
|
||||
|
||||
@@ -79,11 +79,11 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.34, 12, 28);
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||
|
||||
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
||||
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
||||
WebViewHostBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.24, 10, 22);
|
||||
AddressBarBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.22, 10, 20);
|
||||
AddressBarBorder.Padding = new Thickness(8, 6);
|
||||
|
||||
if (RootBorder.Child is Grid rootGrid)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<Grid Grid.Row="1">
|
||||
<ScrollViewer x:Name="ContentScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="CourseListPanel" />
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -198,12 +198,32 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return;
|
||||
}
|
||||
|
||||
if (courseIndex < CourseListPanel.Children.Count)
|
||||
// 确保在UI线程执行
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (courseIndex >= CourseListPanel.Children.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetChild = CourseListPanel.Children[courseIndex];
|
||||
if (targetChild == null || !targetChild.IsArrangeValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = targetChild.Bounds;
|
||||
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y);
|
||||
}
|
||||
var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
|
||||
var contentHeight = CourseListPanel.Bounds.Height;
|
||||
|
||||
// 计算滚动位置,使当前课程居中显示
|
||||
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
|
||||
|
||||
// 确保不超出边界
|
||||
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
|
||||
|
||||
ContentScrollViewer.Offset = new Vector(0, targetOffset);
|
||||
}, Avalonia.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
@@ -298,6 +318,15 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var currentIndex = FindCurrentCourseIndex();
|
||||
_lastCurrentCourseIndex = currentIndex;
|
||||
HideStatus();
|
||||
|
||||
// 初始化时自动跳转到当前课程
|
||||
if (currentIndex >= 0)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ScrollToCurrentCourse(currentIndex);
|
||||
}, Avalonia.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
RenderScheduleItems();
|
||||
@@ -484,10 +513,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
|
||||
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
|
||||
for (var i = 0; i < visibleItems.Count; i++)
|
||||
for (var i = 0; i < _courseItems.Count; i++)
|
||||
{
|
||||
var item = visibleItems[i];
|
||||
var item = _courseItems[i];
|
||||
var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
|
||||
|
||||
var bullet = new Border
|
||||
@@ -585,18 +613,17 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF3250");
|
||||
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44);
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.45, 24, 44);
|
||||
RootBorder.Background = _isNightVisual
|
||||
? CreateGradientBrush("#171A21", "#0C0E14")
|
||||
: CreateGradientBrush("#F7F8FC", "#ECEFF6");
|
||||
RootBorder.BorderBrush = CreateBrush(_isNightVisual ? "#24FFFFFF" : "#15000000");
|
||||
|
||||
var rootPadding = new Thickness(
|
||||
Math.Clamp(16 * scale, 10, 24),
|
||||
Math.Clamp(14 * scale, 9, 20),
|
||||
Math.Clamp(16 * scale, 10, 24),
|
||||
Math.Clamp(14 * scale, 8, 20));
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 20),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 8, 20));
|
||||
RootBorder.Padding = rootPadding;
|
||||
|
||||
LayoutGrid.RowSpacing = Math.Clamp(14 * scale, 6, 20);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<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"
|
||||
@@ -8,7 +8,7 @@
|
||||
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-panel"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="24">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
|
||||
@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
|
||||
public ClockWidget()
|
||||
{
|
||||
@@ -44,11 +46,32 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDisplayFormat(ClockDisplayFormat format)
|
||||
{
|
||||
DisplayFormat = format;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparentBackground)
|
||||
{
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// --- Class Island “满盈”风格算法 ---
|
||||
|
||||
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
@@ -129,8 +154,39 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
{
|
||||
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
|
||||
}
|
||||
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制“紧密感”
|
||||
RootBorder.MinWidth = cellSize * 2.2;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
Width = 160,
|
||||
Height = 90,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
|
||||
IsHitTestVisible = false
|
||||
@@ -545,10 +545,10 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22),
|
||||
@@ -573,8 +573,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
News1ImageHost.Height = imageHeight;
|
||||
News2ImageHost.Width = imageWidth;
|
||||
News2ImageHost.Height = imageHeight;
|
||||
News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
|
||||
var columnGap = Math.Clamp(12 * scale, 6, 18);
|
||||
NewsItem1Grid.ColumnSpacing = columnGap;
|
||||
@@ -611,7 +611,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class ComponentChromeCornerRadiusHelper
|
||||
{
|
||||
public static double ResolveScale(ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
if (chromeContext is not null)
|
||||
{
|
||||
return Math.Max(0.1d, chromeContext.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
return Math.Max(0.1d, HostAppearanceThemeProvider.GetOrCreate().GetCurrent().GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
public static CornerRadius Scale(double baseRadius, double min, double max, ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
return new CornerRadius(Math.Clamp(baseRadius * scale, min * scale, max * scale));
|
||||
}
|
||||
|
||||
public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
|
||||
{
|
||||
foreach (var chromeLayer in chromeLayers)
|
||||
{
|
||||
if (chromeLayer is not null)
|
||||
{
|
||||
chromeLayer.CornerRadius = radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static CornerRadius ResolveToken(string key, double fallback)
|
||||
{
|
||||
var application = Application.Current;
|
||||
return application is not null &&
|
||||
application.Resources.TryGetResource(key, application.ActualThemeVariant, out var resource) &&
|
||||
resource is CornerRadius radius
|
||||
? radius
|
||||
: new CornerRadius(fallback);
|
||||
}
|
||||
|
||||
public static double ScaleValue(double value, ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
return value * ResolveScale(chromeContext);
|
||||
}
|
||||
|
||||
public static double ResolveContentSafetyScale(
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d);
|
||||
return 1d + ((scale - 1d) * normalizedResponsiveness);
|
||||
}
|
||||
|
||||
public static double SafeValue(
|
||||
double baseValue,
|
||||
double min,
|
||||
double max,
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
{
|
||||
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness);
|
||||
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
|
||||
|
||||
InfoPanel.Padding = new Thickness(
|
||||
Math.Clamp(18 * scale, 10, 28),
|
||||
|
||||
@@ -92,7 +92,7 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(20 * scale, 10, 34),
|
||||
Math.Clamp(16 * scale, 8, 28),
|
||||
|
||||
@@ -328,7 +328,7 @@ public partial class DailyWord2x2Widget : UserControl, IDesktopComponentWidget,
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 14, 40));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(30 * scale, 14, 40);
|
||||
CardBorder.CornerRadius = RootBorder.CornerRadius;
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * scale, 8, 18),
|
||||
|
||||
@@ -298,10 +298,11 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
isFourByThree = widthRatio >= 0.9 && heightRatio >= 1.35;
|
||||
}
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
var containerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
|
||||
RootBorder.CornerRadius = containerRadius;
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
CardBorder.CornerRadius = containerRadius;
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22),
|
||||
|
||||
@@ -325,7 +325,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 16, 40));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(28 * scale, 16, 40);
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(11 * scale, 7, 17));
|
||||
|
||||
LayoutRoot.ColumnSpacing = Math.Clamp(10 * scale, 6, 16);
|
||||
@@ -337,7 +337,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
Math.Clamp(2.4 * scale, 1, 4));
|
||||
CalendarGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(0.8 * scale, 0, 2));
|
||||
|
||||
LunarCardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 14, 34));
|
||||
LunarCardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(24 * scale, 14, 34);
|
||||
LunarCardBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 20));
|
||||
RightPanelGrid.RowSpacing = Math.Clamp(7.5 * scale, 3.5, 11);
|
||||
DividerBorder.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 2), 0, Math.Clamp(1 * scale, 0, 2));
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.Appearance;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
@@ -30,7 +32,11 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
string? displayNameLocalizationKey,
|
||||
Func<Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
: this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver)
|
||||
: this(
|
||||
componentId,
|
||||
displayNameLocalizationKey,
|
||||
_ => controlFactory(),
|
||||
cornerRadiusResolver is null ? null : chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,6 +45,19 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
: this(
|
||||
componentId,
|
||||
displayNameLocalizationKey,
|
||||
controlFactory,
|
||||
cornerRadiusResolver is null ? null : chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
public DesktopComponentRuntimeRegistration(
|
||||
string componentId,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<ComponentChromeContext, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
@@ -57,22 +76,27 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
|
||||
public Func<DesktopComponentControlFactoryContext, Control> ControlFactory { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
public Func<ComponentChromeContext, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
|
||||
public sealed class DesktopComponentRuntimeDescriptor
|
||||
{
|
||||
private static readonly Func<double, double> DefaultCornerRadiusResolver =
|
||||
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
|
||||
private static readonly Func<ComponentChromeContext, double> DefaultCornerRadiusResolver =
|
||||
chromeContext =>
|
||||
{
|
||||
var scale = Math.Max(0.1d, chromeContext.GlobalCornerRadiusScale);
|
||||
var baseRadius = Math.Clamp(chromeContext.CellSize * 0.22, 8, 18);
|
||||
return Math.Clamp(baseRadius * scale, 8 * scale, 18 * scale);
|
||||
};
|
||||
|
||||
private readonly Func<DesktopComponentControlFactoryContext, Control> _controlFactory;
|
||||
private readonly Func<double, double> _cornerRadiusResolver;
|
||||
private readonly Func<ComponentChromeContext, double> _cornerRadiusResolver;
|
||||
|
||||
internal DesktopComponentRuntimeDescriptor(
|
||||
DesktopComponentDefinition definition,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver)
|
||||
Func<ComponentChromeContext, double>? cornerRadiusResolver)
|
||||
{
|
||||
Definition = definition;
|
||||
DisplayNameLocalizationKey = displayNameLocalizationKey;
|
||||
@@ -97,9 +121,16 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
|
||||
var settingsService = settingsFacade.Settings;
|
||||
var appearanceTheme = HostAppearanceThemeProvider.GetOrCreate();
|
||||
var appearanceSnapshot = appearanceTheme.GetCurrent();
|
||||
var componentAccessor = settingsService.GetComponentAccessor(Definition.Id, placementId);
|
||||
var componentSettingsStore = new ComponentSettingsService(settingsService);
|
||||
componentSettingsStore.SetScopedComponentContext(Definition.Id, placementId);
|
||||
var chromeContext = new ComponentChromeContext(
|
||||
Definition.Id,
|
||||
placementId,
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens);
|
||||
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||
Definition,
|
||||
cellSize,
|
||||
@@ -118,6 +149,7 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
settingsFacade,
|
||||
settingsService,
|
||||
appearanceTheme,
|
||||
chromeContext,
|
||||
componentAccessor,
|
||||
componentSettingsStore);
|
||||
|
||||
@@ -145,6 +177,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
placementAwareComponent.SetComponentPlacementContext(Definition.Id, placementId);
|
||||
}
|
||||
|
||||
if (control is IComponentChromeContextAware chromeContextAwareComponent)
|
||||
{
|
||||
chromeContextAwareComponent.SetComponentChromeContext(chromeContext);
|
||||
}
|
||||
|
||||
if (control is IDesktopComponentWidget sizedComponent)
|
||||
{
|
||||
sizedComponent.ApplyCellSize(cellSize);
|
||||
@@ -173,9 +210,22 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
return control;
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(ComponentChromeContext chromeContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chromeContext);
|
||||
|
||||
var resolved = _cornerRadiusResolver(chromeContext with { CellSize = Math.Max(1, chromeContext.CellSize) });
|
||||
return double.IsFinite(resolved) ? Math.Max(0d, resolved) : DefaultCornerRadiusResolver(chromeContext);
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(double cellSize)
|
||||
{
|
||||
return _cornerRadiusResolver(Math.Max(1, cellSize));
|
||||
return ResolveCornerRadius(new ComponentChromeContext(
|
||||
Definition.Id,
|
||||
null,
|
||||
Math.Max(1, cellSize),
|
||||
1d,
|
||||
AppearanceCornerRadiusTokenFactory.Create(1d)));
|
||||
}
|
||||
|
||||
private static void ApplySettingsDependencies(
|
||||
@@ -444,6 +494,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.office_recent_documents",
|
||||
() => new OfficeRecentDocumentsWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopRemovableStorage,
|
||||
"component.removable_storage",
|
||||
() => new RemovableStorageWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.46, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.HolidayCalendar,
|
||||
"component.holiday_calendar",
|
||||
|
||||
@@ -80,8 +80,8 @@ public partial class ExchangeRateCalculatorWidget : UserControl, IDesktopCompone
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 14, 48));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 6, 18));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 14, 48);
|
||||
RootBorder.Padding = new Thickness(ComponentChromeCornerRadiusHelper.SafeValue(12 * scale, 6, 18));
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
|
||||
@@ -10,6 +10,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -18,7 +19,7 @@ using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware
|
||||
{
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
@@ -34,6 +35,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private double _currentCellSize = 48;
|
||||
private ComponentChromeContext? _chromeContext;
|
||||
private double _phase;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
@@ -122,21 +124,34 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Extended4x4);
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4;
|
||||
var radius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 28, 54);
|
||||
RootBorder.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundImageLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundMotionLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundTintLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundLightLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundShadeLayer.CornerRadius = new CornerRadius(radius);
|
||||
var radius = ComponentChromeCornerRadiusHelper.Scale(
|
||||
_currentCellSize * metrics.CornerRadiusScale,
|
||||
28,
|
||||
54,
|
||||
_chromeContext);
|
||||
ComponentChromeCornerRadiusHelper.Apply(
|
||||
radius,
|
||||
RootBorder,
|
||||
BackgroundImageLayer,
|
||||
BackgroundMotionLayer,
|
||||
BackgroundTintLayer,
|
||||
BackgroundLightLayer,
|
||||
BackgroundShadeLayer);
|
||||
var horizontalPadding = Math.Clamp(Math.Min(width * metrics.HorizontalPaddingScale * 0.30, width * 0.11), 4, 34);
|
||||
var verticalPadding = Math.Clamp(Math.Min(height * metrics.VerticalPaddingScale * 0.30, height * 0.11), 4, 34);
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
horizontalPadding,
|
||||
verticalPadding);
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(horizontalPadding, 4, 34, _chromeContext),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(verticalPadding, 4, 34, _chromeContext));
|
||||
ApplyTypography(width, height);
|
||||
}
|
||||
|
||||
public void SetComponentChromeContext(ComponentChromeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_chromeContext = context;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
if (_timeZoneService is not null)
|
||||
|
||||
@@ -216,8 +216,8 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
||||
var titleNeedsTwoLines = isUltraCompact || titleUnits >= (isCompact ? 13 : 17);
|
||||
var dateNeedsTwoLines = isUltraCompact || dateUnits >= (isCompact ? 15 : 20);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(shortSide * 0.13, 10, 46));
|
||||
var padding = Math.Clamp(shortSide * 0.05, 4.5, 21);
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(shortSide * 0.13, 10, 46);
|
||||
var padding = ComponentChromeCornerRadiusHelper.SafeValue(shortSide * 0.05, 4.5, 21);
|
||||
RootBorder.Padding = new Thickness(padding);
|
||||
LayoutRoot.RowSpacing = Math.Clamp(shortSide * 0.028, 2.2, 12);
|
||||
var rowWeights = ApplyAdaptiveRowHeights(isCompact, isUltraCompact, titleNeedsTwoLines, dateNeedsTwoLines);
|
||||
|
||||
@@ -12,13 +12,14 @@ using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -117,6 +118,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private WeatherSnapshot? _latestSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = 48;
|
||||
private ComponentChromeContext? _chromeContext;
|
||||
private WeatherVisualKind _activeVisualKind = WeatherVisualKind.ClearDay;
|
||||
private double _animationPhase;
|
||||
private int _activeParticleCount;
|
||||
@@ -254,6 +256,13 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentChromeContext(ComponentChromeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_chromeContext = context;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -261,17 +270,23 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
var scale = ResolveScale();
|
||||
var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4);
|
||||
var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2);
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 46);
|
||||
var cornerRadius = ComponentChromeCornerRadiusHelper.Scale(
|
||||
_currentCellSize * metrics.CornerRadiusScale,
|
||||
24,
|
||||
46,
|
||||
_chromeContext);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundMotionLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundTintLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
ComponentChromeCornerRadiusHelper.Apply(
|
||||
cornerRadius,
|
||||
RootBorder,
|
||||
BackgroundImageLayer,
|
||||
BackgroundMotionLayer,
|
||||
BackgroundTintLayer,
|
||||
BackgroundLightLayer,
|
||||
BackgroundShadeLayer);
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22),
|
||||
Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18));
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22, _chromeContext),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18, _chromeContext));
|
||||
ApplyAdaptiveTypography();
|
||||
ResetParticles();
|
||||
}
|
||||
|
||||
@@ -400,8 +400,8 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46));
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(32 * softScale, 16, 46);
|
||||
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(32 * softScale, 16, 46);
|
||||
|
||||
var horizontalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||
@@ -452,7 +452,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
|
||||
visual.ImageHost.Width = imageWidth;
|
||||
visual.ImageHost.Height = imageHeight;
|
||||
visual.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(imageHeight * 0.15, 8, 16));
|
||||
visual.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
||||
|
||||
visual.TitleTextBlock.MaxWidth = textWidth;
|
||||
visual.TitleTextBlock.FontSize = titleFont;
|
||||
|
||||
@@ -182,8 +182,8 @@ public partial class LunarCalendarWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 16, 44));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(16 * scale, 8, 24));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(30 * scale, 16, 44);
|
||||
RootBorder.Padding = new Thickness(ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 8, 24));
|
||||
LayoutRoot.RowSpacing = Math.Clamp(10 * scale, 5, 18);
|
||||
DividerBorder.Margin = new Thickness(
|
||||
Math.Clamp(8 * scale, 3, 14),
|
||||
|
||||
@@ -217,8 +217,8 @@ public partial class MonthCalendarWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 14, 40));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 22));
|
||||
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(28 * scale, 14, 40);
|
||||
RootBorder.Padding = new Thickness(ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 8, 22));
|
||||
LayoutRoot.RowSpacing = Math.Clamp(10 * scale, 5, 16);
|
||||
LayoutRoot.Width = Math.Clamp(280 * scale, 220, 420);
|
||||
LayoutRoot.Height = Math.Clamp(280 * scale, 220, 420);
|
||||
|
||||
@@ -10,13 +10,14 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -115,6 +116,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private WeatherSnapshot? _latestSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = 48;
|
||||
private ComponentChromeContext? _chromeContext;
|
||||
private WeatherVisualKind _activeVisualKind = WeatherVisualKind.ClearDay;
|
||||
private double _animationPhase;
|
||||
private int _activeParticleCount;
|
||||
@@ -252,6 +254,13 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentChromeContext(ComponentChromeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_chromeContext = context;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -259,17 +268,23 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var scale = ResolveScale();
|
||||
var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4);
|
||||
var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2);
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 46);
|
||||
var cornerRadius = ComponentChromeCornerRadiusHelper.Scale(
|
||||
_currentCellSize * metrics.CornerRadiusScale,
|
||||
24,
|
||||
46,
|
||||
_chromeContext);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundMotionLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundTintLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
ComponentChromeCornerRadiusHelper.Apply(
|
||||
cornerRadius,
|
||||
RootBorder,
|
||||
BackgroundImageLayer,
|
||||
BackgroundMotionLayer,
|
||||
BackgroundTintLayer,
|
||||
BackgroundLightLayer,
|
||||
BackgroundShadeLayer);
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22),
|
||||
Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18));
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22, _chromeContext),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18, _chromeContext));
|
||||
ApplyAdaptiveTypography();
|
||||
ResetParticles();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Background="#2D5A8E"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
@@ -23,15 +23,15 @@
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-40,-40,0"
|
||||
CornerRadius="70"
|
||||
Background="{DynamicResource SystemAccentColorLight2Brush}"
|
||||
Opacity="0.2"
|
||||
Background="#4A90D9"
|
||||
Opacity="0.3"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Foreground="#D8FFFFFF"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
@@ -48,7 +48,7 @@
|
||||
PointerPressed="OnRefreshPointerPressed">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
Foreground="#B8FFFFFF" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
Width="130"
|
||||
Height="90"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
Background="#3AFFFFFF"
|
||||
Padding="10"
|
||||
Cursor="Hand"
|
||||
PointerPressed="OnDocumentCardPointerPressed">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding FileName}"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Foreground="#D8FFFFFF"
|
||||
FontSize="12"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
@@ -84,7 +84,7 @@
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding TimeAgo}"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
Foreground="#9AFFFFFF"
|
||||
FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
@@ -99,7 +99,7 @@
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="暂无最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
Foreground="#9AFFFFFF"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware
|
||||
{
|
||||
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
|
||||
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private List<OfficeRecentDocument> _documents = new();
|
||||
private string _componentId = BuiltInComponentIds.DesktopOfficeRecentDocuments;
|
||||
private string _placementId = string.Empty;
|
||||
private IReadOnlyList<string> _enabledSources = OfficeRecentDocumentSourceTypes.DefaultValues;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isLoading;
|
||||
@@ -20,6 +26,7 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
InitializeComponent();
|
||||
_recentDocumentsService = new OfficeRecentDocumentsService();
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -44,27 +51,45 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDocuments()
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopOfficeRecentDocuments
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
private async void LoadDocuments()
|
||||
{
|
||||
if (_isLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
ReloadSettings();
|
||||
StatusTextBlock.IsVisible = false;
|
||||
DocumentsItemsControl.ItemsSource = null;
|
||||
|
||||
_documents = _recentDocumentsService.GetRecentDocuments(20);
|
||||
var enabledSources = _enabledSources.ToArray();
|
||||
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20, enabledSources));
|
||||
|
||||
if (_documents.Count == 0)
|
||||
{
|
||||
StatusTextBlock.Text = "暂无最近文档";
|
||||
StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateDisplay();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusTextBlock.Text = "加载失败";
|
||||
AppLogger.Warn("OfficeRecentDocsWidget", "Failed to load recent Office documents.", ex);
|
||||
StatusTextBlock.Text = "\u52a0\u8f7d\u5931\u8d25";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
}
|
||||
finally
|
||||
@@ -73,6 +98,14 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadSettings()
|
||||
{
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
|
||||
@@ -90,15 +123,29 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
var span = DateTime.Now - dateTime;
|
||||
|
||||
if (span.TotalMinutes < 1)
|
||||
return "刚刚";
|
||||
{
|
||||
return "\u521a\u521a";
|
||||
}
|
||||
|
||||
if (span.TotalMinutes < 60)
|
||||
return $"{(int)span.TotalMinutes} 分钟前";
|
||||
{
|
||||
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalHours < 24)
|
||||
return $"{(int)span.TotalHours} 小时前";
|
||||
{
|
||||
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalDays < 7)
|
||||
return $"{(int)span.TotalDays} 天前";
|
||||
{
|
||||
return $"{(int)span.TotalDays} \u5929\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalDays < 30)
|
||||
return $"{(int)(span.TotalDays / 7)} 周前";
|
||||
{
|
||||
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
|
||||
}
|
||||
|
||||
return dateTime.ToString("MM/dd");
|
||||
}
|
||||
|
||||
@@ -63,15 +63,15 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
|
||||
var chromeScale = Math.Clamp(rawScale, 0.62, 2.0);
|
||||
var contentScale = Math.Clamp(rawScale, 0.74, 1.0);
|
||||
|
||||
var rootRadius = Math.Clamp(34 * chromeScale, 16, 56);
|
||||
RootBorder.CornerRadius = new CornerRadius(rootRadius);
|
||||
var rootRadius = ComponentChromeCornerRadiusHelper.Scale(34 * chromeScale, 16, 56);
|
||||
RootBorder.CornerRadius = rootRadius;
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
RecorderCardBorder.CornerRadius = new CornerRadius(rootRadius);
|
||||
RecorderCardBorder.CornerRadius = rootRadius;
|
||||
RecorderContentGrid.Margin = new Thickness(
|
||||
Math.Clamp(24 * contentScale, 14, 26),
|
||||
Math.Clamp(18 * contentScale, 10, 22),
|
||||
Math.Clamp(24 * contentScale, 14, 26),
|
||||
Math.Clamp(18 * contentScale, 10, 24));
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(24 * contentScale, 14, 26),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(18 * contentScale, 10, 22),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(24 * contentScale, 14, 26),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(18 * contentScale, 10, 24));
|
||||
|
||||
var sideButtonSize = Math.Clamp(54 * contentScale, 34, 58);
|
||||
DiscardButtonBorder.Width = sideButtonSize;
|
||||
|
||||
119
LanMountainDesktop/Views/Components/RemovableStorageWidget.axaml
Normal file
119
LanMountainDesktop/Views/Components/RemovableStorageWidget.axaml
Normal file
@@ -0,0 +1,119 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="280"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="28"
|
||||
BorderThickness="1"
|
||||
Padding="16"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Border x:Name="AccentOrb"
|
||||
Width="132"
|
||||
Height="132"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-48,-48,0"
|
||||
CornerRadius="66"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Border x:Name="AccentGlow"
|
||||
Height="76"
|
||||
Margin="-18,0,-18,-34"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
CornerRadius="38"
|
||||
Opacity="0.42"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,*,Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="IconBadge"
|
||||
Width="44"
|
||||
Height="44"
|
||||
CornerRadius="22"
|
||||
VerticalAlignment="Top">
|
||||
<fi:FluentIcon x:Name="DriveIcon"
|
||||
Icon="UsbStick"
|
||||
IconVariant="Regular"
|
||||
FontSize="24"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="HeaderTextStack"
|
||||
Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="DriveNameTextBlock"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="DriveDetailTextBlock"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="1"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Button x:Name="OpenButton"
|
||||
Grid.Row="2"
|
||||
Height="42"
|
||||
Padding="14,0"
|
||||
CornerRadius="999"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="OnOpenClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:FluentIcon x:Name="OpenButtonIcon"
|
||||
Icon="OpenFolder"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="OpenButtonTextBlock"
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="EjectButton"
|
||||
Grid.Row="3"
|
||||
Height="42"
|
||||
Padding="14,0"
|
||||
CornerRadius="999"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="OnEjectClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:FluentIcon x:Name="EjectButtonIcon"
|
||||
Icon="ArrowEject"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="EjectButtonTextBlock"
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user