fix: 修复图例改名不同步、细化网格标尺并支持标签页重排与曲线实色跟随拖拽
- 图例跟随重命名:绘图区图例文本改用信号当前显示名(Title), 左侧列表改名后网格图例立即同步。 - 标签页重排与改名:新增拖拽排序(按住标签左右拖动并显示插入指示条, 重排后保持原激活页)与双击内联改名,新增 MoveTab 正确处理激活下标。 修复改名后未重新布局导致标签页整条消失的问题(SetTabs 会重置标签矩形)。 双击判定改为自行按双击时间识别,不再依赖系统 WM_LBUTTONDBLCLK。 - 信号拖拽:虚影改用原信号颜色与线宽(不再画成虚线),保持原有线段/散点样式; 横向与纵向均自由跟随鼠标位移;拖拽首帧位移为零,虚影始终贴合在鼠标下方。 - 网格与标尺细化:时间轴与幅值轴期望刻度数提高,并在相邻主刻度之间 补绘 5 等分细线,便于目视估读。 - 图例定位与交互:图例移到各网格左上角,与标尺数字冲突时优先显示图例 (标尺数值主动避让);图例条目支持单击选中信号(与左侧列表联动、 样式区随之切换目标)与双击重命名信号。 - 网格划分:合并网格后新勾选的信号不再被并入那个唯一的合并网格, 而是自动切换为自定义划分并独占一个新网格。 - 工具栏:「每信号一格」改名为「拆分信号网格」。 - 清理:移除已无引用的 UiDrawArrowIcon 与 m_styleContentRect。
This commit is contained in:
1 parent
261b8ac07d
commit
4e295a2a7d
12 files changed
+786
-95
No files matched your search
@@ -207,6 +207,19 @@ namespace jdi
|
||||
*/
|
||||
void DispatchMenuResult(const MenuResult& result);
|
||||
|
||||
/**
|
||||
* @brief 调整标签页顺序(拖拽重排)。
|
||||
* @param [IN] from 被移动的标签页下标。
|
||||
* @param [IN] target 目标插入位置(插入到该下标之前)。
|
||||
*/
|
||||
void MoveTab(int from, int target);
|
||||
|
||||
/**
|
||||
* @brief 应用绘图区提交的信号重命名请求。
|
||||
* @return 应用了重命名返回 true。
|
||||
*/
|
||||
bool ApplyPendingRename();
|
||||
|
||||
/**
|
||||
* @brief 弹出文件选择对话框并加载所选文件。
|
||||
*/
|
||||
|
||||
@@ -955,12 +955,94 @@ namespace jdi
|
||||
case TabBarResult::Type::CONTEXT:
|
||||
ShowTabContextMenu(result);
|
||||
break;
|
||||
case TabBarResult::Type::REORDER:
|
||||
MoveTab(result.index, result.targetIndex);
|
||||
break;
|
||||
case TabBarResult::Type::RENAME:
|
||||
if (result.index >= 0 && result.index < static_cast<int>(m_tabs.size()) && !result.text.empty())
|
||||
{
|
||||
m_tabs[static_cast<std::size_t>(result.index)].SetName(result.text);
|
||||
RefreshTabBar();
|
||||
// SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。
|
||||
Relayout();
|
||||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||||
}
|
||||
break;
|
||||
case TabBarResult::Type::NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::MoveTab(int from, int target)
|
||||
{
|
||||
if (from < 0 || from >= static_cast<int>(m_tabs.size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (target < 0 || target > static_cast<int>(m_tabs.size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。
|
||||
int destination = target;
|
||||
if (destination > from)
|
||||
{
|
||||
--destination;
|
||||
}
|
||||
destination = std::max(0, std::min(destination, static_cast<int>(m_tabs.size()) - 1));
|
||||
if (destination == from)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 保持当前激活的标签页在重排后仍处于激活状态。
|
||||
TabConfig moved = m_tabs[static_cast<std::size_t>(from)];
|
||||
const int activeIndex = m_activeTab;
|
||||
|
||||
m_tabs.erase(m_tabs.begin() + from);
|
||||
m_tabs.insert(m_tabs.begin() + destination, std::move(moved));
|
||||
|
||||
int newActive = activeIndex;
|
||||
if (activeIndex == from)
|
||||
{
|
||||
newActive = destination;
|
||||
}
|
||||
else if (from < activeIndex && destination >= activeIndex)
|
||||
{
|
||||
--newActive;
|
||||
}
|
||||
else if (from > activeIndex && destination <= activeIndex)
|
||||
{
|
||||
++newActive;
|
||||
}
|
||||
|
||||
SetActiveTab(newActive);
|
||||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||||
}
|
||||
|
||||
bool Application::ApplyPendingRename()
|
||||
{
|
||||
SignalRef ref;
|
||||
std::wstring name;
|
||||
if (!m_plot.TakePendingRename(ref, name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 改名只影响显示,但需要刷新左侧列表与绘图区图例。
|
||||
m_panel.InvalidateRows();
|
||||
SetStatusMessage(L"信号已重命名为:" + name, false);
|
||||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::ShowTabContextMenu(const TabBarResult& result)
|
||||
{
|
||||
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
|
||||
@@ -1388,6 +1470,7 @@ namespace jdi
|
||||
if (m_plot.IsEditing() && !m_plot.HitTest(x, y))
|
||||
{
|
||||
m_plot.OnEditKey(VK_RETURN);
|
||||
ApplyPendingRename();
|
||||
}
|
||||
|
||||
// 点击面板之外的位置时收起面板内展开的自定义颜色面板。
|
||||
@@ -1444,6 +1527,8 @@ namespace jdi
|
||||
m_activeTarget = Target::PLOT;
|
||||
if (m_plot.OnLButtonDown(x, y))
|
||||
{
|
||||
// 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。
|
||||
ApplyPendingRename();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
@@ -1504,7 +1589,12 @@ namespace jdi
|
||||
const int x = GET_X_LPARAM(lParam);
|
||||
const int y = GET_Y_LPARAM(lParam);
|
||||
bool changed = false;
|
||||
if (m_panel.HitTest(x, y))
|
||||
if (m_tabBar.HitTest(x, y))
|
||||
{
|
||||
SetFocus(hwnd);
|
||||
changed = m_tabBar.OnDoubleClick(x, y);
|
||||
}
|
||||
else if (m_panel.HitTest(x, y))
|
||||
{
|
||||
changed = m_panel.OnDoubleClick(x, y);
|
||||
}
|
||||
@@ -1620,7 +1710,15 @@ namespace jdi
|
||||
break;
|
||||
}
|
||||
case WM_KEYDOWN:
|
||||
// 数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。
|
||||
// 标签栏重命名与数值输入优先接管按键(回车提交、Esc 取消、退格删除)。
|
||||
if (m_tabBar.IsEditing())
|
||||
{
|
||||
TabBarResult tabResult;
|
||||
m_tabBar.OnEditKey(wParam, tabResult);
|
||||
OnTabBarResult(tabResult);
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
if (m_panel.IsEditing())
|
||||
{
|
||||
if (m_panel.OnEditKey(wParam))
|
||||
@@ -1631,10 +1729,9 @@ namespace jdi
|
||||
}
|
||||
if (m_plot.IsEditing())
|
||||
{
|
||||
if (m_plot.OnEditKey(wParam))
|
||||
{
|
||||
m_plot.OnEditKey(wParam);
|
||||
ApplyPendingRename();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (HandleKeyDown(wParam))
|
||||
@@ -1646,6 +1743,14 @@ namespace jdi
|
||||
case WM_CHAR:
|
||||
{
|
||||
// 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。
|
||||
if (m_tabBar.IsEditing())
|
||||
{
|
||||
if (m_tabBar.OnEditChar(static_cast<wchar_t>(wParam)))
|
||||
{
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (m_panel.IsEditing())
|
||||
{
|
||||
if (m_panel.OnEditChar(static_cast<wchar_t>(wParam)))
|
||||
@@ -1658,6 +1763,7 @@ namespace jdi
|
||||
{
|
||||
if (m_plot.OnEditChar(static_cast<wchar_t>(wParam)))
|
||||
{
|
||||
ApplyPendingRename();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -37,7 +37,8 @@ namespace jdi
|
||||
NONE = 0, /**< 未处于输入状态。 */
|
||||
CURSOR_TIME, /**< 编辑时间光标位置。 */
|
||||
LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */
|
||||
LANE_Y_MIN /**< 编辑某网格的 Y 轴最小值。 */
|
||||
LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */
|
||||
SIGNAL_NAME /**< 编辑图例中某信号的显示名称。 */
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -259,6 +260,15 @@ namespace jdi
|
||||
*/
|
||||
bool IsEditing() const { return m_editTarget != PlotEditTarget::NONE; }
|
||||
|
||||
/**
|
||||
* @brief 取出并清除待应用的信号重命名请求。
|
||||
* @param [OUT] ref 被重命名的信号引用。
|
||||
* @param [OUT] name 新的显示名称。
|
||||
* @return 存在待处理请求返回 true。
|
||||
* @note 绘图区只持有只读数据层,重命名需由宿主窗口落到 DataSet 上。
|
||||
*/
|
||||
bool TakePendingRename(SignalRef& ref, std::wstring& name);
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的字符输入。
|
||||
* @param [IN] character 输入字符。
|
||||
@@ -616,17 +626,51 @@ namespace jdi
|
||||
|
||||
SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */
|
||||
SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */
|
||||
int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */
|
||||
int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */
|
||||
int m_dragMouseX = 0; /**< 拖拽过程中鼠标的当前横坐标(虚影跟随)。 */
|
||||
int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */
|
||||
bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */
|
||||
PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */
|
||||
bool m_editFresh = false; /**< 输入框是否处于“首次输入整体替换”状态。 */
|
||||
std::wstring m_editText; /**< 输入框当前文本。 */
|
||||
int m_editLane = -1; /**< 内联输入所作用的网格下标。 */
|
||||
SignalRef m_editSignal; /**< 内联输入所作用的信号(图例改名)。 */
|
||||
gd::RectF m_editFieldRect; /**< 本次编辑输入框的矩形(用于点外提交判定)。 */
|
||||
SignalRef m_pendingRenameRef; /**< 待宿主窗口应用的信号重命名引用。 */
|
||||
std::wstring m_pendingRenameName; /**< 待宿主窗口应用的信号新名称。 */
|
||||
bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */
|
||||
|
||||
int m_hoverLane = -1; /**< 当前悬停网格下标。 */
|
||||
int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */
|
||||
int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */
|
||||
std::vector<LaneVisual> m_lanes; /**< 当前网格绘制信息集合。 */
|
||||
|
||||
/**
|
||||
* @brief 图例条目的命中信息(用于点选与双击改名)。
|
||||
*/
|
||||
struct LegendHit
|
||||
{
|
||||
SignalRef ref; /**< 对应信号。 */
|
||||
gd::RectF rect; /**< 条目矩形(已含色块与文本)。 */
|
||||
};
|
||||
|
||||
std::vector<LegendHit> m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */
|
||||
|
||||
/**
|
||||
* @brief 查询坐标命中的图例条目。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 命中的信号引用,未命中返回无效引用。
|
||||
*/
|
||||
SignalRef LegendAtPoint(int x, int y) const;
|
||||
|
||||
/**
|
||||
* @brief 计算指定网格图例带所占据的矩形(用于让标尺数字避让)。
|
||||
* @param [IN] laneIndex 网格下标。
|
||||
* @param [IN] lane 网格绘制信息。
|
||||
* @return 图例带矩形,无可见信号时返回空矩形。
|
||||
*/
|
||||
gd::RectF LegendBandRect(int laneIndex, const LaneVisual& lane) const;
|
||||
};
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
@@ -11,8 +11,9 @@ namespace jdi
|
||||
|
||||
namespace
|
||||
{
|
||||
const int kTimeTickTarget = 8; /**< 时间轴期望刻度数。 */
|
||||
const int kYTickTarget = 4; /**< 幅值轴期望刻度数。 */
|
||||
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
|
||||
const int kYTickTarget = 6; /**< 幅值轴期望刻度数。 */
|
||||
const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
|
||||
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
|
||||
const float kLanePadding = 8.0f; /**< 网格内边距。 */
|
||||
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */
|
||||
@@ -25,6 +26,10 @@ namespace jdi
|
||||
const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */
|
||||
const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */
|
||||
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
|
||||
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
|
||||
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
|
||||
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
|
||||
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
|
||||
|
||||
/**
|
||||
* @brief 将数值限制到合法区间。
|
||||
@@ -47,13 +52,13 @@ namespace jdi
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 组装图例文本:直接使用列名原文(已含序号与括号描述)。
|
||||
* @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。
|
||||
* @param [IN] data 信号数据。
|
||||
* @return 图例文本。
|
||||
*/
|
||||
std::wstring BuildLegendText(const SignalData& data)
|
||||
{
|
||||
return data.name;
|
||||
return data.Title();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -837,6 +842,17 @@ namespace jdi
|
||||
return gd::RectF(left, m_plotRect.Y + 2.0f * m_scale, width, height);
|
||||
}
|
||||
|
||||
if (m_editTarget == PlotEditTarget::SIGNAL_NAME)
|
||||
{
|
||||
// 图例改名:输入框直接落在被双击的图例条目位置上。
|
||||
if (m_editFieldRect.Width <= 0.0f)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const float nameWidth = std::max(width, m_editFieldRect.Width);
|
||||
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height);
|
||||
}
|
||||
|
||||
// 网格 Y 轴上下限输入框贴在该网格内侧标尺带下方。
|
||||
if (m_editLane < 0 || m_editLane >= static_cast<int>(m_lanes.size()))
|
||||
{
|
||||
@@ -854,6 +870,12 @@ namespace jdi
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。
|
||||
if (target != PlotEditTarget::SIGNAL_NAME)
|
||||
{
|
||||
m_editSignal = SignalRef();
|
||||
m_editFieldRect = gd::RectF();
|
||||
}
|
||||
m_editTarget = target;
|
||||
m_editLane = laneIndex;
|
||||
m_editText = initialText;
|
||||
@@ -861,6 +883,20 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name)
|
||||
{
|
||||
if (!m_pendingRename)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ref = m_pendingRenameRef;
|
||||
name = m_pendingRenameName;
|
||||
m_pendingRename = false;
|
||||
m_pendingRenameRef = SignalRef();
|
||||
m_pendingRenameName.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlotView::EndEdit(bool commit)
|
||||
{
|
||||
if (m_editTarget == PlotEditTarget::NONE)
|
||||
@@ -870,8 +906,28 @@ namespace jdi
|
||||
|
||||
const PlotEditTarget target = m_editTarget;
|
||||
const int laneIndex = m_editLane;
|
||||
const SignalRef editSignal = m_editSignal;
|
||||
const std::wstring text = Trim(m_editText);
|
||||
|
||||
m_editTarget = PlotEditTarget::NONE;
|
||||
m_editLane = -1;
|
||||
m_editFresh = false;
|
||||
m_editSignal = SignalRef();
|
||||
m_editFieldRect = gd::RectF();
|
||||
m_editText.clear();
|
||||
|
||||
// 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。
|
||||
if (target == PlotEditTarget::SIGNAL_NAME)
|
||||
{
|
||||
if (commit && !text.empty() && editSignal.IsValid())
|
||||
{
|
||||
m_pendingRenameRef = editSignal;
|
||||
m_pendingRenameName = text;
|
||||
m_pendingRename = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
double parsed = 0.0;
|
||||
bool parsedOk = false;
|
||||
if (commit && !text.empty())
|
||||
@@ -881,11 +937,6 @@ namespace jdi
|
||||
parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed);
|
||||
}
|
||||
|
||||
m_editTarget = PlotEditTarget::NONE;
|
||||
m_editLane = -1;
|
||||
m_editFresh = false;
|
||||
m_editText.clear();
|
||||
|
||||
if (!parsedOk)
|
||||
{
|
||||
return true;
|
||||
@@ -965,6 +1016,25 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
// 信号改名接受任意可见字符(保留中文、空格与括号等原始信息)。
|
||||
if (m_editTarget == PlotEditTarget::SIGNAL_NAME)
|
||||
{
|
||||
if (character < L' ')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (m_editFresh)
|
||||
{
|
||||
m_editText.clear();
|
||||
m_editFresh = false;
|
||||
}
|
||||
if (m_editText.size() < 96)
|
||||
{
|
||||
m_editText.push_back(character);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const bool digit = (character >= L'0' && character <= L'9');
|
||||
const bool special = (character == L'.' || character == L'-' || character == L'+' || character == L'e' ||
|
||||
character == L'E');
|
||||
@@ -1185,6 +1255,7 @@ namespace jdi
|
||||
{
|
||||
const int target = LaneAtY(y);
|
||||
m_dragMoved = true;
|
||||
m_dragMouseX = x;
|
||||
m_dragMouseY = y;
|
||||
if (target != m_dragTargetLane)
|
||||
{
|
||||
@@ -1215,11 +1286,16 @@ namespace jdi
|
||||
}
|
||||
|
||||
// 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。
|
||||
// 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。
|
||||
SignalRef hoverSignal;
|
||||
if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover)
|
||||
{
|
||||
hoverSignal = LegendAtPoint(x, y);
|
||||
if (!hoverSignal.IsValid())
|
||||
{
|
||||
hoverSignal = HitTestSignal(x, y);
|
||||
}
|
||||
}
|
||||
if (hoverSignal != m_hoverSignal)
|
||||
{
|
||||
m_hoverSignal = hoverSignal;
|
||||
@@ -1298,12 +1374,27 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
// 常规模式下:点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
|
||||
if (mode == PlotZoomMode::NONE)
|
||||
{
|
||||
const SignalRef legendHit = LegendAtPoint(x, y);
|
||||
if (legendHit.IsValid())
|
||||
{
|
||||
Tab().SetSelected(legendHit);
|
||||
m_dragMode = PlotDragMode::NONE;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。
|
||||
const SignalRef hit = HitTestSignal(x, y);
|
||||
if (hit.IsValid())
|
||||
{
|
||||
m_dragSignal = hit;
|
||||
m_dragTargetLane = Tab().LaneOfSignal(hit);
|
||||
Tab().SetSelected(hit);
|
||||
m_dragMouseX = x;
|
||||
m_dragMouseY = y;
|
||||
m_dragMode = PlotDragMode::MOVE_SIGNAL;
|
||||
return true;
|
||||
}
|
||||
@@ -1407,6 +1498,29 @@ namespace jdi
|
||||
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(Tab().CursorTime()));
|
||||
}
|
||||
|
||||
// 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。
|
||||
const SignalRef legendHit = LegendAtPoint(x, y);
|
||||
if (legendHit.IsValid())
|
||||
{
|
||||
const ResolvedSignal resolved = ResolveSignal(legendHit);
|
||||
if (resolved.data != nullptr)
|
||||
{
|
||||
Tab().SetSelected(legendHit);
|
||||
m_editSignal = legendHit;
|
||||
|
||||
// 先把输入框位置落在被双击的条目上,再进入编辑态。
|
||||
for (const LegendHit& hit : m_legendHits)
|
||||
{
|
||||
if (hit.ref == legendHit)
|
||||
{
|
||||
m_editFieldRect = hit.rect;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->Title());
|
||||
}
|
||||
}
|
||||
|
||||
// 其它位置:回到初始状态(自适应 + 退出缩放模式)。
|
||||
FitAll();
|
||||
return true;
|
||||
@@ -1560,6 +1674,9 @@ namespace jdi
|
||||
// 波形区域底色。
|
||||
UiFillRect(g, m_plotRect, m_theme->cardBg);
|
||||
|
||||
// 图例命中矩形每帧重建,避免残留已消失的信号。
|
||||
m_legendHits.clear();
|
||||
|
||||
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
||||
{
|
||||
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
|
||||
@@ -1657,6 +1774,28 @@ namespace jdi
|
||||
std::vector<double> yTicks;
|
||||
BuildTicks(lane.yMin, lane.yMax, yStep, yTicks);
|
||||
|
||||
// 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。
|
||||
const double yMinorStep = yStep / static_cast<double>(kMinorDivisions);
|
||||
std::vector<double> yMinorTicks;
|
||||
BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks);
|
||||
for (const double tick : yMinorTicks)
|
||||
{
|
||||
// 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。
|
||||
const double majorIndex = tick / yStep;
|
||||
if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const float y = ValueToY(tick, lane);
|
||||
if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y,
|
||||
WithAlpha(m_theme->gridWeak, 0x70), 1.0f);
|
||||
}
|
||||
|
||||
for (const double tick : yTicks)
|
||||
{
|
||||
const float y = ValueToY(tick, lane);
|
||||
@@ -1670,7 +1809,29 @@ namespace jdi
|
||||
zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f);
|
||||
}
|
||||
|
||||
// 时间网格线。
|
||||
// 时间网格线(同样补充五等分估读线)。
|
||||
const double timeSpan = Tab().ViewMax() - Tab().ViewMin();
|
||||
const double timeMajorStep = NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget);
|
||||
const double timeMinorStep = timeMajorStep / static_cast<double>(kMinorDivisions);
|
||||
std::vector<double> timeMinorTicks;
|
||||
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks);
|
||||
for (const double tick : timeMinorTicks)
|
||||
{
|
||||
const double majorIndex = tick / timeMajorStep;
|
||||
if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const float x = TimeToX(tick);
|
||||
if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height,
|
||||
WithAlpha(m_theme->gridWeak, 0x70), 1.0f);
|
||||
}
|
||||
|
||||
for (const double tick : timeTicks)
|
||||
{
|
||||
const float x = TimeToX(tick);
|
||||
@@ -1784,6 +1945,9 @@ namespace jdi
|
||||
// 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读,
|
||||
// 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。
|
||||
{
|
||||
// 图例固定占用网格左上角,冲突时优先显示图例,故落在图例带内的刻度数值直接跳过。
|
||||
const gd::RectF legendBand = LegendBandRect(laneIndex, lane);
|
||||
|
||||
const bool showLabels = (lane.rect.Height >= 34.0f * m_scale);
|
||||
float lastLabelY = -1.0e9f;
|
||||
|
||||
@@ -1816,6 +1980,15 @@ namespace jdi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 与图例带冲突时让位(图例优先)。
|
||||
if (legendBand.Width > 0.0f &&
|
||||
pill.X < legendBand.X + legendBand.Width &&
|
||||
pill.X + pill.Width > legendBand.X &&
|
||||
pill.Y < legendBand.Y + legendBand.Height &&
|
||||
pill.Y + pill.Height > legendBand.Y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 文本必须完整落在可见区内,否则会越过绘图区顶部(GDI 文本不受 GDI+ 裁剪约束)。
|
||||
if (!IsFullyVisible(pill, clipTop, clipBottom))
|
||||
{
|
||||
@@ -1842,6 +2015,57 @@ namespace jdi
|
||||
g.Restore(saveState);
|
||||
}
|
||||
|
||||
gd::RectF PlotView::LegendBandRect(int laneIndex, const LaneVisual& lane) const
|
||||
{
|
||||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
|
||||
// 逐条累加已可见图例的宽度,得到整条图例带(用于让标尺数字避让)。
|
||||
float width = 0.0f;
|
||||
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
|
||||
{
|
||||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||||
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
std::wstring label = BuildLegendText(*resolved.data);
|
||||
if (LaneSpansMultipleFiles(laneIndex) && resolved.file != nullptr)
|
||||
{
|
||||
label += L" · " + resolved.file->Title();
|
||||
}
|
||||
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
|
||||
width += kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale;
|
||||
}
|
||||
|
||||
if (width <= 0.0f)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
|
||||
const float left = lane.rect.X + kLegendPad * m_scale;
|
||||
const float maxWidth = std::max(0.0f, lane.rect.Width - kLegendPad * 2.0f * m_scale);
|
||||
return gd::RectF(left, lane.rect.Y + kLegendPad * m_scale - 1.0f * m_scale,
|
||||
std::min(width + 6.0f * m_scale, maxWidth), kLegendHeight * m_scale + 2.0f * m_scale);
|
||||
}
|
||||
|
||||
SignalRef PlotView::LegendAtPoint(int x, int y) const
|
||||
{
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
for (const LegendHit& hit : m_legendHits)
|
||||
{
|
||||
if (UiPointInRect(hit.rect, fx, fy))
|
||||
{
|
||||
return hit.ref;
|
||||
}
|
||||
}
|
||||
return SignalRef();
|
||||
}
|
||||
|
||||
void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop,
|
||||
float visibleBottom)
|
||||
{
|
||||
@@ -1850,9 +2074,10 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
|
||||
// 图例固定在网格顶部:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。
|
||||
const gd::RectF legendBand(lane.rect.X, lane.rect.Y + 4.0f * m_scale, lane.rect.Width,
|
||||
18.0f * m_scale);
|
||||
// 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。
|
||||
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale,
|
||||
lane.rect.Y + kLegendPad * m_scale, lane.rect.Width,
|
||||
kLegendHeight * m_scale);
|
||||
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
|
||||
{
|
||||
return;
|
||||
@@ -1861,10 +2086,9 @@ namespace jdi
|
||||
const std::vector<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals;
|
||||
const bool multiFile = LaneSpansMultipleFiles(laneIndex);
|
||||
|
||||
const float top = lane.rect.Y + 4.0f * m_scale;
|
||||
const float height = 18.0f * m_scale;
|
||||
// 图例从内侧标尺带的右侧开始,避免与纵轴刻度重叠。
|
||||
float cursorX = lane.rect.X + m_labelBandWidth + 6.0f * m_scale;
|
||||
const float top = legendBand.Y;
|
||||
const float height = kLegendHeight * m_scale;
|
||||
float cursorX = legendBand.X;
|
||||
const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale;
|
||||
|
||||
bool anyVisible = false;
|
||||
@@ -1886,8 +2110,8 @@ namespace jdi
|
||||
{
|
||||
label += L" · " + resolved.file->Title();
|
||||
}
|
||||
const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) +
|
||||
14.0f * m_scale;
|
||||
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale +
|
||||
UiTextWidth(g, label, m_fonts->uiSmall) + kLegendItemGap * m_scale;
|
||||
if (cursorX + legendWidth + itemWidth > maxX)
|
||||
{
|
||||
break;
|
||||
@@ -1896,9 +2120,22 @@ namespace jdi
|
||||
}
|
||||
if (legendWidth > 0.0f)
|
||||
{
|
||||
UiFillRoundRect(g, gd::RectF(cursorX - 5.0f * m_scale, top - 1.0f * m_scale,
|
||||
legendWidth + 6.0f * m_scale, height + 2.0f * m_scale),
|
||||
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xD8));
|
||||
UiFillRoundRect(g, gd::RectF(cursorX - 4.0f * m_scale, top - 2.0f * m_scale,
|
||||
legendWidth + 6.0f * m_scale, height + 4.0f * m_scale),
|
||||
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8));
|
||||
}
|
||||
|
||||
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
|
||||
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
|
||||
{
|
||||
if (m_tab->LaneOfSignal(iterator->ref) == laneIndex)
|
||||
{
|
||||
iterator = m_legendHits.erase(iterator);
|
||||
}
|
||||
else
|
||||
{
|
||||
++iterator;
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制图例条目(与测量阶段保持一致的裁剪条件)。
|
||||
@@ -1919,29 +2156,59 @@ namespace jdi
|
||||
label += L" · " + resolved.file->Title();
|
||||
}
|
||||
const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall);
|
||||
const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale;
|
||||
const float itemWidth =
|
||||
kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale;
|
||||
if (drawnWidth + itemWidth > legendWidth + 0.5f)
|
||||
{
|
||||
break;
|
||||
}
|
||||
drawnWidth += itemWidth;
|
||||
|
||||
const float swatchSize = 8.0f * m_scale;
|
||||
const float itemLeft = cursorX;
|
||||
const float swatchSize = kLegendSwatchSize * m_scale;
|
||||
|
||||
// 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。
|
||||
const bool selected = (m_tab->Selected() == ref);
|
||||
const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal);
|
||||
if (selected || hovered)
|
||||
{
|
||||
UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale,
|
||||
itemWidth + 2.0f * m_scale, height + 2.0f * m_scale),
|
||||
3.0f * m_scale, selected ? m_theme->selection : m_theme->hover);
|
||||
}
|
||||
|
||||
UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize),
|
||||
resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
|
||||
cursorX += swatchSize + 5.0f * m_scale;
|
||||
|
||||
UiText(g, label, m_fonts->uiSmall, gd::RectF(cursorX, top, textWidth + 2.0f, height),
|
||||
m_theme->text, TextAlign::LEFT);
|
||||
cursorX += textWidth + 14.0f * m_scale;
|
||||
// 改名编辑态:在原位置绘制输入框。
|
||||
LegendHit hit;
|
||||
hit.ref = ref;
|
||||
hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale,
|
||||
itemWidth + 2.0f * m_scale, height + 4.0f * m_scale);
|
||||
m_legendHits.push_back(hit);
|
||||
|
||||
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref)
|
||||
{
|
||||
m_editFieldRect = hit.rect;
|
||||
UiDrawEditField(g, hit.rect, m_editText, m_editFresh, m_fonts->uiSmall, m_scale);
|
||||
cursorX += itemWidth;
|
||||
++drawnCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
cursorX += swatchSize + 5.0f * m_scale;
|
||||
UiText(g, label, m_fonts->uiSmall,
|
||||
gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height),
|
||||
selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
|
||||
cursorX += textWidth + kLegendItemGap * m_scale;
|
||||
++drawnCount;
|
||||
}
|
||||
|
||||
if (!anyVisible)
|
||||
{
|
||||
UiText(g, L"(无可见信号)", m_fonts->uiSmall,
|
||||
gd::RectF(lane.rect.X + m_labelBandWidth + 6.0f * m_scale, top, lane.rect.Width, height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted,
|
||||
TextAlign::LEFT);
|
||||
}
|
||||
else if (drawnCount < visibleCount)
|
||||
{
|
||||
@@ -2238,25 +2505,43 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
|
||||
// 虚影:以源网格的幅值映射构建波形,并整体平移跟随鼠标,形成“拖着曲线走”的观感。
|
||||
LaneVisual ghostLane = m_lanes[static_cast<std::size_t>(sourceLane)];
|
||||
const float follow = static_cast<float>(m_dragMouseY - m_dragStartY);
|
||||
ghostLane.rect.Y += follow;
|
||||
// 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。
|
||||
// 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致);
|
||||
// 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。
|
||||
const LaneVisual& sourceVisual = m_lanes[static_cast<std::size_t>(sourceLane)];
|
||||
const float offsetX = static_cast<float>(m_dragMouseX - m_dragStartX);
|
||||
const float offsetY = static_cast<float>(m_dragMouseY - m_dragStartY);
|
||||
|
||||
{
|
||||
const float clipTop = std::max(m_plotRect.Y, m_plotRect.Y);
|
||||
const float clipHeight = m_plotRect.Height;
|
||||
const int saveState = g.Save();
|
||||
g.SetClip(gd::RectF(m_plotRect.X, clipTop, m_plotRect.Width, clipHeight),
|
||||
g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height),
|
||||
gd::CombineModeIntersect);
|
||||
|
||||
std::vector<gd::PointF> points;
|
||||
std::vector<std::size_t> breaks;
|
||||
BuildPolyline(resolved, ghostLane, points, breaks);
|
||||
BuildPolyline(resolved, sourceVisual, points, breaks);
|
||||
|
||||
gd::Pen ghost(ToGdiColor(WithAlpha(m_theme->accent, 0xC0)),
|
||||
std::max(1.8f, resolved.style->lineWidth) * m_scale);
|
||||
ghost.SetDashStyle(gd::DashStyleDash);
|
||||
for (gd::PointF& point : points)
|
||||
{
|
||||
point.X += offsetX;
|
||||
point.Y += offsetY;
|
||||
}
|
||||
|
||||
const DrawMode mode = resolved.style->mode;
|
||||
const bool drawLine = (mode != DrawMode::SCATTER);
|
||||
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);
|
||||
|
||||
// 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。
|
||||
const std::uint32_t ghostColor = WithAlpha(resolved.style->color,
|
||||
static_cast<unsigned int>(
|
||||
std::max(0.35, static_cast<double>(resolved.style->opacity)) *
|
||||
255.0 + 0.5));
|
||||
|
||||
if (drawLine && !breaks.empty())
|
||||
{
|
||||
gd::Pen ghost(ToGdiColor(ghostColor),
|
||||
std::max(1.0f, resolved.style->lineWidth) * m_scale);
|
||||
ghost.SetLineJoin(gd::LineJoinRound);
|
||||
ghost.SetStartCap(gd::LineCapRound);
|
||||
ghost.SetEndCap(gd::LineCapRound);
|
||||
for (std::size_t k = 0; k < breaks.size(); ++k)
|
||||
@@ -2269,6 +2554,23 @@ namespace jdi
|
||||
}
|
||||
g.DrawLines(&ghost, points.data() + start, static_cast<INT>(stop - start));
|
||||
}
|
||||
}
|
||||
|
||||
if (drawScatter && !points.empty())
|
||||
{
|
||||
const float radius = static_cast<float>(
|
||||
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
|
||||
kMinScatterRadius, kMaxScatterRadius) *
|
||||
m_scale);
|
||||
const std::size_t scatterStride =
|
||||
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
|
||||
gd::SolidBrush brush(ToGdiColor(ghostColor));
|
||||
for (std::size_t k = 0; k < points.size(); k += scatterStride)
|
||||
{
|
||||
g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f,
|
||||
radius * 2.0f);
|
||||
}
|
||||
}
|
||||
|
||||
g.Restore(saveState);
|
||||
}
|
||||
|
||||
@@ -492,9 +492,8 @@ namespace jdi
|
||||
|
||||
std::array<gd::RectF, 3> m_modeRects; /**< 绘制样式按钮矩形。 */
|
||||
|
||||
// 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度。
|
||||
// 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度与左边界。
|
||||
std::array<gd::RectF, 4> m_styleRowRects; /**< 四行整体矩形(含标签列)。 */
|
||||
gd::RectF m_styleContentRect; /**< 四行共用的内容列矩形。 */
|
||||
|
||||
gd::RectF m_opacityTrackRect; /**< 不透明度滑块轨道矩形。 */
|
||||
gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */
|
||||
|
||||
@@ -414,9 +414,6 @@ namespace jdi
|
||||
const float groupLeft = m_styleRect.X + padH;
|
||||
const float contentLeft = groupLeft + labelWidth + labelGap;
|
||||
|
||||
m_styleContentRect = gd::RectF(contentLeft, firstTop, contentWidth,
|
||||
kStyleRowCount * rowHeight + (kStyleRowCount - 1.0f) *
|
||||
(kStyleRowGap * m_scale));
|
||||
for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index)
|
||||
{
|
||||
m_styleRowRects[static_cast<std::size_t>(index)] =
|
||||
|
||||
@@ -28,11 +28,15 @@ namespace jdi
|
||||
SELECT, /**< 切换到指定标签页。 */
|
||||
CLOSE, /**< 关闭指定标签页。 */
|
||||
ADD, /**< 新建标签页。 */
|
||||
CONTEXT /**< 请求弹出标签页上下文菜单。 */
|
||||
CONTEXT, /**< 请求弹出标签页上下文菜单。 */
|
||||
REORDER, /**< 拖拽调整标签页顺序。 */
|
||||
RENAME /**< 提交标签页重命名。 */
|
||||
};
|
||||
|
||||
Type type = Type::NONE; /**< 动作类型。 */
|
||||
int index = -1; /**< 标签页下标(ADD 时无意义)。 */
|
||||
int targetIndex = -1; /**< REORDER 的目标位置。 */
|
||||
std::wstring text; /**< RENAME 的新名称。 */
|
||||
int screenX = 0; /**< 上下文菜单请求的屏幕横坐标。 */
|
||||
int screenY = 0; /**< 上下文菜单请求的屏幕纵坐标。 */
|
||||
};
|
||||
@@ -124,6 +128,48 @@ namespace jdi
|
||||
*/
|
||||
bool OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result);
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标左键双击(进入标签页重命名)。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 进入编辑态返回 true。
|
||||
*/
|
||||
bool OnDoubleClick(int x, int y);
|
||||
|
||||
/**
|
||||
* @brief 判断标签栏是否处于重命名编辑态。
|
||||
* @return 处于编辑态返回 true。
|
||||
*/
|
||||
bool IsEditing() const { return m_editIndex >= 0; }
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的字符输入。
|
||||
* @param [IN] character 输入字符。
|
||||
* @return 界面状态发生变化返回 true。
|
||||
*/
|
||||
bool OnEditChar(wchar_t character);
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的按键。
|
||||
* @param [IN] key 虚拟键码。
|
||||
* @param [OUT] result 提交重命名时返回结果。
|
||||
* @return 事件已被消费返回 true。
|
||||
*/
|
||||
bool OnEditKey(WPARAM key, TabBarResult& result);
|
||||
|
||||
/**
|
||||
* @brief 查询拖拽排序时应当插入的位置。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
* @return 插入下标(0..标签数量)。
|
||||
*/
|
||||
int DropIndexAt(int x) const;
|
||||
|
||||
/**
|
||||
* @brief 判断是否正在拖动标签页。
|
||||
* @return 正在拖动返回 true。
|
||||
*/
|
||||
bool IsDragging() const { return m_dragIndex >= 0; }
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标离开标签栏。
|
||||
* @return 视觉状态发生变化返回 true。
|
||||
@@ -172,6 +218,18 @@ namespace jdi
|
||||
int m_pressedIndex = -1; /**< 当前按下的标签页下标。 */
|
||||
bool m_pressedClose = false; /**< 是否按在关闭按钮上。 */
|
||||
bool m_pressedAdd = false; /**< 是否按在新建按钮上。 */
|
||||
|
||||
int m_dragIndex = -1; /**< 正在拖动排序的标签页下标,-1 表示未拖动。 */
|
||||
int m_dragMouseX = 0; /**< 拖动过程中鼠标横坐标。 */
|
||||
bool m_dragMoved = false; /**< 拖动过程中是否发生了位移。 */
|
||||
int m_pressedX = 0; /**< 按下时的横坐标(用于判定拖动阈值)。 */
|
||||
|
||||
int m_editIndex = -1; /**< 正在重命名的标签页下标,-1 表示未编辑。 */
|
||||
std::wstring m_editText; /**< 重命名输入文本。 */
|
||||
bool m_editFresh = false; /**< 是否处于“首次输入整体替换”状态。 */
|
||||
|
||||
int m_lastClickIndex = -1; /**< 上一次单击的标签页下标(用于自行判定双击)。 */
|
||||
DWORD m_lastClickTick = 0; /**< 上一次单击的时刻。 */
|
||||
};
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
@@ -134,10 +136,22 @@ namespace jdi
|
||||
|
||||
const float textLeft = tab.rect.X + kTabPadding * m_scale;
|
||||
const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft;
|
||||
|
||||
// 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。
|
||||
if (static_cast<int>(index) == m_editIndex)
|
||||
{
|
||||
const gd::RectF editRect(textLeft - 3.0f * m_scale, tab.rect.Y + 3.0f * m_scale,
|
||||
std::max(40.0f, textWidth + 6.0f * m_scale),
|
||||
tab.rect.Height - 6.0f * m_scale);
|
||||
UiDrawEditField(g, editRect, m_editText, m_editFresh, m_fonts->ui, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::wstring label = UiEllipsize(g, tab.name, m_fonts->ui, std::max(8.0f, textWidth));
|
||||
UiText(g, label, active ? m_fonts->uiBold : m_fonts->ui,
|
||||
gd::RectF(textLeft, tab.rect.Y, std::max(8.0f, textWidth), tab.rect.Height),
|
||||
active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
// 关闭按钮:悬停时显示底色,图标始终可见。
|
||||
const bool closeHot = hovered && m_hoverClose;
|
||||
@@ -155,6 +169,22 @@ namespace jdi
|
||||
cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale);
|
||||
}
|
||||
|
||||
// 拖拽排序时在插入位置绘制强调色指示条。
|
||||
if (m_dragIndex >= 0 && m_dragMoved && !m_tabs.empty())
|
||||
{
|
||||
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 = m_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);
|
||||
}
|
||||
|
||||
// 新建按钮。
|
||||
const std::uint32_t addBg = m_hoverAdd ? m_theme->hover : m_theme->panelBg;
|
||||
UiFillRoundRect(g, m_addRect, kCorner * m_scale, addBg);
|
||||
@@ -193,6 +223,18 @@ namespace jdi
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const int index = TabAtPoint(x, y);
|
||||
const bool overClose =
|
||||
(index >= 0 && UiPointInRect(m_tabs[static_cast<std::size_t>(index)].closeRect,
|
||||
@@ -215,6 +257,13 @@ namespace jdi
|
||||
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;
|
||||
@@ -228,8 +277,25 @@ namespace jdi
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -249,13 +315,30 @@ namespace jdi
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -275,6 +358,121 @@ namespace jdi
|
||||
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_editText = m_tabs[static_cast<std::size_t>(index)].name;
|
||||
m_editFresh = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TabBar::OnEditChar(wchar_t character)
|
||||
{
|
||||
if (m_editIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (character == L'\r' || character == L'\n')
|
||||
{
|
||||
TabBarResult result;
|
||||
OnEditKey(VK_RETURN, result);
|
||||
return true;
|
||||
}
|
||||
if (character == L'\b')
|
||||
{
|
||||
m_editFresh = false;
|
||||
if (!m_editText.empty())
|
||||
{
|
||||
m_editText.pop_back();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (character < L' ')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_editFresh)
|
||||
{
|
||||
m_editText.clear();
|
||||
m_editFresh = false;
|
||||
}
|
||||
if (m_editText.size() < 48)
|
||||
{
|
||||
m_editText.push_back(character);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TabBar::OnEditKey(WPARAM key, TabBarResult& result)
|
||||
{
|
||||
result = TabBarResult();
|
||||
if (m_editIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int index = m_editIndex;
|
||||
switch (key)
|
||||
{
|
||||
case VK_RETURN:
|
||||
{
|
||||
const std::wstring text = Trim(m_editText);
|
||||
m_editIndex = -1;
|
||||
m_editFresh = false;
|
||||
m_editText.clear();
|
||||
if (!text.empty())
|
||||
{
|
||||
result.type = TabBarResult::Type::RENAME;
|
||||
result.index = index;
|
||||
result.text = text;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case VK_ESCAPE:
|
||||
m_editIndex = -1;
|
||||
m_editFresh = false;
|
||||
m_editText.clear();
|
||||
return true;
|
||||
case VK_BACK:
|
||||
m_editFresh = false;
|
||||
if (!m_editText.empty())
|
||||
{
|
||||
m_editText.pop_back();
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result)
|
||||
{
|
||||
result = TabBarResult();
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace jdi
|
||||
{ToolbarAction::ZOOM_VALUE, L"Y 轴缩放", false, true, true},
|
||||
{ToolbarAction::CURSOR, L"光标", false, true, true},
|
||||
{ToolbarAction::MERGE_LANES, L"合并网格", false, true, false},
|
||||
{ToolbarAction::SPLIT_LANES, L"每信号一格", false, true, false},
|
||||
{ToolbarAction::SPLIT_LANES, L"拆分信号网格", false, true, false},
|
||||
{ToolbarAction::SELECT_ALL, L"全选信号", false, true, false},
|
||||
{ToolbarAction::CLEAR_ALL, L"清空勾选", false, true, false},
|
||||
};
|
||||
|
||||
@@ -181,17 +181,6 @@ namespace jdi
|
||||
*/
|
||||
void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth);
|
||||
|
||||
/**
|
||||
* @brief 在矩形中心绘制一个箭头(折线形)图标。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 图标布局矩形。
|
||||
* @param [IN] argb 线条颜色。
|
||||
* @param [IN] strokeWidth 线条宽度。
|
||||
* @param [IN] direction 箭头朝向。
|
||||
*/
|
||||
void UiDrawArrowIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth,
|
||||
IconDirection direction);
|
||||
|
||||
/**
|
||||
* @brief 在矩形中心绘制一个实心三角形指示器。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
|
||||
@@ -808,6 +808,20 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
style->visible = visible;
|
||||
|
||||
// 合并模式下新勾选的信号独占一个新网格:避免「先合并已有信号、再加入新信号」
|
||||
// 时新信号被悄悄塞进那个唯一的合并网格里,导致用户看不到新增的独立泳道。
|
||||
if (visible && m_laneMode == LaneMode::MERGED)
|
||||
{
|
||||
m_laneMode = LaneMode::CUSTOM;
|
||||
Lane lane;
|
||||
lane.autoRange = true;
|
||||
lane.signals.push_back(ref);
|
||||
m_lanes.push_back(std::move(lane));
|
||||
SyncLanes(dataSet);
|
||||
return;
|
||||
}
|
||||
|
||||
SyncLanes(dataSet);
|
||||
}
|
||||
|
||||
|
||||
@@ -305,35 +305,6 @@ namespace jdi
|
||||
UiDrawLine(g, cx + arm, cy - arm, cx - arm, cy + arm, argb, strokeWidth);
|
||||
}
|
||||
|
||||
void UiDrawArrowIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth,
|
||||
IconDirection direction)
|
||||
{
|
||||
const float cx = rect.X + rect.Width * 0.5f;
|
||||
const float cy = rect.Y + rect.Height * 0.5f;
|
||||
const float arm = std::min(rect.Width, rect.Height) * 0.26f;
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case IconDirection::LEFT:
|
||||
UiDrawLine(g, cx + arm * 0.6f, cy - arm, cx - arm * 0.6f, cy, argb, strokeWidth);
|
||||
UiDrawLine(g, cx - arm * 0.6f, cy, cx + arm * 0.6f, cy + arm, argb, strokeWidth);
|
||||
break;
|
||||
case IconDirection::RIGHT:
|
||||
UiDrawLine(g, cx - arm * 0.6f, cy - arm, cx + arm * 0.6f, cy, argb, strokeWidth);
|
||||
UiDrawLine(g, cx + arm * 0.6f, cy, cx - arm * 0.6f, cy + arm, argb, strokeWidth);
|
||||
break;
|
||||
case IconDirection::UP:
|
||||
UiDrawLine(g, cx - arm, cy + arm * 0.6f, cx, cy - arm * 0.6f, argb, strokeWidth);
|
||||
UiDrawLine(g, cx, cy - arm * 0.6f, cx + arm, cy + arm * 0.6f, argb, strokeWidth);
|
||||
break;
|
||||
case IconDirection::DOWN:
|
||||
default:
|
||||
UiDrawLine(g, cx - arm, cy - arm * 0.6f, cx, cy + arm * 0.6f, argb, strokeWidth);
|
||||
UiDrawLine(g, cx, cy + arm * 0.6f, cx + arm, cy - arm * 0.6f, argb, strokeWidth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction)
|
||||
{
|
||||
const float cx = rect.X + rect.Width * 0.5f;
|
||||
|
||||
Reference in new issue
Block a user