mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081abeb688 | ||
|
|
594a62132f |
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>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
|
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopRemovableStorage,
|
||||||
|
"Removable Storage",
|
||||||
|
"Storage",
|
||||||
|
"File",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" 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="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
<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.description": "Choose which components appear on the top status bar.",
|
||||||
"settings.status_bar.clock_header": "Clock Component",
|
"settings.status_bar.clock_header": "Clock Component",
|
||||||
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
|
"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_header": "Component Spacing",
|
||||||
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
|
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
|
||||||
"settings.status_bar.spacing_mode_compact": "Compact",
|
"settings.status_bar.spacing_mode_compact": "Compact",
|
||||||
@@ -588,6 +590,7 @@
|
|||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
"component.office_recent_documents": "Recent Documents",
|
"component.office_recent_documents": "Recent Documents",
|
||||||
|
"component.removable_storage": "Removable Storage",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
"component.study_session_control": "Study Session Control",
|
"component.study_session_control": "Study Session Control",
|
||||||
@@ -789,6 +792,20 @@
|
|||||||
"study.environment.settings.show_display_db": "Show display dB",
|
"study.environment.settings.show_display_db": "Show display dB",
|
||||||
"study.environment.settings.show_dbfs": "Show dBFS",
|
"study.environment.settings.show_dbfs": "Show dBFS",
|
||||||
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
"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.start": "Start Study Session",
|
||||||
"study.session_control.action.stop": "Stop Study Session",
|
"study.session_control.action.stop": "Stop Study Session",
|
||||||
"study.session_control.idle_hint": "Tap the right button to start",
|
"study.session_control.idle_hint": "Tap the right button to start",
|
||||||
|
|||||||
@@ -85,6 +85,8 @@
|
|||||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||||
"settings.status_bar.clock_header": "时间组件",
|
"settings.status_bar.clock_header": "时间组件",
|
||||||
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
|
"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_header": "组件间距",
|
||||||
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
|
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
|
||||||
"settings.status_bar.spacing_mode_compact": "紧凑",
|
"settings.status_bar.spacing_mode_compact": "紧凑",
|
||||||
@@ -782,6 +784,21 @@
|
|||||||
"study.environment.value.unavailable": "--",
|
"study.environment.value.unavailable": "--",
|
||||||
"study.environment.value.display_format": "{0:F1} dB",
|
"study.environment.value.display_format": "{0:F1} dB",
|
||||||
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
"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.title": "环境组件设置",
|
||||||
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
|
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
|
||||||
"study.environment.settings.show_display_db": "显示 display dB",
|
"study.environment.settings.show_display_db": "显示 display dB",
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
|
||||||
|
|
||||||
|
public bool StatusBarClockTransparentBackground { get; set; }
|
||||||
|
|
||||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||||
|
|
||||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public static class DesktopComponentEditorRegistryFactory
|
|||||||
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
||||||
BuiltInComponentIds.DesktopStudyEnvironment,
|
BuiltInComponentIds.DesktopStudyEnvironment,
|
||||||
context => new StudyEnvironmentComponentEditor(context)),
|
context => new StudyEnvironmentComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopRemovableStorage] = new(
|
||||||
|
BuiltInComponentIds.DesktopRemovableStorage,
|
||||||
|
context => new RemovableStorageComponentEditor(context)),
|
||||||
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||||
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||||
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.RegularExpressions;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using System.Threading;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
using MudTools.OfficeInterop;
|
||||||
|
using MudTools.OfficeInterop.Excel;
|
||||||
|
using MudTools.OfficeInterop.Word;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -25,31 +30,49 @@ public sealed class OfficeRecentDocument
|
|||||||
public DateTime LastModifiedTime { get; set; }
|
public DateTime LastModifiedTime { get; set; }
|
||||||
public long FileSizeBytes { get; set; }
|
public long FileSizeBytes { get; set; }
|
||||||
public string IconGlyph { get; set; } = string.Empty;
|
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
|
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||||
{
|
{
|
||||||
|
private const string LogCategory = "OfficeRecentDocs";
|
||||||
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
||||||
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
||||||
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
||||||
|
private static readonly Regex OfficeFilePathRegex = new(
|
||||||
|
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex OfficeMruTimestampRegex = new(
|
||||||
|
@"\[T(?<filetime>[0-9A-F]+)\]",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
||||||
{
|
{
|
||||||
var documents = new List<OfficeRecentDocument>();
|
var documents = new List<OfficeRecentDocument>();
|
||||||
|
|
||||||
// 方法1: 从注册表读取Office最近文档(最可靠)
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
|
||||||
TryGetFromRegistry(documents);
|
TryGetFromRegistry(documents);
|
||||||
|
|
||||||
// 方法2: 从Recent文件夹读取快捷方式(备用)
|
|
||||||
TryGetFromRecentFolders(documents);
|
TryGetFromRecentFolders(documents);
|
||||||
|
TryGetFromJumpLists(documents);
|
||||||
|
|
||||||
// 方法3: 从Windows Jump List读取(如果可用)
|
if (documents.Count < maxCount)
|
||||||
TryGetFromJumpList(documents);
|
{
|
||||||
|
TryGetFromMudToolsInterop(documents);
|
||||||
|
}
|
||||||
|
|
||||||
return documents
|
return documents
|
||||||
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(g => g.OrderByDescending(d => d.LastModifiedTime).First())
|
.Select(MergeDocuments)
|
||||||
.OrderByDescending(d => d.LastModifiedTime)
|
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
|
||||||
|
.ThenBy(d => d.SourcePriority)
|
||||||
|
.ThenBy(d => d.SourceOrder)
|
||||||
|
.ThenByDescending(d => d.LastModifiedTime)
|
||||||
.Take(maxCount)
|
.Take(maxCount)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -63,261 +86,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
|||||||
FileName = filePath,
|
FileName = filePath,
|
||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
Process.Start(startInfo);
|
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
|
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)
|
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Word最近文档
|
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Word\Reading Locations");
|
if (officeRoot == null)
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Word\Reading Locations");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Word\Reading Locations");
|
|
||||||
|
|
||||||
// Excel最近文档
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Excel\Reading Locations");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Excel\Reading Locations");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Excel\Reading Locations");
|
|
||||||
|
|
||||||
// PowerPoint最近文档
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\PowerPoint\Reading Locations");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\PowerPoint\Reading Locations");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\PowerPoint\Reading Locations");
|
|
||||||
|
|
||||||
// 通用Office最近文档
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Word");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Excel");
|
|
||||||
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office PowerPoint");
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略注册表访问错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryGetFromOfficeRegistry(List<OfficeRecentDocument> documents, string registryPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
|
|
||||||
if (key == null) return;
|
|
||||||
|
|
||||||
foreach (var subKeyName in key.GetSubKeyNames())
|
|
||||||
{
|
{
|
||||||
try
|
return;
|
||||||
{
|
}
|
||||||
using var subKey = key.OpenSubKey(subKeyName);
|
|
||||||
if (subKey == null) continue;
|
|
||||||
|
|
||||||
var filePath = subKey.GetValue("Path") as string;
|
var versions = officeRoot
|
||||||
if (string.IsNullOrEmpty(filePath)) continue;
|
.GetSubKeyNames()
|
||||||
|
.Where(IsOfficeVersionKey)
|
||||||
|
.OrderByDescending(ParseVersionKey)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
AddDocumentIfExists(documents, filePath);
|
var sourceOrder = 0;
|
||||||
}
|
foreach (var version in versions)
|
||||||
catch
|
{
|
||||||
{
|
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
|
||||||
// 忽略单个子键访问错误
|
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
|
||||||
}
|
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 忽略注册表访问错误
|
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
|
||||||
|
|
||||||
|
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
|
||||||
|
if (userMruRoot == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var identityKey in userMruRoot.GetSubKeyNames())
|
||||||
|
{
|
||||||
|
TryGetFromRegistryMruKey(
|
||||||
|
documents,
|
||||||
|
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
|
||||||
|
ref sourceOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = key
|
||||||
|
.GetValueNames()
|
||||||
|
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(name => new
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Order = ParseMruItemOrder(name),
|
||||||
|
Value = key.GetValue(name) as string
|
||||||
|
})
|
||||||
|
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
|
||||||
|
.OrderBy(entry => entry.Order);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
|
||||||
|
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#pragma warning restore CA1416 // 平台兼容性警告
|
|
||||||
|
|
||||||
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
|
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
|
||||||
{
|
{
|
||||||
var recentPaths = GetRecentFolders();
|
try
|
||||||
|
|
||||||
foreach (var recentPath in recentPaths)
|
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(recentPath))
|
var linkFiles = GetRecentFolders()
|
||||||
{
|
.Where(Directory.Exists)
|
||||||
continue;
|
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
|
||||||
}
|
.Select(path => new FileInfo(path))
|
||||||
|
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
try
|
var sourceOrder = 0;
|
||||||
|
foreach (var linkFile in linkFiles)
|
||||||
{
|
{
|
||||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
var targetPath = GetShortcutTarget(linkFile.FullName);
|
||||||
foreach (var lnkPath in files)
|
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
|
||||||
{
|
|
||||||
var targetPath = GetShortcutTarget(lnkPath);
|
|
||||||
if (string.IsNullOrEmpty(targetPath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
AddDocumentIfExists(documents, targetPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略文件夹访问错误
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryGetFromJumpList(List<OfficeRecentDocument> documents)
|
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Windows Jump List存储在以下位置
|
var jumpListFiles = GetJumpListFolders()
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
.Where(Directory.Exists)
|
||||||
var jumpListPath = Path.Combine(
|
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
|
||||||
"Microsoft", "Windows", "Recent", "AutomaticDestinations");
|
.Select(path => new FileInfo(path))
|
||||||
|
.OrderByDescending(info => info.LastWriteTimeUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (!Directory.Exists(jumpListPath)) return;
|
var sourceOrder = 0;
|
||||||
|
foreach (var jumpListFile in jumpListFiles)
|
||||||
// Office应用的Jump List文件
|
|
||||||
var officeJumpListFiles = new[]
|
|
||||||
{
|
{
|
||||||
"a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
|
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
|
||||||
"9b524fe3be704a4d.automaticDestinations-ms", // Excel
|
}
|
||||||
"d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
|
}
|
||||||
};
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var jumpFile in officeJumpListFiles)
|
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(jumpListFile.FullName);
|
||||||
|
foreach (var filePath in ExtractPossiblePaths(bytes))
|
||||||
{
|
{
|
||||||
var fullPath = Path.Combine(jumpListPath, jumpFile);
|
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
|
||||||
if (File.Exists(fullPath))
|
|
||||||
{
|
|
||||||
TryParseJumpListFile(fullPath, documents);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Jump List解析失败,忽略
|
// Ignore a single Jump List file and keep scanning the rest.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryParseJumpListFile(string jumpListPath, List<OfficeRecentDocument> documents)
|
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
|
||||||
{
|
{
|
||||||
try
|
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
// Jump List文件是二进制格式,这里使用简化的方法
|
|
||||||
// 读取文件并尝试提取文件路径
|
|
||||||
var bytes = File.ReadAllBytes(jumpListPath);
|
|
||||||
var text = Encoding.Unicode.GetString(bytes);
|
|
||||||
|
|
||||||
// 查找可能的文件路径(简化实现)
|
foreach (var text in new[]
|
||||||
var possiblePaths = ExtractPossiblePaths(text);
|
{
|
||||||
foreach (var path in possiblePaths)
|
Encoding.Unicode.GetString(bytes),
|
||||||
|
Encoding.Latin1.GetString(bytes)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
foreach (Match match in OfficeFilePathRegex.Matches(text))
|
||||||
{
|
{
|
||||||
AddDocumentIfExists(documents, path);
|
var normalizedPath = NormalizeFilePath(match.Value);
|
||||||
}
|
if (!string.IsNullOrWhiteSpace(normalizedPath))
|
||||||
}
|
|
||||||
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', ' ', '"');
|
paths.Add(normalizedPath);
|
||||||
if (!string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
paths.Add(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 忽略正则表达式错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddDocumentIfExists(List<OfficeRecentDocument> documents, string filePath)
|
private void AddDocumentIfExists(
|
||||||
|
List<OfficeRecentDocument> documents,
|
||||||
|
string? filePath,
|
||||||
|
int sourcePriority,
|
||||||
|
int sourceOrder,
|
||||||
|
DateTime? recentAccessTime)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
var normalizedPath = NormalizeFilePath(filePath);
|
||||||
if (!IsOfficeFile(extension))
|
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
|
||||||
|
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileInfo = new FileInfo(normalizedPath);
|
||||||
var doc = new OfficeRecentDocument
|
documents.Add(new OfficeRecentDocument
|
||||||
{
|
{
|
||||||
FileName = Path.GetFileNameWithoutExtension(filePath),
|
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
|
||||||
FilePath = filePath,
|
FilePath = normalizedPath,
|
||||||
Extension = extension,
|
Extension = extension,
|
||||||
LastModifiedTime = fileInfo.LastWriteTime,
|
LastModifiedTime = fileInfo.LastWriteTime,
|
||||||
FileSizeBytes = fileInfo.Length,
|
FileSizeBytes = fileInfo.Length,
|
||||||
IconGlyph = GetIconGlyph(extension)
|
IconGlyph = GetIconGlyph(extension),
|
||||||
};
|
RecentAccessTime = recentAccessTime,
|
||||||
|
SourcePriority = sourcePriority,
|
||||||
if (!documents.Any(d => string.Equals(d.FilePath, filePath, StringComparison.OrdinalIgnoreCase)))
|
SourceOrder = sourceOrder
|
||||||
{
|
});
|
||||||
documents.Add(doc);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
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);
|
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);
|
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)
|
private static bool IsOfficeFile(string extension)
|
||||||
{
|
{
|
||||||
return OfficeExtensions.Contains(extension) ||
|
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||||
ExcelExtensions.Contains(extension) ||
|
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||||
PowerPointExtensions.Contains(extension);
|
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetIconGlyph(string extension)
|
private static string GetIconGlyph(string extension)
|
||||||
@@ -335,4 +684,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
|||||||
{
|
{
|
||||||
return ShortcutHelper.GetShortcutTarget(lnkPath);
|
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,
|
bool EnableDynamicTaskbarActions,
|
||||||
string TaskbarLayoutMode,
|
string TaskbarLayoutMode,
|
||||||
string ClockDisplayFormat,
|
string ClockDisplayFormat,
|
||||||
|
bool ClockTransparentBackground,
|
||||||
string SpacingMode,
|
string SpacingMode,
|
||||||
int CustomSpacingPercent);
|
int CustomSpacingPercent);
|
||||||
public sealed record WeatherSettingsState(
|
public sealed record WeatherSettingsState(
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
snapshot.EnableDynamicTaskbarActions,
|
snapshot.EnableDynamicTaskbarActions,
|
||||||
snapshot.TaskbarLayoutMode,
|
snapshot.TaskbarLayoutMode,
|
||||||
snapshot.ClockDisplayFormat,
|
snapshot.ClockDisplayFormat,
|
||||||
|
snapshot.StatusBarClockTransparentBackground,
|
||||||
snapshot.StatusBarSpacingMode,
|
snapshot.StatusBarSpacingMode,
|
||||||
snapshot.StatusBarCustomSpacingPercent);
|
snapshot.StatusBarCustomSpacingPercent);
|
||||||
}
|
}
|
||||||
@@ -373,6 +374,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
|
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
|
||||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||||
|
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
@@ -385,6 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
|||||||
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
||||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||||
|
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
|
||||||
<Style Selector="Window.component-editor-window">
|
<Style Selector="Window.component-editor-window">
|
||||||
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -74,7 +75,21 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Window.component-editor-window RadioButton">
|
<Style Selector="Window.component-editor-window RadioButton">
|
||||||
|
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
<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>
|
||||||
|
|
||||||
<Style Selector="Window.component-editor-window ToggleSwitch">
|
<Style Selector="Window.component-editor-window ToggleSwitch">
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
|
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _clockTransparentBackground;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||||
|
|
||||||
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _clockFormatLabel = string.Empty;
|
private string _clockFormatLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _clockTransparentBackgroundLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _spacingHeader = string.Empty;
|
private string _spacingHeader = string.Empty;
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
|
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
|
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
|
||||||
?? ClockFormats[1];
|
?? ClockFormats[1];
|
||||||
|
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||||
|
|
||||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||||
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnClockTransparentBackgroundChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||||
{
|
{
|
||||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
state.EnableDynamicTaskbarActions,
|
state.EnableDynamicTaskbarActions,
|
||||||
state.TaskbarLayoutMode,
|
state.TaskbarLayoutMode,
|
||||||
SelectedClockFormat.Value,
|
SelectedClockFormat.Value,
|
||||||
|
ClockTransparentBackground,
|
||||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||||
}
|
}
|
||||||
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
|||||||
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
|
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
|
||||||
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
|
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");
|
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");
|
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
|
|||||||
"DataLine" => MaterialIconKind.ChartLine,
|
"DataLine" => MaterialIconKind.ChartLine,
|
||||||
"Edit" => MaterialIconKind.Pencil,
|
"Edit" => MaterialIconKind.Pencil,
|
||||||
"Calculator" => MaterialIconKind.Calculator,
|
"Calculator" => MaterialIconKind.Calculator,
|
||||||
|
"Storage" => MaterialIconKind.UsbFlashDrive,
|
||||||
"Globe" => MaterialIconKind.Web,
|
"Globe" => MaterialIconKind.Web,
|
||||||
"Play" => MaterialIconKind.Play,
|
"Play" => MaterialIconKind.Play,
|
||||||
_ => MaterialIconKind.Settings
|
_ => MaterialIconKind.Settings
|
||||||
|
|||||||
@@ -22,14 +22,17 @@
|
|||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
Classes="component-editor-section-title" />
|
Classes="component-editor-section-title" />
|
||||||
<StackPanel Spacing="8">
|
<ComboBox x:Name="ColorSchemeComboBox"
|
||||||
<RadioButton x:Name="FollowSystemRadioButton"
|
Classes="component-editor-select"
|
||||||
GroupName="ColorScheme"
|
HorizontalAlignment="Stretch"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||||
<RadioButton x:Name="UseNativeRadioButton"
|
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||||
GroupName="ColorScheme"
|
Classes="component-editor-select-item"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
Tag="follow_system" />
|
||||||
</StackPanel>
|
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="native" />
|
||||||
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||||
|
|
||||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
DescriptionTextBlock.Text = L(
|
||||||
|
"schedule.settings.desc",
|
||||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
"Import a ClassIsland CSES schedule file and choose which one to use.");
|
||||||
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");
|
||||||
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||||
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;
|
_suppressEvents = true;
|
||||||
|
ColorSchemeComboBox.SelectedItem =
|
||||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||||
{
|
? FollowSystemColorSchemeItem
|
||||||
FollowSystemRadioButton.IsChecked = true;
|
: UseNativeColorSchemeItem;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
UseNativeRadioButton.IsChecked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_suppressEvents = false;
|
_suppressEvents = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
if (_suppressEvents)
|
if (_suppressEvents)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
var colorSchemeSource = useNative
|
? tag
|
||||||
? ThemeAppearanceValues.ColorSchemeNative
|
|
||||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
|
||||||
var snapshot = LoadSnapshot();
|
var snapshot = LoadSnapshot();
|
||||||
@@ -121,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
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,
|
AllowMultiple = false,
|
||||||
FileTypeFilter =
|
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"]
|
Patterns = ["*.cses", "*.yaml", "*.yml"]
|
||||||
}
|
}
|
||||||
@@ -155,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
||||||
?? L("schedule.settings.unnamed", "未命名课表"),
|
?? L("schedule.settings.unnamed", "Untitled Schedule"),
|
||||||
FilePath = importedPath
|
FilePath = importedPath
|
||||||
});
|
});
|
||||||
_activeScheduleId = _importedSchedules[^1].Id;
|
_activeScheduleId = _importedSchedules[^1].Id;
|
||||||
@@ -219,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
var title = new TextBlock
|
var title = new TextBlock
|
||||||
{
|
{
|
||||||
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
||||||
? L("schedule.settings.unnamed", "未命名课表")
|
? L("schedule.settings.unnamed", "Untitled Schedule")
|
||||||
: item.DisplayName,
|
: item.DisplayName,
|
||||||
FontWeight = FontWeight.SemiBold
|
FontWeight = FontWeight.SemiBold
|
||||||
};
|
};
|
||||||
@@ -234,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
|||||||
|
|
||||||
var deleteButton = new Button
|
var deleteButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("schedule.settings.delete", "删除"),
|
Content = L("schedule.settings.delete", "Delete"),
|
||||||
Tag = item.Id,
|
Tag = item.Id,
|
||||||
Padding = new Thickness(12, 8),
|
Padding = new Thickness(12, 8),
|
||||||
HorizontalAlignment = HorizontalAlignment.Right
|
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"
|
mc:Ignorable="d"
|
||||||
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||||
<StackPanel Spacing="16">
|
<StackPanel Spacing="16">
|
||||||
<Border Classes="component-editor-hero_card"
|
<Border Classes="component-editor-hero-card"
|
||||||
Padding="24">
|
Padding="24">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock x:Name="HeadlineTextBlock"
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
@@ -23,14 +23,17 @@
|
|||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||||
Classes="component-editor-section-title" />
|
Classes="component-editor-section-title" />
|
||||||
<StackPanel Spacing="8">
|
<ComboBox x:Name="ColorSchemeComboBox"
|
||||||
<RadioButton x:Name="FollowSystemRadioButton"
|
Classes="component-editor-select"
|
||||||
GroupName="ColorScheme"
|
HorizontalAlignment="Stretch"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
SelectionChanged="OnColorSchemeSelectionChanged">
|
||||||
<RadioButton x:Name="UseNativeRadioButton"
|
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
|
||||||
GroupName="ColorScheme"
|
Classes="component-editor-select-item"
|
||||||
IsCheckedChanged="OnColorSchemeChanged" />
|
Tag="follow_system" />
|
||||||
</StackPanel>
|
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="native" />
|
||||||
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="component-editor_card"
|
<Border Classes="component-editor-card"
|
||||||
Padding="20">
|
Padding="20">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock x:Name="DbfsHeaderTextBlock"
|
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
@@ -34,43 +36,40 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||||
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
DescriptionTextBlock.Text = L(
|
||||||
|
"study.environment.settings.desc",
|
||||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
"Configure the realtime audio level information shown on the right side.");
|
||||||
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");
|
||||||
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
|
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
|
||||||
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;
|
_suppressEvents = true;
|
||||||
|
ColorSchemeComboBox.SelectedItem =
|
||||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||||
{
|
? FollowSystemColorSchemeItem
|
||||||
FollowSystemRadioButton.IsChecked = true;
|
: UseNativeColorSchemeItem;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
UseNativeRadioButton.IsChecked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
||||||
DbfsToggleSwitch.IsChecked = showDbfs;
|
DbfsToggleSwitch.IsChecked = showDbfs;
|
||||||
_suppressEvents = false;
|
_suppressEvents = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
if (_suppressEvents)
|
if (_suppressEvents)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
var colorSchemeSource = useNative
|
? tag
|
||||||
? ThemeAppearanceValues.ColorSchemeNative
|
|
||||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||||
|
|
||||||
var snapshot = LoadSnapshot();
|
var snapshot = LoadSnapshot();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
|||||||
|
|
||||||
private TimeZoneService? _timeZoneService;
|
private TimeZoneService? _timeZoneService;
|
||||||
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||||
|
private bool _transparentBackground;
|
||||||
|
private double _lastAppliedCellSize = 100;
|
||||||
|
|
||||||
public ClockWidget()
|
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)
|
public void SetDisplayFormat(ClockDisplayFormat format)
|
||||||
{
|
{
|
||||||
DisplayFormat = format;
|
DisplayFormat = format;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetTransparentBackground(bool transparentBackground)
|
||||||
|
{
|
||||||
|
TransparentBackground = transparentBackground;
|
||||||
|
}
|
||||||
|
|
||||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||||
{
|
{
|
||||||
ClearTimeZoneService();
|
ClearTimeZoneService();
|
||||||
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
|||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
{
|
{
|
||||||
|
_lastAppliedCellSize = cellSize;
|
||||||
|
|
||||||
// --- Class Island “满盈”风格算法 ---
|
// --- Class Island “满盈”风格算法 ---
|
||||||
|
|
||||||
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||||
@@ -129,8 +154,39 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
|||||||
{
|
{
|
||||||
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
|
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,由代码控制“紧密感”
|
// 确保清除可能存在的固定 Padding,由代码控制“紧密感”
|
||||||
|
RootBorder.MinWidth = cellSize * 2.2;
|
||||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
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",
|
"component.office_recent_documents",
|
||||||
() => new OfficeRecentDocumentsWidget(),
|
() => new OfficeRecentDocumentsWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.HolidayCalendar,
|
BuiltInComponentIds.HolidayCalendar,
|
||||||
"component.holiday_calendar",
|
"component.holiday_calendar",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Views.Components;
|
|
||||||
|
|
||||||
namespace 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
|
try
|
||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
StatusTextBlock.IsVisible = false;
|
StatusTextBlock.IsVisible = false;
|
||||||
|
DocumentsItemsControl.ItemsSource = null;
|
||||||
|
|
||||||
_documents = _recentDocumentsService.GetRecentDocuments(20);
|
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20));
|
||||||
|
|
||||||
if (_documents.Count == 0)
|
if (_documents.Count == 0)
|
||||||
{
|
{
|
||||||
StatusTextBlock.Text = "暂无最近文档";
|
StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
|
||||||
StatusTextBlock.IsVisible = true;
|
StatusTextBlock.IsVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateDisplay();
|
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;
|
StatusTextBlock.IsVisible = true;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -90,15 +97,29 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
|||||||
var span = DateTime.Now - dateTime;
|
var span = DateTime.Now - dateTime;
|
||||||
|
|
||||||
if (span.TotalMinutes < 1)
|
if (span.TotalMinutes < 1)
|
||||||
return "刚刚";
|
{
|
||||||
|
return "\u521a\u521a";
|
||||||
|
}
|
||||||
|
|
||||||
if (span.TotalMinutes < 60)
|
if (span.TotalMinutes < 60)
|
||||||
return $"{(int)span.TotalMinutes} 分钟前";
|
{
|
||||||
|
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
|
||||||
|
}
|
||||||
|
|
||||||
if (span.TotalHours < 24)
|
if (span.TotalHours < 24)
|
||||||
return $"{(int)span.TotalHours} 小时前";
|
{
|
||||||
|
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
|
||||||
|
}
|
||||||
|
|
||||||
if (span.TotalDays < 7)
|
if (span.TotalDays < 7)
|
||||||
return $"{(int)span.TotalDays} 天前";
|
{
|
||||||
|
return $"{(int)span.TotalDays} \u5929\u524d";
|
||||||
|
}
|
||||||
|
|
||||||
if (span.TotalDays < 30)
|
if (span.TotalDays < 30)
|
||||||
return $"{(int)(span.TotalDays / 7)} 周前";
|
{
|
||||||
|
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
|
||||||
|
}
|
||||||
|
|
||||||
return dateTime.ToString("MM/dd");
|
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 = snapshot.ClockDisplayFormat == "HourMinute"
|
||||||
? ClockDisplayFormat.HourMinute
|
? ClockDisplayFormat.HourMinute
|
||||||
: ClockDisplayFormat.HourMinuteSecond;
|
: ClockDisplayFormat.HourMinuteSecond;
|
||||||
|
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
|
||||||
|
|
||||||
if (ClockWidget is not null)
|
if (ClockWidget is not null)
|
||||||
{
|
{
|
||||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||||
|
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +415,7 @@ public partial class MainWindow
|
|||||||
if (ClockWidget is not null)
|
if (ClockWidget is not null)
|
||||||
{
|
{
|
||||||
ClockWidget.IsVisible = showClock;
|
ClockWidget.IsVisible = showClock;
|
||||||
|
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||||
if (showClock)
|
if (showClock)
|
||||||
{
|
{
|
||||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ public partial class MainWindow
|
|||||||
_currentDesktopSurfaceIndex = target;
|
_currentDesktopSurfaceIndex = target;
|
||||||
BeginDesktopPageContextSettle(previousIndex, target);
|
BeginDesktopPageContextSettle(previousIndex, target);
|
||||||
ApplyDesktopSurfaceOffset();
|
ApplyDesktopSurfaceOffset();
|
||||||
PersistSettings();
|
SchedulePersistSettings(delayMs: Math.Max(280, (int)FluttermotionToken.Page.TotalMilliseconds + 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanSwipeDesktopSurface()
|
private bool CanSwipeDesktopSurface()
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
|
|
||||||
|
if (_suppressOwnSettingsReloadCount > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var changedKeys = e.ChangedKeys.ToArray();
|
var changedKeys = e.ChangedKeys.ToArray();
|
||||||
@@ -382,6 +387,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private void PersistSettings()
|
private void PersistSettings()
|
||||||
{
|
{
|
||||||
|
_persistSettingsRevision++;
|
||||||
if (_suppressSettingsPersistence)
|
if (_suppressSettingsPersistence)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -389,6 +395,8 @@ public partial class MainWindow
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Saving our own state should not trigger a full external reload cycle.
|
||||||
|
_suppressOwnSettingsReloadCount++;
|
||||||
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
|
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
|
||||||
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
|
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
|
||||||
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
|
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
|
||||||
@@ -397,11 +405,29 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
|
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_suppressOwnSettingsReloadCount > 0)
|
||||||
|
{
|
||||||
|
_suppressOwnSettingsReloadCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SchedulePersistSettings(int delayMs = 200)
|
private void SchedulePersistSettings(int delayMs = 200)
|
||||||
{
|
{
|
||||||
DispatcherTimer.RunOnce(PersistSettings, TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
|
var revision = ++_persistSettingsRevision;
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (revision != _persistSettingsRevision)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistSettings();
|
||||||
|
},
|
||||||
|
TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void ReloadFromPersistedSettings()
|
internal void ReloadFromPersistedSettings()
|
||||||
@@ -521,6 +547,7 @@ public partial class MainWindow
|
|||||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||||
|
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,7 +145,9 @@
|
|||||||
<TranslateTransform>
|
<TranslateTransform>
|
||||||
<TranslateTransform.Transitions>
|
<TranslateTransform.Transitions>
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
<DoubleTransition Property="X"
|
||||||
|
Duration="{StaticResource FluttermotionToken.Duration.Page}"
|
||||||
|
Easing="0.22,1,0.36,1" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</TranslateTransform.Transitions>
|
</TranslateTransform.Transitions>
|
||||||
</TranslateTransform>
|
</TranslateTransform>
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private string _gridSpacingPreset = "Relaxed";
|
private string _gridSpacingPreset = "Relaxed";
|
||||||
private string _statusBarSpacingMode = "Relaxed";
|
private string _statusBarSpacingMode = "Relaxed";
|
||||||
private int _statusBarCustomSpacingPercent = 12;
|
private int _statusBarCustomSpacingPercent = 12;
|
||||||
|
private bool _statusBarClockTransparentBackground;
|
||||||
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
||||||
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
||||||
private string _languageCode = "zh-CN";
|
private string _languageCode = "zh-CN";
|
||||||
@@ -153,6 +154,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private bool _isWeatherPreviewInProgress;
|
private bool _isWeatherPreviewInProgress;
|
||||||
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
|
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||||
private bool _externalSettingsReloadPending;
|
private bool _externalSettingsReloadPending;
|
||||||
|
private int _persistSettingsRevision;
|
||||||
|
private int _suppressOwnSettingsReloadCount;
|
||||||
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
|
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
|
|||||||
@@ -38,6 +38,20 @@
|
|||||||
</ComboBox>
|
</ComboBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:SettingsExpanderItem>
|
</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>
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
<Separator Classes="settings-separator" />
|
<Separator Classes="settings-separator" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user