Files
DeepSeek V4.1 Flash 8de8a8fb85 预览曲线按面板高亮 + 统计数字不再卡在“统计中…” + 中心轴面板改表格 + 默认主题 Light + 标签页 DPI 修复 + 布局边界幽灵元素
一、预览曲线悬停高亮(只开弱峰+中心轴时无反应;三个全开时总亮均值曲线)
   根因:PaintLaneDerivedOverlays 里只有 MEAN 分支传 highlight,且悬停状态没有记录面板种类。
   修法:新增 m_meanRowHoverKind,高亮条件 = 悬停行 == 本行 && 面板种类 == 本曲线来源;
         条目栏强调色同样按种类门控;悬停种类进入面板缓存键。

二、统计数字松手后停在“统计中…”
   根因:交互期间面板缓存键被冻结(为拖动跟手),冻结期满后没有重绘请求(鼠标已停、
         统计也已完成),最后一次绘制仍是冻结帧。
   修法:记录 m_panelKeysFrozen 并公开 PanelRefreshPending();Render 末尾把它并入
         滚动动画定时器的武装条件 ⇒ 空闲后自动补一帧真实数字,随后停表。

三、「中心轴边沿计数」面板改为表格
   表头固定:信号名 / 上升沿计数 / 下降沿计数;每行底色 = 该信号配色(未勾选的行普通底色),
   三列与表头严格对齐,内容可滚动,单击行展开两行参数滑块。
   - EdgePanelLayout 新增 tableHeader / tableColumns[3];EdgePanelLayoutFor 新增 centerTable 分支;
   - 新增 EdgeCenterTableHeader() / EdgeCountCellText();EnsureEdgeCenterItem 默认 collapsed;
   - PanelSlotKey 的 CENTER 分支改为按 item 判断并混合计数(表格已没有 rowStats)。

四、默认主题改为 Light(Application、ThemeFor/ThemeName 默认分支、CurrentTheme 兜底)。

五、标签页文本在 DPI 切换后被省略
   根因:标签宽度恰好等于“内边距+文本+间距+关闭按钮”,无任何余量;TabItemWidget 缓存了
         Configure 时的 scale 并用它反推可用文本宽度,而 TabBar::SetScale 不重建控件树
         ⇒ 125%→100% 后可用宽度少约 4.5px ⇒ 吃掉末字。
   修法:SetScale 变化时重建控件树;并把文本可用矩形改由 ArrangeWidgets 统一下发
         (SetState 增加 textRect 参数),绘制侧不再自行反推。

六、布局边界里的幽灵元素
   - 新增 SignalPanel::HasStyleTarget();BuildBoxes 与 QueryCursor 在无样式目标时
     不再上报/响应色块、样式、调整、线宽、透明度等控件;
   - Paint 的“无网格”分支显式清空 m_legendHits / m_cursorLabelRects / m_cursorSpanRect /
     m_freeMarkerHits / m_panelSlots / m_panelOrder(关闭整个分组后不再残留边界框);
   - 浮动面板之上的滚动条 / 回到最上方 / 内联输入框改为按可见性上报几何。

验证(离屏探针,SW_HIDE + 合成消息,不占用鼠标键盘屏幕)
  .preflight/p21.exe 9/9   .preflight/p22.exe 14/14   .preflight/p23.exe 7/7
  .preflight/p20 系列(9/11/5)全部回归通过
  make release / make debug 零 warning;Release 813,568 B
2026-11-02 15:22:26 +08:00

