From ffc210f827139b4ac46b2fb56e223324af45387c Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 1 Nov 2026 23:15:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20CueBanner=20?= =?UTF-8?q?=E7=B1=BB=E5=8F=8A=E7=9B=B8=E5=85=B3=E6=8C=89=E9=92=AE=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 CueBanner.cs 文件,移除输入框的占位提示功能。 - 更新 MainForm.cs 和 MainFormUi.cs,删除与 Browse 按钮相关的代码,简化用户界面。 - 调整控件布局以提升整体用户体验,确保界面更加简洁。 --- TfsExcelImporter/CueBanner.cs | 45 ------ TfsExcelImporter/MainForm.cs | 1 - TfsExcelImporter/MainFormUi.cs | 151 +++++++++++------- TfsExcelImporter/Theme.cs | 280 +++++++++++++++++++++++++++++++-- 4 files changed, 363 insertions(+), 114 deletions(-) delete mode 100644 TfsExcelImporter/CueBanner.cs diff --git a/TfsExcelImporter/CueBanner.cs b/TfsExcelImporter/CueBanner.cs deleted file mode 100644 index 412a525..0000000 --- a/TfsExcelImporter/CueBanner.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Windows.Forms; - -namespace TfsExcelImporter -{ - /// - /// 输入框空态占位文字(cue banner)。net48 的 TextBox 没有 PlaceholderText, - /// 用原生 EM_SETCUEBANNER 实现;ComboBox(DropDown)要发给它内嵌的 Edit 子窗口, - /// 直接发给组合框本体无效。文字只在输入框为空时显示,与 Web 表单 placeholder 手感一致。 - /// - internal static class CueBanner - { - private const int EM_SETCUEBANNER = 0x1501; - private const uint GW_CHILD = 5; - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam); - - [DllImport("user32.dll")] - private static extern IntPtr GetWindow(IntPtr hWnd, uint cmd); - - /// 设置占位文字。控件句柄可能尚未创建,挂 HandleCreated 兜底; - /// wParam=1 让占位文字在聚焦时也显示(有真实输入后自然消失)。 - public static void Set(Control c, string text) - { - if (c == null || string.IsNullOrEmpty(text)) return; - Action apply = () => Send(c, text); - if (c.IsHandleCreated) apply(); - c.HandleCreated += (s, e) => apply(); - } - - private static void Send(Control c, string text) - { - IntPtr target = c.Handle; - if (c is ComboBox) - { - // DropDown 组合框的内嵌 Edit 子窗口:cue banner 只认它 - target = GetWindow(c.Handle, GW_CHILD); - if (target == IntPtr.Zero) return; - } - SendMessage(target, EM_SETCUEBANNER, (IntPtr)1, text); - } - } -} diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs index 90fe9ae..5d9a14a 100644 --- a/TfsExcelImporter/MainForm.cs +++ b/TfsExcelImporter/MainForm.cs @@ -37,7 +37,6 @@ namespace TfsExcelImporter { _btnTest.Click += async (s, e) => await TestConnectionAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); - _btnBrowse.Click += (s, e) => BrowseExcel(); _btnTemplate.Click += (s, e) => ExportTemplate(); _btnPreview.Click += async (s, e) => await PreviewDataAsync(); _btnImport.Click += async (s, e) => await ImportAsync(); diff --git a/TfsExcelImporter/MainFormUi.cs b/TfsExcelImporter/MainFormUi.cs index c34704d..7eceac3 100644 --- a/TfsExcelImporter/MainFormUi.cs +++ b/TfsExcelImporter/MainFormUi.cs @@ -25,7 +25,6 @@ namespace TfsExcelImporter private TextBox _txtParentPbi = new TextBox(); private TextBox _txtAreaPath = new TextBox(); private Button _btnLoadIterations = new Button(); - private Button _btnBrowse = new Button(); private Button _btnTemplate = new Button(); private CheckBox _chkSkip = new CheckBox(); @@ -67,6 +66,8 @@ namespace TfsExcelImporter private static int G { get { return Theme.Px(8); } } private static int LogBodyH { get { return Theme.Px(150); } } private string _lastReportPath = ""; + /// 上一次真正执行 Relayout 的客户区尺寸:尺寸没变就不重排(还原/激活时 WM_SIZE 会重发一次)。 + private Size _laidOutSize; /// 需要在窗口拉宽时跟着变宽的控件。 private class Stretch @@ -114,7 +115,15 @@ namespace TfsExcelImporter Controls.Add(_cardLog); Controls.Add(_cardAdv); - Resize += (s, e) => Relayout(); + Resize += (s, e) => + { + // 最小化会收到 WM_SIZE(SIZE_MINIMIZED) 加一个假尺寸(实测 237x39),还原时尺寸又回到原值: + // 这两次重排都白干——最小化那次把 80 多个控件重摆到 237x39 上,还原那次再摆回去, + // 而还原时窗口已经可见,UI 线程却卡在重排里(实测 123ms)不出像素,屏幕上就是一片黑。 + if (WindowState == FormWindowState.Minimized) return; + if (ClientSize == _laidOutSize) return; + Relayout(); + }; _uiReady = true; Relayout(); @@ -168,17 +177,18 @@ namespace TfsExcelImporter private void BuildRibbon() { + // 设计方案:连接信息栏与步骤卡片同一套白卡视觉,「灰标签 + 深值」分组,不再用淡蓝底 _ribbon = new Surface { - LineColor = Theme.PrimarySoftBorder, - FaceColor = Theme.PrimarySoft, + LineColor = Theme.Border, + FaceColor = Theme.Card, Height = Theme.Px(44) }; AddRibbonGroup("服务器", true); AddRibbonGroup("项目", true); AddRibbonGroup("工作项", false); - _chipConn = new StatusChip { BackColor = Theme.PrimarySoft }; + _chipConn = new StatusChip { BackColor = Theme.Card }; _ribbon.Controls.Add(_chipConn); } @@ -189,9 +199,9 @@ namespace TfsExcelImporter { Text = caption, Font = Theme.Caption, - ForeColor = Theme.Ink2, + ForeColor = Theme.Ink3, AutoSize = true, - BackColor = Theme.PrimarySoft + BackColor = Theme.Card }; var val = new Label { @@ -199,14 +209,14 @@ namespace TfsExcelImporter Font = Theme.BodyBold, ForeColor = Theme.Ink, AutoSize = true, - BackColor = Theme.PrimarySoft + BackColor = Theme.Card }; _ribbon.Controls.Add(cap); _ribbon.Controls.Add(val); Panel sep = null; if (withSeparator) { - sep = new Panel { BackColor = Color.FromArgb(0xC2, 0xD3, 0xEF) }; + sep = new Panel { BackColor = Theme.Border }; _ribbon.Controls.Add(sep); } _ribItems.Add(new RibGroup { Cap = cap, Val = val, Sep = sep }); @@ -220,15 +230,15 @@ namespace TfsExcelImporter var cap = group.Cap; var val = group.Val; var sep = group.Sep; - cap.SetBounds(x, (_ribbon.Height - cap.PreferredHeight) / 2, cap.PreferredWidth, cap.PreferredHeight); + Theme.Place(cap, x, (_ribbon.Height - cap.PreferredHeight) / 2, cap.PreferredWidth, cap.PreferredHeight); x += cap.Width + Theme.Px(6); - val.SetBounds(x, (_ribbon.Height - val.PreferredHeight) / 2, val.PreferredWidth, val.PreferredHeight); + Theme.Place(val, x, (_ribbon.Height - val.PreferredHeight) / 2, val.PreferredWidth, val.PreferredHeight); x += val.Width + Theme.Px(12); if (sep == null) continue; - sep.SetBounds(x - Theme.Px(8), (_ribbon.Height - Theme.Px(16)) / 2, 1, Theme.Px(16)); + Theme.Place(sep, x - Theme.Px(8), (_ribbon.Height - Theme.Px(16)) / 2, 1, Theme.Px(16)); x += Theme.Px(5); } - _chipConn.SetBounds(_ribbon.Width - _chipConn.Width - Theme.Px(12), + Theme.Place(_chipConn, _ribbon.Width - _chipConn.Width - Theme.Px(12), (_ribbon.Height - _chipConn.Height) / 2, _chipConn.Width, _chipConn.Height); } @@ -243,12 +253,11 @@ namespace TfsExcelImporter _fUser = NewField(_txtUser, 150, false); _fPwd = NewField(_txtPwd, 150, true); - // 空态占位提示(cue banner):只在该输入框没填内容时显示 - CueBanner.Set(_txtUrl, "如 http://server:8080/tfs/DefaultCollection"); - CueBanner.Set(_txtProject, "如 Tools_Dev"); - CueBanner.Set(_txtType, "如 Task"); - CueBanner.Set(_txtUser, "留空用当前 Windows 身份"); - CueBanner.Set(_txtPwd, "域账号密码(同上可选)"); + // 空态占位提示(cue banner):只在该输入框没填内容时显示。 + // 用户/密码不设提示:这两格只有 150 设计px 宽,长句必被裁断,留空即可用当前 Windows 身份。 + _fUrl.SetCue("如 http://server:8080/tfs/DefaultCollection"); + _fProject.SetCue("如 Tools_Dev"); + _fType.SetCue("如 Task"); _btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130); @@ -316,31 +325,37 @@ namespace TfsExcelImporter int x = M; for (int i = 0; i < n; i++) { - _connRowCaptions[i].SetBounds(x, _connRowY, lblW[i], Theme.FieldH); + Theme.Place(_connRowCaptions[i], x, _connRowY, lblW[i], Theme.FieldH); x += lblW[i] + lblGap; - _connRowFields[i].SetBounds(x, _connRowY, boxW, Theme.FieldH); + Theme.Place(_connRowFields[i], x, _connRowY, boxW, Theme.FieldH); x += boxW + gap; } - _btnTest.SetBounds(x - gap + G, _connRowY, _btnTest.Width, Theme.BtnH); + Theme.Place(_btnTest, x - gap + G, _connRowY, _btnTest.Width, Theme.BtnH); } // --- 步骤 2:迭代与数据源 --- private void BuildCardData() { _cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44)); + _cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,四项状态一眼可辨 _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径 _fIteration = NewField(_cmbIteration, 500, false); _fExcel = NewField(_txtExcel, 500, true); _fParent = NewField(_txtParentPbi, 150, true); - CueBanner.Set(_cmbIteration, "选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1"); - CueBanner.Set(_txtExcel, "选择 .xlsx 文件,或点右侧「浏览…」"); - CueBanner.Set(_txtParentPbi, "如 219975"); + _fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1"); + _fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览"); + _fParent.SetCue("如 219975"); _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130); - _btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92); _btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142); + // 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上, + // 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的 + // 宽度变成同一个值,上面两个输入框的右边框才对得齐。 + int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width); + _btnLoadIterations.Width = wBtn; + _btnTemplate.Width = wBtn; _chkSkip = NewCheck("跳过已存在标题", ""); // 三行字段对齐同一条左缘:标签列宽取最大值 @@ -348,11 +363,12 @@ namespace TfsExcelImporter int y = _cardData.HeaderH + Theme.S12; y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration); AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top); - Track(_fIteration, _cardData, _btnLoadIterations.Width + G + M, 0); + Track(_fIteration, _cardData, wBtn + G + M, 0); y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl), _fExcel); + // 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮 + _fExcel.AddSuffix(IconKind.Folder, BrowseExcel); AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top); - AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top); - Track(_fExcel, _cardData, _btnTemplate.Width + _btnBrowse.Width + G * 2 + M, 0); + Track(_fExcel, _cardData, wBtn + G + M, 0); y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent, new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖")); // 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。 @@ -446,24 +462,24 @@ namespace TfsExcelImporter { int badge = Theme.Px(22); int cy = (_actionBar.Height - badge) / 2; - _badgeRun.SetBounds(M + Theme.Px(2), cy, badge, badge); + Theme.Place(_badgeRun, M + Theme.Px(2), cy, badge, badge); _badgeRun.Set(4, _runBadge); - _btnPreview.SetBounds(M + Theme.Px(42), (_actionBar.Height - _btnPreview.Height) / 2, + Theme.Place(_btnPreview, M + Theme.Px(42), (_actionBar.Height - _btnPreview.Height) / 2, _btnPreview.Width, _btnPreview.Height); - _btnImport.SetBounds(_btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2, + Theme.Place(_btnImport, _btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2, _btnImport.Width, _btnImport.Height); - _chipFail.SetBounds(_actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height); - _chipSkip.SetBounds(_chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height); - _chipOk.SetBounds(_chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height); + Theme.Place(_chipFail, _actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height); + Theme.Place(_chipSkip, _chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height); + Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height); // 状态文字按最长常规文案实测给宽,且只给一行的高度: // 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。 int statW = StatusLabelWidth(); int statH = Theme.Caption.Height + Theme.Px(4); - _lblStatus.SetBounds(_chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH); + Theme.Place(_lblStatus, _chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH); int left = _btnImport.Right + Theme.S16; int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left); - _pbar.SetBounds(left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height); + Theme.Place(_pbar, left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height); } /// @@ -520,16 +536,16 @@ namespace TfsExcelImporter int x = _cardLog.Width - Theme.Px(12) - Theme.Px(30); // 让开折叠箭头 // 头部所有元素共用一条中线:按钮和勾选框都按 cy - 高度/2 摆,文字才齐平 int cy = _cardLog.HeaderH / 2; - _btnOpenReport.SetBounds(x - _btnOpenReport.Width, cy - _btnOpenReport.Height / 2, + Theme.Place(_btnOpenReport, x - _btnOpenReport.Width, cy - _btnOpenReport.Height / 2, _btnOpenReport.Width, _btnOpenReport.Height); - _btnClearLog.SetBounds(_btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2, + Theme.Place(_btnClearLog, _btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2, _btnClearLog.Width, _btnClearLog.Height); // net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框); // 高度取原生 PreferredHeight,整体压在中线上 var pref = _chkAutoScroll.GetPreferredSize(Size.Empty); int chkH = Math.Max(Theme.Px(18), pref.Height); int chkW = Math.Max(Theme.TextWidth(_chkAutoScroll.Text, Theme.Caption) + Theme.Px(22), pref.Width); - _chkAutoScroll.SetBounds(_btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, chkH); + Theme.Place(_chkAutoScroll, _btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, chkH); }); } @@ -539,7 +555,7 @@ namespace TfsExcelImporter _cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36)); _cardAdv.ShowBadge = false; _fArea = NewField(_txtAreaPath, 420, true); - CueBanner.Set(_txtAreaPath, "如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area"); + _fArea.SetCue("如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area"); _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158); _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120); @@ -753,22 +769,22 @@ namespace TfsExcelImporter { int cw = Math.Max(Theme.Px(320), ClientSize.Width - M * 2); - _ribbon.SetBounds(M, M, cw, _ribbon.Height); + Theme.Place(_ribbon, M, M, cw, _ribbon.Height); int y = _ribbon.Bottom + G; foreach (var card in new[] { _cardConn, _cardData }) { - card.SetBounds(M, y, cw, card.Height); + Theme.Place(card, M, y, cw, card.Height); y = card.Bottom + G; } // 从底部往上排 int by = ClientSize.Height - M; - _cardAdv.SetBounds(M, by - _cardAdv.Height, cw, _cardAdv.Height); + Theme.Place(_cardAdv, M, by - _cardAdv.Height, cw, _cardAdv.Height); by = _cardAdv.Top - G; - _cardLog.SetBounds(M, by - _cardLog.Height, cw, _cardLog.Height); + Theme.Place(_cardLog, M, by - _cardLog.Height, cw, _cardLog.Height); by = _cardLog.Top - G; - _actionBar.SetBounds(M, by - _actionBar.Height, cw, _actionBar.Height); + Theme.Place(_actionBar, M, by - _actionBar.Height, cw, _actionBar.Height); by = _actionBar.Top - G; // 预览卡吃剩余高度;空间不足时先让日志收成一行 @@ -777,14 +793,14 @@ namespace TfsExcelImporter _cardLog.Expanded = false; _cardLog.ApplyHeight(); by = ClientSize.Height - M; - _cardAdv.SetBounds(M, by - _cardAdv.Height, cw, _cardAdv.Height); + Theme.Place(_cardAdv, M, by - _cardAdv.Height, cw, _cardAdv.Height); by = _cardAdv.Top - G; - _cardLog.SetBounds(M, by - _cardLog.Height, cw, _cardLog.Height); + Theme.Place(_cardLog, M, by - _cardLog.Height, cw, _cardLog.Height); by = _cardLog.Top - G; - _actionBar.SetBounds(M, by - _actionBar.Height, cw, _actionBar.Height); + Theme.Place(_actionBar, M, by - _actionBar.Height, cw, _actionBar.Height); by = _actionBar.Top - G; } - _cardPreview.SetBounds(M, y, cw, Math.Max(Theme.Px(120), by - G - y)); + Theme.Place(_cardPreview, M, y, cw, Math.Max(Theme.Px(120), by - G - y)); _cardPreview.BodyH = _cardPreview.Height - _cardPreview.HeaderH; foreach (var st in _stretches) @@ -792,18 +808,34 @@ namespace TfsExcelImporter if (st.C == null || st.Owner == null) continue; int w = Math.Max(Theme.Px(60), st.Owner.Width - st.X - st.Right); int h = st.FillHeight ? Math.Max(Theme.Px(20), st.Owner.Height - st.Y - st.Bottom) : st.C.Height; - st.C.SetBounds(st.X, st.Y, w, h); + Theme.Place(st.C, st.X, st.Y, w, h); } foreach (var ra in _rightAnchors) { if (ra.C == null || ra.Owner == null) continue; - ra.C.SetBounds(ra.Owner.Width - ra.FromRight - ra.C.Width, ra.Y, ra.C.Width, ra.C.Height); + Theme.Place(ra.C, ra.Owner.Width - ra.FromRight - ra.C.Width, ra.Y, ra.C.Width, ra.C.Height); } LayoutRibbon(); foreach (var act in _layoutExtras) act(); _cardConn.Invalidate(); _cardData.Invalidate(); + _laidOutSize = ClientSize; + } + + /// + /// 自绘界面平时不擦背景(AllPaintingInWmPaint + 双缓冲),窗口被重新暴露时那块像素是系统给的黑色。 + /// 还原/切前台时完整首帧要一百多毫秒,这段时间就是用户看到的「黑屏一闪」; + /// 这里在 WM_ERASEBKGND 上用 GDI 直接把客户区铺成面色,黑底一秒都不露,完整首帧随后补上细节。 + /// + protected override void WndProc(ref Message m) + { + if (m.Msg == 0x0014 && Theme.FastErase(m.HWnd, m.WParam, BackColor)) + { + m.Result = (IntPtr)1; + return; + } + base.WndProc(ref m); } // ------------------------------------------------------------- 状态呈现 @@ -962,7 +994,7 @@ namespace TfsExcelImporter _btnLoadIterations.Enabled = !busy; _btnFixArea.Enabled = !busy; _btnLinkParent.Enabled = !busy; - _btnBrowse.Enabled = !busy; + _fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用 _btnTemplate.Enabled = !busy; } } @@ -981,16 +1013,21 @@ namespace TfsExcelImporter protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; - int icon = Theme.Px(40); + int well = Theme.Px(48); // 圆形浅灰图标底:裸图标太轻,兜一层「底座」才有空状态的分量 + int icon = Theme.Px(22); int textH = Theme.Px(18); - int blockH = icon + Theme.Px(10) + textH; + int blockH = well + Theme.Px(12) + textH; int top = Math.Max(Theme.Px(4), (Height - blockH) / 2); + int wellX = (Width - well) / 2; var old = g.SmoothingMode; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; - Icons.Draw(g, IconKind.Table, new Rectangle((Width - icon) / 2, top, icon, icon), Theme.Ink3); + using (var b = new SolidBrush(Theme.Disabled)) + g.FillEllipse(b, wellX, top, well, well); + Icons.Draw(g, IconKind.Table, + new Rectangle(wellX + (well - icon) / 2, top + (well - icon) / 2, icon, icon), Theme.Ink3); g.SmoothingMode = old; TextRenderer.DrawText(g, Text, Theme.Body, - new Rectangle(Theme.Px(16), top + icon + Theme.Px(10), Width - Theme.Px(32), textH), + new Rectangle(Theme.Px(16), top + well + Theme.Px(12), Width - Theme.Px(32), textH), Theme.Ink3, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); diff --git a/TfsExcelImporter/Theme.cs b/TfsExcelImporter/Theme.cs index 6c73645..7d97ebd 100644 --- a/TfsExcelImporter/Theme.cs +++ b/TfsExcelImporter/Theme.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; using System.Windows.Forms; namespace TfsExcelImporter @@ -149,10 +151,64 @@ namespace TfsExcelImporter return Card; } - public static int TextWidth(string text, Font font) { + private static readonly Dictionary _eraseBrushes = new Dictionary(); + + /// + /// WM_ERASEBKGND 的快速擦除:直接用 GDI 把客户区铺成底色,不经过双缓冲、不建 GraphicsPath。 + /// 自绘控件为了不闪都不擦背景,代价是窗口被重新暴露(还原、切前台)时那块像素保持系统的黑色, + /// 要等完整首帧(实测整窗 100ms+)才盖掉——这就是「黑色界面闪现」的来源。 + /// 这里先用一次 FillRect 换掉黑底:即使完整首帧还没来,用户看到的也只是和界面同色的浅底。 + /// + public static bool FastErase(IntPtr hwnd, IntPtr hdc, Color color) + { + RECT rc; + if (color.A == 0 || !GetClientRect(hwnd, out rc) || rc.right <= 0 || rc.bottom <= 0) return false; + int key = color.ToArgb(); + SolidBrush brush; + if (!_eraseBrushes.TryGetValue(key, out brush)) + { + brush = new SolidBrush(color); + _eraseBrushes[key] = brush; + } + var g = Graphics.FromHdc(hdc); + try { g.FillRectangle(brush, 0, 0, rc.right, rc.bottom); } + finally { g.Dispose(); } + return true; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int left, top, right, bottom; } + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr hWnd, out RECT rc); + + private static readonly Dictionary _textWidths = new Dictionary(); + + public static int TextWidth(string text, Font font) + { if (string.IsNullOrEmpty(text)) return 0; - return TextRenderer.MeasureText(text, font, Size.Empty, + // 排版会反复量同一批文案(拖拽缩放每帧都要全量量一遍),而 MeasureText 每次都要建 DC、选字体, + // 和查一次字典差两个数量级。字体是静态单例,键里带字体名/字号/字形就足以区分。 + string key = font.Name + "|" + font.SizeInPoints.ToString("F1") + "|" + (int)font.Style + "|" + text; + int w; + if (_textWidths.TryGetValue(key, out w)) return w; + w = TextRenderer.MeasureText(text, font, Size.Empty, TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width; + if (_textWidths.Count > 800) _textWidths.Clear(); // 日志摘要/路径是动态文案,别让它无限涨 + _textWidths[key] = w; + return w; + } + + /// + /// 位置尺寸都没变就不动。每次 SetBounds 都是一次 WM_WINDOWPOSCHANGED 加一次重绘, + /// 而重排里绝大多数控件其实待在原地——打字、状态刷新这类重排尤其如此, + /// 拦掉空转会把这些场景的 70~100ms 直接抹平。 + /// + public static void Place(Control c, int x, int y, int w, int h) + { + var b = c.Bounds; + if (b.X == x && b.Y == y && b.Width == w && b.Height == h) return; + c.SetBounds(x, y, w, h); } public static Color ChipInk(ChipKind kind) @@ -393,6 +449,8 @@ namespace TfsExcelImporter 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); + if (Kind == BadgeKind.Pending) + using (var p = new Pen(Theme.BorderStrong, Theme.Hair)) g.DrawEllipse(p, r); g.SmoothingMode = old; // 勾画在圆内缩 4px(14px 盒子 / 22px 圆),铺满整圆会贴边看着像被裁掉 if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(ClientRectangle), ink); @@ -423,6 +481,16 @@ namespace TfsExcelImporter TabStop = false; } + protected override void WndProc(ref Message m) + { + if (m.Msg == 0x0014 && Theme.FastErase(m.HWnd, m.WParam, BackColor)) + { + m.Result = (IntPtr)1; + return; + } + base.WndProc(ref m); + } + protected override void OnPaint(PaintEventArgs e) { Theme.FillPanel(e.Graphics, new Rectangle(0, 0, Width, Height), _face, LineColor, @@ -446,6 +514,8 @@ namespace TfsExcelImporter public bool ShowBadge = true; /// 卡片头部右侧留给按钮的宽度,摘要文字不越过它。 public int HeaderReserve = 0; + /// 头部摘要按「圆点 + 短语」分段绘制(状态摘要样式);默认纯文本。 + public bool SummaryDots = false; public int HeaderH = 44; public int BodyH = 0; public Action OnToggle; @@ -461,6 +531,17 @@ namespace TfsExcelImporter HeaderH = Theme.Px(44); } + // 卡片占了大半客户区,窗口重新暴露时它不擦背景就是一大片黑——和 MainForm 一样先铺面色。 + protected override void WndProc(ref Message m) + { + if (m.Msg == 0x0014 && Theme.FastErase(m.HWnd, m.WParam, BackColor)) + { + m.Result = (IntPtr)1; + return; + } + base.WndProc(ref m); + } + public Rectangle BodyRect { get { return new Rectangle(0, HeaderH, Width, BodyH); } } private Rectangle HeaderRect { get { return new Rectangle(0, 0, Width, HeaderH); } } @@ -570,6 +651,10 @@ namespace TfsExcelImporter var old = g.SmoothingMode; g.SmoothingMode = SmoothingMode.AntiAlias; using (var b = new SolidBrush(badgeFill)) g.FillEllipse(b, badge); + // 未激活徽章加一圈浅描边:纯灰底在白卡上存在感太弱,描边后与「激活/完成」的实心徽章同属一套语义 + if (Badge == BadgeKind.Pending) + using (var p = new Pen(Theme.BorderStrong, Theme.Hair)) + g.DrawEllipse(p, badge.X + 0.5f, badge.Y + 0.5f, badge.Width - 1f, badge.Height - 1f); g.SmoothingMode = old; if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(badge), badgeInk); else @@ -588,11 +673,15 @@ namespace TfsExcelImporter | 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); + int sumW = Math.Max(0, Width - chevW - sx - Theme.Px(12)); + if (SummaryDots) + DrawDotSummary(g, Summary, sx, sumW); + else + TextRenderer.DrawText(g, Summary, Theme.Caption, + new Rectangle(sx, 0, sumW, HeaderH), + Invalid ? Theme.Danger : Theme.Ink2, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding + | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); if (Collapsible) { @@ -605,6 +694,40 @@ namespace TfsExcelImporter using (var p = new Pen(Theme.Border)) g.DrawLine(p, Theme.S16, HeaderH, Width - Theme.S16, HeaderH); } + + /// 「圆点 + 短语」状态摘要:按 " · " 分段,每段一枚圆点;放不下的段整段省略。 + private void DrawDotSummary(Graphics g, string summary, int x, int availW) + { + if (string.IsNullOrEmpty(summary) || availW <= 0) return; + var segments = summary.Split(new[] { " · " }, StringSplitOptions.None); + int dot = Theme.Px(6); + int dotGap = Theme.Px(6); + int segGap = Theme.Px(14); + int cursor = x; + int limit = x + availW; + foreach (var segment in segments) + { + if (cursor + dot + dotGap > limit) break; + var oldSmoothing = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + using (var b = new SolidBrush(Invalid ? Theme.Danger : Theme.Ink3)) + g.FillEllipse(b, cursor, (HeaderH - dot) / 2, dot, dot); + g.SmoothingMode = oldSmoothing; + cursor += dot + dotGap; + int remain = limit - cursor; + if (remain <= 0) break; + int tw = Theme.TextWidth(segment, Theme.Caption); + bool truncated = tw > remain; + var flags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding + | TextFormatFlags.SingleLine; + if (truncated) flags |= TextFormatFlags.EndEllipsis; + TextRenderer.DrawText(g, segment, Theme.Caption, + new Rectangle(cursor, 0, Math.Min(tw + Theme.Px(2), remain), HeaderH), + Invalid ? Theme.Danger : Theme.Ink2, flags); + cursor += Math.Min(tw, remain) + segGap; + if (truncated) break; + } + } } internal enum BtnKind { Secondary, Primary, Ghost } @@ -831,14 +954,25 @@ namespace TfsExcelImporter private bool _invalid; private ComboBox _combo; private Chevron _chevron; + private SuffixIcon _suffix; + private Control _content; // 主内容控件(TextBox/ComboBox),占位层据此判定显隐 + private CueLabel _cue; // 自绘占位提示:原生 EM_SETCUEBANNER 控制不了颜色/字号/对齐 // 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带, // 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。 // 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。 private Panel _box; + /// 上一次 LayoutInner 时的宿主尺寸:尺寸没变就不用重摆子控件、更不用重建圆角 Region。 + private Size _innerSize = new Size(-1, -1); /// 宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。 private int ArrowZone { get { return Theme.Px(26); } } + /// 内容右侧的固定预留:组合框是箭头热区,文本框是尾部图标按钮(没有则 0)。 + private int RightReserve + { + get { return _chevron != null ? ArrowZone : (_suffix != null ? Theme.Px(26) : 0); } + } + /// 内容左缘相对 _box 的缩进。TextBox 就摆在 Px(7),组合框闭合态文字必须共用同一基准, /// 否则同一排的输入框文字会错开一条竖线。 private static int TextInsetX { get { return Theme.Px(7); } } @@ -896,16 +1030,56 @@ namespace TfsExcelImporter _chevron.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) ToggleComboDropDown(); }; _box.Controls.Add(_chevron); } + _content = inner; 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(); + LayoutInner(true); return inner; } + /// 文本框尾部图标按钮(如 Excel 字段的「浏览」):点击执行回调,悬停着色提示可点。 + public void AddSuffix(IconKind glyph, Action onClick) + { + if (_suffix != null) return; + _suffix = new SuffixIcon { Glyph = glyph }; + _suffix.Click += (s, e) => { if (onClick != null) onClick(); }; + _box.Controls.Add(_suffix); + LayoutInner(true); + } + + /// 忙等时独立禁用尾部按钮,不影响输入框本身的编辑。 + public void SetSuffixEnabled(bool enabled) + { + if (_suffix != null) _suffix.Enabled = enabled; + } + + /// + /// 自绘占位提示:比原生 EM_SETCUEBANNER 多出「浅灰、小字号、水平居中」三件事。 + /// 鼠标经 HTTRANSPARENT 穿透到输入框,文字随内容输入即时显隐,与 Web placeholder 手感一致。 + /// + public void SetCue(string text) + { + if (_cue == null) + { + _cue = new CueLabel(); + _box.Controls.Add(_cue); + } + _cue.Text = text ?? ""; + if (_content != null) _content.TextChanged += (s, e) => UpdateCueVisibility(); + UpdateCueVisibility(); + LayoutInner(true); + } + + private void UpdateCueVisibility() + { + if (_cue != null && _content != null) + _cue.Visible = string.IsNullOrEmpty(_content.Text); + } + protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); LayoutInner(); } private void DrawComboItem(object sender, DrawItemEventArgs e) @@ -953,14 +1127,19 @@ namespace TfsExcelImporter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); } - private void LayoutInner() + private void LayoutInner(bool force = false) { if (_box == null) return; // 构造中途触发 OnSizeChanged 时容器可能还没建好 + // 尺寸没变 → 子控件位置也不会变,直接跳过:这里一次要重摆 3~5 个真实子窗并重建 Region, + // 单次 3~4ms,重排里被反复调用的正是这种空转。 + var hostSize = new Size(Width, Height); + if (!force && hostSize == _innerSize) return; + _innerSize = hostSize; _box.SetBounds(2, 2, Width - 4, Height - 4); SetBoxRegion(); foreach (Control c in _box.Controls) { - if (c is Chevron) continue; + if (c is Chevron || c is SuffixIcon || c is CueLabel) continue; var tb = c as TextBox; var cb = c as ComboBox; if (cb != null) @@ -974,7 +1153,13 @@ namespace TfsExcelImporter } 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); + // Edit 自绘时把文字画在客户区上沿往下 Px(2) 处,而 PreferredHeight 底部还含着约 + // Px(4) 的边框留白(BorderStyle=None 也不减)。直接按 PreferredHeight 居中,等于把 + // 「上留白 2 + 文字 + 下留白 4」整体居中——文字就偏上 Px(2),肉眼一眼能看出来。 + // 把这段底部留白的一半补回来,文字才落在框内视觉中线上(Consolas / 雅黑都适用)。 + int textTopFix = tb != null ? Theme.Px(2) : 0; + c.SetBounds(Theme.Px(7), Math.Max(Theme.Px(2), (_box.Height - th) / 2) + textTopFix, + _box.Width - Theme.Px(14) - RightReserve, th); } if (_chevron != null) { @@ -982,6 +1167,21 @@ namespace TfsExcelImporter _chevron.SetBounds(_box.Width - Theme.Px(7) - size, Theme.Px(2), size, _box.Height - Theme.Px(4)); _chevron.BringToFront(); // 后加的兄弟控件在底层,必须提到组合框上方才看得见 } + if (_suffix != null) + { + int size = Theme.Px(16); + _suffix.SetBounds(_box.Width - Theme.Px(8) - size, Theme.Px(2), size, _box.Height - Theme.Px(4)); + } + if (_cue != null) + { + // 与内容文字同一缩进基准,宽度让开尾部箭头/图标区;文字左对齐(不再居中)。 + // 起点就用 TextInsetX:实测(150% 截图)空态占位与已填内容的第一个墨迹列 + // 相对左边框都在 +13px,多让或让少都会在「敲字瞬间」看到文字横跳。 + _cue.SetBounds(TextInsetX, Theme.Px(2), + Math.Max(Theme.Px(24), _box.Width - TextInsetX - Theme.Px(7) - RightReserve), + _box.Height - Theme.Px(4)); + _cue.BringToFront(); + } Invalidate(); } @@ -1018,6 +1218,64 @@ namespace TfsExcelImporter } } + /// 输入框尾部图标按钮:白底上悬停变主色,点击交给外部回调。 + private sealed class SuffixIcon : Control + { + public IconKind Glyph = IconKind.None; + private bool _hover; + + public SuffixIcon() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint + | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw + | ControlStyles.SupportsTransparentBackColor, true); + BackColor = Theme.Card; + Cursor = Cursors.Hand; + TabStop = false; + } + + protected override void OnMouseEnter(EventArgs e) { base.OnMouseEnter(e); _hover = true; Invalidate(); } + protected override void OnMouseLeave(EventArgs e) { base.OnMouseLeave(e); _hover = false; Invalidate(); } + protected override void OnEnabledChanged(EventArgs e) { base.OnEnabledChanged(e); Invalidate(); } + + protected override void OnPaint(PaintEventArgs e) + { + int size = Theme.Px(16); + Icons.Draw(e.Graphics, Glyph, + new Rectangle((Width - size) / 2, (Height - size) / 2, size, size), + _hover && Enabled ? Theme.Primary : Theme.Ink2); + } + } + + /// 占位提示层:小一号字号、置灰、左对齐并贴住真实文字的左缘;鼠标事件穿透,不挡输入框聚焦与编辑。 + private sealed class CueLabel : Control + { + public CueLabel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint + | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Theme.Card; + TabStop = false; + } + + protected override void WndProc(ref Message m) + { + const int WM_NCHITTEST = 0x84; + if (m.Msg == WM_NCHITTEST) { m.Result = (IntPtr)(-1); return; } // HTTRANSPARENT:点击穿透 + base.WndProc(ref m); + } + + protected override void OnPaint(PaintEventArgs e) + { + // 左对齐:占位文字必须从真实文字的起点起头,用户一敲字两个位置才不跳动。 + // NoPadding 去掉 DrawText 自带的 2px 文字边距,起点完全由外层给的 x 决定。 + TextRenderer.DrawText(e.Graphics, Text, Theme.Caption, + new Rectangle(0, 0, Width, Height), Theme.Ink3, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding + | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + } + protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e);