mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0 6 7
可移动存储组件
This commit is contained in:
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"
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryGetFromOfficeRegistry(List<OfficeRecentDocument> documents, string registryPath)
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
|
||||
{
|
||||
try
|
||||
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;
|
||||
if (key == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var subKeyName in key.GetSubKeyNames())
|
||||
var entries = key
|
||||
.GetValueNames()
|
||||
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(name => new
|
||||
{
|
||||
try
|
||||
{
|
||||
using var subKey = key.OpenSubKey(subKeyName);
|
||||
if (subKey == null) continue;
|
||||
Name = name,
|
||||
Order = ParseMruItemOrder(name),
|
||||
Value = key.GetValue(name) as string
|
||||
})
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
|
||||
.OrderBy(entry => entry.Order);
|
||||
|
||||
var filePath = subKey.GetValue("Path") as string;
|
||||
if (string.IsNullOrEmpty(filePath)) continue;
|
||||
|
||||
AddDocumentIfExists(documents, filePath);
|
||||
}
|
||||
catch
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
// 忽略单个子键访问错误
|
||||
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
|
||||
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略注册表访问错误
|
||||
}
|
||||
}
|
||||
#pragma warning restore CA1416 // 平台兼容性警告
|
||||
|
||||
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
|
||||
{
|
||||
var recentPaths = GetRecentFolders();
|
||||
|
||||
foreach (var recentPath in recentPaths)
|
||||
{
|
||||
if (!Directory.Exists(recentPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
||||
foreach (var lnkPath in files)
|
||||
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(lnkPath);
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
var targetPath = GetShortcutTarget(linkFile.FullName);
|
||||
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
continue;
|
||||
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
AddDocumentIfExists(documents, targetPath);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TryGetFromJumpList(List<OfficeRecentDocument> documents)
|
||||
return paths;
|
||||
}
|
||||
|
||||
private void AddDocumentIfExists(
|
||||
List<OfficeRecentDocument> documents,
|
||||
string? filePath,
|
||||
int sourcePriority,
|
||||
int sourceOrder,
|
||||
DateTime? recentAccessTime)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows Jump List存储在以下位置
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var jumpListPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft", "Windows", "Recent", "AutomaticDestinations");
|
||||
|
||||
if (!Directory.Exists(jumpListPath)) return;
|
||||
|
||||
// Office应用的Jump List文件
|
||||
var officeJumpListFiles = new[]
|
||||
{
|
||||
"a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
|
||||
"9b524fe3be704a4d.automaticDestinations-ms", // Excel
|
||||
"d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
|
||||
};
|
||||
|
||||
foreach (var jumpFile in officeJumpListFiles)
|
||||
{
|
||||
var fullPath = Path.Combine(jumpListPath, jumpFile);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
TryParseJumpListFile(fullPath, documents);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Jump List解析失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
private void TryParseJumpListFile(string jumpListPath, List<OfficeRecentDocument> documents)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jump List文件是二进制格式,这里使用简化的方法
|
||||
// 读取文件并尝试提取文件路径
|
||||
var bytes = File.ReadAllBytes(jumpListPath);
|
||||
var text = Encoding.Unicode.GetString(bytes);
|
||||
|
||||
// 查找可能的文件路径(简化实现)
|
||||
var possiblePaths = ExtractPossiblePaths(text);
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
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 path = match.Value.Trim('\0', ' ', '"');
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略正则表达式错误
|
||||
}
|
||||
}
|
||||
|
||||
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void AddDocumentIfExists(List<OfficeRecentDocument> documents, string filePath)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed record StatusBarSettingsState(
|
||||
bool EnableDynamicTaskbarActions,
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
public sealed record WeatherSettingsState(
|
||||
|
||||
@@ -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)
|
||||
]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (%)");
|
||||
|
||||
@@ -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 课表文件并选择启用项。");
|
||||
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", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
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", "添加课表");
|
||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
||||
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,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", "配置右侧实时噪音值显示内容。");
|
||||
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", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
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", "显示 display dB");
|
||||
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
|
||||
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
|
||||
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();
|
||||
|
||||
@@ -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)
|
||||
@@ -130,7 +155,38 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -547,6 +547,7 @@ public partial class MainWindow
|
||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user