mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.19
This commit is contained in:
@@ -3,4 +3,5 @@ namespace LanMontainDesktop.ComponentSystem;
|
||||
public static class BuiltInComponentIds
|
||||
{
|
||||
public const string Clock = "Clock";
|
||||
public const string Blank2x4 = "Blank2x4";
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ public sealed class ComponentRegistry
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: true,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Blank2x4,
|
||||
"Blank 2x4",
|
||||
"Rectangle",
|
||||
"Layout",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true)
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -74,9 +74,16 @@
|
||||
"filepicker.video_files": "Video files",
|
||||
"common.day": "Day",
|
||||
"common.night": "Night",
|
||||
"common.back": "Back",
|
||||
"common.close": "Close",
|
||||
"common.recommended": "Recommended",
|
||||
"common.monet": "Monet",
|
||||
"desktop.page_index_format": "Desktop {0}",
|
||||
"launcher.title": "App Launcher",
|
||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||
"launcher.empty": "No Start Menu entries found.",
|
||||
"launcher.empty_folder": "This folder is empty.",
|
||||
"launcher.folder_items_format": "{0} apps",
|
||||
"button.component_library": "Component Library",
|
||||
"tooltip.component_library": "Component Library",
|
||||
"component_library.title": "Component Library",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"settings.nav.status_bar": "状态栏",
|
||||
"settings.nav.region": "地区",
|
||||
"settings.wallpaper.title": "壁纸",
|
||||
"settings.wallpaper.description": "选择图片或视频后可立刻设为应用窗口壁纸。",
|
||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||
"settings.wallpaper.current_label": "当前壁纸",
|
||||
"settings.wallpaper.placement_label": "显示方式",
|
||||
"settings.wallpaper.pick_button": "浏览文件",
|
||||
@@ -28,10 +28,10 @@
|
||||
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
|
||||
"settings.wallpaper.cleared": "背景已恢复为纯色。",
|
||||
"settings.wallpaper.default_status": "当前使用纯色背景。",
|
||||
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,使用纯色背景。",
|
||||
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
|
||||
"settings.wallpaper.restored": "已恢复保存的壁纸。",
|
||||
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
|
||||
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,使用纯色背景。",
|
||||
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
|
||||
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
|
||||
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
|
||||
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
|
||||
@@ -54,8 +54,8 @@
|
||||
"settings.color.monet_refreshed": "莫奈色已刷新。",
|
||||
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
|
||||
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
|
||||
"settings.color.theme_updated_wallpaper": "壁纸更新,莫奈色已刷新。",
|
||||
"settings.color.theme_updated_video": "视频壁纸更新,主题色已刷新。",
|
||||
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
|
||||
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
|
||||
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
|
||||
"settings.status_bar.title": "状态栏",
|
||||
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
|
||||
@@ -74,8 +74,20 @@
|
||||
"filepicker.video_files": "视频文件",
|
||||
"common.day": "日间",
|
||||
"common.night": "夜间",
|
||||
"common.back": "返回",
|
||||
"common.close": "关闭",
|
||||
"common.recommended": "推荐",
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
"launcher.title": "应用启动台",
|
||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
"launcher.empty_folder": "此文件夹为空。",
|
||||
"launcher.folder_items_format": "{0} 个应用",
|
||||
"button.component_library": "组件库",
|
||||
"tooltip.component_library": "组件库",
|
||||
"component_library.title": "组件库",
|
||||
"component_library.empty": "暂无组件,后续会在这里显示。",
|
||||
"placement.fill": "填充",
|
||||
"placement.fit": "适应",
|
||||
"placement.stretch": "拉伸",
|
||||
|
||||
@@ -29,4 +29,8 @@ public sealed class AppSettingsSnapshot
|
||||
public bool EnableDynamicTaskbarActions { get; set; } = false;
|
||||
|
||||
public string TaskbarLayoutMode { get; set; } = "BottomFullRowMacStyle";
|
||||
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
|
||||
}
|
||||
|
||||
12
LanMontainDesktop/Models/StartMenuAppEntry.cs
Normal file
12
LanMontainDesktop/Models/StartMenuAppEntry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public sealed class StartMenuAppEntry
|
||||
{
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
public byte[]? IconPngBytes { get; init; }
|
||||
}
|
||||
24
LanMontainDesktop/Models/StartMenuFolderNode.cs
Normal file
24
LanMontainDesktop/Models/StartMenuFolderNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public sealed class StartMenuFolderNode
|
||||
{
|
||||
public StartMenuFolderNode(string name, string relativePath)
|
||||
{
|
||||
Name = name;
|
||||
RelativePath = relativePath;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string RelativePath { get; }
|
||||
|
||||
public List<StartMenuFolderNode> Folders { get; } = [];
|
||||
|
||||
public List<StartMenuAppEntry> Apps { get; } = [];
|
||||
|
||||
public int TotalAppCount => Apps.Count + Folders.Sum(folder => folder.TotalAppCount);
|
||||
}
|
||||
|
||||
@@ -41,17 +41,17 @@ public static class GlassEffectService
|
||||
|
||||
// 面板颜色 - 使用 Mica 材质
|
||||
resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2,
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xF0 : (byte)0xF8,
|
||||
micaBackground.R, micaBackground.G, micaBackground.B));
|
||||
resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(
|
||||
Color.FromArgb(0x1F, neutralElevated.R, neutralElevated.G, neutralElevated.B));
|
||||
resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF5,
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xF4 : (byte)0xFB,
|
||||
micaElevated.R, micaElevated.G, micaElevated.B));
|
||||
resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(
|
||||
Color.FromArgb(0x29, neutralElevated.R, neutralElevated.G, neutralElevated.B));
|
||||
resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xCC : (byte)0xE6,
|
||||
Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2,
|
||||
micaBackground.R, micaBackground.G, micaBackground.B));
|
||||
|
||||
// 模糊半径(Mica 不需要强模糊)
|
||||
@@ -60,9 +60,9 @@ public static class GlassEffectService
|
||||
resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 30.0 : 40.0;
|
||||
|
||||
// 不透明度(Mica 材质接近不透明)
|
||||
resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.95 : 0.98;
|
||||
resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 0.97 : 0.99;
|
||||
resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.85 : 0.92;
|
||||
resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.99 : 1.0;
|
||||
resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 1.0 : 1.0;
|
||||
resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.94 : 0.97;
|
||||
resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.01 : 0.008;
|
||||
}
|
||||
}
|
||||
|
||||
475
LanMontainDesktop/Services/UwpManifestIconResolver.cs
Normal file
475
LanMontainDesktop/Services/UwpManifestIconResolver.cs
Normal file
@@ -0,0 +1,475 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class UwpManifestIconResolver
|
||||
{
|
||||
private const int ErrorSuccess = 0;
|
||||
private const int ErrorInsufficientBuffer = 122;
|
||||
|
||||
private static readonly Regex ScaleRegex =
|
||||
new(@"scale-(?<n>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex TargetSizeRegex =
|
||||
new(@"targetsize-(?<n>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] ManifestLogoAttributePriority =
|
||||
[
|
||||
"Square44x44Logo",
|
||||
"Square150x150Logo",
|
||||
"Square310x310Logo",
|
||||
"Square71x71Logo",
|
||||
"Wide310x150Logo",
|
||||
"Logo",
|
||||
"SmallLogo"
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, byte[]?> IconCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly object CacheLock = new();
|
||||
|
||||
public static bool TryGetIconPngBytesFromAumid(string aumid, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
if (string.IsNullOrWhiteSpace(aumid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (CacheLock)
|
||||
{
|
||||
if (IconCache.TryGetValue(aumid, out var cached))
|
||||
{
|
||||
pngBytes = cached;
|
||||
return cached is not null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TrySplitAumid(aumid, out var packageFamilyName, out var appId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var installLocation in GetPackageInstallLocations(packageFamilyName))
|
||||
{
|
||||
if (TryExtractIconFromManifestInstallLocation(installLocation, appId, out pngBytes))
|
||||
{
|
||||
lock (CacheLock)
|
||||
{
|
||||
IconCache[aumid] = pngBytes;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
lock (CacheLock)
|
||||
{
|
||||
IconCache[aumid] = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TrySplitAumid(string aumid, out string packageFamilyName, out string appId)
|
||||
{
|
||||
packageFamilyName = string.Empty;
|
||||
appId = string.Empty;
|
||||
var separatorIndex = aumid.IndexOf('!');
|
||||
if (separatorIndex <= 0 || separatorIndex >= aumid.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
packageFamilyName = aumid[..separatorIndex].Trim();
|
||||
appId = aumid[(separatorIndex + 1)..].Trim();
|
||||
return !string.IsNullOrWhiteSpace(packageFamilyName) && !string.IsNullOrWhiteSpace(appId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetPackageInstallLocations(string packageFamilyName)
|
||||
{
|
||||
var fromApi = GetPackageInstallLocationsByFamilyApi(packageFamilyName);
|
||||
return fromApi.Count > 0
|
||||
? fromApi
|
||||
: GetPackageInstallLocationsByDirectoryScan(packageFamilyName);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetPackageInstallLocationsByFamilyApi(string packageFamilyName)
|
||||
{
|
||||
var results = new List<(string PackageFullName, string InstallLocation)>();
|
||||
|
||||
uint packageCount = 0;
|
||||
uint bufferLength = 0;
|
||||
var firstCall = GetPackagesByPackageFamily(
|
||||
packageFamilyName,
|
||||
ref packageCount,
|
||||
null,
|
||||
ref bufferLength,
|
||||
null);
|
||||
if (firstCall != ErrorInsufficientBuffer && firstCall != ErrorSuccess)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (packageCount == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var packageFullNamePointers = new IntPtr[packageCount];
|
||||
var packageNamesBuffer = new char[Math.Max(1, (int)bufferLength)];
|
||||
var secondCall = GetPackagesByPackageFamily(
|
||||
packageFamilyName,
|
||||
ref packageCount,
|
||||
packageFullNamePointers,
|
||||
ref bufferLength,
|
||||
packageNamesBuffer);
|
||||
if (secondCall != ErrorSuccess)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
for (var i = 0; i < packageCount; i++)
|
||||
{
|
||||
var packageFullName = Marshal.PtrToStringUni(packageFullNamePointers[i]);
|
||||
if (string.IsNullOrWhiteSpace(packageFullName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
uint pathLength = 0;
|
||||
var getPathFirst = GetPackagePathByFullName(packageFullName, ref pathLength, null);
|
||||
if (getPathFirst != ErrorInsufficientBuffer || pathLength == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pathBuilder = new StringBuilder((int)pathLength);
|
||||
var getPathSecond = GetPackagePathByFullName(packageFullName, ref pathLength, pathBuilder);
|
||||
if (getPathSecond != ErrorSuccess)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var installPath = pathBuilder.ToString().TrimEnd('\0').Trim();
|
||||
if (string.IsNullOrWhiteSpace(installPath) || !Directory.Exists(installPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add((packageFullName, installPath));
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(item => ParsePackageVersion(item.PackageFullName))
|
||||
.Select(item => item.InstallLocation)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetPackageInstallLocationsByDirectoryScan(string packageFamilyName)
|
||||
{
|
||||
if (!TrySplitPackageFamilyName(packageFamilyName, out var packageName, out var publisherId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var windowsAppsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
|
||||
"WindowsApps");
|
||||
|
||||
if (!Directory.Exists(windowsAppsDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directories = Directory
|
||||
.EnumerateDirectories(windowsAppsDirectory)
|
||||
.Where(directoryPath =>
|
||||
{
|
||||
var directoryName = Path.GetFileName(directoryPath);
|
||||
if (string.IsNullOrWhiteSpace(directoryName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return directoryName.StartsWith(packageName + "_", StringComparison.OrdinalIgnoreCase) &&
|
||||
directoryName.Contains("__" + publisherId, StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.OrderByDescending(directoryPath => ParsePackageVersion(Path.GetFileName(directoryPath)))
|
||||
.ToList();
|
||||
|
||||
return directories;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySplitPackageFamilyName(string packageFamilyName, out string packageName, out string publisherId)
|
||||
{
|
||||
packageName = string.Empty;
|
||||
publisherId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(packageFamilyName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separatorIndex = packageFamilyName.LastIndexOf('_');
|
||||
if (separatorIndex <= 0 || separatorIndex >= packageFamilyName.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
packageName = packageFamilyName[..separatorIndex].Trim();
|
||||
publisherId = packageFamilyName[(separatorIndex + 1)..].Trim();
|
||||
return !string.IsNullOrWhiteSpace(packageName) && !string.IsNullOrWhiteSpace(publisherId);
|
||||
}
|
||||
|
||||
private static Version ParsePackageVersion(string? packageIdentity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageIdentity))
|
||||
{
|
||||
return new Version(0, 0);
|
||||
}
|
||||
|
||||
var identity = packageIdentity.Trim();
|
||||
var firstUnderscore = identity.IndexOf('_');
|
||||
if (firstUnderscore < 0 || firstUnderscore >= identity.Length - 1)
|
||||
{
|
||||
return new Version(0, 0);
|
||||
}
|
||||
|
||||
var secondUnderscore = identity.IndexOf('_', firstUnderscore + 1);
|
||||
if (secondUnderscore < 0)
|
||||
{
|
||||
return new Version(0, 0);
|
||||
}
|
||||
|
||||
var versionText = identity[(firstUnderscore + 1)..secondUnderscore];
|
||||
return Version.TryParse(versionText, out var version)
|
||||
? version
|
||||
: new Version(0, 0);
|
||||
}
|
||||
|
||||
private static bool TryExtractIconFromManifestInstallLocation(string installLocation, string appId, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
var manifestPath = Path.Combine(installLocation, "AppxManifest.xml");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Load(manifestPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var applicationNodes = document
|
||||
.Descendants()
|
||||
.Where(node => string.Equals(node.Name.LocalName, "Application", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (applicationNodes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var applicationNode = applicationNodes.FirstOrDefault(node =>
|
||||
string.Equals(
|
||||
node.Attributes().FirstOrDefault(attr => string.Equals(attr.Name.LocalName, "Id", StringComparison.OrdinalIgnoreCase))?.Value,
|
||||
appId,
|
||||
StringComparison.OrdinalIgnoreCase)) ?? applicationNodes.First();
|
||||
|
||||
var logoCandidates = new List<string>();
|
||||
CollectManifestLogoCandidates(applicationNode, logoCandidates);
|
||||
|
||||
foreach (var rawLogoPath in logoCandidates.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var candidateFilePath in EnumerateManifestLogoFiles(installLocation, rawLogoPath))
|
||||
{
|
||||
if (TryConvertImageFileToPngBytes(candidateFilePath, out pngBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void CollectManifestLogoCandidates(XElement applicationNode, List<string> output)
|
||||
{
|
||||
foreach (var node in applicationNode.DescendantsAndSelf())
|
||||
{
|
||||
var localName = node.Name.LocalName;
|
||||
if (!string.Equals(localName, "VisualElements", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(localName, "DefaultTile", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attributeName in ManifestLogoAttributePriority)
|
||||
{
|
||||
var attribute = node
|
||||
.Attributes()
|
||||
.FirstOrDefault(attr => string.Equals(attr.Name.LocalName, attributeName, StringComparison.OrdinalIgnoreCase));
|
||||
if (attribute is not null && !string.IsNullOrWhiteSpace(attribute.Value))
|
||||
{
|
||||
output.Add(attribute.Value.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateManifestLogoFiles(string installLocation, string rawLogoPath)
|
||||
{
|
||||
var normalizedAssetPath = NormalizeManifestAssetPath(rawLogoPath);
|
||||
if (string.IsNullOrWhiteSpace(normalizedAssetPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var baseCandidatePath = Path.GetFullPath(Path.Combine(installLocation, normalizedAssetPath));
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (File.Exists(baseCandidatePath))
|
||||
{
|
||||
files.Add(baseCandidatePath);
|
||||
}
|
||||
|
||||
var directoryPath = Path.GetDirectoryName(baseCandidatePath);
|
||||
if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
|
||||
{
|
||||
return files;
|
||||
}
|
||||
|
||||
var baseName = Path.GetFileNameWithoutExtension(baseCandidatePath);
|
||||
var extension = Path.GetExtension(baseCandidatePath);
|
||||
var searchPattern = string.IsNullOrWhiteSpace(extension)
|
||||
? baseName + ".*"
|
||||
: baseName + "*" + extension;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(directoryPath, searchPattern, SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
files.Add(filePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore inaccessible folders
|
||||
}
|
||||
|
||||
return files
|
||||
.OrderByDescending(ScoreManifestIconCandidate)
|
||||
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeManifestAssetPath(string rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = rawValue.Trim().Trim('"');
|
||||
if (cleaned.StartsWith("ms-resource:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (cleaned.StartsWith("ms-appx:///", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cleaned = cleaned["ms-appx:///".Length..];
|
||||
}
|
||||
|
||||
cleaned = cleaned
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar)
|
||||
.TrimStart(Path.DirectorySeparatorChar);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static int ScoreManifestIconCandidate(string filePath)
|
||||
{
|
||||
var score = 0;
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
var targetSizeMatch = TargetSizeRegex.Match(fileName);
|
||||
if (targetSizeMatch.Success && int.TryParse(targetSizeMatch.Groups["n"].Value, out var targetSize))
|
||||
{
|
||||
score += targetSize * 100;
|
||||
}
|
||||
|
||||
var scaleMatch = ScaleRegex.Match(fileName);
|
||||
if (scaleMatch.Success && int.TryParse(scaleMatch.Groups["n"].Value, out var scale))
|
||||
{
|
||||
score += scale * 10;
|
||||
}
|
||||
|
||||
if (fileName.Contains("altform-unplated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 300;
|
||||
}
|
||||
|
||||
if (Path.GetExtension(fileName).Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 80;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool TryConvertImageFileToPngBytes(string filePath, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
try
|
||||
{
|
||||
using var image = Image.FromFile(filePath);
|
||||
using var stream = new MemoryStream();
|
||||
image.Save(stream, ImageFormat.Png);
|
||||
pngBytes = stream.ToArray();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int GetPackagesByPackageFamily(
|
||||
string packageFamilyName,
|
||||
ref uint count,
|
||||
IntPtr[]? packageFullNames,
|
||||
ref uint bufferLength,
|
||||
char[]? buffer);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int GetPackagePathByFullName(
|
||||
string packageFullName,
|
||||
ref uint pathLength,
|
||||
StringBuilder? path);
|
||||
}
|
||||
|
||||
859
LanMontainDesktop/Services/WindowsIconService.cs
Normal file
859
LanMontainDesktop/Services/WindowsIconService.cs
Normal file
@@ -0,0 +1,859 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class WindowsIconService
|
||||
{
|
||||
private const int HighResolutionIconSize = 256;
|
||||
private const int MaxShellPath = 1024;
|
||||
private const int StgmRead = 0x00000000;
|
||||
private const uint SiigbfBiggerSizeOk = 0x00000001;
|
||||
private const uint SiigbfIconOnly = 0x00000004;
|
||||
private const uint ShgfiIcon = 0x00000100;
|
||||
private const uint ShgfiLargeIcon = 0x00000000;
|
||||
private const uint ShgfiUseFileAttributes = 0x00000010;
|
||||
private const uint FileAttributeNormal = 0x00000080;
|
||||
private const uint FileAttributeDirectory = 0x00000010;
|
||||
private const uint CoinitApartmentThreaded = 0x2;
|
||||
private const int SOk = 0;
|
||||
private const int SFalse = 1;
|
||||
private const int RpcEChangedMode = unchecked((int)0x80010106);
|
||||
private static readonly Guid CLSID_ShellLink = new("00021401-0000-0000-C000-000000000046");
|
||||
private static readonly Guid IID_IShellItemImageFactory = new("BCC18B79-BA16-442F-80C4-8A59C30C463B");
|
||||
private static readonly Regex AumidRegex =
|
||||
new(@"shell:AppsFolder\\(?<aumid>[^\s""]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedEntryPath = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(normalizedEntryPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(normalizedEntryPath);
|
||||
if (extension.Equals(".lnk", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (TryReadLnkIconLocation(normalizedEntryPath, out var iconLocation, out var iconIndex) &&
|
||||
TryResolveIconPath(iconLocation, normalizedEntryPath, out var resolvedIconPath) &&
|
||||
TryExtractIconFromResourceFile(resolvedIconPath, iconIndex, out var pngBytesFromLnkIconLocation))
|
||||
{
|
||||
return pngBytesFromLnkIconLocation;
|
||||
}
|
||||
|
||||
if (TryReadLnkArguments(normalizedEntryPath, out var arguments) &&
|
||||
TryParseAumidFromArguments(arguments, out var aumid))
|
||||
{
|
||||
var appsFolderPath = $"shell:AppsFolder\\{aumid}";
|
||||
if (TryExtractIconWithShellItemImageFactory(appsFolderPath, out var pngBytesFromAppsFolder))
|
||||
{
|
||||
return pngBytesFromAppsFolder;
|
||||
}
|
||||
|
||||
if (UwpManifestIconResolver.TryGetIconPngBytesFromAumid(aumid, out var pngBytesFromManifest))
|
||||
{
|
||||
return pngBytesFromManifest;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryReadLnkTargetPath(normalizedEntryPath, out var targetPath) &&
|
||||
TryExtractIconFromResourceFile(targetPath, 0, out var pngBytesFromLnkTarget))
|
||||
{
|
||||
return pngBytesFromLnkTarget;
|
||||
}
|
||||
}
|
||||
else if (extension.Equals(".url", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (TryReadUrlIconLocation(normalizedEntryPath, out var iconFile, out var iconIndex) &&
|
||||
TryResolveIconPath(iconFile, normalizedEntryPath, out var resolvedIconPath) &&
|
||||
TryExtractIconFromResourceFile(resolvedIconPath, iconIndex, out var pngBytesFromUrlIconLocation))
|
||||
{
|
||||
return pngBytesFromUrlIconLocation;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryExtractIconWithShellItemImageFactory(normalizedEntryPath, out var pngBytesFromShellItem))
|
||||
{
|
||||
return pngBytesFromShellItem;
|
||||
}
|
||||
|
||||
if (TryExtractIconWithShGetFileInfo(normalizedEntryPath, out var pngBytesFromShGetFileInfo))
|
||||
{
|
||||
return pngBytesFromShGetFileInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the HICON-based path first to preserve alpha better for folder glyphs.
|
||||
if (TryExtractFolderIconWithShGetFileInfo(out var shGetFolderIcon) &&
|
||||
shGetFolderIcon is not null)
|
||||
{
|
||||
return shGetFolderIcon;
|
||||
}
|
||||
|
||||
var isWin11 = OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000);
|
||||
var preferredProbePaths = isWin11
|
||||
? new[]
|
||||
{
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
Environment.SystemDirectory
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
Environment.SystemDirectory,
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
|
||||
};
|
||||
|
||||
foreach (var probePath in preferredProbePaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(probePath) || !Directory.Exists(probePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryExtractIconWithShellItemImageFactory(probePath, out var shellFolderIcon))
|
||||
{
|
||||
return shellFolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseAumidFromArguments(string arguments, out string aumid)
|
||||
{
|
||||
aumid = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = AumidRegex.Match(arguments);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
aumid = match.Groups["aumid"].Value.Trim().Trim('"');
|
||||
return !string.IsNullOrWhiteSpace(aumid);
|
||||
}
|
||||
|
||||
private static bool TryReadLnkIconLocation(string lnkFilePath, out string iconLocation, out int iconIndex)
|
||||
{
|
||||
iconLocation = string.Empty;
|
||||
iconIndex = 0;
|
||||
if (!TryInitializeCom(out var shouldUninitialize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateShellLink(out var shellLink))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryLoadShellLink(shellLink, lnkFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var iconPathBuilder = new StringBuilder(MaxShellPath);
|
||||
if (shellLink.GetIconLocation(iconPathBuilder, iconPathBuilder.Capacity, out iconIndex) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
iconLocation = iconPathBuilder.ToString().Trim();
|
||||
return !string.IsNullOrWhiteSpace(iconLocation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(shellLink);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UninitializeCom(shouldUninitialize);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadLnkTargetPath(string lnkFilePath, out string targetPath)
|
||||
{
|
||||
targetPath = string.Empty;
|
||||
if (!TryInitializeCom(out var shouldUninitialize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateShellLink(out var shellLink))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryLoadShellLink(shellLink, lnkFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetPathBuilder = new StringBuilder(MaxShellPath);
|
||||
if (shellLink.GetPath(targetPathBuilder, targetPathBuilder.Capacity, IntPtr.Zero, 0) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
targetPath = targetPathBuilder.ToString().Trim();
|
||||
return !string.IsNullOrWhiteSpace(targetPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(shellLink);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UninitializeCom(shouldUninitialize);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadLnkArguments(string lnkFilePath, out string arguments)
|
||||
{
|
||||
arguments = string.Empty;
|
||||
if (!TryInitializeCom(out var shouldUninitialize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateShellLink(out var shellLink))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryLoadShellLink(shellLink, lnkFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var argumentsBuilder = new StringBuilder(MaxShellPath);
|
||||
if (shellLink.GetArguments(argumentsBuilder, argumentsBuilder.Capacity) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
arguments = argumentsBuilder.ToString().Trim();
|
||||
return !string.IsNullOrWhiteSpace(arguments);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(shellLink);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UninitializeCom(shouldUninitialize);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateShellLink(out IShellLinkW shellLink)
|
||||
{
|
||||
shellLink = null!;
|
||||
var shellLinkType = Type.GetTypeFromCLSID(CLSID_ShellLink);
|
||||
if (shellLinkType is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shellLink = (IShellLinkW?)Activator.CreateInstance(shellLinkType)!;
|
||||
return shellLink is not null;
|
||||
}
|
||||
|
||||
private static bool TryLoadShellLink(IShellLinkW shellLink, string lnkFilePath)
|
||||
{
|
||||
if (shellLink is not IPersistFile persistFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
persistFile.Load(lnkFilePath, StgmRead);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUrlIconLocation(string urlFilePath, out string iconFile, out int iconIndex)
|
||||
{
|
||||
iconFile = string.Empty;
|
||||
iconIndex = 0;
|
||||
if (!File.Exists(urlFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var rawLine in File.ReadLines(urlFilePath))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.StartsWith("IconFile=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
iconFile = line["IconFile=".Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("IconIndex=", StringComparison.OrdinalIgnoreCase) &&
|
||||
int.TryParse(line["IconIndex=".Length..].Trim(), out var parsedIndex))
|
||||
{
|
||||
iconIndex = parsedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(iconFile))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TrySplitIconFileAndIndex(iconFile, out var splitIconFile, out var splitIndex))
|
||||
{
|
||||
iconFile = splitIconFile;
|
||||
if (iconIndex == 0)
|
||||
{
|
||||
iconIndex = splitIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveIconPath(string rawIconLocation, string shortcutPath, out string resolvedIconPath)
|
||||
{
|
||||
resolvedIconPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawIconLocation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleaned = rawIconLocation.Trim().Trim('"');
|
||||
if (cleaned.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
cleaned = cleaned[1..];
|
||||
}
|
||||
|
||||
if (TrySplitIconFileAndIndex(cleaned, out var splitPath, out _))
|
||||
{
|
||||
cleaned = splitPath;
|
||||
}
|
||||
|
||||
cleaned = Environment.ExpandEnvironmentVariables(cleaned);
|
||||
if (cleaned.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(cleaned))
|
||||
{
|
||||
var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
|
||||
if (!string.IsNullOrWhiteSpace(shortcutDirectory))
|
||||
{
|
||||
var relativeResolved = Path.GetFullPath(Path.Combine(shortcutDirectory, cleaned));
|
||||
if (File.Exists(relativeResolved))
|
||||
{
|
||||
resolvedIconPath = relativeResolved;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var systemResolved = Path.Combine(Environment.SystemDirectory, cleaned);
|
||||
if (File.Exists(systemResolved))
|
||||
{
|
||||
resolvedIconPath = systemResolved;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(cleaned))
|
||||
{
|
||||
resolvedIconPath = cleaned;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TrySplitIconFileAndIndex(string rawValue, out string iconFile, out int iconIndex)
|
||||
{
|
||||
iconFile = rawValue.Trim();
|
||||
iconIndex = 0;
|
||||
if (string.IsNullOrWhiteSpace(iconFile))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var commaIndex = iconFile.LastIndexOf(',');
|
||||
if (commaIndex <= 0 || commaIndex >= iconFile.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var possibleIndex = iconFile[(commaIndex + 1)..].Trim();
|
||||
if (!int.TryParse(possibleIndex, out iconIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
iconFile = iconFile[..commaIndex].Trim().Trim('"');
|
||||
return !string.IsNullOrWhiteSpace(iconFile);
|
||||
}
|
||||
|
||||
private static bool TryExtractIconFromResourceFile(string resourceFilePath, int iconIndex, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
if (string.IsNullOrWhiteSpace(resourceFilePath) || !File.Exists(resourceFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryExtractIconWithShDefExtractIcon(resourceFilePath, iconIndex, out pngBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryExtractIconWithPrivateExtractIcons(resourceFilePath, iconIndex, out pngBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryExtractIconWithExtractIconEx(resourceFilePath, iconIndex, out pngBytes);
|
||||
}
|
||||
|
||||
private static bool TryExtractIconWithShDefExtractIcon(string filePath, int iconIndex, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
var requestedSize = MakeLong(HighResolutionIconSize, HighResolutionIconSize);
|
||||
var hr = SHDefExtractIcon(filePath, iconIndex, 0, out var largeIcon, out _, (uint)requestedSize);
|
||||
if (hr < 0 || largeIcon == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHiconToPngBytes(largeIcon);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DestroyIcon(largeIcon);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractIconWithPrivateExtractIcons(string filePath, int iconIndex, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
var iconHandles = new IntPtr[1];
|
||||
var iconIds = new uint[1];
|
||||
var extracted = PrivateExtractIcons(
|
||||
filePath,
|
||||
iconIndex,
|
||||
HighResolutionIconSize,
|
||||
HighResolutionIconSize,
|
||||
iconHandles,
|
||||
iconIds,
|
||||
1,
|
||||
0);
|
||||
if (extracted == 0 || extracted == 0xFFFFFFFF || iconHandles[0] == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHiconToPngBytes(iconHandles[0]);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DestroyIcon(iconHandles[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractIconWithExtractIconEx(string filePath, int iconIndex, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
var largeIcons = new IntPtr[1];
|
||||
var extracted = ExtractIconEx(filePath, iconIndex, largeIcons, null, 1);
|
||||
if (extracted <= 0 || largeIcons[0] == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHiconToPngBytes(largeIcons[0]);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DestroyIcon(largeIcons[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractIconWithShellItemImageFactory(string filePath, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
if (SHCreateItemFromParsingName(filePath, IntPtr.Zero, IID_IShellItemImageFactory, out var imageFactoryObject) < 0 ||
|
||||
imageFactoryObject is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var imageFactory = (IShellItemImageFactory)imageFactoryObject;
|
||||
var size = new SizeStruct { cx = HighResolutionIconSize, cy = HighResolutionIconSize };
|
||||
var flags = SiigbfIconOnly | SiigbfBiggerSizeOk;
|
||||
if (imageFactory.GetImage(size, flags, out var hBitmap) < 0 || hBitmap == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHbitmapToPngBytes(hBitmap);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DeleteObject(hBitmap);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(imageFactoryObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractIconWithShGetFileInfo(string filePath, out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
if (SHGetFileInfo(
|
||||
filePath,
|
||||
FileAttributeNormal,
|
||||
out var fileInfo,
|
||||
(uint)Marshal.SizeOf<SHFILEINFO>(),
|
||||
ShgfiIcon | ShgfiUseFileAttributes) == IntPtr.Zero ||
|
||||
fileInfo.hIcon == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHiconToPngBytes(fileInfo.hIcon);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DestroyIcon(fileInfo.hIcon);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractFolderIconWithShGetFileInfo(out byte[]? pngBytes)
|
||||
{
|
||||
pngBytes = null;
|
||||
if (SHGetFileInfo(
|
||||
"folder",
|
||||
FileAttributeDirectory,
|
||||
out var fileInfo,
|
||||
(uint)Marshal.SizeOf<SHFILEINFO>(),
|
||||
ShgfiIcon | ShgfiLargeIcon | ShgfiUseFileAttributes) == IntPtr.Zero ||
|
||||
fileInfo.hIcon == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
pngBytes = ConvertHiconToPngBytes(fileInfo.hIcon);
|
||||
return pngBytes is not null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = DestroyIcon(fileInfo.hIcon);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ConvertHiconToPngBytes(IntPtr iconHandle)
|
||||
{
|
||||
if (iconHandle == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var icon = Icon.FromHandle(iconHandle);
|
||||
var width = Math.Max(16, icon.Width);
|
||||
var height = Math.Max(16, icon.Height);
|
||||
using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.DrawIcon(icon, new Rectangle(0, 0, width, height));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ConvertHbitmapToPngBytes(IntPtr bitmapHandle)
|
||||
{
|
||||
if (bitmapHandle == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var source = Image.FromHbitmap(bitmapHandle);
|
||||
using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||
{
|
||||
shouldUninitialize = false;
|
||||
var result = CoInitializeEx(IntPtr.Zero, CoinitApartmentThreaded);
|
||||
if (result is SOk or SFalse)
|
||||
{
|
||||
shouldUninitialize = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return result == RpcEChangedMode;
|
||||
}
|
||||
|
||||
private static void UninitializeCom(bool shouldUninitialize)
|
||||
{
|
||||
if (shouldUninitialize)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
private static int MakeLong(int lowWord, int highWord)
|
||||
{
|
||||
return (highWord << 16) | (lowWord & 0xFFFF);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct SHFILEINFO
|
||||
{
|
||||
public IntPtr hIcon;
|
||||
public int iIcon;
|
||||
public uint dwAttributes;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
|
||||
public string szDisplayName;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
|
||||
public string szTypeName;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SizeStruct
|
||||
{
|
||||
public int cx;
|
||||
public int cy;
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("000214F9-0000-0000-C000-000000000046")]
|
||||
private interface IShellLinkW
|
||||
{
|
||||
[PreserveSig]
|
||||
int GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, IntPtr pfd, uint fFlags);
|
||||
[PreserveSig] int GetIDList(out IntPtr ppidl);
|
||||
[PreserveSig] int SetIDList(IntPtr pidl);
|
||||
[PreserveSig] int GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch);
|
||||
[PreserveSig] int SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
[PreserveSig] int GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch);
|
||||
[PreserveSig] int SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
||||
[PreserveSig] int GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch);
|
||||
[PreserveSig] int SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
||||
[PreserveSig] int GetHotkey(out short pwHotkey);
|
||||
[PreserveSig] int SetHotkey(short wHotkey);
|
||||
[PreserveSig] int GetShowCmd(out int piShowCmd);
|
||||
[PreserveSig] int SetShowCmd(int iShowCmd);
|
||||
[PreserveSig] int GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int iIcon);
|
||||
[PreserveSig] int SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
||||
[PreserveSig] int SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved);
|
||||
[PreserveSig] int Resolve(IntPtr hwnd, uint fFlags);
|
||||
[PreserveSig] int SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("BCC18B79-BA16-442F-80C4-8A59C30C463B")]
|
||||
private interface IShellItemImageFactory
|
||||
{
|
||||
[PreserveSig]
|
||||
int GetImage(SizeStruct size, uint flags, out IntPtr phbm);
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr SHGetFileInfo(
|
||||
string pszPath,
|
||||
uint dwFileAttributes,
|
||||
out SHFILEINFO psfi,
|
||||
uint cbFileInfo,
|
||||
uint uFlags);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHDefExtractIcon(
|
||||
string pszIconFile,
|
||||
int iIndex,
|
||||
uint uFlags,
|
||||
out IntPtr phiconLarge,
|
||||
out IntPtr phiconSmall,
|
||||
uint nIconSize);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHCreateItemFromParsingName(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
||||
IntPtr pbc,
|
||||
[MarshalAs(UnmanagedType.LPStruct)] Guid riid,
|
||||
[MarshalAs(UnmanagedType.Interface)] out object ppv);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint PrivateExtractIcons(
|
||||
string szFileName,
|
||||
int nIconIndex,
|
||||
int cxIcon,
|
||||
int cyIcon,
|
||||
IntPtr[] phicon,
|
||||
uint[] piconid,
|
||||
uint nIcons,
|
||||
uint flags);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint ExtractIconEx(
|
||||
string lpszFile,
|
||||
int nIconIndex,
|
||||
IntPtr[]? phiconLarge,
|
||||
IntPtr[]? phiconSmall,
|
||||
uint nIcons);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
|
||||
[DllImport("gdi32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DeleteObject(IntPtr hObject);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern void CoUninitialize();
|
||||
}
|
||||
232
LanMontainDesktop/Services/WindowsStartMenuService.cs
Normal file
232
LanMontainDesktop/Services/WindowsStartMenuService.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed class WindowsStartMenuService
|
||||
{
|
||||
private static readonly CompareInfo SortCompareInfo = CultureInfo.GetCultureInfo("zh-CN").CompareInfo;
|
||||
private static readonly CompareOptions SortOptions =
|
||||
CompareOptions.IgnoreCase |
|
||||
CompareOptions.IgnoreKanaType |
|
||||
CompareOptions.IgnoreWidth |
|
||||
CompareOptions.StringSort;
|
||||
|
||||
private static readonly HashSet<string> SupportedEntryExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".lnk",
|
||||
".url",
|
||||
".appref-ms"
|
||||
};
|
||||
|
||||
public StartMenuFolderNode Load()
|
||||
{
|
||||
var root = new StartMenuFolderNode("All Apps", string.Empty);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
foreach (var programsRoot in EnumerateProgramsRoots())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(programsRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scannedRoot = ScanFolder(programsRoot, programsRoot, "All Apps");
|
||||
MergeFolder(root, scannedRoot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unreadable start menu roots to keep launcher rendering resilient.
|
||||
}
|
||||
}
|
||||
|
||||
NormalizeFolderHierarchy(root);
|
||||
SortFolder(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateProgramsRoots()
|
||||
{
|
||||
var userStartMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
||||
var commonStartMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(userStartMenu, "Programs"),
|
||||
Path.Combine(commonStartMenu, "Programs")
|
||||
};
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static StartMenuFolderNode ScanFolder(string folderPath, string rootPath, string? nameOverride = null)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, folderPath);
|
||||
if (string.Equals(relativePath, ".", StringComparison.Ordinal))
|
||||
{
|
||||
relativePath = string.Empty;
|
||||
}
|
||||
|
||||
var folder = new StartMenuFolderNode(
|
||||
nameOverride ?? Path.GetFileName(folderPath),
|
||||
relativePath);
|
||||
|
||||
foreach (var subFolderPath in Directory.EnumerateDirectories(folderPath))
|
||||
{
|
||||
var folderName = Path.GetFileName(subFolderPath);
|
||||
if (folderName.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
folder.Folders.Add(ScanFolder(subFolderPath, rootPath));
|
||||
}
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(folderPath))
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SupportedEntryExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedName = fileName.Replace('_', ' ').Trim();
|
||||
folder.Apps.Add(new StartMenuAppEntry
|
||||
{
|
||||
DisplayName = normalizedName,
|
||||
FilePath = filePath,
|
||||
RelativePath = Path.GetRelativePath(rootPath, filePath),
|
||||
IconPngBytes = OperatingSystem.IsWindows()
|
||||
? WindowsIconService.TryGetIconPngBytes(filePath)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static void MergeFolder(StartMenuFolderNode target, StartMenuFolderNode source)
|
||||
{
|
||||
var appPathSet = new HashSet<string>(
|
||||
target.Apps.Select(app => app.RelativePath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var app in source.Apps)
|
||||
{
|
||||
if (appPathSet.Add(app.RelativePath))
|
||||
{
|
||||
target.Apps.Add(app);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var sourceFolder in source.Folders)
|
||||
{
|
||||
var existing = target.Folders.FirstOrDefault(folder =>
|
||||
string.Equals(folder.Name, sourceFolder.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is null)
|
||||
{
|
||||
target.Folders.Add(sourceFolder);
|
||||
continue;
|
||||
}
|
||||
|
||||
MergeFolder(existing, sourceFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SortFolder(StartMenuFolderNode folder)
|
||||
{
|
||||
folder.Folders.Sort((left, right) => CompareDisplayName(left.Name, right.Name));
|
||||
folder.Apps.Sort((left, right) => CompareDisplayName(left.DisplayName, right.DisplayName));
|
||||
foreach (var child in folder.Folders)
|
||||
{
|
||||
SortFolder(child);
|
||||
}
|
||||
}
|
||||
|
||||
private static int CompareDisplayName(string? left, string? right)
|
||||
{
|
||||
var normalizedLeft = NormalizeForSort(left);
|
||||
var normalizedRight = NormalizeForSort(right);
|
||||
return SortCompareInfo.Compare(normalizedLeft, normalizedRight, SortOptions);
|
||||
}
|
||||
|
||||
private static string NormalizeForSort(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return "~";
|
||||
}
|
||||
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
private static void NormalizeFolderHierarchy(StartMenuFolderNode root)
|
||||
{
|
||||
if (root.Folders.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedChildren = new List<StartMenuFolderNode>();
|
||||
foreach (var child in root.Folders)
|
||||
{
|
||||
var normalizedChild = CollapseSingleChildFolders(child);
|
||||
MergeIntoFolderList(normalizedChildren, normalizedChild);
|
||||
}
|
||||
|
||||
root.Folders.Clear();
|
||||
root.Folders.AddRange(normalizedChildren);
|
||||
}
|
||||
|
||||
private static StartMenuFolderNode CollapseSingleChildFolders(StartMenuFolderNode folder)
|
||||
{
|
||||
if (folder.Folders.Count > 0)
|
||||
{
|
||||
var normalizedChildren = new List<StartMenuFolderNode>();
|
||||
foreach (var child in folder.Folders)
|
||||
{
|
||||
var normalizedChild = CollapseSingleChildFolders(child);
|
||||
MergeIntoFolderList(normalizedChildren, normalizedChild);
|
||||
}
|
||||
|
||||
folder.Folders.Clear();
|
||||
folder.Folders.AddRange(normalizedChildren);
|
||||
}
|
||||
|
||||
while (folder.Apps.Count == 0 && folder.Folders.Count == 1)
|
||||
{
|
||||
folder = folder.Folders[0];
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static void MergeIntoFolderList(List<StartMenuFolderNode> folders, StartMenuFolderNode source)
|
||||
{
|
||||
var existing = folders.FirstOrDefault(folder =>
|
||||
string.Equals(folder.Name, source.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is null)
|
||||
{
|
||||
folders.Add(source);
|
||||
return;
|
||||
}
|
||||
|
||||
MergeFolder(existing, source);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,14 @@
|
||||
<Setter Property="BoxShadow" Value="0 2 4 #26000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.mica-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.glass-overlay">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMontainDesktop.ComponentSystem;
|
||||
@@ -298,14 +299,14 @@ public partial class MainWindow
|
||||
TaskbarDynamicActionsPanel.Children.Clear();
|
||||
}
|
||||
|
||||
if (WallpaperPreviewTaskbarDynamicActionsPanel is not null)
|
||||
if (WallpaperPreviewTaskbarDynamicActionsHost is not null)
|
||||
{
|
||||
WallpaperPreviewTaskbarDynamicActionsPanel.Children.Clear();
|
||||
WallpaperPreviewTaskbarDynamicActionsHost.Children.Clear();
|
||||
}
|
||||
|
||||
if (actions.Count == 0 ||
|
||||
TaskbarDynamicActionsPanel is null ||
|
||||
WallpaperPreviewTaskbarDynamicActionsPanel is null)
|
||||
WallpaperPreviewTaskbarDynamicActionsHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -341,7 +342,77 @@ public partial class MainWindow
|
||||
BorderThickness = new Thickness(0),
|
||||
Child = previewText
|
||||
};
|
||||
WallpaperPreviewTaskbarDynamicActionsPanel.Children.Add(previewBorder);
|
||||
WallpaperPreviewTaskbarDynamicActionsHost.Children.Add(previewBorder);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateComponentLibraryItems()
|
||||
{
|
||||
if (ComponentLibraryItemsPanel is null || ComponentLibraryEmptyTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ComponentLibraryItemsPanel.Children.Clear();
|
||||
|
||||
var definitions = _componentRegistry
|
||||
.GetAll()
|
||||
.Where(definition => definition.AllowDesktopPlacement)
|
||||
.ToList();
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = definition.DisplayName,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Foreground
|
||||
};
|
||||
|
||||
var details = new TextBlock
|
||||
{
|
||||
Text = $"Min {definition.MinWidthCells}x{definition.MinHeightCells}",
|
||||
Foreground = Foreground,
|
||||
Opacity = 0.8
|
||||
};
|
||||
|
||||
var category = new TextBlock
|
||||
{
|
||||
Text = definition.Category,
|
||||
Foreground = Foreground,
|
||||
Opacity = 0.68
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 5,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
content.Children.Add(title);
|
||||
content.Children.Add(details);
|
||||
content.Children.Add(category);
|
||||
|
||||
var card = new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10),
|
||||
Margin = new Thickness(0, 0, 10, 10),
|
||||
Width = 180,
|
||||
MinHeight = 92,
|
||||
Child = content
|
||||
};
|
||||
|
||||
ComponentLibraryItemsPanel.Children.Add(card);
|
||||
}
|
||||
|
||||
var hasAny = definitions.Count > 0;
|
||||
ComponentLibraryEmptyTextBlock.IsVisible = !hasAny;
|
||||
if (ComponentLibraryItemsScrollViewer is not null)
|
||||
{
|
||||
ComponentLibraryItemsScrollViewer.IsVisible = hasAny;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
785
LanMontainDesktop/Views/MainWindow.DesktopPaging.cs
Normal file
785
LanMontainDesktop/Views/MainWindow.DesktopPaging.cs
Normal file
@@ -0,0 +1,785 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using LanMontainDesktop.Models;
|
||||
using LanMontainDesktop.Services;
|
||||
|
||||
namespace LanMontainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private const int MinDesktopPageCount = 1;
|
||||
private const int MaxDesktopPageCount = 12;
|
||||
private readonly WindowsStartMenuService _windowsStartMenuService = new();
|
||||
private readonly Dictionary<string, Bitmap> _launcherIconCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
||||
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
|
||||
private byte[]? _launcherFolderIconPngBytes;
|
||||
private Bitmap? _launcherFolderIconBitmap;
|
||||
private int _desktopPageCount = MinDesktopPageCount;
|
||||
private int _currentDesktopSurfaceIndex;
|
||||
private double _desktopSurfacePageWidth;
|
||||
private TranslateTransform? _desktopPagesHostTransform;
|
||||
private bool _isDesktopSwipeActive;
|
||||
private Point _desktopSwipeStartPoint;
|
||||
private Point _desktopSwipeCurrentPoint;
|
||||
private double _desktopSwipeBaseOffset;
|
||||
|
||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||
|
||||
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
||||
|
||||
private void InitializeDesktopSurfaceState(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
var loadedPageCount = snapshot.DesktopPageCount <= 0 ? MinDesktopPageCount : snapshot.DesktopPageCount;
|
||||
_desktopPageCount = Math.Clamp(loadedPageCount, MinDesktopPageCount, MaxDesktopPageCount);
|
||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
private async void LoadLauncherEntriesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var loadResult = await Task.Run(() =>
|
||||
{
|
||||
var loadedRoot = _windowsStartMenuService.Load();
|
||||
var folderIconBytes = OperatingSystem.IsWindows()
|
||||
? WindowsIconService.TryGetSystemFolderIconPngBytes()
|
||||
: null;
|
||||
return (Root: loadedRoot, FolderIcon: folderIconBytes);
|
||||
});
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
_startMenuRoot = loadResult.Root;
|
||||
_launcherFolderIconPngBytes = loadResult.FolderIcon;
|
||||
_launcherFolderIconBitmap?.Dispose();
|
||||
_launcherFolderIconBitmap = null;
|
||||
RenderLauncherRootTiles();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_startMenuRoot = new StartMenuFolderNode("All Apps", string.Empty);
|
||||
_launcherFolderIconPngBytes = null;
|
||||
_launcherFolderIconBitmap?.Dispose();
|
||||
_launcherFolderIconBitmap = null;
|
||||
RenderLauncherRootTiles();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDesktopSurfaceLayout(GridMetrics gridMetrics)
|
||||
{
|
||||
if (DesktopPagesViewport is null ||
|
||||
DesktopPagesHost is null ||
|
||||
DesktopPagesContainer is null ||
|
||||
LauncherPagePanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopPagesHostTransform = DesktopPagesHost.RenderTransform as TranslateTransform;
|
||||
if (_desktopPagesHostTransform is null)
|
||||
{
|
||||
_desktopPagesHostTransform = new TranslateTransform();
|
||||
DesktopPagesHost.RenderTransform = _desktopPagesHostTransform;
|
||||
}
|
||||
|
||||
var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0;
|
||||
var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1;
|
||||
var pageWidth = Math.Max(1, gridMetrics.ColumnCount * gridMetrics.CellSize);
|
||||
var pageHeight = Math.Max(1, viewportRowSpan * gridMetrics.CellSize);
|
||||
|
||||
Grid.SetRow(DesktopPagesViewport, viewportRow);
|
||||
Grid.SetColumn(DesktopPagesViewport, 0);
|
||||
Grid.SetRowSpan(DesktopPagesViewport, viewportRowSpan);
|
||||
Grid.SetColumnSpan(DesktopPagesViewport, gridMetrics.ColumnCount);
|
||||
DesktopPagesViewport.Width = pageWidth;
|
||||
DesktopPagesViewport.Height = pageHeight;
|
||||
|
||||
DesktopPagesHost.RowDefinitions.Clear();
|
||||
DesktopPagesHost.RowDefinitions.Add(new RowDefinition(new GridLength(pageHeight, GridUnitType.Pixel)));
|
||||
DesktopPagesHost.ColumnDefinitions.Clear();
|
||||
DesktopPagesHost.ColumnDefinitions.Add(
|
||||
new ColumnDefinition(new GridLength(pageWidth * _desktopPageCount, GridUnitType.Pixel)));
|
||||
DesktopPagesHost.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
|
||||
DesktopPagesHost.Width = pageWidth * TotalSurfaceCount;
|
||||
DesktopPagesHost.Height = pageHeight;
|
||||
|
||||
DesktopPagesContainer.RowDefinitions.Clear();
|
||||
DesktopPagesContainer.RowDefinitions.Add(new RowDefinition(new GridLength(pageHeight, GridUnitType.Pixel)));
|
||||
DesktopPagesContainer.ColumnDefinitions.Clear();
|
||||
DesktopPagesContainer.Children.Clear();
|
||||
DesktopPagesContainer.Width = pageWidth * _desktopPageCount;
|
||||
DesktopPagesContainer.Height = pageHeight;
|
||||
for (var index = 0; index < _desktopPageCount; index++)
|
||||
{
|
||||
DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
|
||||
var pageSurface = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(10)
|
||||
};
|
||||
|
||||
if (_desktopPageCount > 1)
|
||||
{
|
||||
pageSurface.Child = new TextBlock
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Foreground = Foreground,
|
||||
Opacity = 0.72,
|
||||
Text = Lf("desktop.page_index_format", "Desktop {0}", index + 1)
|
||||
};
|
||||
}
|
||||
|
||||
Grid.SetColumn(pageSurface, index);
|
||||
Grid.SetRow(pageSurface, 0);
|
||||
DesktopPagesContainer.Children.Add(pageSurface);
|
||||
}
|
||||
|
||||
Grid.SetColumn(LauncherPagePanel, 1);
|
||||
Grid.SetRow(LauncherPagePanel, 0);
|
||||
|
||||
// 为启动台添加安全边距以确保圆角不被裁剪
|
||||
var launcherMargin = Math.Clamp(gridMetrics.CellSize * 0.15, 6, 16);
|
||||
LauncherPagePanel.Margin = new Thickness(launcherMargin);
|
||||
LauncherPagePanel.Width = Math.Max(1, pageWidth - launcherMargin * 2);
|
||||
LauncherPagePanel.Height = Math.Max(1, pageHeight - launcherMargin * 2);
|
||||
LauncherPagePanel.MaxWidth = pageWidth - launcherMargin * 2;
|
||||
LauncherPagePanel.MaxHeight = pageHeight - launcherMargin * 2;
|
||||
|
||||
if (LauncherFolderPanel is not null)
|
||||
{
|
||||
LauncherFolderPanel.MaxWidth = Math.Max(320, pageWidth - 96);
|
||||
LauncherFolderPanel.MaxHeight = Math.Max(220, pageHeight - 96);
|
||||
}
|
||||
|
||||
// 更新启动台图标布局
|
||||
UpdateLauncherTileLayout();
|
||||
|
||||
_desktopSurfacePageWidth = pageWidth;
|
||||
ClampSurfaceIndex();
|
||||
ApplyDesktopSurfaceOffset();
|
||||
}
|
||||
|
||||
private void UpdateLauncherTileLayout()
|
||||
{
|
||||
if (LauncherRootTilePanel is null || LauncherPagePanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取启动台面板的实际可用宽度(减去Padding)
|
||||
var availableWidth = Math.Max(1, LauncherPagePanel.Bounds.Width - 36); // 18px padding on each side
|
||||
var availableHeight = Math.Max(1, LauncherPagePanel.Bounds.Height - 100); // 预留标题空间
|
||||
|
||||
if (availableWidth <= 1 || availableHeight <= 1)
|
||||
{
|
||||
// 如果尺寸还未计算,使用默认值
|
||||
availableWidth = 600;
|
||||
availableHeight = 400;
|
||||
}
|
||||
|
||||
// 计算最佳图标尺寸
|
||||
// 目标:每行显示4-8个图标,根据屏幕宽度调整
|
||||
const int minColumns = 4;
|
||||
const int maxColumns = 8;
|
||||
const double targetAspectRatio = 1.2; // 图标宽高比
|
||||
|
||||
// 计算每列可以显示的图标数量
|
||||
var optimalColumnCount = Math.Clamp((int)Math.Floor(availableWidth / 120), minColumns, maxColumns);
|
||||
|
||||
// 根据列数计算图标尺寸
|
||||
var tileWidth = Math.Floor(availableWidth / optimalColumnCount) - 12; // 12px spacing
|
||||
var tileHeight = Math.Min(tileWidth / targetAspectRatio, availableHeight / 4); // 至少显示4行
|
||||
|
||||
// 确保最小尺寸
|
||||
tileWidth = Math.Max(tileWidth, 100);
|
||||
tileHeight = Math.Max(tileHeight, 80);
|
||||
|
||||
// 更新WrapPanel的Item尺寸
|
||||
LauncherRootTilePanel.Width = availableWidth;
|
||||
|
||||
// 更新所有子元素的尺寸
|
||||
foreach (var child in LauncherRootTilePanel.Children)
|
||||
{
|
||||
if (child is Button button)
|
||||
{
|
||||
button.Width = tileWidth;
|
||||
button.Height = tileHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// 同样更新文件夹视图的图标尺寸
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Width = availableWidth;
|
||||
foreach (var child in LauncherFolderTilePanel.Children)
|
||||
{
|
||||
if (child is Button button)
|
||||
{
|
||||
button.Width = tileWidth;
|
||||
button.Height = tileHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampSurfaceIndex()
|
||||
{
|
||||
_currentDesktopSurfaceIndex = Math.Clamp(_currentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
private IBrush GetThemeBrush(string key)
|
||||
{
|
||||
if (Resources.TryGetResource(key, ActualThemeVariant, out var resource) && resource is IBrush brush)
|
||||
{
|
||||
return brush;
|
||||
}
|
||||
|
||||
return Brushes.Transparent;
|
||||
}
|
||||
|
||||
private void ApplyDesktopSurfaceOffset()
|
||||
{
|
||||
if (_desktopPagesHostTransform is null || _desktopSurfacePageWidth <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
_desktopPagesHostTransform.X = targetOffset;
|
||||
|
||||
if (_currentDesktopSurfaceIndex != LauncherSurfaceIndex)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveSurfaceBy(int delta)
|
||||
{
|
||||
if (delta == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = Math.Clamp(_currentDesktopSurfaceIndex + delta, 0, LauncherSurfaceIndex);
|
||||
if (target == _currentDesktopSurfaceIndex)
|
||||
{
|
||||
ApplyDesktopSurfaceOffset();
|
||||
return;
|
||||
}
|
||||
|
||||
_currentDesktopSurfaceIndex = target;
|
||||
ApplyDesktopSurfaceOffset();
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private bool CanSwipeDesktopSurface()
|
||||
{
|
||||
return !_isSettingsOpen && !_isComponentLibraryOpen && _desktopSurfacePageWidth > 1;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!CanSwipeDesktopSurface() || DesktopPagesViewport is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsInteractivePointerSource(e.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDesktopSwipeActive = true;
|
||||
_desktopSwipeStartPoint = e.GetPosition(DesktopPagesViewport);
|
||||
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
|
||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
e.Pointer.Capture(DesktopPagesViewport);
|
||||
}
|
||||
|
||||
private static bool IsInteractivePointerSource(object? source)
|
||||
{
|
||||
if (source is not Visual visual)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var node in visual.GetSelfAndVisualAncestors())
|
||||
{
|
||||
if (node is Button or TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDesktopSwipeActive || DesktopPagesViewport is null || _desktopPagesHostTransform is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopSwipeCurrentPoint = e.GetPosition(DesktopPagesViewport);
|
||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||
var minOffset = -LauncherSurfaceIndex * _desktopSurfacePageWidth;
|
||||
var tentative = _desktopSwipeBaseOffset + deltaX;
|
||||
_desktopPagesHostTransform.X = Math.Clamp(tentative, minOffset, 0);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
private void EndDesktopSwipeInteraction(IPointer? pointer)
|
||||
{
|
||||
if (!_isDesktopSwipeActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
pointer.Capture(null);
|
||||
}
|
||||
|
||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||
var threshold = Math.Max(56, _desktopSurfacePageWidth * 0.16);
|
||||
if (Math.Abs(deltaX) >= threshold && Math.Abs(deltaX) > Math.Abs(deltaY))
|
||||
{
|
||||
MoveSurfaceBy(deltaX < 0 ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyDesktopSurfaceOffset();
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (!CanSwipeDesktopSurface())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var prefersHorizontal = Math.Abs(e.Delta.X) > Math.Abs(e.Delta.Y) ||
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Shift);
|
||||
if (!prefersHorizontal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var delta = e.Delta.X != 0 ? e.Delta.X : e.Delta.Y;
|
||||
if (Math.Abs(delta) < double.Epsilon)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MoveSurfaceBy(delta < 0 ? 1 : -1);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void RenderLauncherRootTiles()
|
||||
{
|
||||
if (LauncherRootTilePanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherRootTilePanel.Children.Clear();
|
||||
var folders = _startMenuRoot.Folders;
|
||||
var apps = _startMenuRoot.Apps;
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
LauncherRootTilePanel.Children.Add(CreateLauncherFolderTile(folder));
|
||||
}
|
||||
|
||||
foreach (var app in apps)
|
||||
{
|
||||
LauncherRootTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
|
||||
if (LauncherRootTilePanel.Children.Count == 0)
|
||||
{
|
||||
LauncherRootTilePanel.Children.Add(CreateLauncherHintTile(
|
||||
L("launcher.empty", "No Start Menu entries found."),
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
// 在图标渲染完成后,应用布局计算
|
||||
Dispatcher.UIThread.Post(() => UpdateLauncherTileLayout(), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private Button CreateLauncherFolderTile(StartMenuFolderNode folder)
|
||||
{
|
||||
var title = folder.Name;
|
||||
var subtitle = Lf("launcher.folder_items_format", "{0} apps", folder.TotalAppCount);
|
||||
var folderIconBitmap = GetLauncherFolderIconBitmap();
|
||||
return CreateLauncherTileButton(
|
||||
title,
|
||||
subtitle,
|
||||
monogram: "DIR",
|
||||
iconBitmap: folderIconBitmap,
|
||||
() => OpenLauncherFolder(folder));
|
||||
}
|
||||
|
||||
private Button CreateLauncherAppTile(StartMenuAppEntry app)
|
||||
{
|
||||
var iconBitmap = GetLauncherIconBitmap(app);
|
||||
var monogram = BuildMonogram(app.DisplayName);
|
||||
return CreateLauncherTileButton(
|
||||
app.DisplayName,
|
||||
subtitle: string.Empty,
|
||||
monogram,
|
||||
iconBitmap,
|
||||
() => LaunchStartMenuEntry(app));
|
||||
}
|
||||
|
||||
private Control CreateLauncherHintTile(string title, string subtitle)
|
||||
{
|
||||
var panel = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = subtitle,
|
||||
Opacity = 0.75,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
}
|
||||
|
||||
return new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
BorderThickness = new Thickness(0),
|
||||
Margin = new Thickness(0, 0, 12, 12),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Child = panel
|
||||
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
||||
};
|
||||
}
|
||||
|
||||
private Button CreateLauncherTileButton(
|
||||
string title,
|
||||
string subtitle,
|
||||
string monogram,
|
||||
Bitmap? iconBitmap,
|
||||
Action clickAction)
|
||||
{
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = iconBitmap,
|
||||
Width = 40,
|
||||
Height = 40,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 40,
|
||||
Height = 40,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
BorderThickness = new Thickness(0),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var textPanel = new StackPanel
|
||||
{
|
||||
Spacing = 3,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
textPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
textPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = subtitle,
|
||||
Opacity = 0.72,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
}
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(textPanel);
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
Margin = new Thickness(0, 0, 12, 12),
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(10),
|
||||
Content = content
|
||||
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
||||
};
|
||||
button.Click += (_, _) => clickAction();
|
||||
return button;
|
||||
}
|
||||
|
||||
private Bitmap? GetLauncherIconBitmap(StartMenuAppEntry app)
|
||||
{
|
||||
if (app.IconPngBytes is null || app.IconPngBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_launcherIconCache.TryGetValue(app.RelativePath, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(app.IconPngBytes, writable: false);
|
||||
var bitmap = new Bitmap(stream);
|
||||
_launcherIconCache[app.RelativePath] = bitmap;
|
||||
return bitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap? GetLauncherFolderIconBitmap()
|
||||
{
|
||||
if (_launcherFolderIconBitmap is not null)
|
||||
{
|
||||
return _launcherFolderIconBitmap;
|
||||
}
|
||||
|
||||
if (_launcherFolderIconPngBytes is null || _launcherFolderIconPngBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(_launcherFolderIconPngBytes, writable: false);
|
||||
_launcherFolderIconBitmap = new Bitmap(stream);
|
||||
return _launcherFolderIconBitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_launcherFolderIconBitmap = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenLauncherFolder(StartMenuFolderNode folder)
|
||||
{
|
||||
_launcherFolderStack.Push(folder);
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
private void CloseLauncherFolderOverlay()
|
||||
{
|
||||
_launcherFolderStack.Clear();
|
||||
if (LauncherFolderOverlay is not null)
|
||||
{
|
||||
LauncherFolderOverlay.IsVisible = false;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderLauncherFolderFromStack()
|
||||
{
|
||||
if (LauncherFolderOverlay is null ||
|
||||
LauncherFolderTilePanel is null ||
|
||||
LauncherFolderTitleTextBlock is null ||
|
||||
LauncherFolderBackButton is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_launcherFolderStack.Count == 0)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
var folder = _launcherFolderStack.Peek();
|
||||
LauncherFolderOverlay.IsVisible = true;
|
||||
LauncherFolderTitleTextBlock.Text = folder.Name;
|
||||
LauncherFolderBackButton.IsVisible = _launcherFolderStack.Count > 1;
|
||||
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel.Children.Count == 0)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherHintTile(
|
||||
L("launcher.empty_folder", "This folder is empty."),
|
||||
string.Empty));
|
||||
}
|
||||
|
||||
// 在图标渲染完成后,应用布局计算
|
||||
Dispatcher.UIThread.Post(() => UpdateLauncherTileLayout(), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static string BuildMonogram(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
var letters = text
|
||||
.Trim()
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part => part[0])
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (letters.Length == 0)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
return new string(letters).ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static void LaunchStartMenuEntry(StartMenuAppEntry app)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = app.FilePath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore failures to launch malformed shortcuts.
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLauncherFolderBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_launcherFolderStack.Count <= 1)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_launcherFolderStack.Pop();
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
private void OnLauncherFolderOverlayPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (LauncherFolderPanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var point = e.GetCurrentPoint(LauncherFolderPanel).Position;
|
||||
if (point.X >= 0 &&
|
||||
point.Y >= 0 &&
|
||||
point.X <= LauncherFolderPanel.Bounds.Width &&
|
||||
point.Y <= LauncherFolderPanel.Bounds.Height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CloseLauncherFolderOverlay();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLauncherFolderCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
|
||||
private void DisposeLauncherResources()
|
||||
{
|
||||
foreach (var bitmap in _launcherIconCache.Values)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
|
||||
_launcherIconCache.Clear();
|
||||
_launcherFolderIconBitmap?.Dispose();
|
||||
_launcherFolderIconBitmap = null;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public partial class MainWindow
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? L("settings.region.language_en", "English")
|
||||
: L("settings.region.language_zh", "中文");
|
||||
: L("settings.region.language_zh", "Chinese");
|
||||
}
|
||||
|
||||
private string GetLocalizedPlacementDisplayName(WallpaperPlacement placement)
|
||||
@@ -58,16 +58,21 @@ public partial class MainWindow
|
||||
BackToWindowsTextBlock.Text = L("button.back_to_windows", "Back to Windows");
|
||||
WallpaperPreviewBackButtonTextBlock.Text = L("button.back_to_windows", "Back to Windows");
|
||||
ToolTip.SetTip(BackToWindowsButton, L("tooltip.back_to_windows", "Back to Windows"));
|
||||
OpenComponentLibraryTextBlock.Text = L("button.component_library", "组件库");
|
||||
WallpaperPreviewComponentLibraryTextBlock.Text = L("button.component_library", "组件库");
|
||||
ToolTip.SetTip(OpenComponentLibraryButton, L("tooltip.component_library", "组件库"));
|
||||
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "组件库");
|
||||
ToolTip.SetTip(CloseComponentLibraryButton, L("common.close", "关闭"));
|
||||
|
||||
OpenComponentLibraryTextBlock.Text = L("button.component_library", "Component Library");
|
||||
WallpaperPreviewComponentLibraryTextBlock.Text = L("button.component_library", "Component Library");
|
||||
ToolTip.SetTip(OpenComponentLibraryButton, L("tooltip.component_library", "Component Library"));
|
||||
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Component Library");
|
||||
ToolTip.SetTip(CloseComponentLibraryButton, L("common.close", "Close"));
|
||||
ComponentLibraryEmptyTextBlock.Text = L(
|
||||
"component_library.empty",
|
||||
"暂无组件,后续将在这里显示。");
|
||||
"No components yet. Components will appear here later.");
|
||||
|
||||
LauncherTitleTextBlock.Text = L("launcher.title", "应用启动台");
|
||||
LauncherSubtitleTextBlock.Text = L("launcher.subtitle", "按 Windows 开始菜单结构显示所有应用与文件夹");
|
||||
ToolTip.SetTip(LauncherFolderBackButton, L("common.back", "返回"));
|
||||
ToolTip.SetTip(LauncherFolderCloseButton, L("common.close", "关闭"));
|
||||
|
||||
SettingsTitleTextBlock.Text = L("settings.title", "Settings");
|
||||
SettingsNavHeaderTextBlock.Text = L("settings.nav_header", "Settings");
|
||||
SettingsNavWallpaperItem.Content = L("settings.nav.wallpaper", "Wallpaper");
|
||||
SettingsNavGridItem.Content = L("settings.nav.grid", "Grid");
|
||||
@@ -75,21 +80,18 @@ public partial class MainWindow
|
||||
SettingsNavStatusBarItem.Content = L("settings.nav.status_bar", "Status Bar");
|
||||
SettingsNavRegionItem.Content = L("settings.nav.region", "Region");
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Wallpaper");
|
||||
WallpaperPanelDescriptionTextBlock.Text = L("settings.wallpaper.description", "Pick wallpaper.");
|
||||
WallpaperCurrentLabelTextBlock.Text = L("settings.wallpaper.current_label", "Current Wallpaper");
|
||||
WallpaperPlacementLabelTextBlock.Text = L("settings.wallpaper.placement_label", "Placement");
|
||||
PickWallpaperButton.Content = L("settings.wallpaper.pick_button", "Browse Files");
|
||||
ClearWallpaperButton.Content = L("settings.wallpaper.clear_button", "Reset");
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "个性化您的背景");
|
||||
WallpaperPanelDescriptionTextBlock.Text = L("settings.wallpaper.description", "选择图片或视频");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "选择契合度");
|
||||
WallpaperPlacementSettingsExpander.Description = L("settings.wallpaper.placement_desc", "调整图像在桌面上的填充方式。");
|
||||
PickWallpaperButton.Content = L("settings.wallpaper.pick_button", "浏览照片");
|
||||
ClearWallpaperButton.Content = L("settings.wallpaper.clear_button", "重置");
|
||||
|
||||
GridPanelTitleTextBlock.Text = L("settings.grid.title", "Grid Layout");
|
||||
GridPanelDescriptionTextBlock.Text = L("settings.grid.description", "Each component should occupy at least 1x1.");
|
||||
GridShortSideLabelTextBlock.Text = L("settings.grid.short_side_label", "Short Side Cells");
|
||||
ApplyGridButton.Content = L("settings.grid.apply_button", "Apply");
|
||||
|
||||
ColorPanelTitleTextBlock.Text = L("settings.color.title", "Color");
|
||||
ColorPanelDescriptionTextBlock.Text = L("settings.color.description", "Theme and accent settings.");
|
||||
DayNightModeLabelTextBlock.Text = L("settings.color.day_night_label", "Day/Night");
|
||||
ThemeModeSettingsExpander.Header = L("settings.color.day_night_label", "Day/Night");
|
||||
NightModeToggleSwitch.OnContent = L("settings.color.day_night_on", "Night");
|
||||
NightModeToggleSwitch.OffContent = L("settings.color.day_night_off", "Day");
|
||||
RecommendedColorsLabelTextBlock.Text = L("settings.color.recommended_label", "Recommended Colors");
|
||||
@@ -97,15 +99,11 @@ public partial class MainWindow
|
||||
RefreshMonetColorsButton.Content = L("settings.color.refresh_button", "Refresh");
|
||||
|
||||
StatusBarPanelTitleTextBlock.Text = L("settings.status_bar.title", "Status Bar");
|
||||
StatusBarPanelDescriptionTextBlock.Text = L("settings.status_bar.description", "Status bar components.");
|
||||
StatusBarClockSettingsExpander.Header = L("settings.status_bar.clock_header", "Clock");
|
||||
StatusBarClockDescriptionTextBlock.Text = L("settings.status_bar.clock_description", "Display clock in top status bar.");
|
||||
|
||||
RegionPanelTitleTextBlock.Text = L("settings.region.title", "Region");
|
||||
RegionPanelDescriptionTextBlock.Text = L("settings.region.description", "Select language.");
|
||||
LanguageSettingsExpander.Header = L("settings.region.language_header", "Language");
|
||||
LanguageLabelTextBlock.Text = L("settings.region.language_label", "Language");
|
||||
LanguageChineseItem.Content = L("settings.region.language_zh", "中文");
|
||||
LanguageChineseItem.Content = L("settings.region.language_zh", "Chinese");
|
||||
LanguageEnglishItem.Content = L("settings.region.language_en", "English");
|
||||
|
||||
if (WallpaperPlacementComboBox?.ItemCount >= 5)
|
||||
@@ -117,9 +115,6 @@ public partial class MainWindow
|
||||
if (WallpaperPlacementComboBox.Items[4] is ComboBoxItem tileItem) tileItem.Content = L("placement.tile", "Tile");
|
||||
}
|
||||
|
||||
ThemeModeStatusTextBlock.Text = _isNightMode
|
||||
? L("settings.color.mode_night", "Night mode enabled")
|
||||
: L("settings.color.mode_day", "Day mode enabled");
|
||||
|
||||
GridInfoTextBlock.Text = Lf(
|
||||
"settings.grid.info_format",
|
||||
@@ -128,6 +123,8 @@ public partial class MainWindow
|
||||
DesktopGrid.RowDefinitions.Count,
|
||||
DesktopGrid.RowDefinitions.Count > 0 ? DesktopGrid.RowDefinitions[0].Height.Value : 0d);
|
||||
|
||||
PopulateComponentLibraryItems();
|
||||
RenderLauncherRootTiles();
|
||||
UpdateOpenSettingsActionVisualState();
|
||||
UpdateWallpaperDisplay();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -302,6 +305,21 @@ public partial class MainWindow
|
||||
TileMode = TileMode.None
|
||||
};
|
||||
|
||||
if (forPreview)
|
||||
{
|
||||
// For preview, we want to simulate how the image looks on a real screen.
|
||||
// Assuming a nominal screen width of 1920 for calculation.
|
||||
// The preview width is 480, so the scale is 480/1920 = 0.25.
|
||||
const double nominalScreenWidth = 1920.0;
|
||||
const double previewWidth = 480.0;
|
||||
double scale = previewWidth / nominalScreenWidth;
|
||||
|
||||
if (placement == WallpaperPlacement.Center)
|
||||
{
|
||||
brush.Transform = new ScaleTransform(scale, scale);
|
||||
}
|
||||
}
|
||||
|
||||
switch (placement)
|
||||
{
|
||||
case WallpaperPlacement.Fill:
|
||||
@@ -618,7 +636,9 @@ public partial class MainWindow
|
||||
TopStatusComponentIds = _topStatusComponentIds.ToList(),
|
||||
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
|
||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||
TaskbarLayoutMode = _taskbarLayoutMode
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
DesktopPageCount = _desktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = _currentDesktopSurfaceIndex
|
||||
};
|
||||
|
||||
_appSettingsService.Save(snapshot);
|
||||
@@ -670,13 +690,11 @@ public partial class MainWindow
|
||||
{
|
||||
_isNightMode = enabled;
|
||||
RequestedThemeVariant = enabled ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||
UpdateThemeModeIcon();
|
||||
|
||||
_suppressThemeToggleEvents = true;
|
||||
NightModeToggleSwitch.IsChecked = enabled;
|
||||
_suppressThemeToggleEvents = false;
|
||||
ThemeModeStatusTextBlock.Text = enabled
|
||||
? L("settings.color.mode_night", "Night mode enabled")
|
||||
: L("settings.color.mode_day", "Day mode enabled");
|
||||
|
||||
if (refreshPalettes)
|
||||
{
|
||||
@@ -1001,4 +1019,70 @@ public partial class MainWindow
|
||||
SettingsPage.IsVisible = false;
|
||||
}, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private void InitializeSettingsIcons()
|
||||
{
|
||||
const IconVariant variant = IconVariant.Regular;
|
||||
|
||||
if (WallpaperPlacementSettingsExpander is not null)
|
||||
{
|
||||
WallpaperPlacementSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Image,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
if (GridSizeSettingsExpander is not null)
|
||||
{
|
||||
GridSizeSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Grid,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
if (ThemeColorSettingsExpander is not null)
|
||||
{
|
||||
ThemeColorSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Color,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
if (StatusBarClockSettingsExpander is not null)
|
||||
{
|
||||
StatusBarClockSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Clock,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
if (LanguageSettingsExpander is not null)
|
||||
{
|
||||
LanguageSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Earth,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
UpdateThemeModeIcon();
|
||||
}
|
||||
|
||||
private void UpdateThemeModeIcon()
|
||||
{
|
||||
if (ThemeModeSettingsExpander is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ThemeModeSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = _isNightMode ? Symbol.WeatherMoon : Symbol.WeatherSunny,
|
||||
IconVariant = IconVariant.Regular
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
@@ -130,6 +131,8 @@ public partial class MainWindow : Window
|
||||
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
|
||||
ApplyTaskbarSettings(snapshot);
|
||||
InitializeLocalization(snapshot.LanguageCode);
|
||||
InitializeDesktopSurfaceState(snapshot);
|
||||
InitializeSettingsIcons();
|
||||
|
||||
TryRestoreWallpaper(snapshot.WallpaperPath);
|
||||
ApplyWallpaperBrush();
|
||||
@@ -151,6 +154,8 @@ public partial class MainWindow : Window
|
||||
DesktopHost.SizeChanged += OnDesktopHostSizeChanged;
|
||||
WallpaperPreviewHost.SizeChanged += OnWallpaperPreviewHostSizeChanged;
|
||||
RebuildDesktopGrid();
|
||||
PopulateComponentLibraryItems();
|
||||
LoadLauncherEntriesAsync();
|
||||
|
||||
_suppressSettingsPersistence = false;
|
||||
PersistSettings();
|
||||
@@ -164,6 +169,7 @@ public partial class MainWindow : Window
|
||||
_previewVideoWallpaperMedia = null;
|
||||
_previewVideoWallpaperPlayer?.Dispose();
|
||||
_previewVideoWallpaperPlayer = null;
|
||||
DisposeLauncherResources();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_videoWallpaperPlayer?.Dispose();
|
||||
@@ -257,6 +263,7 @@ public partial class MainWindow : Window
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
|
||||
ApplyWidgetSizing(gridMetrics.CellSize);
|
||||
UpdateDesktopSurfaceLayout(gridMetrics);
|
||||
UpdateSettingsViewportInsets(gridMetrics.CellSize);
|
||||
|
||||
GridInfoTextBlock.Text = Lf(
|
||||
@@ -378,7 +385,7 @@ public partial class MainWindow : Window
|
||||
|
||||
private void UpdateSettingsViewportInsets(double cellSize)
|
||||
{
|
||||
if (SettingsBackdropOverlay is null || SettingsContentPanel is null)
|
||||
if (SettingsContentPanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -388,16 +395,29 @@ public partial class MainWindow : Window
|
||||
var verticalGap = Math.Clamp(clampedCell * 0.16, 6, 18);
|
||||
var topInset = clampedCell + verticalGap;
|
||||
var bottomInset = clampedCell + verticalGap;
|
||||
var inset = new Thickness(horizontalInset, topInset, horizontalInset, bottomInset);
|
||||
|
||||
SettingsBackdropOverlay.Margin = inset;
|
||||
// 添加额外的安全边距以确保圆角不被裁剪
|
||||
var cornerSafetyMargin = Math.Clamp(clampedCell * 0.12, 4, 12);
|
||||
var inset = new Thickness(
|
||||
horizontalInset + cornerSafetyMargin,
|
||||
topInset + cornerSafetyMargin,
|
||||
horizontalInset + cornerSafetyMargin,
|
||||
bottomInset + cornerSafetyMargin);
|
||||
|
||||
// 使用 Margin 来定位,而不是直接设置 Width/Height
|
||||
// 这样可以让面板自然填充可用空间,同时保持边距
|
||||
SettingsContentPanel.HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
SettingsContentPanel.VerticalAlignment = VerticalAlignment.Stretch;
|
||||
SettingsContentPanel.Margin = inset;
|
||||
SettingsContentPanel.Width = double.NaN;
|
||||
SettingsContentPanel.Height = double.NaN;
|
||||
}
|
||||
|
||||
private void UpdateWallpaperPreviewLayout()
|
||||
{
|
||||
if (WallpaperPreviewFrame is null ||
|
||||
WallpaperPreviewHost is null ||
|
||||
WallpaperPreviewViewport is null ||
|
||||
WallpaperPreviewGrid is null)
|
||||
{
|
||||
return;
|
||||
@@ -411,80 +431,65 @@ public partial class MainWindow : Window
|
||||
_isUpdatingWallpaperPreviewLayout = true;
|
||||
try
|
||||
{
|
||||
var desktopWidth = Math.Max(1, DesktopHost.Bounds.Width);
|
||||
var desktopHeight = Math.Max(1, DesktopHost.Bounds.Height);
|
||||
var aspectRatio = desktopWidth / desktopHeight;
|
||||
var availableWidth = WallpaperPreviewHost.Bounds.Width - 20;
|
||||
var availableHeight = WallpaperPreviewHost.Bounds.Height - 20;
|
||||
if (availableWidth <= 1)
|
||||
{
|
||||
availableWidth = WallpaperPreviewFrame.Width;
|
||||
}
|
||||
if (availableHeight <= 1)
|
||||
{
|
||||
availableHeight = WallpaperPreviewFrame.Height;
|
||||
}
|
||||
availableWidth = Math.Max(1, availableWidth);
|
||||
availableHeight = Math.Max(1, availableHeight);
|
||||
var desktopWidth = Math.Max(1, DesktopHost.Bounds.Width);
|
||||
var desktopHeight = Math.Max(1, DesktopHost.Bounds.Height);
|
||||
var aspectRatio = desktopWidth / desktopHeight;
|
||||
|
||||
var previewWidth = Math.Min(availableWidth, WallpaperPreviewMaxWidth);
|
||||
var previewHeight = previewWidth / aspectRatio;
|
||||
if (previewHeight > availableHeight)
|
||||
{
|
||||
previewHeight = availableHeight;
|
||||
previewWidth = previewHeight * aspectRatio;
|
||||
}
|
||||
// Use the host width (which is roughly 50% of the settings area)
|
||||
// Subtract padding for the outer host container if needed, but let it stretch
|
||||
var availableWidth = Math.Max(100, WallpaperPreviewHost.Bounds.Width);
|
||||
|
||||
if (Math.Abs(WallpaperPreviewFrame.Width - previewWidth) > 0.5)
|
||||
{
|
||||
// Calculate height based on aspect ratio
|
||||
var previewWidth = availableWidth;
|
||||
var previewHeight = previewWidth / aspectRatio;
|
||||
|
||||
// Apply sizes to the monitor frame
|
||||
WallpaperPreviewFrame.Width = previewWidth;
|
||||
}
|
||||
|
||||
if (Math.Abs(WallpaperPreviewFrame.Height - previewHeight) > 0.5)
|
||||
{
|
||||
WallpaperPreviewFrame.Height = previewHeight;
|
||||
}
|
||||
|
||||
WallpaperPreviewClockTextBlock.Text = DateTime.Now.ToString("HH:mm");
|
||||
WallpaperPreviewClockTextBlock.Text = DateTime.Now.ToString("HH:mm");
|
||||
|
||||
var gridMetrics = CalculateGridMetrics(previewWidth, previewHeight, _targetShortSideCells);
|
||||
if (gridMetrics.CellSize <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var gridMetrics = CalculateGridMetrics(previewWidth, previewHeight, _targetShortSideCells);
|
||||
if (gridMetrics.CellSize <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WallpaperPreviewGrid.RowDefinitions.Clear();
|
||||
WallpaperPreviewGrid.ColumnDefinitions.Clear();
|
||||
WallpaperPreviewGrid.Width = gridMetrics.ColumnCount * gridMetrics.CellSize;
|
||||
WallpaperPreviewGrid.Height = gridMetrics.RowCount * gridMetrics.CellSize;
|
||||
WallpaperPreviewGrid.Width = gridMetrics.ColumnCount * gridMetrics.CellSize;
|
||||
WallpaperPreviewGrid.Height = gridMetrics.RowCount * gridMetrics.CellSize;
|
||||
|
||||
for (var row = 0; row < gridMetrics.RowCount; row++)
|
||||
{
|
||||
WallpaperPreviewGrid.RowDefinitions.Add(
|
||||
new RowDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel)));
|
||||
}
|
||||
// This can be triggered by layout changes; always rebuild the preview grid definitions
|
||||
// to avoid definitions accumulating and shifting overlay components out of place.
|
||||
WallpaperPreviewGrid.RowDefinitions.Clear();
|
||||
WallpaperPreviewGrid.ColumnDefinitions.Clear();
|
||||
|
||||
for (var col = 0; col < gridMetrics.ColumnCount; col++)
|
||||
{
|
||||
WallpaperPreviewGrid.ColumnDefinitions.Add(
|
||||
new ColumnDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel)));
|
||||
}
|
||||
for (var row = 0; row < gridMetrics.RowCount; row++)
|
||||
{
|
||||
WallpaperPreviewGrid.RowDefinitions.Add(
|
||||
new RowDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel)));
|
||||
}
|
||||
|
||||
PlaceStatusBarComponent(
|
||||
WallpaperPreviewTopStatusBarHost,
|
||||
column: 0,
|
||||
requestedColumnSpan: gridMetrics.ColumnCount,
|
||||
totalColumns: gridMetrics.ColumnCount);
|
||||
for (var col = 0; col < gridMetrics.ColumnCount; col++)
|
||||
{
|
||||
WallpaperPreviewGrid.ColumnDefinitions.Add(
|
||||
new ColumnDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel)));
|
||||
}
|
||||
|
||||
var taskbarRow = gridMetrics.RowCount - 1;
|
||||
Grid.SetRow(WallpaperPreviewBottomTaskbarContainer, taskbarRow);
|
||||
Grid.SetColumn(WallpaperPreviewBottomTaskbarContainer, 0);
|
||||
Grid.SetRowSpan(WallpaperPreviewBottomTaskbarContainer, 1);
|
||||
Grid.SetColumnSpan(WallpaperPreviewBottomTaskbarContainer, gridMetrics.ColumnCount);
|
||||
PlaceStatusBarComponent(
|
||||
WallpaperPreviewTopStatusBarHost,
|
||||
column: 0,
|
||||
requestedColumnSpan: gridMetrics.ColumnCount,
|
||||
totalColumns: gridMetrics.ColumnCount);
|
||||
|
||||
ApplyTopStatusComponentVisibility();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
ApplyPreviewWidgetSizing(gridMetrics.CellSize);
|
||||
var taskbarRow = gridMetrics.RowCount - 1;
|
||||
Grid.SetRow(WallpaperPreviewBottomTaskbarContainer, taskbarRow);
|
||||
Grid.SetColumn(WallpaperPreviewBottomTaskbarContainer, 0);
|
||||
Grid.SetRowSpan(WallpaperPreviewBottomTaskbarContainer, 1);
|
||||
Grid.SetColumnSpan(WallpaperPreviewBottomTaskbarContainer, gridMetrics.ColumnCount);
|
||||
|
||||
ApplyTopStatusComponentVisibility();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
ApplyPreviewWidgetSizing(gridMetrics.CellSize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
BIN
LanMontainDesktop/build_output.txt
Normal file
BIN
LanMontainDesktop/build_output.txt
Normal file
Binary file not shown.
1
testicon/Program.cs
Normal file
1
testicon/Program.cs
Normal file
@@ -0,0 +1 @@
|
||||
using System; class Program { static void Main() { foreach (var name in Enum.GetNames(typeof(FluentIcons.Common.Symbol))) Console.WriteLine(name); } }
|
||||
10
testicon/testicon.csproj
Normal file
10
testicon/testicon.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user