Files
Shigure/UI/UiTheme.cs

1106 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Shigure;
internal static class UiTheme
{
private static readonly Dictionary<int, Image?> ClassIcons = new();
private static readonly Dictionary<(int ClassId, int SpecId), Image?> SpecIcons = new();
private static readonly ConditionalWeakTable<ListView, ListColumnLayoutState> ListColumnLayouts = new();
private const int DwmwaUseImmersiveDarkMode = 20;
private const int DwmwaMicaEffect = 1029;
private const int DwmwaSystemBackdropType = 38;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwcpRound = 2;
private const int DwmsbtMainWindow = 2;
private const int DwmsbtTransientWindow = 3;
private const int WcaAccentPolicy = 19;
private const int AccentEnableBlurBehind = 3;
private const int AccentEnableAcrylicBlurBehind = 4;
public static readonly Color Background = Color.FromArgb(12, 15, 19);
public static readonly Color Surface = Color.FromArgb(23, 28, 35);
public static readonly Color SurfaceRaised = Color.FromArgb(28, 35, 43);
public static readonly Color Field = Color.FromArgb(34, 42, 51);
public static readonly Color Hover = Color.FromArgb(42, 53, 63);
public static readonly Color Pressed = Color.FromArgb(50, 63, 74);
public static readonly Color Border = Color.FromArgb(48, 58, 69);
public static readonly Color RowAlt = Color.FromArgb(26, 32, 39);
public static readonly Color Text = Color.FromArgb(231, 237, 243);
public static readonly Color Muted = Color.FromArgb(152, 164, 178);
public static readonly Color Accent = Color.FromArgb(82, 224, 209);
public static readonly Color AccentSoft = Color.FromArgb(24, 63, 64);
public static readonly Color Success = Color.FromArgb(103, 211, 145);
public static readonly Color Warning = Color.FromArgb(232, 196, 106);
public static readonly Color Danger = Color.FromArgb(240, 122, 122);
public static readonly Color DangerSoft = Color.FromArgb(64, 30, 35);
internal enum ButtonKind
{
Secondary,
Primary,
Danger
}
internal readonly record struct ListColumn(
string Text,
int MinimumWidth,
int MaximumWidth = 480,
bool FillRemaining = false,
bool FixedWidth = false);
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
[DllImport("dwmapi.dll")]
private static extern int DwmExtendFrameIntoClientArea(nint hwnd, ref Margins margins);
[DllImport("gdi32.dll")]
private static extern nint CreateRoundRectRgn(int left, int top, int right, int bottom, int widthEllipse, int heightEllipse);
[DllImport("gdi32.dll")]
private static extern bool DeleteObject(nint hObject);
[DllImport("user32.dll")]
private static extern int SetWindowCompositionAttribute(nint hwnd, ref WindowCompositionAttributeData data);
[StructLayout(LayoutKind.Sequential)]
private struct Margins
{
public int LeftWidth;
public int RightWidth;
public int TopHeight;
public int BottomHeight;
}
[StructLayout(LayoutKind.Sequential)]
private struct AccentPolicy
{
public int AccentState;
public int AccentFlags;
public int GradientColor;
public int AnimationId;
}
[StructLayout(LayoutKind.Sequential)]
private struct WindowCompositionAttributeData
{
public int Attribute;
public nint Data;
public int SizeOfData;
}
public static void ApplyDarkTitleBar(Form form)
{
var dark = 1;
_ = DwmSetWindowAttribute(form.Handle, DwmwaUseImmersiveDarkMode, ref dark, sizeof(int));
}
public static bool ApplyRoundedCorners(Form form)
{
var preference = DwmwcpRound;
if (DwmSetWindowAttribute(form.Handle, DwmwaWindowCornerPreference, ref preference, sizeof(int)) == 0)
{
return true;
}
ApplyFallbackRoundedCorners(form);
return false;
}
public static void ApplyFallbackRoundedCorners(Form form)
{
if (form.Width <= 0 || form.Height <= 0)
{
return;
}
var regionHandle = CreateRoundRectRgn(0, 0, form.Width + 1, form.Height + 1, 16, 16);
if (regionHandle == 0)
{
return;
}
try
{
var previousRegion = form.Region;
form.Region = Region.FromHrgn(regionHandle);
previousRegion?.Dispose();
}
finally
{
_ = DeleteObject(regionHandle);
}
}
public static void ApplyTranslucentBackground(Form form)
{
form.BackColor = Color.FromArgb(18, 21, 26);
var margins = new Margins
{
LeftWidth = -1,
RightWidth = -1,
TopHeight = -1,
BottomHeight = -1
};
_ = DwmExtendFrameIntoClientArea(form.Handle, ref margins);
// Windows 11: transient backdrop is Acrylic-like and does not affect child control opacity.
var backdrop = DwmsbtTransientWindow;
var hr = DwmSetWindowAttribute(form.Handle, DwmwaSystemBackdropType, ref backdrop, sizeof(int));
if (hr != 0)
{
backdrop = DwmsbtMainWindow;
_ = DwmSetWindowAttribute(form.Handle, DwmwaSystemBackdropType, ref backdrop, sizeof(int));
}
// Windows 10/older fallback: apply Acrylic blur behind the form background only.
if (!TryApplyAccentPolicy(form.Handle, AccentEnableAcrylicBlurBehind, Color.FromArgb(18, 21, 26), 10))
{
_ = TryApplyAccentPolicy(form.Handle, AccentEnableBlurBehind, Color.FromArgb(18, 21, 26), 150);
}
// Fallback for older Windows 11 builds where system backdrop exists but acrylic fails.
var enable = 1;
_ = DwmSetWindowAttribute(form.Handle, DwmwaMicaEffect, ref enable, sizeof(int));
}
private static bool TryApplyAccentPolicy(nint hwnd, int accentState, Color tint, byte alpha)
{
var policy = new AccentPolicy
{
AccentState = accentState,
AccentFlags = 2,
GradientColor = ToAbgr(tint, alpha),
AnimationId = 0
};
var policySize = Marshal.SizeOf<AccentPolicy>();
var policyPointer = Marshal.AllocHGlobal(policySize);
try
{
Marshal.StructureToPtr(policy, policyPointer, false);
var data = new WindowCompositionAttributeData
{
Attribute = WcaAccentPolicy,
Data = policyPointer,
SizeOfData = policySize
};
return SetWindowCompositionAttribute(hwnd, ref data) != 0;
}
finally
{
Marshal.FreeHGlobal(policyPointer);
}
}
private static int ToAbgr(Color color, byte alpha)
{
return unchecked((int)(((uint)alpha << 24) | ((uint)color.B << 16) | ((uint)color.G << 8) | color.R));
}
public static Button CreateButton(string text, Color backColor, Color foreColor)
{
var button = new Button
{
Text = text,
AutoSize = true,
FlatStyle = FlatStyle.Flat,
BackColor = backColor,
ForeColor = foreColor,
Padding = new Padding(10, 2, 10, 2),
Margin = new Padding(6, 0, 0, 0),
UseVisualStyleBackColor = false,
Cursor = Cursors.Hand,
TabStop = false
};
button.FlatAppearance.BorderSize = 1;
button.FlatAppearance.BorderColor = backColor == Accent ? Accent : Border;
button.FlatAppearance.MouseOverBackColor = backColor == Accent ? Color.FromArgb(112, 234, 221) : Hover;
button.FlatAppearance.MouseDownBackColor = backColor == Accent ? Color.FromArgb(62, 194, 181) : Pressed;
ApplyControlRoundedRegion(button, 8);
return button;
}
public static Button CreateButton(string text, ButtonKind kind)
{
var button = kind switch
{
ButtonKind.Primary => CreateButton(text, Accent, Color.FromArgb(10, 31, 31)),
ButtonKind.Danger => CreateButton(text, Field, Danger),
_ => CreateButton(text, Field, Text)
};
button.TabStop = true;
return button;
}
public static int Scale(Control control, int logicalPixels)
=> Math.Max(1, (int)Math.Round(logicalPixels * control.DeviceDpi / 96F));
public static GraphicsPath CreateRoundedRectanglePath(Rectangle bounds, int radius)
{
var path = new GraphicsPath();
if (bounds.Width <= 0 || bounds.Height <= 0)
{
path.AddRectangle(bounds);
return path;
}
var diameter = Math.Max(2, Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)));
var arc = new Rectangle(bounds.Location, new Size(diameter, diameter));
path.AddArc(arc, 180, 90);
arc.X = bounds.Right - diameter;
path.AddArc(arc, 270, 90);
arc.Y = bounds.Bottom - diameter;
path.AddArc(arc, 0, 90);
arc.X = bounds.Left;
path.AddArc(arc, 90, 90);
path.CloseFigure();
return path;
}
public static void DrawExternalLinkIcon(
Graphics graphics,
Rectangle clientBounds,
string text,
Font font,
Color color,
float scale)
{
var iconSize = 17F * scale;
var iconGap = 6F * scale;
var textSize = TextRenderer.MeasureText(
graphics,
text,
font,
Size.Empty,
TextFormatFlags.NoPadding | TextFormatFlags.SingleLine);
var groupWidth = textSize.Width + iconGap + iconSize;
var left = clientBounds.Left
+ ((clientBounds.Width - groupWidth) / 2F)
+ textSize.Width
+ iconGap;
var top = clientBounds.Top + (clientBounds.Height - iconSize) / 2F;
var previousSmoothingMode = graphics.SmoothingMode;
graphics.SmoothingMode = SmoothingMode.AntiAlias;
using var pen = new Pen(color, 1.8F * scale)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
PointF Point(float x, float y) => new(left + (x * scale), top + (y * scale));
graphics.DrawLines(
pen,
[
Point(7, 2),
Point(4, 2),
Point(2, 4),
Point(2, 13),
Point(4, 15),
Point(13, 15),
Point(15, 13),
Point(15, 9)
]);
graphics.DrawLine(pen, Point(8, 9), Point(15, 2));
graphics.DrawLines(pen, [Point(10, 2), Point(15, 2), Point(15, 7)]);
graphics.SmoothingMode = previousSmoothingMode;
}
public static void ApplyControlRoundedRegion(Control control, int logicalRadius = 8)
{
void UpdateRegion()
{
if (control.Width <= 0 || control.Height <= 0)
{
return;
}
var diameter = Math.Min(
Scale(control, logicalRadius) * 2,
Math.Min(control.Width, control.Height));
var regionHandle = CreateRoundRectRgn(0, 0, control.Width + 1, control.Height + 1, diameter, diameter);
if (regionHandle == 0)
{
return;
}
try
{
var previous = control.Region;
control.Region = Region.FromHrgn(regionHandle);
previous?.Dispose();
}
finally
{
_ = DeleteObject(regionHandle);
}
}
control.Resize -= OnRoundedRegionResize;
control.Resize += OnRoundedRegionResize;
control.HandleCreated -= OnRoundedRegionHandleCreated;
control.HandleCreated += OnRoundedRegionHandleCreated;
if (control.IsHandleCreated)
{
UpdateRegion();
}
void OnRoundedRegionResize(object? sender, EventArgs e) => UpdateRegion();
void OnRoundedRegionHandleCreated(object? sender, EventArgs e) => UpdateRegion();
}
public static void StyleTextBox(TextBox textBox)
{
textBox.BackColor = Field;
textBox.ForeColor = Text;
textBox.BorderStyle = BorderStyle.FixedSingle;
}
public static void StyleNumericUpDown(NumericUpDown numeric)
{
numeric.BackColor = Field;
numeric.ForeColor = Text;
numeric.BorderStyle = BorderStyle.FixedSingle;
}
public static void StyleCheckedListBox(CheckedListBox listBox)
{
listBox.BackColor = Field;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.FixedSingle;
listBox.CheckOnClick = true;
listBox.IntegralHeight = false;
listBox.ItemHeight = Math.Max(30, listBox.Font.Height + 14);
}
public static void StyleComboBox(ComboBox comboBox)
{
comboBox.DropDownStyle = ComboBoxStyle.DropDownList;
comboBox.FlatStyle = FlatStyle.Flat;
comboBox.BackColor = Field;
comboBox.ForeColor = Text;
comboBox.DrawMode = DrawMode.OwnerDrawFixed;
comboBox.ItemHeight = Math.Max(28, comboBox.Font.Height + 12);
comboBox.DropDownHeight = 320;
comboBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var isSelected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var isEditBox = (e.State & DrawItemState.ComboBoxEdit) == DrawItemState.ComboBoxEdit;
using (var background = new SolidBrush(isEditBox ? Field : isSelected ? Hover : Surface))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
var textColor = !isEditBox && isSelected ? Accent : Text;
var textBounds = new Rectangle(e.Bounds.X + 8, e.Bounds.Y, e.Bounds.Width - 8, e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
comboBox.Items[e.Index]?.ToString(),
comboBox.Font,
textBounds,
textColor,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
};
}
public static void StyleListBox(
ListBox listBox,
Font font,
Func<int, (int? ClassId, int? SpecId)>? moduleMatchSelector = null,
bool showClassIconWithSpec = true,
int? logicalIconSize = null)
{
var logicalItemHeight = logicalIconSize is { } requestedIconSize
? Math.Max(40, Math.Clamp(requestedIconSize, 24, 64) + 16)
: 36;
listBox.BackColor = Surface;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.None;
listBox.DrawMode = DrawMode.OwnerDrawFixed;
listBox.ItemHeight = Math.Max(logicalItemHeight, font.Height + 14);
listBox.IntegralHeight = false;
listBox.HandleCreated += (_, _) => listBox.ItemHeight = Math.Max(
Scale(listBox, logicalItemHeight),
font.Height + Scale(listBox, 14));
listBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var backgroundColor = selected
? Hover
: e.Index % 2 == 0
? listBox.BackColor
: RowAlt;
using (var background = new SolidBrush(backgroundColor))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
if (selected)
{
using var accent = new SolidBrush(Accent);
e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + 5, 3, e.Bounds.Height - 10);
}
var textLeft = e.Bounds.Left + 10;
if (moduleMatchSelector is not null)
{
var (classId, specId) = moduleMatchSelector(e.Index);
Image?[] icons = showClassIconWithSpec
?
[
classId is { } matchedClassId ? GetClassIcon(matchedClassId) : null,
classId is { } matchedSpecClassId && specId is { } matchedSpecId
? GetSpecIcon(matchedSpecClassId, matchedSpecId)
: null
]
:
[
classId is { } singleClassId
? specId is { } singleSpecId
? GetSpecIcon(singleClassId, singleSpecId)
: GetClassIcon(singleClassId)
: null
];
var iconSize = Math.Min(
logicalIconSize is { } requestedSize
? Scale(listBox, Math.Clamp(requestedSize, 24, 64))
: font.Height,
e.Bounds.Height - Scale(listBox, 8));
foreach (var icon in icons)
{
if (icon is null)
{
continue;
}
var iconBounds = new Rectangle(
textLeft,
e.Bounds.Top + (e.Bounds.Height - iconSize) / 2,
iconSize,
iconSize);
e.Graphics.DrawImage(icon, iconBounds);
textLeft = iconBounds.Right + 4;
}
}
var textBounds = new Rectangle(
textLeft,
e.Bounds.Top,
Math.Max(0, e.Bounds.Right - textLeft - 4),
e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
listBox.Items[e.Index]?.ToString() ?? string.Empty,
font,
textBounds,
selected ? Text : Muted,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
};
}
public static void StyleClassIconListBox(
ListBox listBox,
Func<object?, int?> classIdSelector,
int iconSize = 64)
{
var logicalIconSize = Math.Clamp(iconSize, 24, 64);
var logicalItemHeight = Math.Max(40, logicalIconSize + 16);
listBox.BackColor = Surface;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.None;
listBox.DrawMode = DrawMode.OwnerDrawFixed;
listBox.ItemHeight = Math.Max(logicalItemHeight, listBox.Font.Height + 14);
listBox.IntegralHeight = false;
listBox.HandleCreated += (_, _) => listBox.ItemHeight = Math.Max(
Scale(listBox, logicalItemHeight),
listBox.Font.Height + Scale(listBox, 14));
var hoveredIndex = -1;
listBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var hovered = e.Index == hoveredIndex;
var backgroundColor = selected
? Hover
: e.Index % 2 == 0
? listBox.BackColor
: RowAlt;
using (var background = new SolidBrush(backgroundColor))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
if (selected || hovered)
{
using var indicator = new SolidBrush(selected ? Color.White : Muted);
var indicatorInset = Math.Max(6, (e.Bounds.Height - Scale(listBox, logicalIconSize)) / 2);
e.Graphics.FillRectangle(
indicator,
e.Bounds.Left,
e.Bounds.Top + indicatorInset,
4,
e.Bounds.Height - (indicatorInset * 2));
}
var item = listBox.Items[e.Index];
var classId = classIdSelector(item);
var icon = classId is null ? null : GetClassIcon(classId.Value);
var iconSize = Math.Min(
Scale(listBox, logicalIconSize),
e.Bounds.Height - Scale(listBox, 8));
var visibleWidth = GetVisibleClientWidth(listBox);
var iconBounds = new Rectangle(
e.Bounds.Left + (visibleWidth - iconSize) / 2,
e.Bounds.Top + (e.Bounds.Height - iconSize) / 2,
iconSize,
iconSize);
if (icon is not null)
{
e.Graphics.DrawImage(icon, iconBounds);
using var border = new Pen(selected ? Accent : Border);
e.Graphics.DrawRectangle(border, iconBounds);
}
else
{
TextRenderer.DrawText(
e.Graphics,
"?",
listBox.Font,
iconBounds,
selected ? Text : Muted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
};
listBox.MouseMove += (_, e) =>
{
var index = listBox.IndexFromPoint(e.Location);
if (index == hoveredIndex)
{
return;
}
hoveredIndex = index;
listBox.Invalidate();
};
listBox.MouseLeave += (_, _) =>
{
hoveredIndex = -1;
listBox.Invalidate();
};
}
private static int GetVisibleClientWidth(Control control)
{
var visibleBounds = control.RectangleToScreen(control.ClientRectangle);
for (var parent = control.Parent; parent is not null; parent = parent.Parent)
{
visibleBounds = Rectangle.Intersect(
visibleBounds,
parent.RectangleToScreen(parent.ClientRectangle));
}
return Math.Max(0, visibleBounds.Width);
}
private static Image? GetClassIcon(int classId)
{
if (ClassIcons.TryGetValue(classId, out var cached))
{
return cached;
}
var fileName = ClassNames.GetConfigFileName(classId).ToLowerInvariant();
var resourceName = $"{typeof(UiTheme).Namespace}.Assets.Class.{fileName}.jpg";
using var stream = typeof(UiTheme).Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
ClassIcons[classId] = null;
return null;
}
using var source = Image.FromStream(stream);
var icon = new Bitmap(source);
ClassIcons[classId] = icon;
return icon;
}
public static void StyleSpecIconListBox(
ListBox listBox,
Func<object?, (int ClassId, int SpecId)?> specIdSelector,
int iconSize = 56)
{
var logicalIconSize = Math.Clamp(iconSize, 24, 64);
var logicalItemHeight = Math.Max(40, logicalIconSize + 16);
listBox.BackColor = Surface;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.None;
listBox.DrawMode = DrawMode.OwnerDrawFixed;
listBox.ItemHeight = Math.Max(logicalItemHeight, listBox.Font.Height + 14);
listBox.IntegralHeight = false;
listBox.HandleCreated += (_, _) => listBox.ItemHeight = Math.Max(
Scale(listBox, logicalItemHeight),
listBox.Font.Height + Scale(listBox, 14));
var hoveredIndex = -1;
listBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var hovered = e.Index == hoveredIndex;
var backgroundColor = selected
? Hover
: hovered
? SurfaceRaised
: e.Index % 2 == 0
? listBox.BackColor
: RowAlt;
using (var background = new SolidBrush(backgroundColor))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
if (selected || hovered)
{
using var indicator = new SolidBrush(selected ? Accent : Muted);
var indicatorInset = Math.Max(6, (e.Bounds.Height - Scale(listBox, logicalIconSize)) / 2);
e.Graphics.FillRectangle(
indicator,
e.Bounds.Left,
e.Bounds.Top + indicatorInset,
4,
e.Bounds.Height - (indicatorInset * 2));
}
var item = listBox.Items[e.Index];
var specIds = specIdSelector(item);
var icon = specIds is { } ids ? GetSpecIcon(ids.ClassId, ids.SpecId) : null;
var drawnIconSize = Math.Min(
Scale(listBox, logicalIconSize),
e.Bounds.Height - Scale(listBox, 12));
var iconBounds = new Rectangle(
e.Bounds.Left + 12,
e.Bounds.Top + (e.Bounds.Height - drawnIconSize) / 2,
drawnIconSize,
drawnIconSize);
if (icon is not null)
{
e.Graphics.DrawImage(icon, iconBounds);
using var border = new Pen(selected ? Accent : Border);
e.Graphics.DrawRectangle(border, iconBounds);
}
else
{
using var placeholder = new SolidBrush(Field);
e.Graphics.FillRectangle(placeholder, iconBounds);
TextRenderer.DrawText(
e.Graphics,
"?",
listBox.Font,
iconBounds,
selected ? Text : Muted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
var textBounds = new Rectangle(
iconBounds.Right + 12,
e.Bounds.Top,
Math.Max(0, e.Bounds.Right - iconBounds.Right - 18),
e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
item?.ToString() ?? string.Empty,
listBox.Font,
textBounds,
selected ? Text : Muted,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left |
TextFormatFlags.EndEllipsis | TextFormatFlags.SingleLine);
};
listBox.MouseMove += (_, e) =>
{
var index = listBox.IndexFromPoint(e.Location);
if (index == hoveredIndex)
{
return;
}
hoveredIndex = index;
listBox.Invalidate();
};
listBox.MouseLeave += (_, _) =>
{
hoveredIndex = -1;
listBox.Invalidate();
};
}
private static Image? GetSpecIcon(int classId, int specId)
{
var key = (classId, specId);
if (SpecIcons.TryGetValue(key, out var cached))
{
return cached;
}
var fileName = ClassNames.GetSpecIconFileName(classId, specId);
if (fileName is null)
{
SpecIcons[key] = null;
return null;
}
var resourceName = $"{typeof(UiTheme).Namespace}.Assets.Spec.{fileName}.jpg";
using var stream = typeof(UiTheme).Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
SpecIcons[key] = null;
return null;
}
using var source = Image.FromStream(stream);
var icon = new Bitmap(source);
SpecIcons[key] = icon;
return icon;
}
public static void StyleListView(ListView listView, Font font)
{
listView.Dock = DockStyle.Fill;
listView.View = View.Details;
listView.FullRowSelect = true;
listView.GridLines = false;
listView.HideSelection = false;
listView.HeaderStyle = ColumnHeaderStyle.Nonclickable;
listView.BackColor = Surface;
listView.ForeColor = Text;
listView.BorderStyle = BorderStyle.None;
listView.OwnerDraw = true;
listView.ShowItemToolTips = true;
ApplyListViewMetrics(listView, font);
typeof(Control)
.GetProperty("DoubleBuffered", BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(listView, true);
listView.DrawColumnHeader += (_, e) =>
{
using var brush = new SolidBrush(Field);
e.Graphics.FillRectangle(brush, e.Bounds);
using var border = new Pen(Border);
e.Graphics.DrawLine(border, e.Bounds.Left, e.Bounds.Bottom - 1, e.Bounds.Right, e.Bounds.Bottom - 1);
TextRenderer.DrawText(
e.Graphics,
e.Header?.Text ?? string.Empty,
font,
new Rectangle(e.Bounds.X + 8, e.Bounds.Y, e.Bounds.Width - 10, e.Bounds.Height),
Muted,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
};
listView.DrawItem += (_, _) =>
{
// Sub-items draw the full row in Details view.
};
listView.DrawSubItem += (_, e) =>
{
if (e.Item is null)
{
return;
}
var selected = e.Item.Selected;
var rowBack = selected
? Hover
: e.Item.Index % 2 == 0
? Surface
: RowAlt;
using (var brush = new SolidBrush(rowBack))
{
e.Graphics.FillRectangle(brush, e.Bounds);
}
if (selected && e.ColumnIndex == 0)
{
using var accent = new SolidBrush(Accent);
e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + 4, 3, e.Bounds.Height - 8);
}
var textBounds = new Rectangle(e.Bounds.X + 8, e.Bounds.Y, e.Bounds.Width - 10, e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
e.SubItem?.Text ?? string.Empty,
font,
textBounds,
selected ? Text : e.ColumnIndex == 0 ? Muted : Text,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
};
listView.HandleCreated += (_, _) => ApplyListViewMetrics(listView, font);
}
public static void StyleDataGridView(DataGridView grid)
{
grid.Dock = DockStyle.Fill;
grid.Margin = new Padding(0);
grid.BackgroundColor = Surface;
grid.BorderStyle = BorderStyle.None;
grid.GridColor = Border;
grid.EnableHeadersVisualStyles = false;
grid.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single;
grid.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
grid.ColumnHeadersHeight = Math.Max(38, grid.Font.Height + 16);
grid.ColumnHeadersDefaultCellStyle.BackColor = Field;
grid.ColumnHeadersDefaultCellStyle.ForeColor = Muted;
grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = Field;
grid.ColumnHeadersDefaultCellStyle.SelectionForeColor = Muted;
grid.ColumnHeadersDefaultCellStyle.Padding = new Padding(8, 0, 8, 0);
grid.DefaultCellStyle.BackColor = Surface;
grid.DefaultCellStyle.ForeColor = Text;
grid.DefaultCellStyle.SelectionBackColor = Hover;
grid.DefaultCellStyle.SelectionForeColor = Text;
grid.DefaultCellStyle.Padding = new Padding(8, 6, 8, 6);
grid.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleLeft;
grid.DefaultCellStyle.WrapMode = DataGridViewTriState.False;
grid.AlternatingRowsDefaultCellStyle.BackColor = RowAlt;
grid.AlternatingRowsDefaultCellStyle.ForeColor = Text;
grid.RowTemplate.Height = Math.Max(40, grid.Font.Height + 16);
grid.RowHeadersVisible = false;
grid.AllowUserToResizeRows = false;
grid.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal;
grid.ShowCellToolTips = true;
void ApplyMetrics()
{
var verticalPadding = Scale(grid, 12);
grid.ColumnHeadersHeight = Math.Max(Scale(grid, 38), grid.Font.Height + verticalPadding);
grid.RowTemplate.Height = Math.Max(Scale(grid, 40), grid.Font.Height + verticalPadding);
foreach (DataGridViewRow row in grid.Rows)
{
if (!row.IsNewRow)
{
row.Height = Math.Max(row.Height, grid.RowTemplate.Height);
}
}
EnsureGridColumnsReadable(grid);
}
grid.HandleCreated += (_, _) => ApplyMetrics();
grid.ColumnAdded += (_, _) => EnsureGridColumnsReadable(grid);
grid.CellFormatting += (_, e) =>
{
if (e.RowIndex < 0 || e.ColumnIndex < 0 || e.Value is null)
{
return;
}
grid.Rows[e.RowIndex].Cells[e.ColumnIndex].ToolTipText = e.Value.ToString();
};
}
public static ListView CreateListView(Font font, params (string Text, int Width)[] columns)
{
var layouts = columns
.Select((column, index) => new ListColumn(
column.Text,
column.Width,
Math.Max(column.Width, index == columns.Length - 1 ? 1200 : 420),
FillRemaining: index == columns.Length - 1,
FixedWidth: index == 0 && column.Text == "#"))
.ToArray();
return CreateListView(font, layouts);
}
public static ListView CreateListView(Font font, params ListColumn[] columns)
{
var listView = new ListView();
StyleListView(listView, font);
foreach (var column in columns)
{
listView.Columns.Add(column.Text, column.MinimumWidth);
}
ListColumnLayouts.Add(listView, new ListColumnLayoutState(columns));
listView.Resize += (_, _) => FitListViewColumns(listView);
listView.HandleCreated += (_, _) =>
{
// ListView 在 OnHandleCreated 内部会重建原生 item此时 Items.Count 已更新,
// 但索引器可能短暂返回 null。延迟到本轮消息结束后再按内容测量列宽。
listView.BeginInvoke(() =>
{
if (!listView.IsDisposed && listView.IsHandleCreated)
{
FitListViewColumns(listView);
}
});
};
return listView;
}
public static void FitListViewColumns(ListView listView)
{
if (listView.IsDisposed
|| !ListColumnLayouts.TryGetValue(listView, out var state)
|| state.Columns.Count != listView.Columns.Count
|| listView.Columns.Count == 0)
{
return;
}
var availableWidth = Math.Max(0, listView.ClientSize.Width - Scale(listView, 2));
var widths = new int[state.Columns.Count];
var fillIndexes = new List<int>();
var usedWidth = 0;
for (var columnIndex = 0; columnIndex < state.Columns.Count; columnIndex++)
{
var layout = state.Columns[columnIndex];
var minimum = Scale(listView, layout.MinimumWidth);
var maximum = Scale(listView, Math.Max(layout.MinimumWidth, layout.MaximumWidth));
var measured = MeasureListColumn(listView, columnIndex, layout.Text);
var width = layout.FixedWidth ? minimum : Math.Clamp(measured, minimum, maximum);
widths[columnIndex] = width;
if (layout.FillRemaining)
{
fillIndexes.Add(columnIndex);
}
else
{
usedWidth += width;
}
}
if (fillIndexes.Count > 0)
{
var remaining = Math.Max(0, availableWidth - usedWidth);
var share = remaining / fillIndexes.Count;
foreach (var index in fillIndexes)
{
var layout = state.Columns[index];
widths[index] = Math.Clamp(
share,
Scale(listView, layout.MinimumWidth),
Scale(listView, Math.Max(layout.MinimumWidth, layout.MaximumWidth)));
}
}
for (var i = 0; i < widths.Length; i++)
{
listView.Columns[i].Width = widths[i];
}
}
private static void ApplyListViewMetrics(ListView listView, Font font)
{
var rowHeight = Math.Max(Scale(listView, 36), font.Height + Scale(listView, 14));
if (listView.SmallImageList is null)
{
listView.SmallImageList = new ImageList();
}
listView.SmallImageList.ImageSize = new Size(1, rowHeight);
}
private static int MeasureListColumn(ListView listView, int columnIndex, string header)
{
var width = TextRenderer.MeasureText(header, listView.Font).Width + Scale(listView, 24);
var sampleCount = Math.Min(listView.Items.Count, 100);
for (var rowIndex = 0; rowIndex < sampleCount; rowIndex++)
{
ListViewItem? item;
try
{
item = listView.Items[rowIndex];
}
catch (ArgumentOutOfRangeException)
{
// 数据刷新和句柄重建都可能让采样数量在本轮测量中发生变化。
break;
}
if (item is null || columnIndex >= item.SubItems.Count)
{
continue;
}
var subItem = item.SubItems[columnIndex];
if (subItem is null)
{
continue;
}
width = Math.Max(
width,
TextRenderer.MeasureText(subItem.Text ?? string.Empty, listView.Font).Width + Scale(listView, 24));
}
return width;
}
private static void EnsureGridColumnsReadable(DataGridView grid)
{
foreach (DataGridViewColumn column in grid.Columns)
{
var headerWidth = TextRenderer.MeasureText(column.HeaderText ?? string.Empty, grid.Font).Width
+ Scale(grid, 24);
var typeMinimum = column is DataGridViewButtonColumn
? Scale(grid, 36)
: column is DataGridViewCheckBoxColumn
? Scale(grid, 56)
: Scale(grid, 72);
column.MinimumWidth = Math.Max(column.MinimumWidth, Math.Max(headerWidth, typeMinimum));
column.Width = Math.Max(column.Width, column.MinimumWidth);
}
}
private sealed class ListColumnLayoutState(IReadOnlyList<ListColumn> columns)
{
public IReadOnlyList<ListColumn> Columns { get; } = columns;
}
public static string FormatValue(object? value)
{
return value switch
{
null => "-",
bool b => b ? "是" : "否",
_ => value.ToString() ?? "-"
};
}
}