- 新增 app.manifest 文件以实现系统 DPI 感知,确保界面在不同缩放设置下正常显示。 - 更新 MainForm.cs 和 Theme.cs,调整控件布局和样式以适应 DPI 变化。 - 修改 README.md,增加关于 DPI 感知的说明。 - 新增 MainFormUi.cs,负责界面布局与状态呈现,优化用户体验。 - 新增 debug.log 文件以记录错误信息,便于调试和问题追踪。
1226 lines
56 KiB
C#
1226 lines
56 KiB
C#
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<string> 不用改签名。
|
||
/// 继承 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;
|
||
}
|
||
}
|
||
}
|