可移动存储组件
This commit is contained in:
lincube
2026-03-19 00:17:21 +08:00
parent 594a62132f
commit 081abeb688
32 changed files with 2009 additions and 265 deletions

View 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"

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -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";
}

View File

@@ -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",

View File

@@ -55,6 +55,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" />

View File

@@ -86,6 +86,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",
@@ -588,6 +590,7 @@
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.office_recent_documents": "Recent Documents",
"component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_session_control": "Study Session Control",
@@ -789,6 +792,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",

View File

@@ -85,6 +85,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": "紧凑",
@@ -782,6 +784,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",

View File

@@ -101,6 +101,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;

View File

@@ -72,6 +72,9 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),

View File

@@ -1,13 +1,18 @@
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 Microsoft.Win32;
using MudTools.OfficeInterop;
using MudTools.OfficeInterop.Excel;
using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services;
@@ -25,31 +30,49 @@ 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)
{
var documents = new List<OfficeRecentDocument>();
// 方法1: 从注册表读取Office最近文档最可靠
if (!OperatingSystem.IsWindows())
{
return documents;
}
TryGetFromRegistry(documents);
// 方法2: 从Recent文件夹读取快捷方式备用
TryGetFromRecentFolders(documents);
TryGetFromJumpLists(documents);
// 方法3: 从Windows Jump List读取如果可用
TryGetFromJumpList(documents);
if (documents.Count < maxCount)
{
TryGetFromMudToolsInterop(documents);
}
return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(d => d.LastModifiedTime).First())
.OrderByDescending(d => d.LastModifiedTime)
.Select(MergeDocuments)
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount)
.ToList();
}
@@ -63,261 +86,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
{
}
}
#pragma warning disable CA1416 // 平台兼容性警告
[SupportedOSPlatform("windows")]
private static void RunOnStaThread(Action action)
{
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
{
// Word最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Word\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Word\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Word\Reading Locations");
// Excel最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Excel\Reading Locations");
// PowerPoint最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\PowerPoint\Reading Locations");
// 通用Office最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Word");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Excel");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office PowerPoint");
}
catch
{
// 忽略注册表访问错误
}
}
private void TryGetFromOfficeRegistry(List<OfficeRecentDocument> documents, string registryPath)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null) return;
foreach (var subKeyName in key.GetSubKeyNames())
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
if (officeRoot == null)
{
try
{
using var subKey = key.OpenSubKey(subKeyName);
if (subKey == null) continue;
return;
}
var filePath = subKey.GetValue("Path") as string;
if (string.IsNullOrEmpty(filePath)) continue;
var versions = officeRoot
.GetSubKeyNames()
.Where(IsOfficeVersionKey)
.OrderByDescending(ParseVersionKey)
.ToList();
AddDocumentIfExists(documents, filePath);
}
catch
{
// 忽略单个子键访问错误
}
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
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);
}
}
#pragma warning restore CA1416 // 平台兼容性警告
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{
var recentPaths = GetRecentFolders();
foreach (var recentPath in recentPaths)
try
{
if (!Directory.Exists(recentPath))
{
continue;
}
var linkFiles = GetRecentFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
try
var sourceOrder = 0;
foreach (var linkFile in linkFiles)
{
var files = Directory.GetFiles(recentPath, "*.lnk");
foreach (var lnkPath in files)
{
var targetPath = GetShortcutTarget(lnkPath);
if (string.IsNullOrEmpty(targetPath))
{
continue;
}
AddDocumentIfExists(documents, targetPath);
}
}
catch
{
// 忽略文件夹访问错误
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 TryGetFromJumpList(List<OfficeRecentDocument> documents)
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
{
try
{
// Windows Jump List存储在以下位置
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var jumpListPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "Windows", "Recent", "AutomaticDestinations");
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();
if (!Directory.Exists(jumpListPath)) return;
// Office应用的Jump List文件
var officeJumpListFiles = new[]
var sourceOrder = 0;
foreach (var jumpListFile in jumpListFiles)
{
"a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
"9b524fe3be704a4d.automaticDestinations-ms", // Excel
"d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
};
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
}
}
foreach (var jumpFile in officeJumpListFiles)
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
{
try
{
var bytes = File.ReadAllBytes(jumpListFile.FullName);
foreach (var filePath in ExtractPossiblePaths(bytes))
{
var fullPath = Path.Combine(jumpListPath, jumpFile);
if (File.Exists(fullPath))
{
TryParseJumpListFile(fullPath, documents);
}
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
}
}
catch
{
// Jump List解析失败忽略
// Ignore a single Jump List file and keep scanning the rest.
}
}
private void TryParseJumpListFile(string jumpListPath, List<OfficeRecentDocument> documents)
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
{
try
{
// Jump List文件是二进制格式这里使用简化的方法
// 读取文件并尝试提取文件路径
var bytes = File.ReadAllBytes(jumpListPath);
var text = Encoding.Unicode.GetString(bytes);
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// 查找可能的文件路径(简化实现)
var possiblePaths = ExtractPossiblePaths(text);
foreach (var path in possiblePaths)
foreach (var text in new[]
{
Encoding.Unicode.GetString(bytes),
Encoding.Latin1.GetString(bytes)
})
{
foreach (Match match in OfficeFilePathRegex.Matches(text))
{
AddDocumentIfExists(documents, path);
}
}
catch
{
// Jump List解析失败忽略
}
}
private IEnumerable<string> ExtractPossiblePaths(string text)
{
var paths = new List<string>();
// 查找常见的文件路径模式
var patterns = new[]
{
@"[A-Z]:\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)",
@"\\\\[^\\]+\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)"
};
foreach (var pattern in patterns)
{
try
{
var matches = System.Text.RegularExpressions.Regex.Matches(text, pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match match in matches)
var normalizedPath = NormalizeFilePath(match.Value);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
var path = match.Value.Trim('\0', ' ', '"');
if (!string.IsNullOrEmpty(path))
{
paths.Add(path);
}
paths.Add(normalizedPath);
}
}
catch
{
// 忽略正则表达式错误
}
}
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
return paths;
}
private void AddDocumentIfExists(List<OfficeRecentDocument> documents, string filePath)
private void AddDocumentIfExists(
List<OfficeRecentDocument> documents,
string? filePath,
int sourcePriority,
int sourceOrder,
DateTime? recentAccessTime)
{
try
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
if (!IsOfficeFile(extension))
var normalizedPath = NormalizeFilePath(filePath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return;
}
if (!File.Exists(filePath))
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{
return;
}
var fileInfo = new FileInfo(filePath);
var doc = new OfficeRecentDocument
var fileInfo = new FileInfo(normalizedPath);
documents.Add(new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(filePath),
FilePath = filePath,
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
FilePath = normalizedPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension)
};
if (!documents.Any(d => string.Equals(d.FilePath, filePath, StringComparison.OrdinalIgnoreCase)))
{
documents.Add(doc);
}
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 List<string> GetRecentFolders()
private static IEnumerable<string> GetRecentFolders()
{
var folders = new List<string>();
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"));
// 添加Office 365路径
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent"));
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)
@@ -335,4 +684,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);
}
}

