refactor: 移除 CueBanner 类及相关按钮,优化界面布局

- 删除 CueBanner.cs 文件,移除输入框的占位提示功能。
- 更新 MainForm.cs 和 MainFormUi.cs,删除与 Browse 按钮相关的代码,简化用户界面。
- 调整控件布局以提升整体用户体验,确保界面更加简洁。
This commit is contained in:
chenshoulin
2026-11-01 23:15:15 +08:00
parent 8824a81758
commit ffc210f827
4 changed files with 363 additions and 114 deletions
-45
View File
@@ -1,45 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 输入框空态占位文字(cue banner)。net48 的 TextBox 没有 PlaceholderText,
/// 用原生 EM_SETCUEBANNER 实现;ComboBox(DropDown)要发给它内嵌的 Edit 子窗口,
/// 直接发给组合框本体无效。文字只在输入框为空时显示,与 Web 表单 placeholder 手感一致。
/// </summary>
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);
/// <summary>设置占位文字。控件句柄可能尚未创建,挂 HandleCreated 兜底;
/// wParam=1 让占位文字在聚焦时也显示(有真实输入后自然消失)。</summary>
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);
}
}
}
-1
View File
@@ -37,7 +37,6 @@ namespace TfsExcelImporter
{ {
_btnTest.Click += async (s, e) => await TestConnectionAsync(); _btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnBrowse.Click += (s, e) => BrowseExcel();
_btnTemplate.Click += (s, e) => ExportTemplate(); _btnTemplate.Click += (s, e) => ExportTemplate();
_btnPreview.Click += async (s, e) => await PreviewDataAsync(); _btnPreview.Click += async (s, e) => await PreviewDataAsync();
_btnImport.Click += async (s, e) => await ImportAsync(); _btnImport.Click += async (s, e) => await ImportAsync();
+94 -57
View File
@@ -25,7 +25,6 @@ namespace TfsExcelImporter
private TextBox _txtParentPbi = new TextBox(); private TextBox _txtParentPbi = new TextBox();
private TextBox _txtAreaPath = new TextBox(); private TextBox _txtAreaPath = new TextBox();
private Button _btnLoadIterations = new Button(); private Button _btnLoadIterations = new Button();
private Button _btnBrowse = new Button();
private Button _btnTemplate = new Button(); private Button _btnTemplate = new Button();
private CheckBox _chkSkip = new CheckBox(); private CheckBox _chkSkip = new CheckBox();
@@ -67,6 +66,8 @@ namespace TfsExcelImporter
private static int G { get { return Theme.Px(8); } } private static int G { get { return Theme.Px(8); } }
private static int LogBodyH { get { return Theme.Px(150); } } private static int LogBodyH { get { return Theme.Px(150); } }
private string _lastReportPath = ""; private string _lastReportPath = "";
/// <summary>上一次真正执行 Relayout 的客户区尺寸:尺寸没变就不重排(还原/激活时 WM_SIZE 会重发一次)。</summary>
private Size _laidOutSize;
/// <summary>需要在窗口拉宽时跟着变宽的控件。</summary> /// <summary>需要在窗口拉宽时跟着变宽的控件。</summary>
private class Stretch private class Stretch
@@ -114,7 +115,15 @@ namespace TfsExcelImporter
Controls.Add(_cardLog); Controls.Add(_cardLog);
Controls.Add(_cardAdv); 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; _uiReady = true;
Relayout(); Relayout();
@@ -168,17 +177,18 @@ namespace TfsExcelImporter
private void BuildRibbon() private void BuildRibbon()
{ {
// 设计方案:连接信息栏与步骤卡片同一套白卡视觉,「灰标签 + 深值」分组,不再用淡蓝底
_ribbon = new Surface _ribbon = new Surface
{ {
LineColor = Theme.PrimarySoftBorder, LineColor = Theme.Border,
FaceColor = Theme.PrimarySoft, FaceColor = Theme.Card,
Height = Theme.Px(44) Height = Theme.Px(44)
}; };
AddRibbonGroup("服务器", true); AddRibbonGroup("服务器", true);
AddRibbonGroup("项目", true); AddRibbonGroup("项目", true);
AddRibbonGroup("工作项", false); AddRibbonGroup("工作项", false);
_chipConn = new StatusChip { BackColor = Theme.PrimarySoft }; _chipConn = new StatusChip { BackColor = Theme.Card };
_ribbon.Controls.Add(_chipConn); _ribbon.Controls.Add(_chipConn);
} }
@@ -189,9 +199,9 @@ namespace TfsExcelImporter
{ {
Text = caption, Text = caption,
Font = Theme.Caption, Font = Theme.Caption,
ForeColor = Theme.Ink2, ForeColor = Theme.Ink3,
AutoSize = true, AutoSize = true,
BackColor = Theme.PrimarySoft BackColor = Theme.Card
}; };
var val = new Label var val = new Label
{ {
@@ -199,14 +209,14 @@ namespace TfsExcelImporter
Font = Theme.BodyBold, Font = Theme.BodyBold,
ForeColor = Theme.Ink, ForeColor = Theme.Ink,
AutoSize = true, AutoSize = true,
BackColor = Theme.PrimarySoft BackColor = Theme.Card
}; };
_ribbon.Controls.Add(cap); _ribbon.Controls.Add(cap);
_ribbon.Controls.Add(val); _ribbon.Controls.Add(val);
Panel sep = null; Panel sep = null;
if (withSeparator) if (withSeparator)
{ {
sep = new Panel { BackColor = Color.FromArgb(0xC2, 0xD3, 0xEF) }; sep = new Panel { BackColor = Theme.Border };
_ribbon.Controls.Add(sep); _ribbon.Controls.Add(sep);
} }
_ribItems.Add(new RibGroup { Cap = cap, Val = val, Sep = sep }); _ribItems.Add(new RibGroup { Cap = cap, Val = val, Sep = sep });
@@ -220,15 +230,15 @@ namespace TfsExcelImporter
var cap = group.Cap; var cap = group.Cap;
var val = group.Val; var val = group.Val;
var sep = group.Sep; 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); 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); x += val.Width + Theme.Px(12);
if (sep == null) continue; 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); 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); (_ribbon.Height - _chipConn.Height) / 2, _chipConn.Width, _chipConn.Height);
} }
@@ -243,12 +253,11 @@ namespace TfsExcelImporter
_fUser = NewField(_txtUser, 150, false); _fUser = NewField(_txtUser, 150, false);
_fPwd = NewField(_txtPwd, 150, true); _fPwd = NewField(_txtPwd, 150, true);
// 空态占位提示(cue banner):只在该输入框没填内容时显示 // 空态占位提示(cue banner):只在该输入框没填内容时显示。
CueBanner.Set(_txtUrl, "如 http://server:8080/tfs/DefaultCollection"); // 用户/密码不设提示:这两格只有 150 设计px 宽,长句必被裁断,留空即可用当前 Windows 身份。
CueBanner.Set(_txtProject, "如 Tools_Dev"); _fUrl.SetCue("如 http://server:8080/tfs/DefaultCollection");
CueBanner.Set(_txtType, "如 Task"); _fProject.SetCue("如 Tools_Dev");
CueBanner.Set(_txtUser, "留空用当前 Windows 身份"); _fType.SetCue("如 Task");
CueBanner.Set(_txtPwd, "域账号密码(同上可选)");
_btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130); _btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130);
@@ -316,31 +325,37 @@ namespace TfsExcelImporter
int x = M; int x = M;
for (int i = 0; i < n; i++) 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; x += lblW[i] + lblGap;
_connRowFields[i].SetBounds(x, _connRowY, boxW, Theme.FieldH); Theme.Place(_connRowFields[i], x, _connRowY, boxW, Theme.FieldH);
x += boxW + gap; 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:迭代与数据源 --- // --- 步骤 2:迭代与数据源 ---
private void BuildCardData() private void BuildCardData()
{ {
_cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44)); _cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44));
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,四项状态一眼可辨
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径 _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
_fIteration = NewField(_cmbIteration, 500, false); _fIteration = NewField(_cmbIteration, 500, false);
_fExcel = NewField(_txtExcel, 500, true); _fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_txtParentPbi, 150, true); _fParent = NewField(_txtParentPbi, 150, true);
CueBanner.Set(_cmbIteration, "选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1"); _fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
CueBanner.Set(_txtExcel, "选择 .xlsx 文件,或点右侧「浏览…」"); _fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
CueBanner.Set(_txtParentPbi, "如 219975"); _fParent.SetCue("如 219975");
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130); _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142); _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("跳过已存在标题", ""); _chkSkip = NewCheck("跳过已存在标题", "");
// 三行字段对齐同一条左缘:标签列宽取最大值 // 三行字段对齐同一条左缘:标签列宽取最大值
@@ -348,11 +363,12 @@ namespace TfsExcelImporter
int y = _cardData.HeaderH + Theme.S12; int y = _cardData.HeaderH + Theme.S12;
y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration); y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration);
AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top); 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); y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl), _fExcel);
// 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top); AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top); Track(_fExcel, _cardData, wBtn + G + M, 0);
Track(_fExcel, _cardData, _btnTemplate.Width + _btnBrowse.Width + G * 2 + M, 0);
y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent, y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent,
new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖")); new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖"));
// 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。 // 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。
@@ -446,24 +462,24 @@ namespace TfsExcelImporter
{ {
int badge = Theme.Px(22); int badge = Theme.Px(22);
int cy = (_actionBar.Height - badge) / 2; 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); _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); _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); _btnImport.Width, _btnImport.Height);
_chipFail.SetBounds(_actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height); Theme.Place(_chipFail, _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); Theme.Place(_chipSkip, _chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height);
_chipOk.SetBounds(_chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height); Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
// 状态文字按最长常规文案实测给宽,且只给一行的高度: // 状态文字按最长常规文案实测给宽,且只给一行的高度:
// 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。 // 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。
int statW = StatusLabelWidth(); int statW = StatusLabelWidth();
int statH = Theme.Caption.Height + Theme.Px(4); 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 left = _btnImport.Right + Theme.S16;
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left); 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);
} }
/// <summary> /// <summary>
@@ -520,16 +536,16 @@ namespace TfsExcelImporter
int x = _cardLog.Width - Theme.Px(12) - Theme.Px(30); // 让开折叠箭头 int x = _cardLog.Width - Theme.Px(12) - Theme.Px(30); // 让开折叠箭头
// 头部所有元素共用一条中线:按钮和勾选框都按 cy - 高度/2 摆,文字才齐平 // 头部所有元素共用一条中线:按钮和勾选框都按 cy - 高度/2 摆,文字才齐平
int cy = _cardLog.HeaderH / 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); _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); _btnClearLog.Width, _btnClearLog.Height);
// net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框); // net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框);
// 高度取原生 PreferredHeight,整体压在中线上 // 高度取原生 PreferredHeight,整体压在中线上
var pref = _chkAutoScroll.GetPreferredSize(Size.Empty); var pref = _chkAutoScroll.GetPreferredSize(Size.Empty);
int chkH = Math.Max(Theme.Px(18), pref.Height); int chkH = Math.Max(Theme.Px(18), pref.Height);
int chkW = Math.Max(Theme.TextWidth(_chkAutoScroll.Text, Theme.Caption) + Theme.Px(22), pref.Width); 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 = MakeCard(0, "高级工具", true, Theme.Px(36));
_cardAdv.ShowBadge = false; _cardAdv.ShowBadge = false;
_fArea = NewField(_txtAreaPath, 420, true); _fArea = NewField(_txtAreaPath, 420, true);
CueBanner.Set(_txtAreaPath, "如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area"); _fArea.SetCue("如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area");
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158); _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120); _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
@@ -753,22 +769,22 @@ namespace TfsExcelImporter
{ {
int cw = Math.Max(Theme.Px(320), ClientSize.Width - M * 2); 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; int y = _ribbon.Bottom + G;
foreach (var card in new[] { _cardConn, _cardData }) 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; y = card.Bottom + G;
} }
// 从底部往上排 // 从底部往上排
int by = ClientSize.Height - M; 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; 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; 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; by = _actionBar.Top - G;
// 预览卡吃剩余高度;空间不足时先让日志收成一行 // 预览卡吃剩余高度;空间不足时先让日志收成一行
@@ -777,14 +793,14 @@ namespace TfsExcelImporter
_cardLog.Expanded = false; _cardLog.Expanded = false;
_cardLog.ApplyHeight(); _cardLog.ApplyHeight();
by = ClientSize.Height - M; 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; 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; 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; 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; _cardPreview.BodyH = _cardPreview.Height - _cardPreview.HeaderH;
foreach (var st in _stretches) foreach (var st in _stretches)
@@ -792,18 +808,34 @@ namespace TfsExcelImporter
if (st.C == null || st.Owner == null) continue; if (st.C == null || st.Owner == null) continue;
int w = Math.Max(Theme.Px(60), st.Owner.Width - st.X - st.Right); 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; 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) foreach (var ra in _rightAnchors)
{ {
if (ra.C == null || ra.Owner == null) continue; 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(); LayoutRibbon();
foreach (var act in _layoutExtras) act(); foreach (var act in _layoutExtras) act();
_cardConn.Invalidate(); _cardConn.Invalidate();
_cardData.Invalidate(); _cardData.Invalidate();
_laidOutSize = ClientSize;
}
/// <summary>
/// 自绘界面平时不擦背景(AllPaintingInWmPaint + 双缓冲),窗口被重新暴露时那块像素是系统给的黑色。
/// 还原/切前台时完整首帧要一百多毫秒,这段时间就是用户看到的「黑屏一闪」;
/// 这里在 WM_ERASEBKGND 上用 GDI 直接把客户区铺成面色,黑底一秒都不露,完整首帧随后补上细节。
/// </summary>
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; _btnLoadIterations.Enabled = !busy;
_btnFixArea.Enabled = !busy; _btnFixArea.Enabled = !busy;
_btnLinkParent.Enabled = !busy; _btnLinkParent.Enabled = !busy;
_btnBrowse.Enabled = !busy; _fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
_btnTemplate.Enabled = !busy; _btnTemplate.Enabled = !busy;
} }
} }
@@ -981,16 +1013,21 @@ namespace TfsExcelImporter
protected override void OnPaint(PaintEventArgs e) protected override void OnPaint(PaintEventArgs e)
{ {
var g = e.Graphics; 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 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 top = Math.Max(Theme.Px(4), (Height - blockH) / 2);
int wellX = (Width - well) / 2;
var old = g.SmoothingMode; var old = g.SmoothingMode;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; 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; g.SmoothingMode = old;
TextRenderer.DrawText(g, Text, Theme.Body, 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, Theme.Ink3,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+269 -11
View File
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Drawing.Drawing2D; using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
using System.Windows.Forms; using System.Windows.Forms;
namespace TfsExcelImporter namespace TfsExcelImporter
@@ -149,10 +151,64 @@ namespace TfsExcelImporter
return Card; return Card;
} }
public static int TextWidth(string text, Font font) { private static readonly Dictionary<int, SolidBrush> _eraseBrushes = new Dictionary<int, SolidBrush>();
/// <summary>
/// WM_ERASEBKGND 的快速擦除:直接用 GDI 把客户区铺成底色,不经过双缓冲、不建 GraphicsPath。
/// 自绘控件为了不闪都不擦背景,代价是窗口被重新暴露(还原、切前台)时那块像素保持系统的黑色,
/// 要等完整首帧(实测整窗 100ms+)才盖掉——这就是「黑色界面闪现」的来源。
/// 这里先用一次 FillRect 换掉黑底:即使完整首帧还没来,用户看到的也只是和界面同色的浅底。
/// </summary>
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<string, int> _textWidths = new Dictionary<string, int>();
public static int TextWidth(string text, Font font)
{
if (string.IsNullOrEmpty(text)) return 0; 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; TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width;
if (_textWidths.Count > 800) _textWidths.Clear(); // 日志摘要/路径是动态文案,别让它无限涨
_textWidths[key] = w;
return w;
}
/// <summary>
/// 位置尺寸都没变就不动。每次 SetBounds 都是一次 WM_WINDOWPOSCHANGED 加一次重绘,
/// 而重排里绝大多数控件其实待在原地——打字、状态刷新这类重排尤其如此,
/// 拦掉空转会把这些场景的 70~100ms 直接抹平。
/// </summary>
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) public static Color ChipInk(ChipKind kind)
@@ -393,6 +449,8 @@ namespace TfsExcelImporter
g.SmoothingMode = SmoothingMode.AntiAlias; g.SmoothingMode = SmoothingMode.AntiAlias;
var r = new RectangleF(0.5f, 0.5f, Width - 1f, Height - 1f); var r = new RectangleF(0.5f, 0.5f, Width - 1f, Height - 1f);
using (var b = new SolidBrush(fill)) g.FillEllipse(b, r); 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; g.SmoothingMode = old;
// 勾画在圆内缩 4px(14px 盒子 / 22px 圆),铺满整圆会贴边看着像被裁掉 // 勾画在圆内缩 4px(14px 盒子 / 22px 圆),铺满整圆会贴边看着像被裁掉
if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(ClientRectangle), ink); if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(ClientRectangle), ink);
@@ -423,6 +481,16 @@ namespace TfsExcelImporter
TabStop = false; 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) protected override void OnPaint(PaintEventArgs e)
{ {
Theme.FillPanel(e.Graphics, new Rectangle(0, 0, Width, Height), _face, LineColor, Theme.FillPanel(e.Graphics, new Rectangle(0, 0, Width, Height), _face, LineColor,
@@ -446,6 +514,8 @@ namespace TfsExcelImporter
public bool ShowBadge = true; public bool ShowBadge = true;
/// <summary>卡片头部右侧留给按钮的宽度,摘要文字不越过它。</summary> /// <summary>卡片头部右侧留给按钮的宽度,摘要文字不越过它。</summary>
public int HeaderReserve = 0; public int HeaderReserve = 0;
/// <summary>头部摘要按「圆点 + 短语」分段绘制(状态摘要样式);默认纯文本。</summary>
public bool SummaryDots = false;
public int HeaderH = 44; public int HeaderH = 44;
public int BodyH = 0; public int BodyH = 0;
public Action<bool> OnToggle; public Action<bool> OnToggle;
@@ -461,6 +531,17 @@ namespace TfsExcelImporter
HeaderH = Theme.Px(44); 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); } } public Rectangle BodyRect { get { return new Rectangle(0, HeaderH, Width, BodyH); } }
private Rectangle HeaderRect { get { return new Rectangle(0, 0, Width, HeaderH); } } private Rectangle HeaderRect { get { return new Rectangle(0, 0, Width, HeaderH); } }
@@ -570,6 +651,10 @@ namespace TfsExcelImporter
var old = g.SmoothingMode; var old = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias; g.SmoothingMode = SmoothingMode.AntiAlias;
using (var b = new SolidBrush(badgeFill)) g.FillEllipse(b, badge); 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; g.SmoothingMode = old;
if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(badge), badgeInk); if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(badge), badgeInk);
else else
@@ -588,11 +673,15 @@ namespace TfsExcelImporter
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
int sx = titleRect.Right + Theme.Px(12); int sx = titleRect.Right + Theme.Px(12);
var sumRect = new Rectangle(sx, 0, Math.Max(0, Width - chevW - sx - Theme.Px(12)), HeaderH); int sumW = Math.Max(0, Width - chevW - sx - Theme.Px(12));
TextRenderer.DrawText(g, Summary, Theme.Caption, sumRect, if (SummaryDots)
Invalid ? Theme.Danger : Theme.Ink2, DrawDotSummary(g, Summary, sx, sumW);
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding else
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); 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) if (Collapsible)
{ {
@@ -605,6 +694,40 @@ namespace TfsExcelImporter
using (var p = new Pen(Theme.Border)) using (var p = new Pen(Theme.Border))
g.DrawLine(p, Theme.S16, HeaderH, Width - Theme.S16, HeaderH); g.DrawLine(p, Theme.S16, HeaderH, Width - Theme.S16, HeaderH);
} }
/// <summary>「圆点 + 短语」状态摘要:按 " · " 分段,每段一枚圆点;放不下的段整段省略。</summary>
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 } internal enum BtnKind { Secondary, Primary, Ghost }
@@ -831,14 +954,25 @@ namespace TfsExcelImporter
private bool _invalid; private bool _invalid;
private ComboBox _combo; private ComboBox _combo;
private Chevron _chevron; private Chevron _chevron;
private SuffixIcon _suffix;
private Control _content; // 主内容控件(TextBox/ComboBox),占位层据此判定显隐
private CueLabel _cue; // 自绘占位提示:原生 EM_SETCUEBANNER 控制不了颜色/字号/对齐
// 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带, // 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带,
// 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。 // 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。
// 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。 // 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。
private Panel _box; private Panel _box;
/// <summary>上一次 LayoutInner 时的宿主尺寸:尺寸没变就不用重摆子控件、更不用重建圆角 Region。</summary>
private Size _innerSize = new Size(-1, -1);
/// <summary>宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。</summary> /// <summary>宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。</summary>
private int ArrowZone { get { return Theme.Px(26); } } private int ArrowZone { get { return Theme.Px(26); } }
/// <summary>内容右侧的固定预留:组合框是箭头热区,文本框是尾部图标按钮(没有则 0)。</summary>
private int RightReserve
{
get { return _chevron != null ? ArrowZone : (_suffix != null ? Theme.Px(26) : 0); }
}
/// <summary>内容左缘相对 _box 的缩进。TextBox 就摆在 Px(7),组合框闭合态文字必须共用同一基准, /// <summary>内容左缘相对 _box 的缩进。TextBox 就摆在 Px(7),组合框闭合态文字必须共用同一基准,
/// 否则同一排的输入框文字会错开一条竖线。</summary> /// 否则同一排的输入框文字会错开一条竖线。</summary>
private static int TextInsetX { get { return Theme.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(); }; _chevron.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) ToggleComboDropDown(); };
_box.Controls.Add(_chevron); _box.Controls.Add(_chevron);
} }
_content = inner;
inner.BackColor = Theme.Card; inner.BackColor = Theme.Card;
inner.Font = mono ? Theme.Mono : Theme.Body; inner.Font = mono ? Theme.Mono : Theme.Body;
inner.ForeColor = Theme.Ink; inner.ForeColor = Theme.Ink;
_box.Controls.Add(inner); _box.Controls.Add(inner);
inner.Enter += (s, e) => Invalidate(); inner.Enter += (s, e) => Invalidate();
inner.Leave += (s, e) => Invalidate(); inner.Leave += (s, e) => Invalidate();
LayoutInner(); LayoutInner(true);
return inner; return inner;
} }
/// <summary>文本框尾部图标按钮(如 Excel 字段的「浏览」):点击执行回调,悬停着色提示可点。</summary>
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);
}
/// <summary>忙等时独立禁用尾部按钮,不影响输入框本身的编辑。</summary>
public void SetSuffixEnabled(bool enabled)
{
if (_suffix != null) _suffix.Enabled = enabled;
}
/// <summary>
/// 自绘占位提示:比原生 EM_SETCUEBANNER 多出「浅灰、小字号、水平居中」三件事。
/// 鼠标经 HTTRANSPARENT 穿透到输入框,文字随内容输入即时显隐,与 Web placeholder 手感一致。
/// </summary>
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(); } protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); LayoutInner(); }
private void DrawComboItem(object sender, DrawItemEventArgs e) private void DrawComboItem(object sender, DrawItemEventArgs e)
@@ -953,14 +1127,19 @@ namespace TfsExcelImporter
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
} }
private void LayoutInner() private void LayoutInner(bool force = false)
{ {
if (_box == null) return; // 构造中途触发 OnSizeChanged 时容器可能还没建好 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); _box.SetBounds(2, 2, Width - 4, Height - 4);
SetBoxRegion(); SetBoxRegion();
foreach (Control c in _box.Controls) 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 tb = c as TextBox;
var cb = c as ComboBox; var cb = c as ComboBox;
if (cb != null) if (cb != null)
@@ -974,7 +1153,13 @@ namespace TfsExcelImporter
} }
int natural = tb != null ? tb.PreferredHeight : Theme.Px(19); int natural = tb != null ? tb.PreferredHeight : Theme.Px(19);
int th = Math.Min(Math.Max(natural, Theme.Px(18)), _box.Height - Theme.Px(4)); 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) 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.SetBounds(_box.Width - Theme.Px(7) - size, Theme.Px(2), size, _box.Height - Theme.Px(4));
_chevron.BringToFront(); // 后加的兄弟控件在底层,必须提到组合框上方才看得见 _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(); Invalidate();
} }
@@ -1018,6 +1218,64 @@ namespace TfsExcelImporter
} }
} }
/// <summary>输入框尾部图标按钮:白底上悬停变主色,点击交给外部回调。</summary>
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);
}
}
/// <summary>占位提示层:小一号字号、置灰、左对齐并贴住真实文字的左缘;鼠标事件穿透,不挡输入框聚焦与编辑。</summary>
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) protected override void OnMouseDown(MouseEventArgs e)
{ {
base.OnMouseDown(e); base.OnMouseDown(e);