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
+269 -11
View File
@@ -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<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;
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;
}
/// <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)
@@ -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;
/// <summary>卡片头部右侧留给按钮的宽度,摘要文字不越过它。</summary>
public int HeaderReserve = 0;
/// <summary>头部摘要按「圆点 + 短语」分段绘制(状态摘要样式);默认纯文本。</summary>
public bool SummaryDots = false;
public int HeaderH = 44;
public int BodyH = 0;
public Action<bool> 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);
}
/// <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 }
@@ -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;
/// <summary>上一次 LayoutInner 时的宿主尺寸:尺寸没变就不用重摆子控件、更不用重建圆角 Region。</summary>
private Size _innerSize = new Size(-1, -1);
/// <summary>宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。</summary>
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>
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;
}
/// <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(); }
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
}
}
/// <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)
{
base.OnMouseDown(e);