mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-06-12 19:11:25 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f8222d8e | |||
| 68842aad9f | |||
| 1d5dc4ac8c | |||
| 31ca8a947f | |||
| 558612f098 | |||
| b7067c2621 | |||
| fe55efc072 | |||
| 39097c27ef |
@@ -0,0 +1,49 @@
|
||||
name: Dev CI Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore CreamInstaller.sln
|
||||
|
||||
- name: Build Release
|
||||
run: dotnet build CreamInstaller.sln --configuration Release --no-restore
|
||||
|
||||
- name: Publish single-file
|
||||
run: dotnet publish CreamInstaller.sln -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true --output ./publish
|
||||
|
||||
- name: Set short commit SHA and branch name
|
||||
id: vars
|
||||
run: |
|
||||
$shortSha = $env:GITHUB_SHA.Substring(0,7)
|
||||
$branch = $env:GITHUB_REF_NAME
|
||||
Write-Output "shortSha=$shortSha" >> $env:GITHUB_ENV
|
||||
Write-Output "branch=$branch" >> $env:GITHUB_ENV
|
||||
shell: pwsh
|
||||
|
||||
- name: Rename EXE with branch and short commit SHA
|
||||
run: |
|
||||
Rename-Item -Path ./publish/CreamInstaller.exe -NewName "CreamInstaller-CI-$env:branch-$env:shortSha.exe"
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}
|
||||
path: ./publish/CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}.exe
|
||||
@@ -75,11 +75,14 @@ internal sealed class CustomTreeView : TreeView
|
||||
|
||||
private void DrawTreeNode(object sender, DrawTreeNodeEventArgs e)
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
TreeNode node = e.Node;
|
||||
if (node is not { IsVisible: true })
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
return;
|
||||
}
|
||||
|
||||
bool dark = Program.DarkModeEnabled;
|
||||
bool highlighted = (e.State & TreeNodeStates.Selected) == TreeNodeStates.Selected && Focused;
|
||||
Graphics graphics = e.Graphics;
|
||||
|
||||
@@ -103,12 +106,43 @@ internal sealed class CustomTreeView : TreeView
|
||||
}
|
||||
}
|
||||
|
||||
Form form = FindForm();
|
||||
|
||||
if (dark && CheckBoxes)
|
||||
{
|
||||
// In dark mode we take full ownership of the row so the system never
|
||||
// gets a chance to paint a light-background checkbox.
|
||||
e.DrawDefault = false;
|
||||
|
||||
// Row background
|
||||
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
|
||||
graphics.FillRectangle(highlighted ? selectionBrush : backBrush, rowRect);
|
||||
|
||||
// Node text
|
||||
Font nodeFont = node.NodeFont ?? Font;
|
||||
Color textColor = Enabled ? ForeColor : SystemColors.GrayText;
|
||||
TextRenderer.DrawText(graphics, node.Text, nodeFont,
|
||||
new Point(node.Bounds.Left, node.Bounds.Top + 1), textColor, TextFormatFlags.Default);
|
||||
|
||||
// Checkbox glyph – pure GDI so it matches the dark-themed CheckBox controls
|
||||
CheckBoxState cbState = node.Checked
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
|
||||
int cbX = node.Bounds.Left - cbSize.Width - 2;
|
||||
int cbY = node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2;
|
||||
ThemeManager.DrawDarkCheckBox(graphics, new Point(cbX, cbY), cbSize, node.Checked, Enabled);
|
||||
}
|
||||
else
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
}
|
||||
|
||||
Font font = node.NodeFont ?? Font;
|
||||
Brush brush = highlighted ? (Brush)selectionBrush : backBrush;
|
||||
Rectangle bounds = node.Bounds;
|
||||
Rectangle selectionBounds = bounds;
|
||||
|
||||
Form form = FindForm();
|
||||
if (form is not SelectForm and not SelectDialogForm)
|
||||
return;
|
||||
|
||||
@@ -168,18 +202,19 @@ internal sealed class CustomTreeView : TreeView
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
}
|
||||
|
||||
CheckBoxState checkBoxState = selection.UseProxy
|
||||
? Enabled ? CheckBoxState.CheckedPressed : CheckBoxState.CheckedDisabled
|
||||
: Enabled
|
||||
? CheckBoxState.UncheckedPressed
|
||||
: CheckBoxState.UncheckedDisabled;
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, checkBoxState);
|
||||
CheckBoxState proxyState = selection.UseProxy
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, proxyState);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
Rectangle checkBoxBounds = bounds;
|
||||
graphics.FillRectangle(backBrush, bounds);
|
||||
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, checkBoxState);
|
||||
if (dark)
|
||||
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseProxy, Enabled);
|
||||
else
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, proxyState);
|
||||
|
||||
text = ProxyToggleString;
|
||||
size = TextRenderer.MeasureText(graphics, text, font);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
|
||||
<UseWindowsForms>True</UseWindowsForms>
|
||||
<ApplicationIcon>Resources\program.ico</ApplicationIcon>
|
||||
<Version>5.0.2.0</Version>
|
||||
<Copyright>2025, FroggMaster (https://github.com/FroggMaster)</Copyright>
|
||||
<Version>5.0.2.2</Version>
|
||||
<Copyright>2026, FroggMaster (https://github.com/FroggMaster)</Copyright>
|
||||
<Company>CreamInstaller</Company>
|
||||
<Product>Automatic DLC Unlocker Installer & Configuration Generator</Product>
|
||||
<StartupObject>CreamInstaller.Program</StartupObject>
|
||||
|
||||
+15
-2
@@ -33,6 +33,7 @@ namespace CreamInstaller.Forms
|
||||
saveButton = new Button();
|
||||
uninstallAllButton = new Button();
|
||||
selectionTreeView = new CustomTreeView();
|
||||
filterTextBox = new System.Windows.Forms.TextBox();
|
||||
groupBox.SuspendLayout();
|
||||
allCheckBoxFlowPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
@@ -51,15 +52,25 @@ namespace CreamInstaller.Forms
|
||||
acceptButton.Text = "OK";
|
||||
acceptButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// filterTextBox
|
||||
//
|
||||
filterTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||||
filterTextBox.Location = new System.Drawing.Point(12, 14);
|
||||
filterTextBox.Name = "filterTextBox";
|
||||
filterTextBox.PlaceholderText = "Enter the name of a game to search";
|
||||
filterTextBox.Size = new System.Drawing.Size(524, 23);
|
||||
filterTextBox.TabIndex = 0;
|
||||
filterTextBox.TextChanged += OnFilterTextChanged;
|
||||
//
|
||||
// groupBox
|
||||
//
|
||||
groupBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||
groupBox.Controls.Add(selectionTreeView);
|
||||
groupBox.Controls.Add(allCheckBoxFlowPanel);
|
||||
groupBox.Location = new System.Drawing.Point(12, 12);
|
||||
groupBox.Location = new System.Drawing.Point(12, 43);
|
||||
groupBox.MinimumSize = new System.Drawing.Size(240, 40);
|
||||
groupBox.Name = "groupBox";
|
||||
groupBox.Size = new System.Drawing.Size(524, 225);
|
||||
groupBox.Size = new System.Drawing.Size(524, 194);
|
||||
groupBox.TabIndex = 3;
|
||||
groupBox.TabStop = false;
|
||||
groupBox.Text = "Choices";
|
||||
@@ -188,6 +199,7 @@ namespace CreamInstaller.Forms
|
||||
Controls.Add(cancelButton);
|
||||
Controls.Add(acceptButton);
|
||||
Controls.Add(groupBox);
|
||||
Controls.Add(filterTextBox);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
@@ -215,5 +227,6 @@ namespace CreamInstaller.Forms
|
||||
private Button saveButton;
|
||||
private CheckBox sortCheckBox;
|
||||
private Button uninstallAllButton;
|
||||
private System.Windows.Forms.TextBox filterTextBox;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace CreamInstaller.Forms;
|
||||
internal sealed partial class SelectDialogForm : CustomForm
|
||||
{
|
||||
private readonly List<(Platform platform, string id, string name)> selected = new();
|
||||
private readonly List<(Platform platform, string id, string name, bool alreadySelected)> allChoices = new();
|
||||
|
||||
internal SelectDialogForm(IWin32Window owner) : base(owner)
|
||||
{
|
||||
@@ -28,12 +29,12 @@ internal sealed partial class SelectDialogForm : CustomForm
|
||||
allCheckBox.Enabled = false;
|
||||
acceptButton.Enabled = false;
|
||||
selectionTreeView.AfterCheck += OnTreeNodeChecked;
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in potentialChoices)
|
||||
{
|
||||
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = alreadySelected };
|
||||
OnTreeNodeChecked(node);
|
||||
_ = selectionTreeView.Nodes.Add(node);
|
||||
}
|
||||
allChoices.Clear();
|
||||
allChoices.AddRange(potentialChoices);
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
|
||||
if (alreadySelected)
|
||||
selected.Add((platform, id, name));
|
||||
ApplyFilter();
|
||||
|
||||
if (selected.Count < 1)
|
||||
OnLoad(null, null);
|
||||
@@ -70,6 +71,32 @@ internal sealed partial class SelectDialogForm : CustomForm
|
||||
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
|
||||
}
|
||||
|
||||
private void OnFilterTextChanged(object sender, EventArgs e) => ApplyFilter();
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
string filter = filterTextBox.Text.Trim();
|
||||
selectionTreeView.AfterCheck -= OnTreeNodeChecked;
|
||||
selectionTreeView.BeginUpdate();
|
||||
selectionTreeView.Nodes.Clear();
|
||||
bool hasSelections = selected.Count > 0;
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
|
||||
{
|
||||
if (filter.Length > 0 && name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
bool checkedState = hasSelections
|
||||
? selected.Any(s => s.platform == platform && s.id == id)
|
||||
: alreadySelected;
|
||||
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = checkedState };
|
||||
_ = selectionTreeView.Nodes.Add(node);
|
||||
}
|
||||
selectionTreeView.EndUpdate();
|
||||
selectionTreeView.AfterCheck += OnTreeNodeChecked;
|
||||
allCheckBox.CheckedChanged -= OnAllCheckBoxChanged;
|
||||
allCheckBox.Checked = selectionTreeView.Nodes.Count > 0 && selectionTreeView.Nodes.Cast<TreeNode>().All(n => n.Checked);
|
||||
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
|
||||
}
|
||||
|
||||
private void OnResize(object s, EventArgs e)
|
||||
=> Text = TextRenderer.MeasureText(Program.ApplicationName, Font).Width > Size.Width - 100
|
||||
? Program.ApplicationNameShort
|
||||
|
||||
@@ -569,6 +569,8 @@ internal sealed partial class SelectForm : CustomForm
|
||||
progressLabel.Text = "Waiting for user to select which programs/games to scan . . .";
|
||||
ShowProgressBar();
|
||||
await ProgramData.Setup(this);
|
||||
ProgramData.ClearLog();
|
||||
ProgramData.Log($"[Scan] CreamInstaller {Program.Version} — scan started at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
bool scan = forceScan;
|
||||
if (!scan && (programsToScan is null || programsToScan.Count < 1 || forceProvideChoices))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CreamInstaller.Utility;
|
||||
@@ -28,16 +29,19 @@ internal static class SteamLibrary
|
||||
{
|
||||
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
|
||||
HashSet<string> gameLibraryDirectories = await GetLibraryDirectories();
|
||||
ProgramData.Log($"[Steam] Found {gameLibraryDirectories.Count} library folder(s).");
|
||||
foreach (string libraryDirectory in gameLibraryDirectories)
|
||||
{
|
||||
if (Program.Canceled)
|
||||
return games;
|
||||
ProgramData.Log($"[Steam] Scanning library: {libraryDirectory}");
|
||||
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) game in (await
|
||||
GetGamesFromLibraryDirectory(
|
||||
libraryDirectory)).Where(game => games.All(_game => _game.appId != game.appId)))
|
||||
games.Add(game);
|
||||
}
|
||||
|
||||
ProgramData.Log($"[Steam] Total games detected: {games.Count}");
|
||||
return games;
|
||||
});
|
||||
|
||||
@@ -47,13 +51,21 @@ internal static class SteamLibrary
|
||||
{
|
||||
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
|
||||
if (Program.Canceled || !libraryDirectory.DirectoryExists())
|
||||
{
|
||||
ProgramData.Log($"[Steam] Skipping library (not found or canceled): {libraryDirectory}");
|
||||
return games;
|
||||
}
|
||||
|
||||
foreach (string file in libraryDirectory.EnumerateDirectory("*.acf"))
|
||||
{
|
||||
if (Program.Canceled)
|
||||
return games;
|
||||
if (!ValveDataFile.TryDeserialize(file.ReadFile(), out VProperty result))
|
||||
{
|
||||
ProgramData.Log($"[Steam] Failed to deserialize ACF: {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
string appId = result.Value.GetChild("appid")?.ToString();
|
||||
string installdir = result.Value.GetChild("installdir")?.ToString();
|
||||
string name = result.Value.GetChild("name")?.ToString();
|
||||
@@ -61,11 +73,23 @@ internal static class SteamLibrary
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(installdir) ||
|
||||
string.IsNullOrWhiteSpace(name)
|
||||
|| string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
ProgramData.Log($"[Steam] Skipping ACF with missing fields: {file}");
|
||||
continue;
|
||||
string gameDirectory = (libraryDirectory + @"\common\" + installdir).ResolvePath();
|
||||
if (gameDirectory is null || !int.TryParse(appId, out int _) ||
|
||||
!int.TryParse(buildId, out int buildIdInt) || games.Any(g => g.appId == appId))
|
||||
}
|
||||
|
||||
string rawGameDirectory = libraryDirectory + @"\common\" + installdir;
|
||||
string gameDirectory = rawGameDirectory.ResolvePath();
|
||||
if (gameDirectory is null)
|
||||
{
|
||||
ProgramData.Log($"[Steam] Game directory not found (drive may be slow or disconnected): {rawGameDirectory} | App: {name} ({appId})");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(appId, out int _) || !int.TryParse(buildId, out int buildIdInt) ||
|
||||
games.Any(g => g.appId == appId))
|
||||
continue;
|
||||
|
||||
VToken userConfig = result.Value.GetChild("UserConfig");
|
||||
string branch = userConfig?.GetChild("BetaKey")?.ToString();
|
||||
branch ??= userConfig?.GetChild("betakey")?.ToString();
|
||||
@@ -78,6 +102,8 @@ internal static class SteamLibrary
|
||||
|
||||
if (string.IsNullOrWhiteSpace(branch))
|
||||
branch = "public";
|
||||
|
||||
ProgramData.Log($"[Steam] Detected game: {name} ({appId}) | Branch: {branch} | Dir: {gameDirectory}");
|
||||
games.Add((appId, name, branch, buildIdInt, gameDirectory));
|
||||
}
|
||||
|
||||
@@ -92,25 +118,50 @@ internal static class SteamLibrary
|
||||
return libraryDirectories;
|
||||
string steamInstallPath = InstallPath;
|
||||
if (steamInstallPath == null || !steamInstallPath.DirectoryExists())
|
||||
{
|
||||
ProgramData.Log($"[Steam] Steam install path not found or inaccessible: {steamInstallPath ?? "(null)"}");
|
||||
return libraryDirectories;
|
||||
}
|
||||
|
||||
string libraryFolder = steamInstallPath + @"\steamapps";
|
||||
if (!libraryFolder.DirectoryExists())
|
||||
{
|
||||
ProgramData.Log($"[Steam] Default steamapps folder not found: {libraryFolder}");
|
||||
return libraryDirectories;
|
||||
}
|
||||
|
||||
_ = libraryDirectories.Add(libraryFolder);
|
||||
ProgramData.Log($"[Steam] Default library folder: {libraryFolder}");
|
||||
|
||||
string libraryFolders = libraryFolder + @"\libraryfolders.vdf";
|
||||
if (!libraryFolders.FileExists() ||
|
||||
!ValveDataFile.TryDeserialize(libraryFolders.ReadFile(), out VProperty result))
|
||||
{
|
||||
ProgramData.Log($"[Steam] libraryfolders.vdf not found or failed to parse: {libraryFolders}");
|
||||
return libraryDirectories;
|
||||
}
|
||||
|
||||
foreach (VToken vToken in result.Value.Where(p =>
|
||||
p is VProperty property && int.TryParse(property.Key, out int _)))
|
||||
{
|
||||
VProperty property = (VProperty)vToken;
|
||||
string path = property.Value.GetChild("path")?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
string rawPath = property.Value.GetChild("path")?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
continue;
|
||||
path += @"\steamapps";
|
||||
if (path.DirectoryExists())
|
||||
_ = libraryDirectories.Add(path);
|
||||
|
||||
// Normalize the path from VDF (may use forward slashes or wrong casing)
|
||||
string normalizedPath = Path.GetFullPath(rawPath);
|
||||
string steamappsPath = normalizedPath + @"\steamapps";
|
||||
string resolvedPath = steamappsPath.ResolvePath();
|
||||
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
ProgramData.Log($"[Steam] External library not accessible (drive may be disconnected or letter changed): {steamappsPath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (libraryDirectories.Add(resolvedPath))
|
||||
ProgramData.Log($"[Steam] Additional library folder found: {resolvedPath}");
|
||||
}
|
||||
|
||||
return libraryDirectories;
|
||||
|
||||
@@ -54,7 +54,16 @@ internal static class Diagnostics
|
||||
if (info.Parent is null)
|
||||
return info.Name.ToUpperInvariant();
|
||||
string parent = ResolvePath(info.Parent.FullName);
|
||||
string name = info.Parent.GetFileSystemInfos(info.Name)[0].Name;
|
||||
return parent is null ? name : Path.Combine(parent, name);
|
||||
try
|
||||
{
|
||||
FileSystemInfo[] infos = info.Parent.GetFileSystemInfos(info.Name);
|
||||
string name = infos.Length > 0 ? infos[0].Name : info.Name;
|
||||
return parent is null ? name : Path.Combine(parent, name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to the raw name if the filesystem call fails (e.g. on a slow external drive)
|
||||
return parent is null ? info.Name : Path.Combine(parent, info.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using Newtonsoft.Json;
|
||||
@@ -28,6 +30,38 @@ internal static class ProgramData
|
||||
private static readonly string DlcChoicesPath = DirectoryPath + @"\dlc.json";
|
||||
private static readonly string KoaloaderProxyChoicesPath = DirectoryPath + @"\proxies.json";
|
||||
|
||||
internal static readonly string LogPath = DirectoryPath + @"\scan.log";
|
||||
|
||||
private static readonly object LogLock = new();
|
||||
|
||||
internal static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
string entry = $"[{timestamp}] {message}{Environment.NewLine}";
|
||||
lock (LogLock)
|
||||
File.AppendAllText(LogPath, entry, Encoding.UTF8);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored — logging must never crash the application
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ClearLog()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(LogPath))
|
||||
File.Delete(LogPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task Setup(Form form = null)
|
||||
=> await Task.Run(() =>
|
||||
{
|
||||
|
||||
@@ -206,6 +206,14 @@ internal static class ThemeManager
|
||||
rtb.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// TextBox follows alternate dark background
|
||||
case TextBox tb:
|
||||
tb.BackColor = DarkBackAlt;
|
||||
tb.ForeColor = DarkFore;
|
||||
tb.BorderStyle = BorderStyle.FixedSingle;
|
||||
NativeMethods.RefreshCueBanner(tb);
|
||||
break;
|
||||
|
||||
// Layout panels set a consistent background
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = DarkBack;
|
||||
@@ -258,6 +266,12 @@ internal static class ThemeManager
|
||||
rtb.BackColor = LightBack;
|
||||
rtb.ForeColor = LightFore;
|
||||
break;
|
||||
case TextBox tb:
|
||||
tb.BackColor = LightBackAlt;
|
||||
tb.ForeColor = LightFore;
|
||||
tb.BorderStyle = BorderStyle.Fixed3D;
|
||||
NativeMethods.RefreshCueBanner(tb);
|
||||
break;
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = LightBack;
|
||||
break;
|
||||
@@ -408,6 +422,96 @@ internal static class ThemeManager
|
||||
// button is centralized here so theming resides in ThemeManager.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Dark checkbox colors – matched to how the system renders the "All" CheckBox control
|
||||
// in dark mode: dark fill, mid-gray border, light foreground tick.
|
||||
private static readonly Color DarkCbBorder = ColorTranslator.FromHtml("#6B6B6B");
|
||||
private static readonly Color DarkCbDisabledBorder = ColorTranslator.FromHtml("#454545");
|
||||
|
||||
/// <summary>
|
||||
/// Draws a checkbox glyph in pure GDI that matches the appearance of a dark-themed
|
||||
/// WinForms CheckBox control (same background, border, tick colors, and rounded corners).
|
||||
/// Use this in owner-draw contexts where CheckBoxRenderer always paints a white background.
|
||||
/// </summary>
|
||||
internal static void DrawDarkCheckBox(Graphics g, Point point, Size glyphSize, bool isChecked, bool enabled = true)
|
||||
{
|
||||
if (g is null) return;
|
||||
int w = glyphSize.Width;
|
||||
int h = glyphSize.Height;
|
||||
Rectangle box = new(point.X, point.Y, w - 1, h - 1);
|
||||
int radius = Math.Max(2, w / 5);
|
||||
|
||||
using System.Drawing.Drawing2D.GraphicsPath path = RoundedRect(box, radius);
|
||||
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
|
||||
if (isChecked && enabled)
|
||||
{
|
||||
// Checked + enabled: accent fill, no border, white tick — matches Windows 11 dark CheckBox
|
||||
using SolidBrush fillBrush = new(Accent);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen tickPen = new(Color.White, 1.7f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
|
||||
};
|
||||
float scaleX = w / 13f;
|
||||
float scaleY = h / 13f;
|
||||
g.DrawLines(tickPen, new PointF[]
|
||||
{
|
||||
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
|
||||
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
|
||||
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
|
||||
});
|
||||
}
|
||||
else if (isChecked)
|
||||
{
|
||||
// Checked + disabled: dimmed accent fill, dimmed tick
|
||||
Color dimAccent = Color.FromArgb(120, Accent);
|
||||
using SolidBrush fillBrush = new(dimAccent);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen tickPen = new(DarkForeDim, 1.7f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
|
||||
};
|
||||
float scaleX = w / 13f;
|
||||
float scaleY = h / 13f;
|
||||
g.DrawLines(tickPen, new PointF[]
|
||||
{
|
||||
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
|
||||
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
|
||||
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unchecked: dark fill, gray border, no tick
|
||||
using SolidBrush fillBrush = new(DarkBackAlt);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen borderPen = new(enabled ? DarkCbBorder : DarkCbDisabledBorder);
|
||||
g.DrawPath(borderPen, path);
|
||||
}
|
||||
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default;
|
||||
}
|
||||
|
||||
private static System.Drawing.Drawing2D.GraphicsPath RoundedRect(Rectangle r, int radius)
|
||||
{
|
||||
int d = radius * 2;
|
||||
System.Drawing.Drawing2D.GraphicsPath path = new();
|
||||
path.AddArc(r.Left, r.Top, d, d, 180, 90);
|
||||
path.AddArc(r.Right - d, r.Top, d, d, 270, 90);
|
||||
path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
|
||||
path.AddArc(r.Left, r.Bottom - d, d, d, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the themed combobox area (background, border and text) used in CustomTreeView.
|
||||
/// This centralizes colors and rendering for light/dark modes.
|
||||
@@ -450,15 +554,54 @@ internal static class ThemeManager
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps Win32 API calls that have no managed equivalent in WinForms.
|
||||
/// These P/Invoke declarations are required because .NET does not expose
|
||||
/// the underlying Windows messages or DWM attributes through its own APIs.
|
||||
/// </summary>
|
||||
internal static class NativeMethods
|
||||
{
|
||||
// DWM attribute index for enabling/disabling the immersive dark title bar.
|
||||
// Documented in dwmapi.h; value 20 corresponds to DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
// (Windows 10 build 19041+ / Windows 11).
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
// DwmSetWindowAttribute allows setting per-window Desktop Window Manager attributes.
|
||||
// We use it here to flip the title bar to dark or light depending on the active theme,
|
||||
// since WinForms has no built-in API to control title bar coloring.
|
||||
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(System.IntPtr hwnd, int attr, ref int attrValue, int attrSize);
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the dark/light title bar chrome for the given window handle.
|
||||
/// Pass <c>1</c> for dark mode, <c>0</c> for light mode.
|
||||
/// </summary>
|
||||
internal static void EnableDarkTitleBar(System.IntPtr handle, int useDark)
|
||||
{
|
||||
_ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int));
|
||||
}
|
||||
|
||||
// Win32 Edit control message that sets or updates the cue (placeholder) banner text.
|
||||
// WinForms sets PlaceholderText once at creation time via this same message internally,
|
||||
// but does not re-send it when the control's colors change. When we restyle a TextBox
|
||||
// for dark/light mode the cue banner can disappear, so we must re-send the message
|
||||
// manually to make the placeholder visible again.
|
||||
private const int EM_SETCUEBANNER = 0x1501;
|
||||
|
||||
// SendMessage is the standard Win32 mechanism for posting messages directly to a
|
||||
// window/control handle. We use the Unicode variant so the placeholder string is
|
||||
// transmitted without any ANSI conversion.
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
private static extern System.IntPtr SendMessage(System.IntPtr hWnd, int msg, System.IntPtr wParam, string lParam);
|
||||
|
||||
/// <summary>
|
||||
/// Re-sends <c>EM_SETCUEBANNER</c> to the given TextBox so its placeholder text
|
||||
/// is redrawn after a theme change has altered the control's background or foreground colors.
|
||||
/// Does nothing if the control handle has not yet been created or the placeholder is empty.
|
||||
/// </summary>
|
||||
internal static void RefreshCueBanner(System.Windows.Forms.TextBox textBox)
|
||||
{
|
||||
if (textBox?.IsHandleCreated == true && textBox.PlaceholderText is { Length: > 0 })
|
||||
SendMessage(textBox.Handle, EM_SETCUEBANNER, (System.IntPtr)1, textBox.PlaceholderText);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user