12 Commits

Author SHA1 Message Date
Frog 54592230c3 Increment App Version
- Increase app version to 5.0.2.3
2026-05-27 01:33:35 -07:00
Frog 34cb3b862c Fix Light Mode Selection Bar
- Adjusts the selection bar so it properly selects the whole field.
- Adjusted the color of the selection bar to be a lighter blue so text is easier to read.
2026-05-27 01:32:24 -07:00
Frog 8040e6bcdb Fix Dark Mode Expander / Selection Bar
- Fixes missing expander in dark mode, which occured when I was fixing the checkboxes. (Woops)
- Fixes the selection bar in dark mode, now properly selects the proxy button as well instead of being cut off. (Light mode still looks like shit though.)
2026-05-27 01:28:28 -07:00
Frog 593f396c54 Add Test Game Generator for Steam and Epic
- Added Test Game Generator to the Debug window for creating fake installed game entries for testing
- Updated ThemeManager to properly theme ListBox controls in dark mode
- Labels now use transparent backgrounds for better appearance
2026-05-27 01:20:03 -07:00
Frog 2f993bfe3b Updated Preview Image
- Updated preview image to Dark Mode
2026-05-26 02:41:03 -07:00
Frog e9f8222d8e Increment App Version
- Increase app version to 5.0.2.2
2026-05-26 02:29:48 -07:00
Frog 68842aad9f Logging Infrastructure / Normalize Paths
- Added base logging infrastructure
- Create scan log during library/game scan in %ProgramData%\CreamInstaller\scan.log
- Normalize Steam library paths (libraryfolders.vdf) using Path.GetFullPath + ResolvePath to handle slashes, casing, and drive changes
- Diagnostics.ResolvePath: wrap GetFileSystemInfos in try/catch and guard against empty results to prevent IndexOutOfRangeException (May assist with issues on slow or intermittently accessible external drives)
2026-05-26 02:11:15 -07:00
Frog 1d5dc4ac8c Increment Version
- Increase version to 5.0.2.1
2026-05-25 16:18:01 -07:00
Frog 31ca8a947f Fix: CustomTreeView Checkbox Themes
- Further adjust the Proxy/CustomTreeView checkbox theme, doesn't perfectly match the top settings checkboxes, but it's good enough.
2026-05-25 14:57:38 -07:00
Frog 558612f098 fix: dark mode checkboxes in CustomTreeView
- Added ThemeManager.DrawDarkCheckBox to render dark-themed rounded checkboxes
- Fixed TreeView and Proxy checkboxes showing white backgrounds in dark mode due to CheckBoxRenderer drawing opaque system-themed glyphs. > Still need to fix the actual checkmark to match the top checkboxes.
- Disabled DrawDefault for checkbox tree nodes in dark mode and manually draw the row background, text, and checkbox glyphs.
- Checkbox glyph sizing now uses CheckBoxRenderer.GetGlyphSize for proper DPI scaling. (Probably)
2026-05-25 14:54:51 -07:00
Frog b7067c2621 Change Game Filter Text to PlaceHolder Text
- Removed the game search label in favor of textbox placeholder text.
 - Added methods to ensure the placeholder text renders properly
- Added inline comments to explain what NativeMethods is for
2026-05-25 02:35:50 -07:00
Frog fe55efc072 Added Game Search / Filter When Scanning Games Closes #15
- Added a "Game search:" label and text box above the game list in
  SelectDialogForm, allowing users to filter the program/game list
  by name in real-time (case-insensitive substring match).
- Added TextBox theming support to ThemeManager for both dark and
  light modes, ensuring the new search field conforms to the active
  theme.
