using System; using System.Drawing; using System.Drawing.Drawing2D; using System.Windows.Forms; namespace TfsExcelImporter { /// /// 设计 token:颜色、字号、间距、圆角。界面代码只引用这里,不散落具体色值。 /// internal static class Theme { // --- 缩放 --- /// 系统缩放系数(96dpi = 1)。必须在 BuildUi 之前按 DeviceDpi 赋值。 public static float Scale = 1f; /// 设计稿(96dpi)像素 → 当前缩放下的像素。字体用磅值,Windows 会自动跟缩放,所以只有像素量需要换算。 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; } /// 细描边固定 1 设备像素(跟 Windows 自身控件一致),不随缩放变粗。 public static float Hair { get { return 1f; } } /// 强调描边(焦点/校验失败)按缩放取整,150% 下为 2 像素。 public static float HairStrong { get { return Math.Max(1f, (float)Math.Round(Scale)); } } /// 填充 + 描边一个圆角面板;borderWidth 为 0 时只填充。 /// Half 像素偏移 + 内嵌画笔让 1px 边线落在整像素上,避免高缩放下糊成双线。 /// 圆角面内切在 r 里,四角和最后 1 行列落在它外面,必须先用 backdrop 铺满整块客户区: /// 自绘控件的双缓冲只清一次,真实屏幕上那些从未绘制的像素会残留成黑色描边。 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; } /// 控件四周应当露出的底色:优先父容器面色,透明底(自绘宿主)退到卡片白。 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, Fetch, Tree } /// /// GDI+ 矢量图标:统一 24 网格、约 2 描边、圆头圆角。界面内不用 emoji、不用位图。 /// 这条流水线的图标实际只有 14~16px,所以几何一律「少笔画、留大间隙」: /// 细节(桶身竖纹、齿轮齿)在小尺寸会糊成一坨,宁可省略也不用 2 个网格以内的短笔画。 /// 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; // 描边随尺寸线性走(24 网格 2px),最小 1.35 保证 14px 下不断线 float stroke = Math.Max(1.35f, 2f * k); using (var pen = new Pen(color, stroke)) using (var brush = new SolidBrush(color)) { pen.StartCap = LineCap.Round; pen.EndCap = LineCap.Round; pen.LineJoin = LineJoin.Round; switch (kind) { case IconKind.ChevronDown: Poly(g, pen, ox, oy, k, 6.2f, 9.2f, 12f, 15f, 17.8f, 9.2f); break; case IconKind.ChevronRight: Poly(g, pen, ox, oy, k, 9.2f, 6.2f, 15f, 12f, 9.2f, 17.8f); break; case IconKind.Check: Poly(g, pen, ox, oy, k, 4.8f, 12.6f, 9.6f, 17.4f, 19.2f, 6.6f); break; case IconKind.Bolt: // 实心:14px 下描边闪电会糊成一根棍 Fill(g, brush, ox, oy, k, 13.2f, 2.4f, 4.8f, 13.4f, 10.4f, 13.4f, 10.8f, 21.6f, 19.2f, 10.6f, 13.6f, 10.6f); break; case IconKind.Folder: Poly(g, pen, ox, oy, k, 2.8f, 6.4f, 9.4f, 6.4f, 11.4f, 8.6f, 21.2f, 8.6f, 21.2f, 18.8f, 2.8f, 18.8f, 2.8f, 6.4f); break; case IconKind.Doc: Poly(g, pen, ox, oy, k, 5.8f, 3f, 14.6f, 3f, 19f, 7.4f, 19f, 21f, 5.8f, 21f, 5.8f, 3f); Poly(g, pen, ox, oy, k, 14.6f, 3f, 14.6f, 7.4f, 19f, 7.4f); Line(g, pen, ox, oy, k, 8.8f, 12.2f, 16f, 12.2f); Line(g, pen, ox, oy, k, 8.8f, 16.2f, 16f, 16.2f); break; case IconKind.ArrowUp: Line(g, pen, ox, oy, k, 12f, 20.4f, 12f, 5.2f); Poly(g, pen, ox, oy, k, 5.8f, 11.4f, 12f, 5.2f, 18.2f, 11.4f); break; case IconKind.Wrench: // 实心开口扳手:环+手柄在 16px 下会读成放大镜,实心剪影才立得住 var wst = g.Save(); g.TranslateTransform(ox + 12f * k, oy + 12f * k); g.RotateTransform(45f); using (var path = new GraphicsPath()) { path.AddPolygon(new[] { new PointF(-4.8f * k, -9.4f * k), new PointF(-1.7f * k, -9.4f * k), new PointF(-1.7f * k, -6.2f * k), new PointF(1.7f * k, -6.2f * k), new PointF(1.7f * k, -9.4f * k), new PointF(4.8f * k, -9.4f * k), new PointF(4.8f * k, -4.4f * k), new PointF(2.2f * k, -1.6f * k), new PointF(2.2f * k, 9.6f * k), new PointF(-2.2f * k, 9.6f * k), new PointF(-2.2f * k, -1.6f * k), new PointF(-4.8f * k, -4.4f * k) }); g.FillPath(brush, path); } g.Restore(wst); break; case IconKind.Trash: Line(g, pen, ox, oy, k, 4.2f, 8.2f, 19.8f, 8.2f); // 盖 Line(g, pen, ox, oy, k, 9.4f, 4.2f, 14.6f, 4.2f); // 提手(与盖留 4 单位空隙) Poly(g, pen, ox, oy, k, 6.8f, 8.2f, 6.8f, 20f, 17.2f, 20f, 17.2f, 8.2f); // 桶身 break; case IconKind.Table: var tr = new RectangleF(ox + 2.8f * k, oy + 4.6f * k, 18.4f * k, 14.8f * k); using (var path = Theme.RoundRect(tr, 2.2f * k)) g.DrawPath(pen, path); Line(g, pen, ox, oy, k, 2.8f, 9.6f, 21.2f, 9.6f); Line(g, pen, ox, oy, k, 10.4f, 9.6f, 10.4f, 19.4f); break; case IconKind.Gear: // 环 + 8 根短齿 + 实心轴:齿短一点才不会糊成太阳 for (int i = 0; i < 8; i++) { double a = i * Math.PI / 4; Line(g, pen, ox, oy, k, 12f + (float)Math.Cos(a) * 5.2f, 12f + (float)Math.Sin(a) * 5.2f, 12f + (float)Math.Cos(a) * 7.4f, 12f + (float)Math.Sin(a) * 7.4f); } g.DrawEllipse(pen, ox + 6.8f * k, oy + 6.8f * k, 10.4f * k, 10.4f * k); g.FillEllipse(brush, ox + 10.2f * k, oy + 10.2f * k, 3.6f * k, 3.6f * k); break; case IconKind.Fetch: // 从服务器拉取(加载迭代列表):下箭头落进托盘,与「开始导入」的上箭头成对 // 圆形箭头在 16px 下始终读成「缺口的圈 + 一坨」,这里换直线几何; // 托盘底边取 19.8 而不是 20.2,100%/125% 下都落在像素中心,不会渲成一条虚灰线 Line(g, pen, ox, oy, k, 12f, 3.4f, 12f, 12.2f); Poly(g, pen, ox, oy, k, 6.4f, 7.4f, 12f, 13.2f, 17.6f, 7.4f); Poly(g, pen, ox, oy, k, 5f, 14.6f, 5f, 19.8f, 19f, 19.8f, 19f, 14.6f); break; case IconKind.Tree: // 父项 + 两个子项:比链环更像「挂到父项下」,16px 也分得清 using (var node = Theme.RoundRect(new RectangleF(ox + 8.8f * k, oy + 2.6f * k, 6.4f * k, 5.2f * k), 1.4f * k)) using (var c1 = Theme.RoundRect(new RectangleF(ox + 2.6f * k, oy + 15.4f * k, 6.4f * k, 5.2f * k), 1.4f * k)) using (var c2 = Theme.RoundRect(new RectangleF(ox + 15f * k, oy + 15.4f * k, 6.4f * k, 5.2f * k), 1.4f * k)) { g.FillPath(brush, node); g.FillPath(brush, c1); g.FillPath(brush, c2); } Line(g, pen, ox, oy, k, 12f, 7.8f, 12f, 12.4f); Line(g, pen, ox, oy, k, 5.8f, 12.4f, 18.2f, 12.4f); Line(g, pen, ox, oy, k, 5.8f, 12.4f, 5.8f, 15.4f); Line(g, pen, ox, oy, k, 18.2f, 12.4f, 18.2f, 15.4f); break; } } g.SmoothingMode = old; } /// 圆徽标里画勾用的内缩盒子:铺满整个圆会贴边,视觉上像被裁掉一块。 public static Rectangle BadgeGlyph(Rectangle circle) { int inset = Math.Max(2, circle.Width * 2 / 11); // 22px 圆 → 4px 内缩(14px 盒子) return Rectangle.Inflate(circle, -inset, -inset); } private static void Poly(Graphics g, Pen pen, float ox, float oy, float k, params float[] xy) { g.DrawLines(pen, Points(ox, oy, k, xy)); } private static void Fill(Graphics g, Brush brush, float ox, float oy, float k, params float[] xy) { g.FillPolygon(brush, Points(ox, oy, k, xy)); } 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); } private static PointF[] Points(float ox, float oy, float k, 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); return pts; } } internal enum BadgeKind { Pending, Active, Done, Warn } /// 独立编号徽标:用于操作条这类没有卡片头部的区域,与 Card 内的徽标同一套语义。 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; // 勾画在圆内缩 4px(14px 盒子 / 22px 圆),铺满整圆会贴边看着像被裁掉 if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(ClientRectangle), ink); else TextRenderer.DrawText(g, Number.ToString(), Theme.CaptionBold, ClientRectangle, ink, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } } /// 白底圆角承载面板:状态条与操作条这类不需要步骤头部的区域。 /// BackColor 跟着面色走,否则子 Label/CheckBox 会按环境属性继承到灰底。 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)); } } /// /// 步骤卡片:编号徽标 + 标题 + 摘要 + 折叠箭头。折叠后只留一行摘要,把高度让给预览表格。 /// 内容子控件由外部按 HeaderH 往下的坐标摆放;展开高度 = HeaderH + BodyH。 /// 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; /// 卡片头部右侧留给按钮的宽度,摘要文字不越过它。 public int HeaderReserve = 0; public int HeaderH = 44; public int BodyH = 0; public Action 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(); } /// 输入控件可能嵌在 InputField 容器里,递归切换 TabStop。 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, Icons.BadgeGlyph(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 } /// 扁平按钮:主/次/幽灵三档,悬停与按下只换色不换尺寸;键盘焦点有描边,宽度按内容量出来。 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; } /// 按「图标 + 文字 + 左右内边距」定宽,designMin 是 96dpi 下的最小宽度。 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(16); } } 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(); } } /// 状态 chip:圆点 + 文字,宽度自适应。 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); } } /// 细进度条:原生 ProgressBar 在浅色主题里是个带灰框的方块, /// 换成圆角轨道 + 主色填充,Minimum/Maximum/Value 与原生同名,可直接替换。 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; } } /// 圆角输入宿主:装无边框 TextBox/ComboBox,焦点与校验态换描边色。 /// 命名避开 Microsoft.TeamFoundation.WorkItemTracking.Client.Field。 internal class InputField : Panel { private bool _invalid; private ComboBox _combo; private Chevron _chevron; // 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带, // 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。 // 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。 private Panel _box; /// 宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。 private int ArrowZone { get { return Theme.Px(26); } } /// 内容左缘相对 _box 的缩进。TextBox 就摆在 Px(7),组合框闭合态文字必须共用同一基准, /// 否则同一排的输入框文字会错开一条竖线。 private static int TextInsetX { get { return Theme.Px(7); } } 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; } /// 点在容器空白处(输入框内边距)时把焦点交给内容控件,与原生输入框手感一致。 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 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); // 文字矩形不能直接用 e.Bounds:原生闭合区自带内缩(左侧约 2px、上沿同理), // 且该内缩随 DPI 放大(150% 下实测 4px),照它画出来的文字会比同排 TextBox // 右移、下沉 3~4px,看上去就是「没居中」。改成在 _box 坐标里定目标矩形 // (与 TextBox 共用 TextInsetX 基准),再换算到组合框客户区,DPI 无关。 // 额外再让 1px:原生那条「闭合区」裁剪边界正好压在文字起点上,贴着画会把首字 // 最左边的反锯齿列切掉("ntlm" 的 n 少半竖就是这个);顺带补上 TextBox 自身 // 1px 的 EM 内边距,两者文字才是同一条竖线。 var target = new Rectangle(TextInsetX + Theme.Px(1), 0, Math.Max(0, _box.Width - TextInsetX - Theme.Px(1) - ArrowZone), _box.Height); var textRect = cb.IsHandleCreated && _box.IsHandleCreated ? cb.RectangleToClient(_box.RectangleToScreen(target)) : target; TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font, textRect, 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); // 列表项贴着自己窗口的左沿(列表窗口对齐宿主外沿),要补上 _box 的 2px 内缩 // 和文字基准缩进,才和闭合态里的文字严格同一条竖线。 int pad = TextInsetX + 2; int right = 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(); } /// 圆角 Region 跟着尺寸重建。Region 比容器再内缩 1px:聚焦描边是 2px 内嵌线, /// AA 过渡会落到距宿主边缘第 3 列上,白底必须留出这条过渡带,否则内沿被切成硬边(毛刺)。 /// 内容控件摆放在第 9 列起,不受这条内缩影响。 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); } } /// 宿主自绘的下拉箭头,同时是点击展开的热区(系统的灰箭头已被裁掉)。 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; // 先让组合框拿到焦点:没焦点就 CB_SHOWDROPDOWN,列表展开后随即被焦点变化收掉, // 表现出来就是「点一下闪一下」。 if (!_combo.Focused) _combo.Focus(); 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; } /// 挂在弹出列表窗口上,在系统定位的瞬间(WM_WINDOWPOSCHANGING)就把矩形改写成 /// 贴齐宿主——原生代码按组合框自身矩形定位,而组合框为藏箭头是挤出宿主的, /// 不拦截的话下拉会先出现在偏右偏宽的位置、等 BeginInvoke 再左移,肉眼可见地跳一下。 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; private IntPtr _listHwnd = IntPtr.Zero; private bool _listSkinned; /// ComboLBox 随组合框句柄一起创建,尽早挂上钩子,第一次下拉就不跳。 private void HookComboList() { if (_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; if (_listHook != null && _listHook.Handle == info.hwndList) return; // 已挂在当前列表窗口上 // 列表窗口被重建过:旧钩子会留在已销毁的句柄上,必须换新的 if (_listHook != null) { _listHook.ReleaseHandle(); _listHook = null; } _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); /// 把弹出列表重摆到宿主的外沿:列表窗口默认跟着组合框自己的矩形走, /// 而组合框为了藏原生箭头是加宽挤出宿主的,不重摆就会右移并比输入框宽一截。 /// 顺带剥掉列表窗口的系统边框(WS_BORDER / WS_EX_CLIENTEDGE), /// 让下拉跟扁平输入框同一套视觉,宽度与宿主完全一致。 /// DropDown 事件里列表窗口还没摆好(原生代码随后才定位),投递到本条消息之后再改。 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; // 列表窗口重建后边框会长回来,按句柄判断是否需要重新剥一次 if (_listHwnd != info.hwndList) { _listHwnd = info.hwndList; _listSkinned = false; } bool skin = false; if (!_listSkinned) { int style = GetWindowLong(info.hwndList, GWL_STYLE); if ((style & WS_BORDER) != 0) { SetWindowLong(info.hwndList, GWL_STYLE, style & ~WS_BORDER); skin = true; } int ex = GetWindowLong(info.hwndList, GWL_EXSTYLE); if ((ex & WS_EX_CLIENTEDGE) != 0) { SetWindowLong(info.hwndList, GWL_EXSTYLE, ex & ~WS_EX_CLIENTEDGE); skin = true; } _listSkinned = true; } RECT r; if (!GetWindowRect(info.hwndList, out r)) return; var host = RectangleToScreen(ClientRectangle); // 位置已经被 ComboListHook 改写到宿主外沿时不要再 SetWindowPos: // 每重摆一次列表窗口都要整窗重算重画,肉眼就是「打开瞬间闪一下」。 bool moved = r.Left != host.Left || r.Top != host.Bottom || (r.Right - r.Left) != host.Width; if (!moved && !skin) return; // 只有真的改了窗口样式才需要 SWP_FRAMECHANGED(它强制整窗重算非客户区) SetWindowPos(info.hwndList, IntPtr.Zero, host.Left, host.Bottom, host.Width, Math.Max(0, r.Bottom - r.Top), SWP_NOZORDER | SWP_NOACTIVATE | (skin ? SWP_FRAMECHANGED : 0)); } 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 } /// /// 分级着色日志控制台。级别按文本内容自动判定,所以 TfsClient 里传进来的 Action<string> 不用改签名。 /// 继承 RichTextBox 但不改其窗口样式:原生控件自绘会破坏滚动与选区。 /// 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; } } /// 最后一行当前是否可见——可见才跟随滚动,用户往上翻看时不打断。 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; } } }