901 lines
26 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "TabBar.hpp"
#include "UiControls.hpp"
#include <algorithm>
#include <cmath>
#include <functional>
#include "UiBox.hpp"
#include "Utf8Util.hpp"
namespace jdi
{
namespace
{
const float kTabHeight = 32.0f; /**< 标签高度(逻辑像素)。 */
const float kTopMargin = 4.0f; /**< 标签顶部留白。 */
const float kLeftPadding = 8.0f; /**< 左侧留白。 */
const float kTabPadding = 14.0f; /**< 标签文本左右内边距。 */
const float kCloseSize = 16.0f; /**< 关闭按钮尺寸。 */
const float kCloseGap = 6.0f; /**< 文本与关闭按钮间距。 */
const float kAddSize = 24.0f; /**< 新建按钮尺寸。 */
const float kAddGap = 6.0f; /**< 新建按钮与末个标签的间距。 */
const float kCorner = 5.0f; /**< 标签圆角半径。 */
const float kMaxTabWidth = 200.0f; /**< 单个标签最大宽度(逻辑像素)。 */
const float kEditingTabWidth = 320.0f; /**< 重命名输入时标签的最大宽度(便于看清长名称)。 */
const float kMinTabWidth = 70.0f; /**< 单个标签最小宽度(逻辑像素)。 */
/**
* @brief 单个标签页控件:只负责"把这一格画出来"。
*
* 手势语义(自行判双击 / 拖拽排序 / 关闭 ✕ 的"移开取消")仍留在 `TabBar` 里 ——
* 那些是多年补丁叠出的隐式约定,迁移控件时**不能顺手重写**。
*/
class TabItemWidget : public UiWidget
{
public:
void Configure(const UiFonts* fonts, const Theme* theme, float scale)
{
m_fonts = fonts;
m_theme = theme;
m_scale = scale;
}
/** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮与文本几何)。 */
void SetState(std::wstring name, bool active, gd::RectF closeRect, bool editing,
const TextEdit* edit, gd::RectF editRect, gd::RectF textRect)
{
const bool changed = (m_name != name) || (m_active != active) || (m_editing != editing);
m_name = std::move(name);
m_active = active;
m_closeRect = closeRect;
m_editing = editing;
m_edit = edit;
m_editRect = editRect;
m_textRect = textRect;
if (changed)
{
Invalidate(Bounds());
}
else
{
// 几何/编辑框位置变化也要重画(重命名时标签会随输入变宽)。
Invalidate(Bounds());
}
}
/** @brief 关闭按钮矩形(`TabBar` 用于手势命中;绘制与命中同源)。 */
const gd::RectF& CloseRect() const { return m_closeRect; }
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override
{
(void)ctx;
return gd::SizeF(Bounds().Width, Bounds().Height);
}
void OnPaint(UiPaintContext& ctx) override
{
if (m_theme == nullptr || m_fonts == nullptr || ctx.g == nullptr)
{
return;
}
const gd::RectF& rect = Bounds();
int hoverX = 0;
int hoverY = 0;
const bool hovered = HoverPoint(hoverX, hoverY);
const bool closeHot =
hovered && UiPointInRect(m_closeRect, static_cast<float>(hoverX),
static_cast<float>(hoverY));
std::uint32_t background = m_theme->panelBg;
if (m_active)
{
background = m_theme->cardBg;
}
else if (hovered)
{
background = m_theme->hover;
}
UiFillRoundRect(*ctx.g, rect, kCorner * m_scale, background);
if (!m_active)
{
UiDrawRoundRect(*ctx.g, rect, kCorner * m_scale, m_theme->border, 1.0f);
}
else
{
// 激活页使用顶部强调条指示。
UiFillRoundRect(*ctx.g,
gd::RectF(rect.X + kCorner * m_scale, rect.Y,
rect.Width - kCorner * 2.0f * m_scale, 2.5f * m_scale),
1.0f * m_scale, m_theme->accent);
}
// ⚠️ 文本可用矩形由 `TabBar::ArrangeWidgets` **统一下发**(与排布同一份几何),
// 不能用控件自己的 `m_scale` 从 `m_closeRect` 反推:标签宽度恰好等于
// `2*pad + 文本 + gap + close`(没有任何余量),而控件缓存的 `m_scale`
// 一旦与排布时的不一致(DPI 切换),反推出来的宽度就会少几像素 ⇒
// `UiEllipsize` 把最后一个字符换成省略号。
const gd::RectF textRect = m_textRect;
const float textLeft = textRect.X;
const float textWidth = textRect.Width;
if (m_editing && m_edit != nullptr)
{
// 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。
UiDrawEditField(*ctx.g, m_editRect, *m_edit, m_fonts->ui, m_scale);
}
else
{
const std::wstring label =
UiEllipsize(m_name, m_fonts->ui, std::max(8.0f, textWidth));
UiText(*ctx.g, label, m_active ? m_fonts->uiBold : m_fonts->ui,
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height),
m_active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
}
// 关闭按钮:悬停时显示底色,图标始终可见。
const float closeIconSize = 8.0f * m_scale;
if (closeHot)
{
UiFillRoundRect(*ctx.g, m_closeRect, 3.0f * m_scale, m_theme->pressed);
}
const float cx = m_closeRect.X + m_closeRect.Width * 0.5f;
const float cy = m_closeRect.Y + m_closeRect.Height * 0.5f;
const std::uint32_t iconColor = closeHot
? m_theme->danger
: (m_active ? m_theme->text : m_theme->textMuted);
UiDrawLine(*ctx.g, cx - closeIconSize * 0.5f, cy - closeIconSize * 0.5f,
cx + closeIconSize * 0.5f, cy + closeIconSize * 0.5f, iconColor,
1.4f * m_scale);
UiDrawLine(*ctx.g, cx + closeIconSize * 0.5f, cy - closeIconSize * 0.5f,
cx - closeIconSize * 0.5f, cy + closeIconSize * 0.5f, iconColor,
1.4f * m_scale);
}
private:
const UiFonts* m_fonts = nullptr; /**< 字体。 */
const Theme* m_theme = nullptr; /**< 主题。 */
float m_scale = 1.0f; /**< 缩放系数。 */
std::wstring m_name; /**< 标签名称。 */
bool m_active = false; /**< 是否激活。 */
bool m_editing = false; /**< 是否处于重命名编辑态。 */
const TextEdit* m_edit = nullptr; /**< 编辑模型(编辑态时非空)。 */
gd::RectF m_editRect; /**< 编辑框矩形。 */ gd::RectF m_textRect; /**< 文本可用矩形(由 `TabBar` 下发)。 */ gd::RectF m_closeRect; /**< 关闭按钮矩形。 */
};
/** @brief 「+」新建按钮控件。 */
class TabAddWidget : public UiWidget
{
public:
void Configure(const UiFonts* fonts, const Theme* theme, float scale)
{
m_fonts = fonts;
m_theme = theme;
m_scale = scale;
}
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override
{
(void)ctx;
return gd::SizeF(Bounds().Width, Bounds().Height);
}
void OnPaint(UiPaintContext& ctx) override
{
if (m_theme == nullptr || ctx.g == nullptr)
{
return;
}
const gd::RectF& rect = Bounds();
const bool hovered = Hovered();
const std::uint32_t addBg = hovered ? m_theme->hover : m_theme->panelBg;
UiFillRoundRect(*ctx.g, rect, kCorner * m_scale, addBg);
UiDrawRoundRect(*ctx.g, rect, kCorner * m_scale, m_theme->border, 1.0f);
const float ax = rect.X + rect.Width * 0.5f;
const float ay = rect.Y + rect.Height * 0.5f;
const float armLength = 4.5f * m_scale;
const std::uint32_t addColor = hovered ? m_theme->accent : m_theme->text;
UiDrawLine(*ctx.g, ax - armLength, ay, ax + armLength, ay, addColor, 1.6f * m_scale);
UiDrawLine(*ctx.g, ax, ay - armLength, ax, ay + armLength, addColor, 1.6f * m_scale);
}
private:
const UiFonts* m_fonts = nullptr; /**< 字体(当前未使用,保留给将来的图标)。 */
const Theme* m_theme = nullptr; /**< 主题。 */
float m_scale = 1.0f; /**< 缩放系数。 */
};
/**
* @brief 覆盖层控件:画"拖拽插入指示条 + 底部分隔线"。
*
* 它必须**最后绘制**(在标签与「+」之后),因此作为根节点的最后一个子节点,
* 且**命中透明**(纯装饰,不参与命中与光标链)。
*/
class TabOverlayWidget : public UiWidget
{
public:
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override
{
(void)ctx;
return gd::SizeF(Bounds().Width, Bounds().Height);
}
void OnPaint(UiPaintContext& ctx) override
{
if (ctx.g == nullptr)
{
return;
}
if (paintOverlay)
{
paintOverlay(*ctx.g, Bounds());
}
}
/** @brief 由 `TabBar` 注入的覆盖层绘制回调(拖拽指示条 + 分隔线)。 */
std::function<void(gd::Graphics&, const gd::RectF&)> paintOverlay;
};
/** @brief 标签栏根:只负责底色(其余交给子节点按 z 序绘制)。 */
class TabRootWidget : public UiWidget
{
public:
void Configure(const Theme* theme)
{
m_theme = theme;
}
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override
{
(void)ctx;
return gd::SizeF(Bounds().Width, Bounds().Height);
}
void OnPaint(UiPaintContext& ctx) override
{
if (m_theme == nullptr || ctx.g == nullptr || !UiRectHasArea(Bounds()))
{
return;
}
UiFillRect(*ctx.g, Bounds(), m_theme->panelBg);
}
private:
const Theme* m_theme = nullptr; /**< 主题。 */
};
} // namespace
TabBar::TabBar() = default;
// 控件树用 `std::unique_ptr<UiWidget>` 持有,而具体根类型只在本 TU 可见
// ⇒ 析构函数必须在这里显式定义。
TabBar::~TabBar() = default;
void TabBar::Setup(const UiFonts* fonts, const Theme* theme)
{
m_fonts = fonts;
m_theme = theme;
}
void TabBar::SetScale(float scale)
{
const float next = (scale > 0.1f) ? scale : 1.0f;
if (next == m_scale)
{
return;
}
m_scale = next;
// ⚠️⚠️ **缩放变化必须重建控件树**(用户 2026-11-02 报告:把窗口从 125% 显示器拖到
// 100% 显示器后,唯一的标签页文本变成省略号,双击重命名再取消才恢复):
// 每个 `TabItemWidget` 在 `Configure` 时缓存了当时的 `m_scale`,而它内部用这个缩放
// 反推“可用文本宽度”(`closeRect.X - kCloseGap*scale - (rect.X + kTabPadding*scale)`)。
// 标签宽度是按新缩放排的(恰好 = 2*pad + 文本 + gap + close,**没有任何余量**),
// 而控件仍用旧缩放扣边距 ⇒ 可用宽度比实测文本宽度小几像素 ⇒ `UiEllipsize` 吃掉末字。
RebuildWidgets();
}
void TabBar::SetTabs(const std::vector<std::wstring>& names)
{
m_tabs.clear();
m_tabs.resize(names.size());
for (std::size_t index = 0; index < names.size(); ++index)
{
m_tabs[index].name = names[index];
}
if (m_activeIndex >= static_cast<int>(m_tabs.size()))
{
m_activeIndex = m_tabs.empty() ? -1 : static_cast<int>(m_tabs.size()) - 1;
}
// 标签数量变化 ⇒ 重建控件树(旧控件里缓存的几何与名称全部作废)。
RebuildWidgets();
}
void TabBar::SetActiveIndex(int index)
{
if (m_activeIndex == index)
{
return;
}
m_activeIndex = index;
ArrangeWidgets();
}
float TabBar::DesiredHeight() const
{
return (kTabHeight + kTopMargin) * m_scale;
}
void TabBar::RebuildWidgets()
{
m_root = std::make_unique<TabRootWidget>();
static_cast<TabRootWidget*>(m_root.get())->Configure(m_theme);
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
auto widget = std::make_unique<TabItemWidget>();
static_cast<TabItemWidget*>(widget.get())->Configure(m_fonts, m_theme, m_scale);
m_root->AddChild(std::move(widget));
}
auto add = std::make_unique<TabAddWidget>();
static_cast<TabAddWidget*>(add.get())->Configure(m_fonts, m_theme, m_scale);
m_root->AddChild(std::move(add));
auto overlay = std::make_unique<TabOverlayWidget>();
TabOverlayWidget* overlayRaw = static_cast<TabOverlayWidget*>(overlay.get());
overlayRaw->SetHitTransparent(true);
overlayRaw->paintOverlay = [this](gd::Graphics& g, const gd::RectF& area)
{
// 拖拽排序时在插入位置绘制强调色指示条。
if (m_dragIndex >= 0 && m_dragMoved && !m_tabs.empty() && m_theme != nullptr)
{
const int drop = DropIndexAt(m_dragMouseX);
float markerX = m_addRect.X;
if (drop < static_cast<int>(m_tabs.size()))
{
markerX = m_tabs[static_cast<std::size_t>(drop)].rect.X - kAddGap * m_scale * 0.5f;
}
const float top = area.Y + kTopMargin * m_scale;
const float bottom = top + kTabHeight * m_scale;
UiFillRoundRect(g,
gd::RectF(markerX - 1.5f * m_scale, top, 3.0f * m_scale, bottom - top),
1.5f * m_scale, m_theme->accent);
}
// 底部分隔线。
if (m_theme != nullptr)
{
UiDrawLine(g, area.X, area.Y + area.Height - 1.0f, area.X + area.Width,
area.Y + area.Height - 1.0f, m_theme->border, 1.0f);
}
};
m_root->AddChild(std::move(overlay));
}
void TabBar::Layout(const gd::RectF& area)
{
m_area = area;
if (m_fonts == nullptr || m_fonts->ui == nullptr)
{
return;
}
if (m_root == nullptr)
{
RebuildWidgets();
}
const float tabHeight = kTabHeight * m_scale;
const float top = area.Y + kTopMargin * m_scale;
const float closeSize = kCloseSize * m_scale;
const float minWidth = kMinTabWidth * m_scale;
const float addSize = kAddSize * m_scale;
// 标签宽度按「加粗字体」测量:激活页用 uiBold 绘制,
// 若按常规字体测量,加粗后的长名称会在激活时被省略号截断(预览过短)。
std::vector<float> desired;
desired.reserve(m_tabs.size());
float total = kLeftPadding * m_scale;
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
const bool editing = (static_cast<int>(index) == m_editIndex);
// 重命名过程中按「正在输入的文本」测量,标签才能随输入逐步变宽;
// 否则始终按已提交的旧名称算宽度,长名称会被挤成很短的一截(预览过短)。
const std::wstring& measureText = editing ? m_edit.Text() : m_tabs[index].name;
const float boldWidth = UiMeasureTextWidth(m_fonts->uiBold, measureText);
const float regularWidth = UiMeasureTextWidth(m_fonts->ui, measureText);
const float textWidth = std::max(boldWidth, regularWidth);
const float maxWidth = (editing ? kEditingTabWidth : kMaxTabWidth) * m_scale;
float width = kTabPadding * 2.0f * m_scale + textWidth + kCloseGap * m_scale + closeSize;
width = std::max(minWidth, std::min(width, maxWidth));
desired.push_back(width);
total += width + kAddGap * m_scale;
}
total += addSize;
// 标签总宽超出可用宽度时整体等比收缩,保证「+」按钮始终可见。
const float available = area.Width - kLeftPadding * m_scale;
float shrink = 1.0f;
if (total > available && total > 0.0f)
{
shrink = available / total;
}
float cursorX = area.X + kLeftPadding * m_scale;
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
TabItem& tab = m_tabs[index];
float width = std::max(minWidth * shrink, desired[index] * shrink);
// 收缩后仍要保证「文本 + 关闭按钮」有最小可用空间。
width = std::max(width, closeSize + kTabPadding * m_scale + 8.0f * m_scale);
tab.rect = gd::RectF(cursorX, top, width, tabHeight);
tab.closeRect = gd::RectF(cursorX + width - kTabPadding * m_scale - closeSize,
top + (tabHeight - closeSize) * 0.5f, closeSize, closeSize);
cursorX += width + kAddGap * m_scale;
}
m_addRect = gd::RectF(std::min(cursorX, area.X + area.Width - addSize - 2.0f * m_scale),
top + (tabHeight - addSize) * 0.5f, addSize, addSize);
ArrangeWidgets();
}
void TabBar::ArrangeWidgets()
{
if (m_root == nullptr || m_root->Children().size() != m_tabs.size() + 2)
{
RebuildWidgets();
}
if (m_root == nullptr)
{
return;
}
m_root->Arrange(m_area);
const std::vector<std::unique_ptr<UiWidget>>& children = m_root->Children();
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
TabItemWidget* widget = static_cast<TabItemWidget*>(children[index].get());
const TabItem& tab = m_tabs[index];
const bool editing = (static_cast<int>(index) == m_editIndex);
// 文本可用矩形:与排布用同一份 `m_scale`,不再由控件自行反推。
const gd::RectF textRect(tab.rect.X + kTabPadding * m_scale, tab.rect.Y,
std::max(8.0f, tab.closeRect.X - kCloseGap * m_scale -
(tab.rect.X + kTabPadding * m_scale)),
tab.rect.Height);
widget->Arrange(tab.rect);
widget->SetState(tab.name, static_cast<int>(index) == m_activeIndex, tab.closeRect, editing,
editing ? &m_edit : nullptr, EditRect(), textRect);
}
static_cast<TabAddWidget*>(children[m_tabs.size()].get())->Arrange(m_addRect);
static_cast<TabOverlayWidget*>(children[m_tabs.size() + 1].get())->Arrange(m_area);
}
void TabBar::Paint(gd::Graphics& g)
{
if (m_theme == nullptr || m_fonts == nullptr)
{
return;
}
if (m_root == nullptr)
{
RebuildWidgets();
ArrangeWidgets();
}
if (m_root == nullptr)
{
return;
}
UiPaintContext ctx;
ctx.g = &g;
ctx.clip = m_area;
ctx.scale = m_scale;
m_root->Paint(ctx);
}
bool TabBar::HitTest(int x, int y) const
{
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
}
void TabBar::BuildBoxes(UiBoxTable& boxes)
{
UiComponent::BuildBoxes(boxes);
if (m_root != nullptr)
{
// ⚠️ 跳过根控件:它的矩形与标签栏区域完全重合(已被上一层登记过)。
// 从控件树的**子节点**(各标签条 / 新建按钮)开始,层级 1。
for (const std::unique_ptr<UiWidget>& child : m_root->Children())
{
if (child != nullptr)
{
child->BuildBoxes(boxes, 1);
}
}
}
// 关闭按钮是「标签条内部的子块」,控件树里没有对应节点(由标签条控件自绘)⇒ 单独登记。
for (const TabItem& tab : m_tabs)
{
boxes.Add(tab.closeRect, 2);
}
}
int TabBar::TabAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
if (UiPointInRect(m_tabs[index].rect, fx, fy))
{
return static_cast<int>(index);
}
}
return -1;
}
bool TabBar::OnMouseMove(int x, int y)
{
// 拖动排序中:只跟踪鼠标位置,由覆盖层绘制插入指示。
if (m_dragIndex >= 0)
{
if (x != m_dragMouseX)
{
m_dragMouseX = x;
m_dragMoved = true;
return true;
}
return false;
}
if (m_root == nullptr)
{
return false;
}
UiEvent event;
event.kind = UiEventKind::MOUSE_MOVE;
event.x = x;
event.y = y;
// 悬停链切换会通过 `Invalidate` 冒泡到根 ⇒ 用脏区判断是否需要重绘
//(与旧实现"悬停下标/关闭/新建变化才重绘"等价,但不再手工维护三个下标)。
return m_root->DispatchRedraw(event);
}
bool TabBar::OnLButtonDown(int x, int y)
{
m_pressedIndex = -1;
m_pressedClose = false;
m_pressedAdd = false;
// 编辑态下点击其它位置先提交重命名。
if (IsEditing())
{
TabBarResult ignored;
OnEditKey(VK_RETURN, ignored);
}
if (UiPointInRect(m_addRect, static_cast<float>(x), static_cast<float>(y)))
{
m_pressedAdd = true;
return true;
}
const int index = TabAtPoint(x, y);
if (index < 0)
{
return false;
}
m_pressedIndex = index;
m_dragIndex = index;
m_dragMouseX = x;
m_pressedX = x;
m_dragMoved = false;
m_pressedClose = UiPointInRect(m_tabs[static_cast<std::size_t>(index)].closeRect,
static_cast<float>(x), static_cast<float>(y));
// 自行判定双击:不依赖系统 WM_LBUTTONDBLCLK,行为在不同环境下更一致。
const DWORD now = GetTickCount();
const bool sameTab = (index == m_lastClickIndex);
const DWORD elapsed = now - m_lastClickTick;
if (!m_pressedClose && sameTab && elapsed <= GetDoubleClickTime())
{
m_lastClickIndex = -1;
OnDoubleClick(x, y);
return true;
}
m_lastClickIndex = m_pressedClose ? -1 : index;
m_lastClickTick = now;
return true;
}
bool TabBar::OnLButtonUp(int x, int y, TabBarResult& result)
{
result = TabBarResult();
if (m_pressedAdd)
{
m_pressedAdd = false;
if (UiPointInRect(m_addRect, static_cast<float>(x), static_cast<float>(y)))
{
result.type = TabBarResult::Type::ADD;
}
return true;
}
if (m_pressedIndex < 0)
{
m_dragIndex = -1;
return false;
}
const int index = m_pressedIndex;
const bool wasClose = m_pressedClose;
const bool dragged = m_dragMoved && (std::abs(x - m_pressedX) > 3);
m_pressedIndex = -1;
m_pressedClose = false;
m_dragIndex = -1;
m_dragMoved = false;
// 拖动超过阈值:按释放位置重新排序。
if (dragged && !wasClose)
{
const int target = DropIndexAt(x);
if (target >= 0 && target != index && target != index + 1)
{
result.type = TabBarResult::Type::REORDER;
result.index = index;
result.targetIndex = target;
}
return true;
}
if (TabAtPoint(x, y) != index)
{
return true;
}
if (wasClose)
{
// 关闭 ✕ 只在"按下后仍在 ✕ 上抬起"时生效:按住后移开再松开视为放弃
// (与信号面板的分组/信号 ✕ 保持一致的交互语义)。
if (UiPointInRect(m_tabs[static_cast<std::size_t>(index)].closeRect,
static_cast<float>(x), static_cast<float>(y)))
{
result.type = TabBarResult::Type::CLOSE;
result.index = index;
}
}
else
{
result.type = TabBarResult::Type::SELECT;
result.index = index;
}
return true;
}
int TabBar::DropIndexAt(int x) const
{
const float fx = static_cast<float>(x);
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
const gd::RectF& rect = m_tabs[index].rect;
if (fx < rect.X + rect.Width * 0.5f)
{
return static_cast<int>(index);
}
}
return static_cast<int>(m_tabs.size());
}
bool TabBar::OnDoubleClick(int x, int y)
{
const int index = TabAtPoint(x, y);
if (index < 0)
{
return false;
}
// 双击关闭按钮不进入重命名。
if (UiPointInRect(m_tabs[static_cast<std::size_t>(index)].closeRect, static_cast<float>(x),
static_cast<float>(y)))
{
return false;
}
m_editIndex = index;
m_edit.Begin(m_tabs[static_cast<std::size_t>(index)].name, true, EditFilter::ANY, 0u);
return true;
}
bool TabBar::OnEditChar(wchar_t character)
{
if (m_editIndex < 0)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
TabBarResult result;
OnEditKey(VK_RETURN, result);
return true;
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,此处忽略全部控制字符。
if (character < L' ' || character == 0x7F)
{
return false;
}
return m_edit.Insert(character);
}
bool TabBar::OnEditKey(WPARAM key, TabBarResult& result)
{
result = TabBarResult();
if (m_editIndex < 0)
{
return false;
}
const int index = m_editIndex;
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
{
const std::wstring text = Trim(m_edit.Text());
m_editIndex = -1;
m_edit.End();
if (!text.empty())
{
result.type = TabBarResult::Type::RENAME;
result.index = index;
result.text = text;
}
return true;
}
case VK_ESCAPE:
m_editIndex = -1;
m_edit.End();
return true;
case VK_BACK:
return m_edit.Backspace();
case VK_DELETE:
return m_edit.DeleteForward();
case VK_LEFT:
return m_edit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_edit.MoveCaret(1, shift);
case VK_HOME:
return m_edit.MoveHome(shift);
case VK_END:
return m_edit.MoveEnd(shift);
case 'A':
if (control)
{
m_edit.SelectAll();
}
return true;
default:
break;
}
return true;
}
bool TabBar::CollapseEditSelectionToEnd()
{
if (m_editIndex < 0)
{
return false;
}
return m_edit.DeselectToEnd();
}
gd::RectF TabBar::EditRect() const
{
if (m_editIndex < 0 || m_editIndex >= static_cast<int>(m_tabs.size()))
{
return gd::RectF();
}
const TabItem& tab = m_tabs[static_cast<std::size_t>(m_editIndex)];
const float pad = 4.0f * m_scale;
const float textLeft = tab.rect.X + kTabPadding * m_scale;
const float textRight = tab.closeRect.X - kCloseGap * m_scale;
return gd::RectF(textLeft, tab.rect.Y + pad, std::max(8.0f, textRight - textLeft),
tab.rect.Height - pad * 2.0f);
}
bool TabBar::OnEditMouseDown(int x, int y)
{
if (m_editIndex < 0)
{
return false;
}
if (!UiPointInRect(EditRect(), static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
UiEditFieldMouseDown(m_fonts->ui, EditRect(), m_edit, m_scale, x, y);
return true;
}
bool TabBar::OnEditMouseMove(int x)
{
if (m_editIndex < 0)
{
return false;
}
UiEditFieldMouseMove(m_fonts->ui, EditRect(), m_edit, m_scale, x);
return true;
}
bool TabBar::OnEditMouseUp()
{
if (m_editIndex < 0)
{
return false;
}
m_edit.EndDrag();
return true;
}
bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result)
{
result = TabBarResult();
const int index = TabAtPoint(x, y);
if (index < 0)
{
return false;
}
result.type = TabBarResult::Type::CONTEXT;
result.index = index;
result.screenX = screenX;
result.screenY = screenY;
return true;
}
bool TabBar::OnMouseLeave()
{
if (m_root == nullptr)
{
return false;
}
UiEvent event;
event.kind = UiEventKind::MOUSE_LEAVE;
event.x = 0;
event.y = 0;
m_root->Dispatch(event);
return UiRectHasArea(m_root->TakeDirty());
}
} // namespace jdi