2026-05-25 02:18:56 -07:00
14 changed files with 1090 additions and 28 deletions
+98 -11
View File
@@ -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,95 @@ 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);
// Expander glyph (expand/collapse) the system skips this when DrawDefault=false
if (node.Nodes.Count > 0)
{
int indent = Indent;
int level = node.Level;
int glyphSize = 13;
int glyphX = level * indent + (indent - glyphSize) / 2 + (ShowRootLines ? 0 : -indent);
int glyphY = node.Bounds.Top + node.Bounds.Height / 2 - glyphSize / 2;
Rectangle glyphRect = new(glyphX, glyphY, glyphSize, glyphSize);
Color glyphBorder = Color.FromArgb(0x6B, 0x6B, 0x6B);
Color glyphBack = Color.FromArgb(0x2D, 0x2D, 0x2D);
Color glyphFore = Color.FromArgb(0xD4, 0xD4, 0xD4);
using (SolidBrush backFill = new(glyphBack))
graphics.FillRectangle(backFill, glyphRect);
using (Pen borderPen = new(glyphBorder))
graphics.DrawRectangle(borderPen, glyphRect);
int mid = glyphY + glyphSize / 2;
int left = glyphX + 3;
int right = glyphX + glyphSize - 3;
using (Pen linePen = new(glyphFore))
{
graphics.DrawLine(linePen, left, mid, right, mid); // horizontal minus
if (!node.IsExpanded)
graphics.DrawLine(linePen, glyphX + glyphSize / 2, glyphY + 3, glyphX + glyphSize / 2, glyphY + glyphSize - 3); // vertical plus
}
}
}
else
{
if (highlighted && CheckBoxes)
{
// In light mode, take ownership of the row when selected so the
// highlight fills the full width (same approach as dark mode).
e.DrawDefault = false;
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
graphics.FillRectangle(selectionBrush, rowRect);
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);
CheckBoxState cbState = node.Checked
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
Point cbPoint = new(node.Bounds.Left - cbSize.Width - 2,
node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2);
CheckBoxRenderer.DrawCheckBox(graphics, cbPoint, cbState);
}
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 +254,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);
graphics.FillRectangle(brush, 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);
@@ -187,7 +274,7 @@ internal sealed class CustomTreeView : TreeView
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + left };
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
checkBoxBounds = new(checkBoxBounds.Location, checkBoxBounds.Size + bounds.Size with { Height = 0 });
graphics.FillRectangle(backBrush, bounds);
graphics.FillRectangle(brush, bounds);
point = new(bounds.Location.X - 1 + left, bounds.Location.Y + 1);
TextRenderer.DrawText(graphics, text, font, point,
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
+2 -2
View File
@@ -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.3</Version>
<Copyright>2026, FroggMaster (https://github.com/FroggMaster)</Copyright>
<Company>CreamInstaller</Company>
<Product>Automatic DLC Unlocker Installer &amp; Configuration Generator</Product>
<StartupObject>CreamInstaller.Program</StartupObject>
+20 -3
View File
@@ -32,16 +32,31 @@ partial class DebugForm
private void InitializeComponent()
{
debugTextBox = new RichTextBox();
testGameButton = new Button();
SuspendLayout();
//
// testGameButton
//
testGameButton.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
testGameButton.AutoSize = true;
testGameButton.AutoSizeMode = AutoSizeMode.GrowAndShrink;
testGameButton.Location = new System.Drawing.Point(10, 10);
testGameButton.Name = "testGameButton";
testGameButton.Padding = new Padding(3, 0, 3, 0);
testGameButton.Size = new System.Drawing.Size(540, 25);
testGameButton.TabIndex = 1;
testGameButton.Text = "Test Game";
testGameButton.UseVisualStyleBackColor = true;
testGameButton.Click += OnTestGame;
//
// debugTextBox
//
debugTextBox.Dock = DockStyle.Fill;
debugTextBox.Location = new System.Drawing.Point(10, 10);
debugTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
debugTextBox.Location = new System.Drawing.Point(10, 41);
debugTextBox.Name = "debugTextBox";
debugTextBox.ReadOnly = true;
debugTextBox.ScrollBars = RichTextBoxScrollBars.ForcedBoth;
debugTextBox.Size = new System.Drawing.Size(540, 317);
debugTextBox.Size = new System.Drawing.Size(540, 286);
debugTextBox.TabIndex = 0;
debugTextBox.TabStop = false;
debugTextBox.Text = "";
@@ -52,6 +67,7 @@ partial class DebugForm
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 337);
ControlBox = false;
Controls.Add(testGameButton);
Controls.Add(debugTextBox);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
@@ -68,4 +84,5 @@ partial class DebugForm
#endregion
private RichTextBox debugTextBox;
private Button testGameButton;
}
+6
View File
@@ -81,4 +81,10 @@ internal sealed partial class DebugForm : CustomForm
debugTextBox.AppendText(text, color, true);
});
}
private void OnTestGame(object sender, EventArgs e)
{
using TestGameForm form = new(this);
_ = form.ShowDialog(this);
}
}
+15 -2
View File
@@ -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;
}
}
+33 -6
View File
@@ -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
+215
View File
@@ -0,0 +1,215 @@
using System.ComponentModel;
using System.Windows.Forms;
namespace CreamInstaller.Forms;
partial class TestGameForm
{
private IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && components is not null)
components.Dispose();
base.Dispose(disposing);
}
// All coordinates are based on ClientSize = 560 x 330
// Left margin = 12, right edge of usable area = 548 (560 - 12)
// Usable width = 536
private void InitializeComponent()
{
platformGroupBox = new GroupBox();
steamRadioButton = new RadioButton();
epicRadioButton = new RadioButton();
appIdLabel = new Label();
appIdTextBox = new TextBox();
gameNameLabel = new Label();
gameNameTextBox = new TextBox();
epicSearchButton = new Button();
epicResultsListBox = new ListBox();
dlcGroupBox = new GroupBox();
dlcListBox = new ListBox();
dlcIdLabel = new Label();
dlcIdTextBox = new TextBox();
dlcNameLabel = new Label();
dlcNameTextBox = new TextBox();
addDlcButton = new Button();
removeDlcButton = new Button();
generateButton = new Button();
clearButton = new Button();
closeButton = new Button();
statusLabel = new Label();
platformGroupBox.SuspendLayout();
dlcGroupBox.SuspendLayout();
SuspendLayout();
// ── Platform group box ── y=8, h=44
platformGroupBox.Location = new System.Drawing.Point(12, 8);
platformGroupBox.Size = new System.Drawing.Size(536, 44);
platformGroupBox.TabStop = false;
platformGroupBox.Text = "Platform";
platformGroupBox.Controls.Add(steamRadioButton);
platformGroupBox.Controls.Add(epicRadioButton);
steamRadioButton.AutoSize = true;
steamRadioButton.Checked = true;
steamRadioButton.Location = new System.Drawing.Point(10, 17);
steamRadioButton.TabStop = true;
steamRadioButton.Text = "Steam";
steamRadioButton.CheckedChanged += OnPlatformChanged;
epicRadioButton.AutoSize = true;
epicRadioButton.Location = new System.Drawing.Point(80, 17);
epicRadioButton.Text = "Epic";
epicRadioButton.CheckedChanged += OnPlatformChanged;
// ── App ID row ── y=62
appIdLabel.AutoSize = true;
appIdLabel.Location = new System.Drawing.Point(12, 66);
appIdLabel.Text = "App ID:";
appIdTextBox.Location = new System.Drawing.Point(105, 63);
appIdTextBox.Size = new System.Drawing.Size(443, 23);
appIdTextBox.PlaceholderText = "e.g. 480";
// ── Game Name row ── y=96
gameNameLabel.AutoSize = true;
gameNameLabel.Location = new System.Drawing.Point(12, 100);
gameNameLabel.Text = "Game Name:";
// Steam: full width; Epic: leaves room for Search button (75px + 4px gap)
gameNameTextBox.Location = new System.Drawing.Point(105, 97);
gameNameTextBox.Size = new System.Drawing.Size(443, 23);
epicSearchButton.Location = new System.Drawing.Point(468, 97);
epicSearchButton.Size = new System.Drawing.Size(80, 23);
epicSearchButton.Text = "Search";
epicSearchButton.Visible = false;
epicSearchButton.Click += OnEpicSearch;
// ── Epic results list ── y=130, same slot as DLC group
epicResultsListBox.Location = new System.Drawing.Point(12, 130);
epicResultsListBox.Size = new System.Drawing.Size(536, 80);
epicResultsListBox.Visible = false;
epicResultsListBox.SelectedIndexChanged += OnEpicResultSelected;
// ── DLC group box ── y=130, h=130
dlcGroupBox.Location = new System.Drawing.Point(12, 130);
dlcGroupBox.Size = new System.Drawing.Size(536, 130);
dlcGroupBox.TabStop = false;
dlcGroupBox.Text = "DLC Entries (Steam only)";
dlcGroupBox.Controls.Add(dlcListBox);
dlcGroupBox.Controls.Add(dlcIdLabel);
dlcGroupBox.Controls.Add(dlcIdTextBox);
dlcGroupBox.Controls.Add(dlcNameLabel);
dlcGroupBox.Controls.Add(dlcNameTextBox);
dlcGroupBox.Controls.Add(addDlcButton);
dlcGroupBox.Controls.Add(removeDlcButton);
dlcListBox.Location = new System.Drawing.Point(6, 20);
dlcListBox.Size = new System.Drawing.Size(524, 60);
// DLC row inside group box — left-to-right:
// "DLC ID:" label + 70px box + "DLC Name:" label + 160px box + "Add"(60) + "Remove"(70)
// Total: ~48 + 70 + ~72 + 160 + 60 + 70 = 480 (fits in 524)
dlcIdLabel.AutoSize = true;
dlcIdLabel.Location = new System.Drawing.Point(6, 92);
dlcIdLabel.Text = "DLC ID:";
dlcIdTextBox.Location = new System.Drawing.Point(62, 89);
dlcIdTextBox.Size = new System.Drawing.Size(70, 23);
dlcIdTextBox.PlaceholderText = "e.g. 12345";
dlcNameLabel.AutoSize = true;
dlcNameLabel.Location = new System.Drawing.Point(140, 92);
dlcNameLabel.Text = "DLC Name:";
dlcNameTextBox.Location = new System.Drawing.Point(216, 89);
dlcNameTextBox.Size = new System.Drawing.Size(184, 23);
dlcNameTextBox.PlaceholderText = "e.g. Test DLC";
addDlcButton.Location = new System.Drawing.Point(406, 89);
addDlcButton.Size = new System.Drawing.Size(52, 23);
addDlcButton.Text = "Add";
addDlcButton.Click += OnAddDlc;
removeDlcButton.Location = new System.Drawing.Point(462, 89);
removeDlcButton.Size = new System.Drawing.Size(62, 23);
removeDlcButton.Text = "Remove";
removeDlcButton.Click += OnRemoveDlc;
// ── Action buttons ── y=270
generateButton.Location = new System.Drawing.Point(12, 270);
generateButton.Size = new System.Drawing.Size(150, 26);
generateButton.Text = "Generate Test Game";
generateButton.Click += OnGenerate;
clearButton.Location = new System.Drawing.Point(168, 270);
clearButton.Size = new System.Drawing.Size(110, 26);
clearButton.Text = "Clear All Tests";
clearButton.Click += OnClearAll;
closeButton.Location = new System.Drawing.Point(284, 270);
closeButton.Size = new System.Drawing.Size(70, 26);
closeButton.Text = "Close";
closeButton.Click += OnClose;
// ── Status label ── y=302
statusLabel.Location = new System.Drawing.Point(12, 302);
statusLabel.Size = new System.Drawing.Size(536, 20);
statusLabel.Font = new System.Drawing.Font("Segoe UI", 8.25F);
// ── Form ──
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 328);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Test Game Generator";
Controls.Add(platformGroupBox);
Controls.Add(appIdLabel);
Controls.Add(appIdTextBox);
Controls.Add(gameNameLabel);
Controls.Add(gameNameTextBox);
Controls.Add(epicSearchButton);
Controls.Add(epicResultsListBox);
Controls.Add(dlcGroupBox);
Controls.Add(generateButton);
Controls.Add(clearButton);
Controls.Add(closeButton);
Controls.Add(statusLabel);
platformGroupBox.ResumeLayout(false);
platformGroupBox.PerformLayout();
dlcGroupBox.ResumeLayout(false);
dlcGroupBox.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
private GroupBox platformGroupBox;
private RadioButton steamRadioButton;
private RadioButton epicRadioButton;
private Label appIdLabel;
private TextBox appIdTextBox;
private Label gameNameLabel;
private TextBox gameNameTextBox;
private Button epicSearchButton;
private ListBox epicResultsListBox;
private GroupBox dlcGroupBox;
private ListBox dlcListBox;
private Label dlcIdLabel;
private TextBox dlcIdTextBox;
private Label dlcNameLabel;
private TextBox dlcNameTextBox;
private Button addDlcButton;
private Button removeDlcButton;
private Button generateButton;
private Button clearButton;
private Button closeButton;
private Label statusLabel;
}
+362
View File
@@ -0,0 +1,362 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller.Components;
using CreamInstaller.Platforms.Epic;
using CreamInstaller.Platforms.Steam;
using CreamInstaller.Utility;
namespace CreamInstaller.Forms;
internal sealed partial class TestGameForm : CustomForm
{
private static readonly string TestGamesRoot =
Path.Combine(ProgramData.DirectoryPath, "TestGames");
private static readonly List<string> CreatedDirectories = [];
// Steam DLC entries per-form: (dlcId, dlcName)
private readonly List<(string id, string name)> dlcEntries = [];
// Cached Epic search results from the last search: (namespace, name)
private readonly List<(string ns, string name)> epicSearchResults = [];
private bool IsEpicMode => epicRadioButton.Checked;
internal TestGameForm(IWin32Window owner) : base(owner)
{
InitializeComponent();
appIdTextBox.Leave += OnAppIdLeave;
RefreshDlcList();
UpdatePlatformMode();
}
private void UpdatePlatformMode()
{
bool epic = IsEpicMode;
// App ID row: Steam only
appIdLabel.Visible = !epic;
appIdTextBox.Visible = !epic;
// Search button: Epic only — shrink the game name box to make room
epicSearchButton.Visible = epic;
gameNameTextBox.Size = new System.Drawing.Size(epic ? 354 : 443, 23);
// Placeholder text — call RefreshCueBanner to flush the Win32 cue so only one text shows
gameNameTextBox.PlaceholderText = epic ? "Enter game name and click Search" : "e.g. Spacewar";
NativeMethods.RefreshCueBanner(gameNameTextBox);
// DLC group and Epic results share the same vertical slot
dlcGroupBox.Visible = !epic;
epicResultsListBox.Visible = false; // hidden until search runs
if (!epic)
epicSearchResults.Clear();
SetStatus(epic
? "Enter a game name and click Search to find it on the Epic store."
: "Enter the App ID, then tab out to auto-detect the game name.");
}
private void OnPlatformChanged(object sender, EventArgs e) => UpdatePlatformMode();
// ── Steam: auto-detect name from AppID ──────────────────────────────────
private async void OnAppIdLeave(object sender, EventArgs e)
{
if (IsEpicMode)
return;
string appId = appIdTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _))
return;
if (!string.IsNullOrWhiteSpace(gameNameTextBox.Text))
return;
SetStatus("Looking up game name . . .");
generateButton.Enabled = false;
string name = await Task.Run(async () =>
{
// Use a dedicated client with a neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic";
try
{
string json = await client.GetStringAsync(url);
Newtonsoft.Json.Linq.JObject root = Newtonsoft.Json.Linq.JObject.Parse(json);
string title = root[appId]?["data"]?["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(title))
return title;
}
catch { /* fall through to SteamCMD */ }
CmdAppData cmdData = await SteamCMD.GetAppInfo(appId);
return cmdData?.Common?.Name;
});
generateButton.Enabled = true;
if (name is not null)
{
gameNameTextBox.Text = name;
SetStatus($"✓ Game name detected: {name}");
}
else
{
SetStatus("Could not auto-detect name — enter it manually.");
}
}
// ── Epic: search by name ─────────────────────────────────────────────────
private async void OnEpicSearch(object sender, EventArgs e)
{
string keyword = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(keyword))
{
SetStatus("Enter a game name to search.");
return;
}
SetStatus("Searching Epic store . . .");
epicSearchButton.Enabled = false;
generateButton.Enabled = false;
epicResultsListBox.Items.Clear();
epicResultsListBox.Visible = false;
epicSearchResults.Clear();
List<(string ns, string name)> results = await EpicStore.QuerySearch(keyword);
epicSearchButton.Enabled = true;
generateButton.Enabled = true;
if (results.Count == 0)
{
SetStatus("No results found. Try a different name.");
return;
}
epicSearchResults.AddRange(results);
foreach ((string _, string name) in results)
epicResultsListBox.Items.Add(name);
epicResultsListBox.Visible = true;
SetStatus($"Found {results.Count} result(s). Select one to use it.");
}
private void OnEpicResultSelected(object sender, EventArgs e)
{
int idx = epicResultsListBox.SelectedIndex;
if (idx < 0 || idx >= epicSearchResults.Count)
return;
gameNameTextBox.Text = epicSearchResults[idx].name;
SetStatus($"✓ Selected: {epicSearchResults[idx].name}");
}
// ── DLC (Steam) ──────────────────────────────────────────────────────────
private void OnAddDlc(object sender, EventArgs e)
{
string dlcId = dlcIdTextBox.Text.Trim();
string dlcName = dlcNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(dlcId) || !int.TryParse(dlcId, out _))
{
SetStatus("DLC ID must be a valid integer.");
return;
}
if (string.IsNullOrWhiteSpace(dlcName))
{
SetStatus("DLC Name cannot be empty.");
return;
}
if (dlcEntries.Any(d => d.id == dlcId))
{
SetStatus($"DLC ID {dlcId} is already in the list.");
return;
}
dlcEntries.Add((dlcId, dlcName));
RefreshDlcList();
dlcIdTextBox.Clear();
dlcNameTextBox.Clear();
SetStatus($"Added DLC: {dlcId} = {dlcName}");
}
private void OnRemoveDlc(object sender, EventArgs e)
{
if (dlcListBox.SelectedIndex < 0)
return;
dlcEntries.RemoveAt(dlcListBox.SelectedIndex);
RefreshDlcList();
SetStatus("Removed selected DLC entry.");
}
private void OnDlcListBoxSelectionChanged(object sender, EventArgs e) { }
private void RefreshDlcList()
{
dlcListBox.Items.Clear();
foreach ((string id, string name) in dlcEntries)
dlcListBox.Items.Add($"{id} = {name}");
}
// ── Generate ────────────────────────────────────────────────────────────
private void OnGenerate(object sender, EventArgs e)
{
if (IsEpicMode)
GenerateEpic();
else
GenerateSteam();
}
private void GenerateSteam()
{
string appId = appIdTextBox.Text.Trim();
string gameName = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _))
{
SetStatus("App ID must be a valid integer.");
return;
}
if (string.IsNullOrWhiteSpace(gameName))
{
SetStatus("Game Name cannot be empty.");
return;
}
if (SteamLibrary.TestGames.Any(g => g.appId == appId))
{
SetStatus($"A test game with App ID {appId} already exists.");
return;
}
try
{
string gameDir = Path.Combine(TestGamesRoot, $"steam_{appId}_{SanitizeName(gameName)}");
Directory.CreateDirectory(gameDir);
string dllPath = Path.Combine(gameDir, "steam_api64.dll");
WriteSteamApiStub(dllPath);
CreatedDirectories.Add(gameDir);
SteamLibrary.TestGames.Add((appId, gameName, "public", 1, gameDir));
ProgramData.Log($"[TestGame] Steam: {gameName} ({appId}) at {gameDir}");
SetStatus($"✓ Steam test game '{gameName}' ({appId}) generated. Press Rescan.");
}
catch (Exception ex)
{
SetStatus($"Error: {ex.Message}");
}
}
private void GenerateEpic()
{
string gameName = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(gameName))
{
SetStatus("Game Name cannot be empty. Search for a game first.");
return;
}
// Use the selected search result namespace if available, otherwise derive a stub
string catalogNamespace;
int idx = epicResultsListBox.SelectedIndex;
if (idx >= 0 && idx < epicSearchResults.Count)
{
catalogNamespace = epicSearchResults[idx].ns;
gameName = epicSearchResults[idx].name;
}
else
{
catalogNamespace = $"test_{SanitizeName(gameName).ToLowerInvariant()}";
}
if (EpicLibrary.TestManifests.Any(m => m.CatalogNamespace == catalogNamespace))
{
SetStatus("An Epic test game with that namespace already exists.");
return;
}
try
{
string gameDir = Path.Combine(TestGamesRoot, $"epic_{SanitizeName(gameName)}");
Directory.CreateDirectory(gameDir);
// Stub DLL so Epic DLL-directory scanning finds the game
string dllPath = Path.Combine(gameDir, "EOSSDK-Win64-Shipping.dll");
WriteSteamApiStub(dllPath);
CreatedDirectories.Add(gameDir);
EpicLibrary.TestManifests.Add(new Manifest
{
DisplayName = gameName,
CatalogNamespace = catalogNamespace,
InstallLocation = gameDir
});
ProgramData.Log($"[TestGame] Epic: {gameName} ({catalogNamespace}) at {gameDir}");
SetStatus($"✓ Epic test game '{gameName}' generated. Press Rescan.");
}
catch (Exception ex)
{
SetStatus($"Error: {ex.Message}");
}
}
// ── Clear / Close ────────────────────────────────────────────────────────
private void OnClearAll(object sender, EventArgs e)
{
SteamLibrary.TestGames.Clear();
EpicLibrary.TestManifests.Clear();
foreach (string dir in CreatedDirectories)
try { Directory.Delete(dir, true); } catch { /* best-effort */ }
CreatedDirectories.Clear();
dlcEntries.Clear();
RefreshDlcList();
epicSearchResults.Clear();
epicResultsListBox.Items.Clear();
epicResultsListBox.Visible = false;
SetStatus("All test games cleared. Press Rescan in the main window.");
}
private void OnClose(object sender, EventArgs e) => Close();
// ── Helpers ──────────────────────────────────────────────────────────────
private void SetStatus(string message)
{
statusLabel.Text = message;
statusLabel.ForeColor = message.StartsWith("✓", StringComparison.Ordinal)
? System.Drawing.Color.Green
: System.Drawing.Color.FromArgb(212, 212, 212);
}
private static string SanitizeName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
}
private static void WriteSteamApiStub(string path)
{
byte[] mzStub =
[
0x4D, 0x5A,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
];
File.WriteAllBytes(path, mzStub);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -28,10 +28,17 @@ internal static class EpicLibrary
}
}
internal static readonly List<Manifest> TestManifests = [];
internal static async Task<List<Manifest>> GetGames()
=> await Task.Run(async () =>
{
List<Manifest> games = new();
foreach (Manifest test in TestManifests)
if (games.All(g => g.CatalogNamespace != test.CatalogNamespace))
games.Add(test);
string manifests = EpicManifestsPath;
if (manifests.DirectoryExists())
foreach (string item in manifests.EnumerateDirectory("*.item"))
@@ -132,6 +132,53 @@ internal static class EpicStore
public static bool EpicBool = true;
internal static async Task<List<(string @namespace, string name)>> QuerySearch(string keyword)
{
List<(string, string)> results = [];
try
{
string query = """
query searchByKeyword($keywords: String!) {
Catalog {
searchStore(keywords: $keywords, category: "games/edition/base", count: 10, country: "US", locale: "en-US", allowCountries: "US") {
elements {
title
namespace
}
}
}
}
""";
var payload = new { query, variables = new { keywords = keyword } };
string payloadJson = JsonConvert.SerializeObject(payload);
using HttpContent content = new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json");
HttpClient client = HttpClientManager.HttpClient;
if (client is null)
return results;
HttpResponseMessage httpResponse =
await client.PostAsync(new Uri("https://launcher.store.epicgames.com/graphql"), content);
_ = httpResponse.EnsureSuccessStatusCode();
string response = await httpResponse.Content.ReadAsStringAsync();
Newtonsoft.Json.Linq.JObject root = Newtonsoft.Json.Linq.JObject.Parse(response);
Newtonsoft.Json.Linq.JToken elements = root["data"]?["Catalog"]?["searchStore"]?["elements"];
if (elements is null)
return results;
foreach (Newtonsoft.Json.Linq.JToken el in elements)
{
string name = el["title"]?.ToString();
string ns = el["namespace"]?.ToString();
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(ns)
&& results.All(r => r.Item1 != ns))
results.Add((ns, name));
}
}
catch
{
// ignored
}
return results;
}
private static async Task<Response> QueryGraphQL(string categoryNamespace)
{
try
@@ -10,6 +10,9 @@ namespace CreamInstaller.Platforms.Steam;
internal static class SteamLibrary
{
internal static readonly List<(string appId, string name, string branch, int buildId, string gameDirectory)>
TestGames = [];
private static string installPath;
internal static string InstallPath
@@ -41,6 +44,11 @@ internal static class SteamLibrary
games.Add(game);
}
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) testGame in
TestGames.Where(t => games.All(g => g.appId != t.appId)))
games.Add(testGame);
if (TestGames.Count > 0)
ProgramData.Log($"[Steam] Injected {TestGames.Count} test game(s).");
ProgramData.Log($"[Steam] Total games detected: {games.Count}");
return games;
});
+157 -4
View File
@@ -43,7 +43,7 @@ internal static class ThemeManager
private static readonly Color LightPlatform = ColorTranslator.FromHtml("#696900");
private static readonly Color LightId = ColorTranslator.FromHtml("#006969");
private static readonly Color LightProxy = ColorTranslator.FromHtml("#006900");
private static readonly Color LightSelectionBack = SystemColors.Highlight;
private static readonly Color LightSelectionBack = ColorTranslator.FromHtml("#ADD6FF");
private static readonly Color LightComboBack = SystemColors.Control;
private static readonly Color LightComboBorder = SystemColors.ControlDark;
private static readonly Color LightComboText = SystemColors.ControlText;
@@ -180,9 +180,9 @@ internal static class ThemeManager
ll.VisitedLinkColor = DarkLink;
break;
// Labels: dark background, light foreground
// Labels: transparent so they blend with whatever container they sit in
case Label lbl:
lbl.BackColor = DarkBack;
lbl.BackColor = Color.Transparent;
lbl.ForeColor = DarkFore;
break;
@@ -206,6 +206,20 @@ internal static class ThemeManager
rtb.ForeColor = DarkFore;
break;
// ListBox follows alternate dark background
case ListBox lb:
lb.BackColor = DarkBackAlt;
lb.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;
@@ -241,7 +255,7 @@ internal static class ThemeManager
ll.VisitedLinkColor = SystemColors.HotTrack;
break;
case Label lbl:
lbl.BackColor = LightBack;
lbl.BackColor = Color.Transparent;
lbl.ForeColor = LightFore;
break;
case ProgressBar pb:
@@ -258,6 +272,16 @@ internal static class ThemeManager
rtb.BackColor = LightBack;
rtb.ForeColor = LightFore;
break;
case ListBox lb:
lb.BackColor = LightBackAlt;
lb.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 +432,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 +564,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);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 39 KiB