Files
TFSImport/TfsExcelImporter/Theme.cs
T
chenshoulin dbe8ff2c08 feat: 添加系统 DPI 感知支持与界面优化
- 新增 app.manifest 文件以实现系统 DPI 感知,确保界面在不同缩放设置下正常显示。
- 更新 MainForm.cs 和 Theme.cs,调整控件布局和样式以适应 DPI 变化。
- 修改 README.md,增加关于 DPI 感知的说明。
- 新增 MainFormUi.cs,负责界面布局与状态呈现,优化用户体验。
- 新增 debug.log 文件以记录错误信息,便于调试和问题追踪。
2026-11-01 18:47:41 +08:00

1226 lines
56 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;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 设计 token:颜色、字号、间距、圆角。界面代码只引用这里,不散落具体色值。
/// </summary>
internal static class Theme
{
// --- 缩放 ---
/// <summary>系统缩放系数(96dpi = 1)。必须在 BuildUi 之前按 DeviceDpi 赋值。</summary>
public static float Scale = 1f;
/// <summary>设计稿(96dpi)像素 → 当前缩放下的像素。字体用磅值,Windows 会自动跟缩放,所以只有像素量需要换算。</summary>
public static int Px(int designPx)
{
return (int)Math.Round(designPx * Scale);
}
// --- 间距刻度(4/8/12/16) ---
public static int S4 { get { return Px(4); } }
public static int S8 { get { return Px(8); } }
public static int S12 { get { return Px(12); } }
public static int S16 { get { return Px(16); } }
public static int Radius { get { return Px(8); } }
public static int RadiusCtl { get { return Px(6); } }
public static int FieldH { get { return Px(32); } }
public static int BtnH { get { return Px(32); } }
// --- 颜色 ---
public static readonly Color Surface = Color.FromArgb(0xF4, 0xF6, 0xFA);
public static readonly Color Card = Color.White;
public static readonly Color Border = Color.FromArgb(0xDF, 0xE3, 0xEB);
public static readonly Color BorderStrong = Color.FromArgb(0xC7, 0xCE, 0xDB);
public static readonly Color Ink = Color.FromArgb(0x1F, 0x24, 0x30);
public static readonly Color Ink2 = Color.FromArgb(0x59, 0x62, 0x70);
public static readonly Color Ink3 = Color.FromArgb(0x8A, 0x93, 0xA3);
public static readonly Color Primary = Color.FromArgb(0x1E, 0x40, 0xAF);
public static readonly Color PrimaryHover = Color.FromArgb(0x1B, 0x3A, 0x9E);
public static readonly Color PrimaryDown = Color.FromArgb(0x17, 0x32, 0x88);
public static readonly Color PrimarySoft = Color.FromArgb(0xEA, 0xF0, 0xFB);
public static readonly Color PrimarySoftBorder = Color.FromArgb(0xCF, 0xDD, 0xF5);
public static readonly Color Accent = Color.FromArgb(0xB4, 0x53, 0x09);
public static readonly Color AccentSoft = Color.FromArgb(0xFD, 0xF3, 0xE3);
public static readonly Color AccentBorder = Color.FromArgb(0xF0, 0xD9, 0xB0);
public static readonly Color Ok = Color.FromArgb(0x15, 0x80, 0x3D);
public static readonly Color OkSoft = Color.FromArgb(0xE8, 0xF6, 0xED);
public static readonly Color OkBorder = Color.FromArgb(0xBF, 0xE3, 0xCC);
public static readonly Color Danger = Color.FromArgb(0xDC, 0x26, 0x26);
public static readonly Color DangerSoft = Color.FromArgb(0xFD, 0xEC, 0xEC);
public static readonly Color DangerBorder = Color.FromArgb(0xF6, 0xC7, 0xC7);
public static readonly Color Hover = Color.FromArgb(0xF2, 0xF5, 0xFA);
public static readonly Color Disabled = Color.FromArgb(0xEE, 0xF1, 0xF6);
// 次按钮的浅色调底:白底卡片上靠色相而非描边区分层级
public static readonly Color Tonal = Color.FromArgb(0xF5, 0xF7, 0xFB);
public static readonly Color TonalDown = Color.FromArgb(0xE4, 0xEA, 0xF5);
public static readonly Color TonalBorder = Color.FromArgb(0xDC, 0xE2, 0xEB);
public static readonly Color RowAlt = Color.FromArgb(0xFA, 0xFB, 0xFD);
public static readonly Color LogBg = Color.FromArgb(0xFB, 0xFC, 0xFE);
// --- 字体(CJK 优先,缺字体时逐级回落) ---
public static readonly Font Body = Make("Microsoft YaHei UI", 9.75f, false, false);
public static readonly Font BodyBold = Make("Microsoft YaHei UI", 9.75f, true, false);
public static readonly Font Caption = Make("Microsoft YaHei UI", 9f, false, false);
public static readonly Font CaptionBold = Make("Microsoft YaHei UI", 9f, true, false);
public static readonly Font Mono = Make("Consolas", 9.75f, false, true);
public static readonly Font MonoBold = Make("Consolas", 9.75f, true, true);
public static readonly Font CountNum = Make("Consolas", 11.25f, true, true);
private static Font Make(string family, float size, bool bold, bool monospace)
{
var style = bold ? FontStyle.Bold : FontStyle.Regular;
if (monospace)
{
try { return new Font(family, size, style, GraphicsUnit.Point); }
catch { return new Font(FontFamily.GenericMonospace, size, style, GraphicsUnit.Point); }
}
foreach (var name in new[] { family, "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" })
{
try { return new Font(name, size, style, GraphicsUnit.Point); }
catch { }
}
return new Font(FontFamily.GenericSansSerif, size, style, GraphicsUnit.Point);
}
public static GraphicsPath RoundRect(RectangleF r, float radius)
{
var path = new GraphicsPath();
float d = radius * 2f;
if (d <= 0f || r.Width <= 0f || r.Height <= 0f)
{
path.AddRectangle(r);
path.CloseFigure();
return path;
}
d = Math.Min(d, Math.Min(r.Width, r.Height));
path.AddArc(r.X, r.Y, d, d, 180f, 90f);
path.AddArc(r.Right - d, r.Y, d, d, 270f, 90f);
path.AddArc(r.Right - d, r.Bottom - d, d, d, 0f, 90f);
path.AddArc(r.X, r.Bottom - d, d, d, 90f, 90f);
path.CloseFigure();
return path;
}
/// <summary>细描边固定 1 设备像素(跟 Windows 自身控件一致),不随缩放变粗。</summary>
public static float Hair { get { return 1f; } }
/// <summary>强调描边(焦点/校验失败)按缩放取整,150% 下为 2 像素。</summary>
public static float HairStrong { get { return Math.Max(1f, (float)Math.Round(Scale)); } }
/// <summary>填充 + 描边一个圆角面板;borderWidth 为 0 时只填充。
/// Half 像素偏移 + 内嵌画笔让 1px 边线落在整像素上,避免高缩放下糊成双线。
/// 圆角面内切在 r 里,四角和最后 1 行列落在它外面,必须先用 backdrop 铺满整块客户区:
/// 自绘控件的双缓冲只清一次,真实屏幕上那些从未绘制的像素会残留成黑色描边。</summary>
public static void FillPanel(Graphics g, Rectangle r, Color fill, Color borderColor,
int radius, float borderWidth, Color backdrop)
{
if (r.Width < 2 || r.Height < 2) return;
var old = g.SmoothingMode;
var oldOffset = g.PixelOffsetMode;
if (backdrop.A > 0)
using (var bg = new SolidBrush(backdrop)) g.FillRectangle(bg, r);
g.SmoothingMode = SmoothingMode.AntiAlias;
g.PixelOffsetMode = PixelOffsetMode.Half;
var box = new RectangleF(r.X, r.Y, r.Width - 1f, r.Height - 1f);
using (var path = RoundRect(box, radius))
{
using (var b = new SolidBrush(fill)) g.FillPath(b, path);
if (borderColor != Color.Transparent && borderWidth > 0f)
using (var pen = new Pen(borderColor, borderWidth))
{
pen.Alignment = PenAlignment.Inset;
g.DrawPath(pen, path);
}
}
g.PixelOffsetMode = oldOffset;
g.SmoothingMode = old;
}
/// <summary>控件四周应当露出的底色:优先父容器面色,透明底(自绘宿主)退到卡片白。</summary>
public static Color BackdropOf(Control owner)
{
var parent = owner.Parent;
if (parent != null && parent.BackColor.A > 0) return parent.BackColor;
if (owner.BackColor.A > 0) return owner.BackColor;
return Card;
}
public static int TextWidth(string text, Font font) {
if (string.IsNullOrEmpty(text)) return 0;
return TextRenderer.MeasureText(text, font, Size.Empty,
TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width;
}
public static Color ChipInk(ChipKind kind)
{
switch (kind)
{
case ChipKind.Ok: return Ok;
case ChipKind.Warn: return Accent;
case ChipKind.Danger: return Danger;
case ChipKind.Info: return Primary;
case ChipKind.Quiet: return Ink3;
default: return Ink2;
}
}
public static Color ChipFace(ChipKind kind)
{
switch (kind)
{
case ChipKind.Ok: return OkSoft;
case ChipKind.Warn: return AccentSoft;
case ChipKind.Danger: return DangerSoft;
case ChipKind.Info: return PrimarySoft;
case ChipKind.Quiet: return Card;
default: return Disabled;
}
}
public static Color ChipBorder(ChipKind kind)
{
switch (kind)
{
case ChipKind.Ok: return OkBorder;
case ChipKind.Warn: return AccentBorder;
case ChipKind.Danger: return DangerBorder;
case ChipKind.Info: return PrimarySoftBorder;
case ChipKind.Quiet: return Border;
default: return Border;
}
}
}
internal enum ChipKind { Neutral, Info, Ok, Warn, Danger, Quiet }
internal enum IconKind
{
None, ChevronDown, ChevronRight, Check, Bolt, Folder, Doc, ArrowUp, Wrench, Trash, Table, Gear, Sync
}
/// <summary>GDI+ 矢量图标:统一 24 网格、约 1.6 描边、圆头。界面内不用 emoji、不用位图。</summary>
internal static class Icons
{
public static void Draw(Graphics g, IconKind kind, Rectangle box, Color color)
{
if (kind == IconKind.None || box.Width < 4 || box.Height < 4) return;
var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
float side = Math.Min(box.Width, box.Height);
float k = side / 24f;
float ox = box.X + (box.Width - side) / 2f;
float oy = box.Y + (box.Height - side) / 2f;
using (var pen = new Pen(color, Math.Max(1.2f, 1.7f * k)))
{
pen.StartCap = LineCap.Round;
pen.EndCap = LineCap.Round;
pen.LineJoin = LineJoin.Round;
switch (kind)
{
case IconKind.ChevronDown:
Poly(g, pen, ox, oy, k, 6, 9, 12, 15, 18, 9);
break;
case IconKind.ChevronRight:
Poly(g, pen, ox, oy, k, 9, 6, 15, 12, 9, 18);
break;
case IconKind.Check:
Poly(g, pen, ox, oy, k, 5, 13, 10, 18, 19, 6);
break;
case IconKind.Bolt:
Poly(g, pen, ox, oy, k, 13, 3, 6, 13, 12, 13, 11, 21, 18, 11, 12, 11, 13, 3);
break;
case IconKind.Folder:
Poly(g, pen, ox, oy, k, 3, 7, 9, 7, 11, 9, 21, 9, 21, 19, 3, 19, 3, 7);
break;
case IconKind.Doc:
Poly(g, pen, ox, oy, k, 6, 3, 15, 3, 19, 7, 19, 21, 6, 21, 6, 3);
Poly(g, pen, ox, oy, k, 15, 3, 15, 7, 19, 7);
Line(g, pen, ox, oy, k, 9, 12, 16, 12);
Line(g, pen, ox, oy, k, 9, 16, 16, 16);
break;
case IconKind.ArrowUp:
Line(g, pen, ox, oy, k, 12, 20, 12, 5);
Poly(g, pen, ox, oy, k, 5, 12, 12, 5, 19, 12);
break;
case IconKind.Wrench:
g.DrawArc(pen, ox + 3 * k, oy + 3 * k, 8 * k, 8 * k, 300f, 250f);
Line(g, pen, ox, oy, k, 10, 14, 19, 21);
break;
case IconKind.Trash:
Poly(g, pen, ox, oy, k, 4, 7, 20, 7);
Poly(g, pen, ox, oy, k, 8, 7, 8, 20, 16, 20, 16, 7);
Line(g, pen, ox, oy, k, 11, 4, 13, 4);
break;
case IconKind.Table:
g.DrawRectangle(pen, ox + 3 * k, oy + 5 * k, 18 * k, 14 * k);
Line(g, pen, ox, oy, k, 3, 10, 21, 10);
Line(g, pen, ox, oy, k, 10, 10, 10, 19);
break;
case IconKind.Gear:
g.DrawEllipse(pen, ox + 8 * k, oy + 8 * k, 8 * k, 8 * k);
g.DrawEllipse(pen, ox + 10.5f * k, oy + 10.5f * k, 3 * k, 3 * k);
break;
case IconKind.Sync: // 拉取服务器数据:带缺口的圆 + 箭头
g.DrawArc(pen, ox + 4 * k, oy + 4 * k, 16 * k, 16 * k, 300f, 250f);
Poly(g, pen, ox, oy, k, 19, 4, 20, 9, 15, 8);
break;
}
}
g.SmoothingMode = old;
}
private static void Poly(Graphics g, Pen pen, float ox, float oy, float k, params float[] xy)
{
var pts = new PointF[xy.Length / 2];
for (int i = 0; i < pts.Length; i++)
pts[i] = new PointF(ox + xy[i * 2] * k, oy + xy[i * 2 + 1] * k);
g.DrawLines(pen, pts);
}
private static void Line(Graphics g, Pen pen, float ox, float oy, float k, float x1, float y1, float x2, float y2)
{
g.DrawLine(pen, ox + x1 * k, oy + y1 * k, ox + x2 * k, oy + y2 * k);
}
}
internal enum BadgeKind { Pending, Active, Done, Warn }
/// <summary>独立编号徽标:用于操作条这类没有卡片头部的区域,与 Card 内的徽标同一套语义。</summary>
internal class StepBadge : Control
{
public int Number;
public BadgeKind Kind = BadgeKind.Pending;
public StepBadge()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
| ControlStyles.SupportsTransparentBackColor, true);
Size = new Size(Theme.Px(22), Theme.Px(22));
BackColor = Color.Transparent;
TabStop = false;
}
public void Set(int number, BadgeKind kind)
{
Number = number;
Kind = kind;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Color fill, ink;
switch (Kind)
{
case BadgeKind.Active: fill = Theme.Primary; ink = Color.White; break;
case BadgeKind.Done: fill = Theme.Ok; ink = Color.White; break;
case BadgeKind.Warn: fill = Theme.Danger; ink = Color.White; break;
default: fill = Theme.Disabled; ink = Theme.Ink2; break;
}
var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
var r = new RectangleF(0.5f, 0.5f, Width - 1f, Height - 1f);
using (var b = new SolidBrush(fill)) g.FillEllipse(b, r);
g.SmoothingMode = old;
if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, ClientRectangle, ink);
else
TextRenderer.DrawText(g, Number.ToString(), Theme.CaptionBold, ClientRectangle, ink,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
}
}
/// <summary>白底圆角承载面板:状态条与操作条这类不需要步骤头部的区域。
/// BackColor 跟着面色走,否则子 Label/CheckBox 会按环境属性继承到灰底。</summary>
internal class Surface : Panel
{
private Color _face = Theme.Card;
public Color LineColor = Theme.Border;
public Color FaceColor
{
get { return _face; }
set { _face = value; BackColor = value; Invalidate(); }
}
public Surface()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Card;
TabStop = false;
}
protected override void OnPaint(PaintEventArgs e)
{
Theme.FillPanel(e.Graphics, new Rectangle(0, 0, Width, Height), _face, LineColor,
Theme.Radius, 1f, Theme.BackdropOf(this));
}
}
/// <summary>
/// 步骤卡片:编号徽标 + 标题 + 摘要 + 折叠箭头。折叠后只留一行摘要,把高度让给预览表格。
/// 内容子控件由外部按 HeaderH 往下的坐标摆放;展开高度 = HeaderH + BodyH。
/// </summary>
internal class Card : Panel
{
public int StepNumber;
public string Title = "";
public string Summary = "";
public BadgeKind Badge = BadgeKind.Pending;
public bool Collapsible = true;
public bool Expanded = true;
public bool Invalid = false;
public bool ShowBadge = true;
/// <summary>卡片头部右侧留给按钮的宽度,摘要文字不越过它。</summary>
public int HeaderReserve = 0;
public int HeaderH = 44;
public int BodyH = 0;
public Action<bool> OnToggle;
private bool _hoverHeader;
public Card()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Card; // 子 Label 通过环境属性继承白底
TabStop = false;
HeaderH = Theme.Px(44);
}
public Rectangle BodyRect { get { return new Rectangle(0, HeaderH, Width, BodyH); } }
private Rectangle HeaderRect { get { return new Rectangle(0, 0, Width, HeaderH); } }
public void SetSummary(string text)
{
if (Summary == text) return;
Summary = text ?? "";
Invalidate(HeaderRect);
}
public void SetBadge(BadgeKind kind)
{
if (Badge == kind) return;
Badge = kind;
Invalidate(HeaderRect);
}
public void SetInvalid(bool invalid)
{
if (Invalid == invalid) return;
Invalid = invalid;
Invalidate(HeaderRect);
}
public void ApplyHeight()
{
Height = Expanded ? HeaderH + BodyH : HeaderH;
// 折叠时靠父面板裁剪隐藏正文,不去改子控件的 Visible——
// 否则会把「按校验状态自己决定显隐」的行内错误提示强行显示出来。
// 但要把 TabStop 关掉,避免键盘 Tab 进不可见的输入框。
SetTabStop(Controls, Expanded);
Invalidate();
}
/// <summary>输入控件可能嵌在 InputField 容器里,递归切换 TabStop。</summary>
private static void SetTabStop(Control.ControlCollection controls, bool enabled)
{
foreach (Control c in controls)
{
if (c is TextBoxBase || c is ComboBox || c is Button || c is CheckBox)
c.TabStop = enabled;
SetTabStop(c.Controls, enabled);
}
}
public void Toggle()
{
Expanded = !Expanded;
if (OnToggle != null) OnToggle(Expanded);
else ApplyHeight();
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
bool h = Collapsible && HeaderRect.Contains(e.Location);
if (h != _hoverHeader)
{
_hoverHeader = h;
Cursor = h ? Cursors.Hand : Cursors.Default;
Invalidate(HeaderRect);
}
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
if (_hoverHeader)
{
_hoverHeader = false;
Cursor = Cursors.Default;
Invalidate(HeaderRect);
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (Collapsible && ButtonPressed(e) && HeaderRect.Contains(e.Location)) Toggle();
}
private static bool ButtonPressed(MouseEventArgs e)
{
return e.Button == MouseButtons.Left;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
var r = new Rectangle(0, 0, Width, Height);
Theme.FillPanel(g, r, Theme.Card,
Invalid ? Theme.Danger : (Expanded ? Theme.PrimarySoftBorder : Theme.Border),
Theme.Radius, Invalid || Expanded ? Theme.HairStrong : Theme.Hair, Theme.BackdropOf(this));
var badge = new Rectangle(Theme.Px(14), (HeaderH - Theme.Px(22)) / 2, Theme.Px(22), Theme.Px(22));
if (ShowBadge)
{
Color badgeFill, badgeInk;
switch (Badge)
{
case BadgeKind.Active: badgeFill = Theme.Primary; badgeInk = Color.White; break;
case BadgeKind.Done: badgeFill = Theme.Ok; badgeInk = Color.White; break;
case BadgeKind.Warn: badgeFill = Theme.Danger; badgeInk = Color.White; break;
default: badgeFill = Theme.Disabled; badgeInk = Theme.Ink2; break;
}
var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var b = new SolidBrush(badgeFill)) g.FillEllipse(b, badge);
g.SmoothingMode = old;
if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, badge, badgeInk);
else
TextRenderer.DrawText(g, StepNumber.ToString(), Theme.CaptionBold, badge, badgeInk,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
}
int tx = ShowBadge ? badge.Right + Theme.Px(10) : Theme.Px(16);
int chevW = (Collapsible ? Theme.Px(30) : 0) + HeaderReserve;
int maxTitle = Math.Max(Theme.Px(40), Width - tx - chevW - Theme.Px(36));
int titleW = Math.Min(Theme.TextWidth(Title, Theme.BodyBold), maxTitle);
var titleRect = new Rectangle(tx, 0, titleW, HeaderH);
TextRenderer.DrawText(g, Title, Theme.BodyBold, titleRect,
Invalid ? Theme.Danger : Theme.Ink,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
int sx = titleRect.Right + Theme.Px(12);
var sumRect = new Rectangle(sx, 0, Math.Max(0, Width - chevW - sx - Theme.Px(12)), HeaderH);
TextRenderer.DrawText(g, Summary, Theme.Caption, sumRect,
Invalid ? Theme.Danger : Theme.Ink2,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
if (Collapsible)
{
var chev = new Rectangle(Width - Theme.Px(30), (HeaderH - Theme.Px(16)) / 2, Theme.Px(16), Theme.Px(16));
Icons.Draw(g, Expanded ? IconKind.ChevronDown : IconKind.ChevronRight, chev,
_hoverHeader ? Theme.Ink : Theme.Ink3);
}
if (Expanded)
using (var p = new Pen(Theme.Border))
g.DrawLine(p, Theme.S16, HeaderH, Width - Theme.S16, HeaderH);
}
}
internal enum BtnKind { Secondary, Primary, Ghost }
/// <summary>扁平按钮:主/次/幽灵三档,悬停与按下只换色不换尺寸;键盘焦点有描边,宽度按内容量出来。</summary>
internal class FlatButton : Button
{
public BtnKind Kind = BtnKind.Secondary;
public IconKind Glyph = IconKind.None;
private bool _hover;
private bool _pressed;
public FlatButton()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
FlatStyle = FlatStyle.Flat;
FlatAppearance.BorderSize = 0;
Cursor = Cursors.Hand;
Font = Theme.Body;
Height = Theme.BtnH;
Margin = Padding.Empty;
TabStop = true;
UseVisualStyleBackColor = false;
}
/// <summary>按「图标 + 文字 + 左右内边距」定宽,designMin 是 96dpi 下的最小宽度。</summary>
public void FitWidth(int designMin)
{
int pad = Theme.Px(13);
int gw = GlyphWidth;
int gap = gw > 0 && !string.IsNullOrEmpty(Text) ? Theme.Px(6) : 0;
Width = Math.Max(Theme.Px(designMin), pad * 2 + gw + gap + Theme.TextWidth(Text, Font));
}
private int GlyphWidth { get { return Glyph == IconKind.None ? 0 : Theme.Px(14); } }
private Color FaceColor
{
get
{
if (!Enabled) return Theme.Disabled;
switch (Kind)
{
case BtnKind.Primary:
return _pressed ? Theme.PrimaryDown : (_hover ? Theme.PrimaryHover : Theme.Primary);
case BtnKind.Ghost:
return _pressed ? Theme.Disabled : (_hover ? Theme.Hover : Theme.Card);
default:
return _pressed ? Theme.TonalDown : (_hover ? Theme.PrimarySoft : Theme.Tonal);
}
}
}
private Color InkColor
{
get
{
if (!Enabled) return Theme.Ink3;
if (Kind == BtnKind.Primary) return Color.White;
return Kind == BtnKind.Ghost ? Theme.Ink2 : Theme.Ink;
}
}
private Color BorderColor
{
get
{
if (!Enabled) return Theme.Border;
if (Kind != BtnKind.Secondary) return Color.Transparent;
return Focused && ShowFocusCues ? Theme.Primary : Theme.TonalBorder;
}
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
var r = new Rectangle(0, 0, Width, Height);
Theme.FillPanel(g, r, FaceColor, BorderColor, Theme.RadiusCtl,
BorderColor == Color.Transparent ? 0f : Theme.Hair, Theme.BackdropOf(this));
string text = Text ?? "";
int tw = Theme.TextWidth(text, Font);
int gw = GlyphWidth;
int gap = gw > 0 && tw > 0 ? Theme.Px(6) : 0;
int x = (Width - (gw + gap + tw)) / 2;
if (gw > 0)
{
Icons.Draw(g, Glyph, new Rectangle(x, (Height - gw) / 2, gw, gw), InkColor);
x += gw + gap;
}
if (tw > 0)
TextRenderer.DrawText(g, text, Font, new Rectangle(x, 0, tw + Theme.Px(2), Height), InkColor,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.NoPrefix);
if (Focused && ShowFocusCues && Enabled)
{
var old = g.SmoothingMode;
var oldOffset = g.PixelOffsetMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.PixelOffsetMode = PixelOffsetMode.Half;
int inset = Theme.Px(2);
using (var pen = new Pen(Kind == BtnKind.Primary ? Color.White : Theme.Primary, Theme.HairStrong))
using (var p = Theme.RoundRect(new RectangleF(r.X + inset, r.Y + inset,
r.Width - 1 - inset * 2, r.Height - 1 - inset * 2), Math.Max(1, Theme.RadiusCtl - inset)))
{
pen.Alignment = PenAlignment.Inset;
g.DrawPath(pen, p);
}
g.PixelOffsetMode = oldOffset;
g.SmoothingMode = old;
}
}
protected override void OnMouseEnter(EventArgs e) { base.OnMouseEnter(e); _hover = true; Invalidate(); }
protected override void OnMouseLeave(EventArgs e) { base.OnMouseLeave(e); _hover = false; _pressed = false; Invalidate(); }
protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _pressed = true; Invalidate(); }
protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _pressed = false; Invalidate(); }
protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); Invalidate(); }
protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); Invalidate(); }
protected override void OnEnabledChanged(EventArgs e) { base.OnEnabledChanged(e); Invalidate(); }
protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); Invalidate(); }
}
/// <summary>状态 chip:圆点 + 文字,宽度自适应。</summary>
internal class StatusChip : Control
{
private string _text = "";
private ChipKind _kind = ChipKind.Neutral;
public StatusChip()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
| ControlStyles.SupportsTransparentBackColor, true);
Font = Theme.Caption;
Height = Theme.Px(24);
BackColor = Color.Transparent;
TabStop = false;
}
public void Set(string text, ChipKind kind)
{
_text = text ?? "";
_kind = kind;
Width = Theme.TextWidth(_text, Font) + Theme.Px(28);
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
var ink = Theme.ChipInk(_kind);
Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
Theme.ChipFace(_kind), Theme.ChipBorder(_kind), Height / 2, 1f, Theme.BackdropOf(this));
var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var b = new SolidBrush(ink)) g.FillEllipse(b, Theme.Px(9), (Height - Theme.Px(7)) / 2, Theme.Px(7), Theme.Px(7));
g.SmoothingMode = old;
TextRenderer.DrawText(g, _text, Font, new Rectangle(Theme.Px(22), 0, Width - Theme.Px(24), Height), ink,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding | TextFormatFlags.SingleLine);
}
}
/// <summary>细进度条:原生 ProgressBar 在浅色主题里是个带灰框的方块,
/// 换成圆角轨道 + 主色填充,Minimum/Maximum/Value 与原生同名,可直接替换。</summary>
internal class ProgressTrack : Control
{
public int Minimum = 0;
public int Maximum = 100;
private int _value;
public int Value
{
get { return _value; }
set
{
int v = Math.Max(Minimum, Math.Min(value, Maximum));
if (v == _value) return;
_value = v;
Invalidate();
}
}
public ProgressTrack()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
| ControlStyles.SupportsTransparentBackColor, true);
BackColor = Color.Transparent;
TabStop = false;
Height = Theme.Px(8);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
int radius = Math.Max(1, Height / 2);
Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
Theme.Disabled, Theme.Border, radius, Theme.Hair, Theme.BackdropOf(this));
int span = Math.Max(1, Maximum - Minimum);
int filled = (int)Math.Round((Value - Minimum) * (double)Width / span);
if (filled <= 0) return;
var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var path = Theme.RoundRect(new RectangleF(0, 0, Width - 1f, Height - 1f), radius))
using (var clip = new Region(path))
{
g.SetClip(clip, CombineMode.Replace);
using (var b = new SolidBrush(Theme.Primary)) g.FillRectangle(b, 0, 0, filled, Height);
}
g.ResetClip();
g.SmoothingMode = old;
}
}
/// <summary>圆角输入宿主:装无边框 TextBox/ComboBox,焦点与校验态换描边色。
/// 命名避开 Microsoft.TeamFoundation.WorkItemTracking.Client.Field。</summary>
internal class InputField : Panel
{
private bool _invalid;
private ComboBox _combo;
private Chevron _chevron;
// 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带,
// 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。
// 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。
private Panel _box;
/// <summary>宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。</summary>
private int ArrowZone { get { return Theme.Px(26); } }
public InputField()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
// _box 必须先于 Height 创建:设置高度会触发 OnSizeChanged → LayoutInner
_box = new Panel { BackColor = Theme.Card, TabStop = false };
_box.MouseDown += BoxClicked;
Controls.Add(_box);
BackColor = Theme.Card;
Height = Theme.FieldH;
TabStop = false;
}
/// <summary>点在容器空白处(输入框内边距)时把焦点交给内容控件,与原生输入框手感一致。</summary>
private void BoxClicked(object sender, MouseEventArgs e)
{
foreach (Control c in _box.Controls)
{
if (c is TextBoxBase || c is ComboBox) { c.Focus(); break; }
}
}
public bool Invalid
{
get { return _invalid; }
set { if (_invalid != value) { _invalid = value; Invalidate(); } }
}
public T Add<T>(T inner, bool mono) where T : Control
{
var tb = inner as TextBoxBase;
if (tb != null)
{
tb.BorderStyle = BorderStyle.None;
tb.Multiline = false;
}
var cb = inner as ComboBox;
if (cb != null)
{
// net48 的 ComboBox 没有 BorderStyle(.NET Core 才加),只能用 FlatStyle;
// 系统那个灰色箭头方块靠不住(Paint 覆盖不掉),改成把控件加宽挤出容器、
// 由容器 Region 裁掉它并自绘箭头,点击右区展开下拉。
_combo = cb;
cb.FlatStyle = FlatStyle.Flat;
cb.DrawMode = DrawMode.OwnerDrawFixed;
cb.ItemHeight = Theme.Px(22);
cb.DropDown += (s, e) => BeginAlignDropDown();
cb.HandleCreated += (s, e) => HookComboList();
cb.DrawItem += DrawComboItem;
_chevron = new Chevron();
_chevron.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) ToggleComboDropDown(); };
_box.Controls.Add(_chevron);
}
inner.BackColor = Theme.Card;
inner.Font = mono ? Theme.Mono : Theme.Body;
inner.ForeColor = Theme.Ink;
_box.Controls.Add(inner);
inner.Enter += (s, e) => Invalidate();
inner.Leave += (s, e) => Invalidate();
LayoutInner();
return inner;
}
protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); LayoutInner(); }
private void DrawComboItem(object sender, DrawItemEventArgs e)
{
var cb = (ComboBox)sender;
if (e.Index < 0 || e.Index >= cb.Items.Count) return;
bool selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
bool inBox = (e.State & DrawItemState.ComboBoxEdit) == DrawItemState.ComboBoxEdit;
var full = new Rectangle(e.Bounds.X, e.Bounds.Y,
Math.Max(e.Bounds.Width, cb.Width - Theme.Px(2)), e.Bounds.Height);
if (inBox)
{
// 闭合态就是普通输入框:白底黑字。焦点/校验态由宿主描边表达,
// 不响应 Selected 标记——否则一聚焦整块变浅蓝,还会把旁边的箭头衬成白色方块。
using (var b = new SolidBrush(Theme.Card)) e.Graphics.FillRectangle(b, full);
TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font,
new Rectangle(full.X, full.Y, Math.Max(0, full.Width - ArrowZone), full.Height),
Theme.Ink,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
return;
}
using (var b = new SolidBrush(selected ? Theme.PrimarySoft : Theme.Card))
e.Graphics.FillRectangle(b, full);
// 列表项贴着自己窗口的左沿,要补一段缩进才和输入框里的文字对齐;
// 闭合态的组合框本身就摆在宿主内缩处,原生 bounds 已经带了这个缩进。
int pad = inBox ? 0 : Theme.Px(10);
int right = inBox ? ArrowZone : Theme.Px(6);
var text = new Rectangle(full.X + pad, full.Y,
Math.Max(0, full.Width - pad - right), full.Height);
TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font, text,
selected ? Theme.Primary : Theme.Ink,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
}
private void LayoutInner()
{
if (_box == null) return; // 构造中途触发 OnSizeChanged 时容器可能还没建好
_box.SetBounds(2, 2, Width - 4, Height - 4);
SetBoxRegion();
foreach (Control c in _box.Controls)
{
if (c is Chevron) continue;
var tb = c as TextBox;
var cb = c as ComboBox;
if (cb != null)
{
// 右端挤出容器、原生箭头由容器 Region 裁掉并由 Chevron 接管;
// 弹出列表的位置与宽度由 ComboListHook 在定位瞬间改写,不受这里加宽影响。
int h = Math.Min(Math.Max(cb.PreferredHeight, Theme.Px(18)), _box.Height - Theme.Px(4));
cb.SetBounds(Theme.Px(7), Math.Max(Theme.Px(2), (_box.Height - h) / 2),
_box.Width - Theme.Px(7) + ArrowZone, h);
continue;
}
int natural = tb != null ? tb.PreferredHeight : Theme.Px(19);
int th = Math.Min(Math.Max(natural, Theme.Px(18)), _box.Height - Theme.Px(4));
c.SetBounds(Theme.Px(7), Math.Max(Theme.Px(2), (_box.Height - th) / 2), _box.Width - Theme.Px(14), th);
}
if (_chevron != null)
{
int size = Theme.Px(18);
_chevron.SetBounds(_box.Width - Theme.Px(7) - size, Theme.Px(2), size, _box.Height - Theme.Px(4));
_chevron.BringToFront(); // 后加的兄弟控件在底层,必须提到组合框上方才看得见
}
Invalidate();
}
/// <summary>圆角 Region 跟着尺寸重建。Region 比容器再内缩 1px:聚焦描边是 2px 内嵌线,
/// AA 过渡会落到距宿主边缘第 3 列上,白底必须留出这条过渡带,否则内沿被切成硬边(毛刺)。
/// 内容控件摆放在第 9 列起,不受这条内缩影响。</summary>
private void SetBoxRegion()
{
using (var path = Theme.RoundRect(
new RectangleF(1, 1, _box.Width - 2f, _box.Height - 2f), Math.Max(2, Theme.RadiusCtl - 3)))
{
if (_box.Region != null) _box.Region.Dispose();
_box.Region = new Region(path);
}
}
/// <summary>宿主自绘的下拉箭头,同时是点击展开的热区(系统的灰箭头已被裁掉)。</summary>
private class Chevron : Control
{
public Chevron()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
| ControlStyles.SupportsTransparentBackColor, true);
BackColor = Theme.Card;
TabStop = false;
}
protected override void OnPaint(PaintEventArgs e)
{
int size = Theme.Px(11);
Icons.Draw(e.Graphics, IconKind.ChevronDown,
new Rectangle((Width - size) / 2, (Height - size) / 2, size, size), Theme.Ink2);
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (_combo == null || e.Button != MouseButtons.Left) return;
if (e.X < Width - ArrowZone) return;
ToggleComboDropDown();
}
// net48 没有 ComboBox.Dropped(.NET Core 才有),只能走 CB_SHOWDROPDOWN
private const int CB_GETDROPPEDSTATE = 0x108B;
private const int CB_SHOWDROPDOWN = 0x014F;
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private void ToggleComboDropDown()
{
if (!_combo.IsHandleCreated) return;
bool dropped = SendMessage(_combo.Handle, CB_GETDROPPEDSTATE, IntPtr.Zero, IntPtr.Zero) != IntPtr.Zero;
SendMessage(_combo.Handle, CB_SHOWDROPDOWN, new IntPtr(dropped ? 0 : 1), IntPtr.Zero);
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct ComboInfo
{
public int cbSize;
public RECT rcItem;
public RECT rcButton;
public int cbState;
public IntPtr hwndCombo;
public IntPtr hwndEdit;
public IntPtr hwndList;
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool GetComboBoxInfo(IntPtr hWnd, ref ComboInfo info);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr after,
int x, int y, int cx, int cy, uint flags);
private const uint SWP_NOZORDER = 0x4;
private const uint SWP_NOACTIVATE = 0x10;
private const uint SWP_FRAMECHANGED = 0x20;
private const int GWL_STYLE = -16;
private const int GWL_EXSTYLE = -20;
private const int WS_BORDER = 0x00800000;
private const int WS_EX_CLIENTEDGE = 0x200;
private const int WM_WINDOWPOSCHANGING = 0x0046;
private const uint SWP_NOSIZE_FLAG = 0x1;
private const uint SWP_NOMOVE_FLAG = 0x2;
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct WINDOWPOS
{
public IntPtr hwnd;
public IntPtr hwndInsertAfter;
public int x;
public int y;
public int cx;
public int cy;
public uint flags;
}
/// <summary>挂在弹出列表窗口上,在系统定位的瞬间(WM_WINDOWPOSCHANGING)就把矩形改写成
/// 贴齐宿主——原生代码按组合框自身矩形定位,而组合框为藏箭头是挤出宿主的,
/// 不拦截的话下拉会先出现在偏右偏宽的位置、等 BeginInvoke 再左移,肉眼可见地跳一下。</summary>
private sealed class ComboListHook : NativeWindow
{
private readonly InputField _owner;
public ComboListHook(InputField owner) { _owner = owner; }
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_WINDOWPOSCHANGING && _owner != null && _owner.IsHandleCreated)
{
var wp = (WINDOWPOS)System.Runtime.InteropServices.Marshal.PtrToStructure(
m.LParam, typeof(WINDOWPOS));
var host = _owner.RectangleToScreen(_owner.ClientRectangle);
if ((wp.flags & SWP_NOMOVE_FLAG) == 0) { wp.x = host.Left; wp.y = host.Bottom; }
if ((wp.flags & SWP_NOSIZE_FLAG) == 0) wp.cx = host.Width;
System.Runtime.InteropServices.Marshal.StructureToPtr(wp, m.LParam, false);
}
base.WndProc(ref m);
}
}
private ComboListHook _listHook;
/// <summary>ComboLBox 随组合框句柄一起创建,尽早挂上钩子,第一次下拉就不跳。</summary>
private void HookComboList()
{
if (_listHook != null || _combo == null || !_combo.IsHandleCreated) return;
var info = new ComboInfo();
info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
_listHook = new ComboListHook(this);
_listHook.AssignHandle(info.hwndList);
}
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongW")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongW")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
/// <summary>把弹出列表重摆到宿主的外沿:列表窗口默认跟着组合框自己的矩形走,
/// 而组合框为了藏原生箭头是加宽挤出宿主的,不重摆就会右移并比输入框宽一截。
/// 顺带剥掉列表窗口的系统边框(WS_BORDER / WS_EX_CLIENTEDGE),
/// 让下拉跟扁平输入框同一套视觉,宽度与宿主完全一致。</summary>
/// <summary>DropDown 事件里列表窗口还没摆好(原生代码随后才定位),投递到本条消息之后再改。</summary>
private void BeginAlignDropDown()
{
if (!IsHandleCreated || _combo == null) return;
BeginInvoke((Action)AlignDropDown);
}
private void AlignDropDown()
{
if (_combo == null || !_combo.IsHandleCreated) return;
HookComboList(); // 兜底:钩子没挂上时先补挂
var info = new ComboInfo();
info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
int style = GetWindowLong(info.hwndList, GWL_STYLE);
if ((style & WS_BORDER) != 0)
SetWindowLong(info.hwndList, GWL_STYLE, style & ~WS_BORDER);
int ex = GetWindowLong(info.hwndList, GWL_EXSTYLE);
if ((ex & WS_EX_CLIENTEDGE) != 0)
SetWindowLong(info.hwndList, GWL_EXSTYLE, ex & ~WS_EX_CLIENTEDGE);
RECT r;
if (!GetWindowRect(info.hwndList, out r)) return;
var host = RectangleToScreen(ClientRectangle);
SetWindowPos(info.hwndList, IntPtr.Zero, host.Left, host.Bottom,
host.Width, r.Bottom - r.Top, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
bool focused = ContainsFocus; // 输入控件嵌在 _box 里,必须递归判断
var color = _invalid ? Theme.Danger : (focused ? Theme.Primary : Theme.BorderStrong);
Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
Theme.Card, color, Theme.RadiusCtl, focused || _invalid ? Theme.HairStrong : Theme.Hair,
Theme.BackdropOf(this));
}
}
internal enum LogLevel { Info, Ok, Skip, Warn, Fail }
/// <summary>
/// 分级着色日志控制台。级别按文本内容自动判定,所以 TfsClient 里传进来的 Action&lt;string&gt; 不用改签名。
/// 继承 RichTextBox 但不改其窗口样式:原生控件自绘会破坏滚动与选区。
/// </summary>
internal class LogView : RichTextBox
{
public bool AutoScrollToBottom = true;
public LogView()
{
ReadOnly = true;
BorderStyle = BorderStyle.None;
BackColor = Theme.LogBg;
ForeColor = Theme.Ink;
Font = Theme.Mono;
DetectUrls = false;
ScrollBars = RichTextBoxScrollBars.Vertical;
Multiline = true;
TabStop = false;
WordWrap = true;
}
public static LogLevel Classify(string msg)
{
if (string.IsNullOrEmpty(msg)) return LogLevel.Info;
if (msg.IndexOf("FAIL", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
if (msg.IndexOf("失败", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
if (msg.IndexOf("错误", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
if (msg.IndexOf("中断", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
if (msg.IndexOf("SKIP", StringComparison.Ordinal) >= 0) return LogLevel.Skip;
if (msg.IndexOf("警告", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
if (msg.IndexOf("不可用", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
if (msg.IndexOf("部分", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
if (msg.IndexOf("] OK", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
if (msg.StartsWith("连接成功", StringComparison.Ordinal)) return LogLevel.Ok;
if (msg.IndexOf("完成", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
if (msg.IndexOf("已加载", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
return LogLevel.Info;
}
private Color InkOf(LogLevel lvl)
{
switch (lvl)
{
case LogLevel.Ok: return Theme.Ok;
case LogLevel.Skip: return Theme.Accent;
case LogLevel.Warn: return Theme.Accent;
case LogLevel.Fail: return Theme.Danger;
default: return Theme.Ink;
}
}
/// <summary>最后一行当前是否可见——可见才跟随滚动,用户往上翻看时不打断。</summary>
private bool LastLineVisible()
{
if (TextLength == 0) return true;
int idx = GetCharIndexFromPosition(new Point(0, ClientSize.Height - 4));
return GetLineFromCharIndex(Math.Max(0, idx)) >= GetLineFromCharIndex(TextLength);
}
public void AppendLine(string time, LogLevel lvl, string msg)
{
if (InvokeRequired) { BeginInvoke(new Action(() => AppendLine(time, lvl, msg))); return; }
bool follow = AutoScrollToBottom && LastLineVisible();
SelectionStart = TextLength;
SelectionLength = 0;
SelectionColor = Theme.Ink3;
SelectedText = "[" + time + "] ";
SelectionColor = InkOf(lvl);
SelectedText = msg;
SelectionColor = Theme.Ink;
SelectedText = Environment.NewLine;
if (follow) { SelectionStart = TextLength; ScrollToCaret(); }
}
public void ClearLog()
{
if (InvokeRequired) { BeginInvoke(new Action(ClearLog)); return; }
Clear();
}
}
internal static class GridStyle
{
public static void Apply(DataGridView g)
{
g.BackgroundColor = Theme.Card;
g.BorderStyle = BorderStyle.None;
g.GridColor = Color.FromArgb(0xEE, 0xF1, 0xF6);
g.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal;
g.EnableHeadersVisualStyles = false;
g.RowHeadersVisible = false;
g.AllowUserToAddRows = false;
g.AllowUserToDeleteRows = false;
g.AllowUserToResizeRows = false;
g.ReadOnly = true;
g.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
g.MultiSelect = false;
g.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
g.RowTemplate.Height = Theme.Px(28);
g.ColumnHeadersHeight = Theme.Px(32);
g.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
g.DefaultCellStyle.BackColor = Theme.Card;
g.DefaultCellStyle.ForeColor = Theme.Ink;
g.DefaultCellStyle.SelectionBackColor = Theme.PrimarySoft;
g.DefaultCellStyle.SelectionForeColor = Theme.Ink;
g.DefaultCellStyle.Font = Theme.Body;
g.DefaultCellStyle.Padding = new Padding(Theme.Px(6), 0, Theme.Px(6), 0);
g.AlternatingRowsDefaultCellStyle.BackColor = Theme.RowAlt;
g.AlternatingRowsDefaultCellStyle.SelectionBackColor = Theme.PrimarySoft;
g.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(0xF7, 0xF9, 0xFC);
g.ColumnHeadersDefaultCellStyle.ForeColor = Theme.Ink2;
g.ColumnHeadersDefaultCellStyle.Font = Theme.CaptionBold;
g.ColumnHeadersDefaultCellStyle.SelectionBackColor = Color.FromArgb(0xF7, 0xF9, 0xFC);
g.ColumnHeadersDefaultCellStyle.SelectionForeColor = Theme.Ink2;
g.ColumnHeadersDefaultCellStyle.Padding = new Padding(Theme.Px(6), 0, Theme.Px(6), 0);
g.ColumnHeadersDefaultCellStyle.WrapMode = DataGridViewTriState.False;
g.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None;
}
}
}