View 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);
}
}
}

View File

@@ -29,6 +29,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
bool ClockTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(

View File

@@ -361,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -373,6 +374,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 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);

View File

@@ -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>
@@ -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">

View File

@@ -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 (%)");

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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));
}
}

View File

@@ -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"

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -444,6 +444,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",

View File

@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views.Components;
@@ -44,27 +44,34 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
}
}
private void LoadDocuments()
private async void LoadDocuments()
{
if (_isLoading)
{
return;
}
try
{
_isLoading = true;
StatusTextBlock.IsVisible = false;
DocumentsItemsControl.ItemsSource = null;
_documents = _recentDocumentsService.GetRecentDocuments(20);
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20));
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
@@ -90,15 +97,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");
}

View 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>

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class RemovableStorageWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
{
private readonly record struct RemovableStoragePalette(
Color BackgroundFrom,
Color BackgroundTo,
Color Border,
Color AccentOrb,
Color AccentGlow,
Color IconBadgeBackground,
Color IconForeground,
Color PrimaryText,
Color SecondaryText,
Color StatusText,
Color Accent,
Color OnAccent,
Color SecondaryButtonBackground,
Color SecondaryButtonBorder,
Color SecondaryButtonForeground,
Color DisabledButtonBackground,
Color DisabledButtonBorder,
Color DisabledButtonForeground);
private readonly DispatcherTimer _pollTimer = new()
{
Interval = TimeSpan.FromSeconds(2)
};
private readonly IRemovableStorageService _removableStorageService = new RemovableStorageService();
private readonly LocalizationService _localizationService = new();
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private IReadOnlyList<RemovableStorageDrive> _connectedDrives = Array.Empty<RemovableStorageDrive>();
private string _componentId = BuiltInComponentIds.DesktopRemovableStorage;
private string _placementId = string.Empty;
private string _languageCode = "zh-CN";
private string? _componentColorScheme;
private string _selectedDriveRootPath = string.Empty;
private string? _statusOverrideText;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _isDisposed;
public RemovableStorageWidget()
{
InitializeComponent();
_pollTimer.Tick += OnPollTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ReloadSettings();
ApplyVisualState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
ApplyLayoutMetrics();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
var shouldRefresh = !_isOnActivePage && isOnActivePage;
_isOnActivePage = isOnActivePage;
UpdatePollingState();
if (shouldRefresh)
{
_ = RefreshDriveListAsync();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopRemovableStorage
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void RefreshFromSettings()
{
ReloadSettings();
ApplyVisualState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = true;
UpdatePollingState();
_ = RefreshDriveListAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = false;
UpdatePollingState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
ApplyLayoutMetrics();
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
ApplyVisualState();
}
private async void OnPollTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
await RefreshDriveListAsync();
}
private async void OnOpenClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
if (_removableStorageService.OpenDrive(drive.RootPath))
{
_statusOverrideText = L("removable_storage.widget.ready", "Ready to open or eject.");
ApplyVisualState();
return;
}
_statusOverrideText = L("removable_storage.widget.open_failed", "Failed to open this drive.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async void OnEjectClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
_statusOverrideText = L("removable_storage.widget.ejecting", "Ejecting drive...");
ApplyVisualState();
var ejected = _removableStorageService.EjectDrive(drive.RootPath);
_statusOverrideText = ejected
? L("removable_storage.widget.ejecting", "Ejecting drive...")
: L("removable_storage.widget.eject_failed", "Could not eject this drive. Close any files on it and try again.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async Task RefreshDriveListAsync()
{
if (_isDisposed || _isRefreshing)
{
return;
}
_isRefreshing = true;
try
{
var previousDriveRoots = new HashSet<string>(
_connectedDrives.Select(drive => drive.RootPath),
StringComparer.OrdinalIgnoreCase);
var latestDrives = await Task.Run(() => _removableStorageService.GetConnectedDrives());
if (_isDisposed)
{
return;
}
var newlyInsertedDrive = latestDrives.FirstOrDefault(drive => !previousDriveRoots.Contains(drive.RootPath));
_connectedDrives = latestDrives;
if (newlyInsertedDrive is not null)
{
_selectedDriveRootPath = newlyInsertedDrive.RootPath;
}
else if (string.IsNullOrWhiteSpace(_selectedDriveRootPath) ||
!_connectedDrives.Any(drive => string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase)))
{
_selectedDriveRootPath = _connectedDrives.FirstOrDefault().RootPath ?? string.Empty;
}
if (_connectedDrives.Count == 0)
{
_selectedDriveRootPath = string.Empty;
_statusOverrideText = null;
}
else if (newlyInsertedDrive is not null)
{
_statusOverrideText = null;
}
ReloadSettings();
ApplyVisualState();
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorageWidget", "Failed to refresh removable storage widget.", ex);
_statusOverrideText = L("removable_storage.widget.refresh_failed", "Drive list refresh failed.");
ApplyVisualState();
}
finally
{
_isRefreshing = false;
}
}
private void ReloadSettings()
{
try
{
var appSettings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
}
catch
{
_languageCode = _localizationService.NormalizeLanguageCode(_languageCode);
}
}
private void ApplyVisualState()
{
var drive = GetSelectedDrive();
var hasDrive = drive is not null;
var palette = ResolvePalette();
RootBorder.Background = CreateGradientBrush(palette.BackgroundFrom, palette.BackgroundTo);
RootBorder.BorderBrush = CreateBrush(palette.Border);
AccentOrb.Background = CreateBrush(palette.AccentOrb);
AccentGlow.Background = CreateBrush(palette.AccentGlow);
IconBadge.Background = CreateBrush(palette.IconBadgeBackground);
DriveIcon.Foreground = CreateBrush(palette.IconForeground);
DriveNameTextBlock.Foreground = CreateBrush(palette.PrimaryText);
DriveDetailTextBlock.Foreground = CreateBrush(palette.SecondaryText);
StatusTextBlock.Foreground = CreateBrush(palette.StatusText);
if (hasDrive)
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.Accent,
palette.OnAccent,
palette.Accent);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.SecondaryButtonBackground,
palette.SecondaryButtonForeground,
palette.SecondaryButtonBorder);
}
else
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
}
OpenButton.IsEnabled = hasDrive;
EjectButton.IsEnabled = hasDrive;
OpenButtonTextBlock.Text = L("removable_storage.action.open", "Open");
EjectButtonTextBlock.Text = L("removable_storage.action.eject", "Eject");
if (hasDrive)
{
var selectedDrive = drive!;
DriveNameTextBlock.Text = ResolveDriveName(selectedDrive);
DriveDetailTextBlock.Text = selectedDrive.DriveLetter;
StatusTextBlock.Text = _statusOverrideText ??
L("removable_storage.widget.ready", "Ready to open or eject.");
}
else
{
DriveNameTextBlock.Text = L("removable_storage.widget.empty_title", "No device inserted");
DriveDetailTextBlock.Text = L("removable_storage.widget.empty_subtitle", "Insert a USB drive to show it here.");
StatusTextBlock.Text = L("removable_storage.widget.empty_hint", "Buttons stay disabled until a removable device is inserted.");
}
ApplyLayoutMetrics();
}
private void ApplyLayoutMetrics()
{
var scale = ResolveScale();
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
var cornerRadius = Math.Clamp(_currentCellSize * 0.44, 18, 34);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22),
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22));
LayoutGrid.RowSpacing = Math.Clamp(10 * scale, 8, 16);
HeaderGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 16);
HeaderTextStack.Spacing = Math.Clamp(2 * scale, 1, 4);
var badgeSize = Math.Clamp(44 * scale, 38, 60);
IconBadge.Width = badgeSize;
IconBadge.Height = badgeSize;
IconBadge.CornerRadius = new CornerRadius(badgeSize * 0.5);
DriveIcon.FontSize = Math.Clamp(24 * scale, 20, 32);
DriveNameTextBlock.FontSize = Math.Clamp(16 * scale, 13, 24);
DriveDetailTextBlock.FontSize = Math.Clamp(11.5 * scale, 10, 16);
StatusTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
StatusTextBlock.MaxWidth = Math.Max(96, width - (RootBorder.Padding.Left + RootBorder.Padding.Right));
var buttonHeight = Math.Clamp(42 * scale, 38, 54);
var buttonPadding = Math.Clamp(14 * scale, 10, 20);
var buttonCornerRadius = Math.Clamp(buttonHeight * 0.5, 18, 999);
OpenButton.Height = buttonHeight;
OpenButton.Padding = new Thickness(buttonPadding, 0);
OpenButton.CornerRadius = new CornerRadius(buttonCornerRadius);
EjectButton.Height = buttonHeight;
EjectButton.Padding = new Thickness(buttonPadding, 0);
EjectButton.CornerRadius = new CornerRadius(buttonCornerRadius);
OpenButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
EjectButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
OpenButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
EjectButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
AccentOrb.Width = Math.Clamp(width * 0.44, 96, 176);
AccentOrb.Height = AccentOrb.Width;
AccentOrb.CornerRadius = new CornerRadius(AccentOrb.Width * 0.5);
AccentGlow.Height = Math.Clamp(76 * scale, 52, 110);
AccentGlow.CornerRadius = new CornerRadius(AccentGlow.Height * 0.5);
}
private RemovableStorageDrive? GetSelectedDrive()
{
if (_connectedDrives.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(_selectedDriveRootPath))
{
var selected = _connectedDrives.FirstOrDefault(drive =>
string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase));
if (selected is not null)
{
return selected;
}
}
return _connectedDrives[0];
}
private string ResolveDriveName(RemovableStorageDrive drive)
{
return string.IsNullOrWhiteSpace(drive.VolumeLabel)
? L("removable_storage.widget.default_name", "Removable Drive")
: drive.VolumeLabel.Trim();
}
private RemovableStoragePalette ResolvePalette()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
if (!useMonetColor)
{
var nativeAccent = Color.Parse("#FF65A8FF");
var nativeBackgroundFrom = Color.Parse("#FF10345F");
var nativeBackgroundTo = Color.Parse("#FF0D213E");
var nativePrimaryText = Color.Parse("#FFF4F8FF");
var nativeSecondaryText = Color.Parse("#C8D9F5FF");
var nativeDisabled = Color.Parse("#30465D7A");
return new RemovableStoragePalette(
nativeBackgroundFrom,
nativeBackgroundTo,
Color.Parse("#6A97D6FF"),
Color.Parse("#2F8BC5FF"),
Color.Parse("#4C79BFFF"),
Color.Parse("#335BAAFF"),
Color.Parse("#FFF5FAFF"),
nativePrimaryText,
nativeSecondaryText,
Color.Parse("#D8E7FFFF"),
nativeAccent,
ColorMath.EnsureContrast(Color.Parse("#FF071420"), nativeAccent, 4.5),
Color.Parse("#24FFFFFF"),
Color.Parse("#5A9ACDFF"),
nativePrimaryText,
nativeDisabled,
Color.Parse("#4D6782A0"),
Color.Parse("#8FA8BDD1"));
}
var surfaceRaised = ResolveThemeColor("AdaptiveSurfaceRaisedBrush", "#FF1A2332");
var surfaceOverlay = ResolveThemeColor("AdaptiveSurfaceOverlayBrush", "#FF111827");
var accent = ResolveThemeColor("AdaptiveAccentBrush", "#FF61A8FF");
var onAccent = ResolveThemeColor("AdaptiveOnAccentBrush", "#FFFFFFFF");
var primaryText = ResolveThemeColor("AdaptiveTextPrimaryBrush", "#FFF8FAFC");
var secondaryText = ResolveThemeColor("AdaptiveTextSecondaryBrush", "#FFD0D7E3");
var mutedText = ResolveThemeColor("AdaptiveTextMutedBrush", "#FFAFB8C7");
var disabledButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, surfaceOverlay, 0.35), 0xD8);
var disabledButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.18), 0x88);
var disabledButtonForeground = ColorMath.WithAlpha(primaryText, 0x88);
var backgroundFrom = ColorMath.Blend(surfaceRaised, accent, 0.18);
var backgroundTo = ColorMath.Blend(surfaceOverlay, surfaceRaised, 0.46);
var border = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.38), 0xB8);
var iconBadgeBackground = ColorMath.Blend(surfaceRaised, accent, 0.28);
var iconForeground = ColorMath.EnsureContrast(accent, iconBadgeBackground, 3.0);
var secondaryButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.10), 0xE6);
var secondaryButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.46), 0xC6);
return new RemovableStoragePalette(
backgroundFrom,
backgroundTo,
border,
ColorMath.WithAlpha(accent, 0x28),
ColorMath.WithAlpha(ColorMath.Blend(accent, backgroundFrom, 0.26), 0x74),
iconBadgeBackground,
iconForeground,
primaryText,
secondaryText,
mutedText,
accent,
onAccent,
secondaryButtonBackground,
secondaryButtonBorder,
primaryText,
disabledButtonBackground,
disabledButtonBorder,
disabledButtonForeground);
}
private Color ResolveThemeColor(string resourceKey, string fallbackHex)
{
if (this.TryFindResource(resourceKey, out var resource))
{
if (resource is ISolidColorBrush solidBrush)
{
return solidBrush.Color;
}
if (resource is SolidColorBrush directSolidBrush)
{
return directSolidBrush.Color;
}
}
return Color.Parse(fallbackHex);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 220d, 0.72, 2.4) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.72, 2.4) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static void ApplyButtonPalette(
Button button,
FluentIcon icon,
TextBlock textBlock,
Color background,
Color foreground,
Color border)
{
button.Background = CreateBrush(background);
button.BorderBrush = CreateBrush(border);
button.BorderThickness = new Thickness(1);
button.Foreground = CreateBrush(foreground);
icon.Foreground = CreateBrush(foreground);
textBlock.Foreground = CreateBrush(foreground);
}
private static IBrush CreateGradientBrush(Color from, Color to)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(from, 0),
new GradientStop(to, 1)
}
};
}
private static SolidColorBrush CreateBrush(Color color)
{
return new(color);
}
private void UpdatePollingState()
{
if (_isAttached && _isOnActivePage)
{
if (!_pollTimer.IsEnabled)
{
_pollTimer.Start();
}
return;
}
_pollTimer.Stop();
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_pollTimer.Stop();
_pollTimer.Tick -= OnPollTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
}
}

View File

@@ -398,10 +398,12 @@ public partial class MainWindow
_clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute"
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
if (ClockWidget is not null)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
}
}
@@ -413,6 +415,7 @@ public partial class MainWindow
if (ClockWidget is not null)
{
ClockWidget.IsVisible = showClock;
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
if (showClock)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);

View File

@@ -547,6 +547,7 @@ public partial class MainWindow
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
TaskbarLayoutMode = _taskbarLayoutMode,
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
StatusBarSpacingMode = _statusBarSpacingMode,
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
};

View File

@@ -131,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private string _gridSpacingPreset = "Relaxed";
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";

View File

@@ -38,6 +38,20 @@
</ComboBox>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ClockTransparentBackgroundLabel}" />
<TextBlock Text="{Binding ClockTransparentBackgroundDescription}"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ClockTransparentBackground}"
VerticalAlignment="Center" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />

View File

@@ -5,6 +5,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>